mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
test(insights): cover graded compression and stub-skip
Pin the snapshot payload budget: a 2-year daily weigher (weight + daily BP) and a six-type dense account both serialise under the 24 000-char cap, proving the graded rewrite holds the line on the worst-case accounts. The stub-skip and timeout-no-persist behaviour is covered by the per-generator cache-read tests + the shared status-cache suite.
This commit is contained in:
1 parent
11a06196f3
commit
68a9f114a5
2 files changed
+279
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Wave 1D — payload-budget verification. A dense daily weigher (2 years
|
||||
* of daily WEIGHT + BP, plus a many-type Apple-Health account for
|
||||
* general-status) used to embed the full ~360-day daily array per
|
||||
* metric, running ~20-100 K tokens. After the graded-series rewrite the
|
||||
* snapshot the prompt carries must sit well under the ~24 000-char
|
||||
* (~6 000-token) cap.
|
||||
*
|
||||
* The snapshot JSON is the `{ … }` block embedded in the user prompt;
|
||||
* `runStatusCompletion` is stubbed to capture it.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
|
||||
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { generateWeightStatusForUser } from "../weight-status";
|
||||
import { generateGeneralStatusForUser } from "../general-status";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const BUDGET_CHARS = 24_000;
|
||||
|
||||
let capturedPrompt: string | null = null;
|
||||
|
||||
function stubCapture() {
|
||||
vi.mocked(runStatusCompletion).mockImplementation(
|
||||
async (args: { userPrompt: string }) => {
|
||||
capturedPrompt = args.userPrompt;
|
||||
return {
|
||||
kind: "ok",
|
||||
content: '{"summary":"OK"}',
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 1,
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotChars(): number {
|
||||
const match = capturedPrompt!.match(/\{[\s\S]*\}/);
|
||||
return match![0].length;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
capturedPrompt = null;
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: 175,
|
||||
} as never);
|
||||
stubCapture();
|
||||
});
|
||||
|
||||
describe("status snapshot payload budget", () => {
|
||||
it("weight-status: a 2-year daily weigher + daily BP stays under the char cap", async () => {
|
||||
const now = new Date();
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (let day = 0; day < 730; day++) {
|
||||
const measuredAt = new Date(now.getTime() - day * dayMs);
|
||||
rows.push({ type: "WEIGHT", value: 80 + (day % 5), measuredAt });
|
||||
rows.push({
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120 + (day % 10),
|
||||
measuredAt,
|
||||
});
|
||||
rows.push({
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 80 + (day % 5),
|
||||
measuredAt,
|
||||
});
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
await generateWeightStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const chars = snapshotChars();
|
||||
// Pre-rewrite this ran ~80-110 KB for this exact account; the
|
||||
// graded rewrite lands it around ~15 KB.
|
||||
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
|
||||
});
|
||||
|
||||
it("general-status: a many-type dense account stays under the char cap", async () => {
|
||||
const now = new Date();
|
||||
const types = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"STEPS",
|
||||
];
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (let day = 0; day < 730; day++) {
|
||||
const measuredAt = new Date(now.getTime() - day * dayMs);
|
||||
for (const type of types) {
|
||||
rows.push({ type, value: 50 + (day % 30), measuredAt });
|
||||
}
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
await generateGeneralStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const chars = snapshotChars();
|
||||
// Pre-rewrite a many-type dense account could exceed 100 K tokens;
|
||||
// the graded rewrite lands it around ~12 KB.
|
||||
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Wave 1D — the per-card status path delivers the REAL provider text.
|
||||
*
|
||||
* Pins two contracts against a real Postgres + a seeded rich account:
|
||||
*
|
||||
* 1. With a provider chain stubbed to return a known assessment, the
|
||||
* status generator returns that text (the real path is reached, not
|
||||
* the generic no-key fallback). This is the regression the
|
||||
* sticky-stub bug used to break.
|
||||
*
|
||||
* 2. A timeout-stub cache row keyed to today is NOT served — the
|
||||
* generator skips it and re-generates, proving the stub no longer
|
||||
* pins the fallback for the day.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Stub the provider resolution so the chain runner has a deterministic
|
||||
// provider to walk. `runRawCompletionWithFallback` runs for real on top
|
||||
// of this — exercising the same plumbing the live status path uses.
|
||||
const KNOWN_TEXT = "Your weight has trended down steadily over the past month.";
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn().mockResolvedValue([
|
||||
{
|
||||
providerType: "anthropic",
|
||||
instance: {
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn().mockResolvedValue({
|
||||
content: JSON.stringify({ summary: KNOWN_TEXT }),
|
||||
model: "test-model",
|
||||
tokensUsed: 42,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
|
||||
}));
|
||||
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function seedRichWeightUser(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
},
|
||||
});
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const rows = Array.from({ length: 120 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 82 - i * 0.02,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("status path delivers real provider text", () => {
|
||||
it("returns the provider's assessment, not the no-key fallback", async () => {
|
||||
const user = await seedRichWeightUser("status-real-text-user");
|
||||
const { generateWeightStatusForUser } = await import(
|
||||
"@/lib/insights/weight-status"
|
||||
);
|
||||
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.hasProvider).toBe(true);
|
||||
expect(result.cached).toBe(false);
|
||||
expect(result.text).toBe(KNOWN_TEXT);
|
||||
|
||||
// The real assessment was persisted (not a stub).
|
||||
const prisma = getPrismaClient();
|
||||
const cached = await prisma.auditLog.findFirst({
|
||||
where: { userId: user.id, action: "insights.weight-status.en" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { details: true },
|
||||
});
|
||||
const parsed = JSON.parse(cached?.details ?? "{}") as {
|
||||
text: string;
|
||||
model: string;
|
||||
timeout?: boolean;
|
||||
};
|
||||
expect(parsed.text).toBe(KNOWN_TEXT);
|
||||
expect(parsed.model).toBe("test-model");
|
||||
expect(parsed.timeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a timeout-stub cache row and regenerates the real text", async () => {
|
||||
const user = await seedRichWeightUser("status-stub-skip-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Pre-seed a stub row keyed to today — the bug used to serve this.
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "insights.weight-status.en",
|
||||
details: JSON.stringify({
|
||||
dateKey: toBerlinDayKey(new Date()),
|
||||
locale: "en",
|
||||
text: "Generic fallback advice that must not be served.",
|
||||
model: "timeout-stub",
|
||||
timeout: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { generateWeightStatusForUser } = await import(
|
||||
"@/lib/insights/weight-status"
|
||||
);
|
||||
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
// The stub is skipped; the real provider text is returned + cached.
|
||||
expect(result.text).toBe(KNOWN_TEXT);
|
||||
expect(result.cached).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user