Files
proof-of-work/proof-of-work/tests/ai-programSchema.test.ts
T
Keysat 794070a1d8
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:8 — tolerate decimal integers in AI output
Local models (Qwen via SparkControl, surfaced on the first SparkControl smoke test) sometimes emit a decimal where the AI-output schema expects an integer — e.g. a half-step "rpe": 7.5 or "reps": 8.0. Zod's .int() rejected these and failed the ENTIRE parse, so one stray decimal killed an otherwise good generation.

Fix: a shared looseInt helper rounds a number to the nearest int before the .int() check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). RPE/reps/sets are stored as integers downstream, so rounding is the correct landing. Transform-before-validate, so inferred types are unchanged.

Parse-only; no schema/data change. 261 tests pass; built + sideloaded to immense-voyage.local (1.2.0:8, clean non-root launch). SparkControl now confirmed working end-to-end.
2026-06-19 15:30:06 -05:00

135 lines
4.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { extractJson, parseAIProgram } from '@/lib/ai/programSchema';
describe('extractJson', () => {
it('extracts a bare JSON object', () => {
expect(extractJson('{"a":1}')).toBe('{"a":1}');
});
it('strips ```json fences', () => {
expect(extractJson('```json\n{"a":1}\n```')).toBe('{"a":1}');
});
it('strips bare ``` fences', () => {
expect(extractJson('```\n{"a":1}\n```')).toBe('{"a":1}');
});
it('finds first balanced object after preamble', () => {
const raw =
'Here is your program:\n\n{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}\n\nHope that helps!';
expect(extractJson(raw)).toBe(
'{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}',
);
});
it('handles braces inside strings', () => {
const raw = '{"notes":"use {brackets} sparingly","x":1}';
expect(extractJson(raw)).toBe(raw);
});
it('returns null when no object present', () => {
expect(extractJson('no json at all')).toBeNull();
});
});
describe('parseAIProgram', () => {
const valid = {
name: 'Test',
type: 'hypertrophy',
durationWeeks: 4,
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{
exerciseId: 'cabc',
exerciseName: 'Bench Press',
order: 0,
sets: 4,
repsMin: 6,
repsMax: 10,
},
],
},
],
},
],
};
it('accepts a valid program', () => {
const r = parseAIProgram(JSON.stringify(valid));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.program.name).toBe('Test');
expect(r.program.weeks[0].days[0].exercises[0].exerciseName).toBe(
'Bench Press',
);
}
});
it('accepts null exerciseId for unresolved exercises', () => {
const variant = structuredClone(valid);
variant.weeks[0].days[0].exercises[0] = {
...variant.weeks[0].days[0].exercises[0],
exerciseId: null as unknown as string,
};
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(true);
});
it('rounds decimal ints from the model instead of failing (RPE 7.5 -> 8)', () => {
const variant = structuredClone(valid);
const ex = variant.weeks[0].days[0].exercises[0] as Record<string, unknown>;
ex.rpe = 7.5; // -> 8
ex.sets = 4.2; // -> 4
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(true);
if (r.ok) {
const out = r.program.weeks[0].days[0].exercises[0];
expect(out.rpe).toBe(8);
expect(out.sets).toBe(4);
}
});
it('rejects when no JSON found', () => {
const r = parseAIProgram('the model just said hello');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
});
it('rejects malformed JSON', () => {
// Unbalanced braces: extractJson never finds a closing `}`, so
// the failure mode is "Could not find a JSON object" rather than
// a parse error per se. Either way, ok=false.
const r = parseAIProgram('{ "name": "x", "weeks": [');
expect(r.ok).toBe(false);
});
it('rejects JSON with a parse-level syntax error inside balanced braces', () => {
const r = parseAIProgram('{ "name": "x", }');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
});
it('rejects when shape is wrong (missing weeks)', () => {
const bad = { name: 'X', type: 'hypertrophy', durationWeeks: 4 };
const r = parseAIProgram(JSON.stringify(bad));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/shape/);
});
it('rejects when dayOfWeek is out of range', () => {
const variant = structuredClone(valid);
variant.weeks[0].days[0].dayOfWeek = 7; // 0-6 only
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
it('handles a model response wrapped in markdown commentary', () => {
const wrapped =
"Sure! Here's your program:\n\n```json\n" +
JSON.stringify(valid) +
'\n```\n\nLet me know if you want changes.';
const r = parseAIProgram(wrapped);
expect(r.ok).toBe(true);
});
});