Files
proof-of-work/proof-of-work/tests/ai-historyContext.test.ts
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

225 lines
6.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.setLog.deleteMany();
await prisma.workout.deleteMany();
await prisma.programExercise.deleteMany();
await prisma.programDay.deleteMany();
await prisma.programWeek.deleteMany();
await prisma.program.deleteMany();
await prisma.exercise.deleteMany();
await prisma.aIGeneration.deleteMany();
await prisma.aIPromptTemplate.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
});
async function setup() {
const user = await prisma.user.create({
data: { email: 'a@x', passwordHash: 'fake' },
});
const bench = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
const squat = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Squat',
type: 'barbell',
muscleGroups: '[]',
},
});
return { user, bench, squat };
}
async function logWorkout(
userId: string,
daysAgo: number,
sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>,
) {
const date = new Date(Date.now() - daysAgo * 86_400_000);
return prisma.workout.create({
data: {
userId,
date,
setLogs: {
create: sets.map((s, i) => ({
exerciseId: s.exerciseId,
setNumber: i + 1,
reps: s.reps,
weight: s.weight,
rpe: s.rpe,
})),
},
},
});
}
describe('buildHistorySummary', () => {
it('returns empty summary for a user with no workouts', async () => {
const user = await prisma.user.create({
data: { email: 'a@x', passwordHash: 'fake' },
});
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(0);
expect(s.exercises).toEqual([]);
});
it('summarizes a single user\'s recent activity', async () => {
const { user, bench, squat } = await setup();
// 3 bench sessions, 2 squat sessions in last 30 days
await logWorkout(user.id, 1, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
await logWorkout(user.id, 4, [
{ exerciseId: bench.id, reps: 5, weight: 235 },
{ exerciseId: bench.id, reps: 5, weight: 235 },
]);
await logWorkout(user.id, 7, [
{ exerciseId: bench.id, reps: 5, weight: 215 },
{ exerciseId: squat.id, reps: 5, weight: 315 },
]);
await logWorkout(user.id, 14, [
{ exerciseId: squat.id, reps: 5, weight: 305 },
]);
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(4);
expect(s.workoutsPerWeek).toBeGreaterThan(0);
expect(s.exercises).toHaveLength(2);
const benchSummary = s.exercises.find((e) => e.name === 'Bench Press');
expect(benchSummary).toBeTruthy();
expect(benchSummary!.totalSets).toBe(5);
expect(benchSummary!.distinctWorkouts).toBe(3);
expect(benchSummary!.bestWeight).toBe(235);
expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago
// Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274
expect(benchSummary!.estimated1RM).toBe(274);
});
it('flags stagnation on a stuck exercise', async () => {
const { user, bench } = await setup();
// 6 sessions all at the same weight
for (let d = 0; d < 6; d++) {
await logWorkout(user.id, d * 5, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
}
const s = await buildHistorySummary(prisma, user.id);
const bs = s.exercises.find((e) => e.name === 'Bench Press');
expect(bs?.stagnant).toBe(true);
});
it('does NOT flag stagnation on a progressing exercise', async () => {
const { user, bench } = await setup();
// 6 sessions with progressive weight
for (let d = 0; d < 6; d++) {
await logWorkout(user.id, (5 - d) * 7, [
{ exerciseId: bench.id, reps: 5, weight: 200 + d * 10 },
]);
}
const s = await buildHistorySummary(prisma, user.id);
const bs = s.exercises.find((e) => e.name === 'Bench Press');
expect(bs?.stagnant).toBe(false);
});
it('excludes workouts outside the window', async () => {
const { user, bench } = await setup();
await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]);
const s = await buildHistorySummary(prisma, user.id, 90);
expect(s.totalWorkouts).toBe(1);
expect(s.exercises[0].totalSets).toBe(1);
});
it('excludes soft-deleted workouts', async () => {
const { user, bench } = await setup();
const w = await logWorkout(user.id, 3, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
await prisma.workout.update({
where: { id: w.id },
data: { deletedAt: new Date() },
});
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(0);
});
it('isolates per-user data (does not bleed across users)', async () => {
const { user, bench } = await setup();
const otherUser = await prisma.user.create({
data: { email: 'b@x', passwordHash: 'fake' },
});
const otherBench = await prisma.exercise.create({
data: {
userId: otherUser.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
await logWorkout(otherUser.id, 1, [
{ exerciseId: otherBench.id, reps: 100, weight: 999 },
]);
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(1);
expect(s.exercises[0].bestWeight).toBe(225); // not 999
});
});
describe('formatHistoryContext', () => {
it('emits a friendly message on empty history', () => {
const out = formatHistoryContext({
windowDays: 90,
totalWorkouts: 0,
workoutsPerWeek: 0,
primaryTypes: [],
exercises: [],
});
expect(out).toMatch(/no workouts/);
});
it('formats a populated summary into a compact block', () => {
const out = formatHistoryContext({
windowDays: 90,
totalWorkouts: 30,
workoutsPerWeek: 3.3,
primaryTypes: ['barbell', 'dumbbell', 'cable'],
exercises: [
{
name: 'Bench Press',
type: 'barbell',
totalSets: 36,
distinctWorkouts: 12,
daysSinceLast: 2,
lastWeight: 235,
lastReps: 5,
bestWeight: 245,
estimated1RM: 286,
avgRpe: 8.5,
stagnant: false,
},
],
});
expect(out).toMatch(/30 workouts/);
expect(out).toMatch(/Bench Press/);
expect(out).toMatch(/STAGNANT|RPE/);
});
});