794070a1d8
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.
135 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|