2b0abad68e
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).
Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).
Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.
Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
89 lines
2.6 KiB
TypeScript
89 lines
2.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parseAIWorkout } from '@/lib/ai/workoutSchema';
|
|
|
|
describe('parseAIWorkout', () => {
|
|
const valid = {
|
|
name: 'Upper — Shoulders',
|
|
notes: 'Overhead press focus',
|
|
exercises: [
|
|
{
|
|
exerciseId: 'cabc',
|
|
exerciseName: 'Overhead Press',
|
|
order: 0,
|
|
sets: 4,
|
|
reps: 6,
|
|
suggestedWeight: 95,
|
|
suggestedWeightUnit: 'lbs',
|
|
rpe: 8,
|
|
},
|
|
{
|
|
exerciseId: 'cdef',
|
|
exerciseName: 'Assault Bike',
|
|
order: 1,
|
|
sets: 1,
|
|
durationSeconds: 600,
|
|
gear: 3,
|
|
},
|
|
],
|
|
};
|
|
|
|
it('accepts a valid single workout', () => {
|
|
const r = parseAIWorkout(JSON.stringify(valid));
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.workout.name).toBe('Upper — Shoulders');
|
|
expect(r.workout.exercises).toHaveLength(2);
|
|
expect(r.workout.exercises[0].suggestedWeight).toBe(95);
|
|
expect(r.workout.exercises[1].gear).toBe(3);
|
|
}
|
|
});
|
|
|
|
it('accepts null exerciseId for unresolved exercises', () => {
|
|
const variant = structuredClone(valid);
|
|
variant.exercises[0].exerciseId = null as unknown as string;
|
|
const r = parseAIWorkout(JSON.stringify(variant));
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('strips markdown fences and commentary', () => {
|
|
const wrapped =
|
|
"Here's today's session:\n\n```json\n" +
|
|
JSON.stringify(valid) +
|
|
'\n```\n\nEnjoy!';
|
|
const r = parseAIWorkout(wrapped);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects when no JSON is present', () => {
|
|
const r = parseAIWorkout('the model just said hi');
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
|
|
});
|
|
|
|
it('rejects a parse-level syntax error inside balanced braces', () => {
|
|
const r = parseAIWorkout('{ "name": "x", }');
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
|
|
});
|
|
|
|
it('rejects when the shape is wrong (missing exercises)', () => {
|
|
const r = parseAIWorkout(JSON.stringify({ name: 'X' }));
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.reason).toMatch(/shape/);
|
|
});
|
|
|
|
it('rejects an out-of-range gear', () => {
|
|
const variant = structuredClone(valid);
|
|
variant.exercises[1].gear = 9; // gear is 1-5
|
|
const r = parseAIWorkout(JSON.stringify(variant));
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it('rejects an empty exercise name', () => {
|
|
const variant = structuredClone(valid);
|
|
variant.exercises[0].exerciseName = '';
|
|
const r = parseAIWorkout(JSON.stringify(variant));
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|