Files
proof-of-work/proof-of-work/tests/ai-lenientJson.test.ts
T
Keysat dba478aa23 v1.1.0:3 — AI upgrades: history context, test connection, cost estimator, streaming preview
Four incremental upgrades to the AI program generator. No schema change, no /data migration.

1. History as context (the killer feature)
   - lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
     frequency, recent weights, estimated 1RM (Epley), avg RPE,
     days-since-last, plus a STAGNANT flag when the heaviest weight in
     the new half doesn't beat the old half.
   - Generate page surfaces an "Include my workout history as context"
     checkbox (default on at >=10 logged workouts). When checked, the
     ~1-3 KB summary is appended to the system prompt so the model can
     recommend things like "you've stalled bench at 245 — try paused reps."
   - We deliberately don't ship raw set logs (privacy + token cost).

2. Test connection
   - POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
     reports latency + first sample, or the error inline.
   - "Test connection" button next to "Save AI config" in
     Settings -> AI integration. Verifies provider/model/key/baseUrl
     without going through full program generation.

3. Cost estimator
   - lib/ai/pricing.ts ships a price table for major models
     (Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
     Ollama always returns 0; openai-compatible returns null.
   - Generation history shows per-row cost + a 30-day rolling total
     at the top of the page.

4. Streaming preview render
   - lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
     auto-closes open strings/brackets/braces in reverse-of-opening
     order, drops dangling key:value pairs and partial keywords.
     Returns a best-effort snapshot of the program-so-far on each chunk.
   - Generate UI now renders a live "Building program..." panel that
     updates as weeks/days/exercises arrive instead of just showing
     raw text and waiting for stream end.

Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.
2026-05-10 22:17:35 -05:00

89 lines
2.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
describe('lenientJsonParse', () => {
it('returns null on empty input', () => {
expect(lenientJsonParse('')).toBeNull();
});
it('returns null when no { is present', () => {
expect(lenientJsonParse('hello world')).toBeNull();
});
it('returns the object when input is already valid', () => {
expect(lenientJsonParse('{"a":1,"b":[2,3]}')).toEqual({ a: 1, b: [2, 3] });
});
it('strips ```json fences', () => {
expect(lenientJsonParse('```json\n{"a":1}\n```')).toEqual({ a: 1 });
});
it('handles fences not yet closed (still streaming)', () => {
expect(lenientJsonParse('```json\n{"a":1, "b":2')).toEqual({ a: 1, b: 2 });
});
it('finds the first { after preamble', () => {
expect(lenientJsonParse('Here you go:\n{"name":"x"}')).toEqual({
name: 'x',
});
});
it('auto-closes a partial object missing its closing }', () => {
const got = lenientJsonParse('{"name":"X","durationWeeks":4');
expect(got).toEqual({ name: 'X', durationWeeks: 4 });
});
it('auto-closes a partial array missing its closing ]', () => {
const got = lenientJsonParse('{"weeks":[1,2,3');
expect(got).toEqual({ weeks: [1, 2, 3] });
});
it('drops a dangling property key with no value yet', () => {
const got = lenientJsonParse('{"name":"X","notes":');
expect(got).toEqual({ name: 'X' });
});
it('drops a trailing comma after a complete value', () => {
const got = lenientJsonParse('{"a":1,"b":2,');
expect(got).toEqual({ a: 1, b: 2 });
});
it('handles a partial nested structure typical of AI program output', () => {
const partial = `{
"name": "Test",
"type": "hypertrophy",
"durationWeeks": 4,
"weeks": [
{
"weekNumber": 1,
"days": [
{
"dayOfWeek": 1,
"name": "Push",
"exercises": [
{"exerciseId": "abc", "exerciseName": "Bench", "order": 0, "sets": 4
`;
const got = lenientJsonParse(partial) as Record<string, any>;
expect(got).toBeTruthy();
expect(got.name).toBe('Test');
expect(Array.isArray(got.weeks)).toBe(true);
expect(got.weeks[0].weekNumber).toBe(1);
// The dangling exercise object may or may not be present
// depending on truncation; what matters is the parser didn't
// throw.
});
it('handles an open string at the end', () => {
const got = lenientJsonParse('{"description":"A long descrip');
expect(got).toBeTruthy();
expect((got as Record<string, string>).description).toMatch(
/^A long descrip/,
);
});
it('returns null for unrecoverable garbage', () => {
// Mismatched closing brace before any opening is unrecoverable
expect(lenientJsonParse('}}}')).toBeNull();
});
});