mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(daily): cover the due-dose rail item and the briefing grounding disposal
This commit is contained in:
1 parent
f8bb8f4b9b
commit
cbb9528cb2
2 files changed
+313
-3
No files matched your search
@@ -6,6 +6,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import {
|
||||
buildDailyDigest,
|
||||
COACH_CHECKIN_RESURFACE_DAYS,
|
||||
DOSE_DUE_LOOKAHEAD_MS,
|
||||
MAX_WORTH_A_LOOK,
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestInput,
|
||||
@@ -211,8 +212,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
expect(dose).toBeDefined();
|
||||
expect(dose?.status).toBe("warning");
|
||||
expect(dose?.moduleKey).toBe("medications");
|
||||
expect(dose?.title).toBe("Medication due");
|
||||
expect(dose?.body).toBe("Ramipril is due today.");
|
||||
expect(dose?.title).toBe("Medication overdue");
|
||||
expect(dose?.body).toBe("Ramipril is past due.");
|
||||
expect(dose?.actions).toHaveLength(1);
|
||||
expect(dose?.actions[0].intent).toBe("dose.log");
|
||||
// Deep-links straight to the overdue medication's card, not the bare
|
||||
@@ -249,7 +250,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT emit a dose-window item when nothing is overdue", () => {
|
||||
it("does NOT emit a dose-window item when nothing is due at all", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ medsToday: meds({ nextDueOverdue: false }) }),
|
||||
t,
|
||||
@@ -257,6 +258,114 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("emits a calm due item for a takeable dose that is not yet overdue", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
activeCount: 1,
|
||||
scheduledToday: 1,
|
||||
nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(),
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
nextDueMedicationId: "med-1",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const dose = d.worthALook.find((i) => i.kind === "dose_window");
|
||||
expect(dose).toBeDefined();
|
||||
// Reads as informational, NOT as the overdue warning.
|
||||
expect(dose?.status).toBe("info");
|
||||
expect(dose?.title).toBe(t("daily.item.doseWindow.title"));
|
||||
expect(dose?.body).toBe(
|
||||
t("daily.item.doseWindow.bodyNamed", { name: "Ramipril" }),
|
||||
);
|
||||
expect(dose?.actions[0]?.href).toBe("/medications?highlight=med-1");
|
||||
});
|
||||
|
||||
it("reads an overdue dose differently from a merely due one", () => {
|
||||
const due = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(),
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
).worthALook.find((i) => i.kind === "dose_window");
|
||||
const overdue = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
nextDueAt: new Date(NOW.getTime() - 30 * 60_000).toISOString(),
|
||||
nextDueOverdue: true,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
).worthALook.find((i) => i.kind === "dose_window");
|
||||
|
||||
expect(overdue?.status).toBe("warning");
|
||||
expect(overdue?.title).toBe(t("daily.item.doseWindow.overdueTitle"));
|
||||
expect(overdue?.body).toBe(
|
||||
t("daily.item.doseWindow.overdueBodyNamed", { name: "Ramipril" }),
|
||||
);
|
||||
// The two faces must not collapse into the same copy.
|
||||
expect(overdue?.title).not.toBe(due?.title);
|
||||
expect(overdue?.body).not.toBe(due?.body);
|
||||
expect(overdue?.status).not.toBe(due?.status);
|
||||
});
|
||||
|
||||
it("does NOT manufacture a dose item on a day whose doses are all taken", () => {
|
||||
// Every scheduled dose resolved; the next display-due slot is tomorrow.
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
activeCount: 2,
|
||||
scheduledToday: 2,
|
||||
takenToday: 2,
|
||||
nextDueAt: new Date(NOW.getTime() + 22 * 60 * 60_000).toISOString(),
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dose beyond the takeable lead off the rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
nextDueAt: new Date(
|
||||
NOW.getTime() + DOSE_DUE_LOOKAHEAD_MS + 60_000,
|
||||
).toISOString(),
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders neither face for a cached block whose anchor has since passed", () => {
|
||||
// `MedsTodayBlock` contract: a past `nextDueAt` with `nextDueOverdue:
|
||||
// false` means the anchor passed AFTER the block was built.
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
nextDueAt: new Date(NOW.getTime() - 5 * 60_000).toISOString(),
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps one sync-issue item per broken integration", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Daily-briefing grounding gate — what stands in place of a rejected briefing.
|
||||
*
|
||||
* The gate itself is absolute: a briefing carrying a number the server never
|
||||
* computed is discarded, always. These tests pin the DISPOSAL, which used to
|
||||
* fork on why the corrective retry failed — a transport failure fell back to
|
||||
* the previous cached briefing, a content failure left a hole and the reader
|
||||
* lost the paragraph, signals, findings and recommendations over one figure.
|
||||
* Both paths now fall back, and the hard strip survives only when there is
|
||||
* genuinely no previous briefing to show.
|
||||
*
|
||||
* They also pin the widened allow-set: the prompt hands the model the WHOLE
|
||||
* features object, so a figure from any block in it (here a medication
|
||||
* compliance rate) is grounded — while an invented number still is not.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const userUpdate = vi.fn();
|
||||
const resolveProviderChain = vi.fn();
|
||||
const resolveProvider = vi.fn();
|
||||
const runRawCompletionWithFallback = vi.fn();
|
||||
const extractFeatures = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
|
||||
$executeRaw: vi.fn(async () => 0),
|
||||
user: {
|
||||
findUnique: (...a: unknown[]) => findUnique(...a),
|
||||
update: (...a: unknown[]) => userUpdate(...a),
|
||||
},
|
||||
auditLog: { deleteMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
|
||||
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: (...a: unknown[]) =>
|
||||
runRawCompletionWithFallback(...a),
|
||||
}));
|
||||
vi.mock("@/lib/insights/features", () => ({
|
||||
FeaturesPayloadTooLargeError: class extends Error {
|
||||
sizeBytes = 0;
|
||||
},
|
||||
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
|
||||
BRIEFING_FEATURE_WINDOW_DAYS: 400,
|
||||
}));
|
||||
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
|
||||
buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null),
|
||||
buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
vi.mock("@/lib/insights/glp1-plateau", () => ({
|
||||
detectGlp1Plateau: vi.fn(async () => null),
|
||||
buildGlp1PlateauPrompt: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
buildAboutMeInsightBlock: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
|
||||
/**
|
||||
* A signals block (so the gate engages at all) plus a medications block. The
|
||||
* medication figures are server-computed and reach the prompt, but the old
|
||||
* hand-maintained allow-set never admitted them.
|
||||
*/
|
||||
const FEATURES = {
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "weight",
|
||||
latest: 81.4,
|
||||
avg7: 82,
|
||||
avg30: 82.1,
|
||||
deltaVs7: -0.6,
|
||||
deltaVs30: -0.7,
|
||||
spread30: 1.2,
|
||||
recentAnomaly: null,
|
||||
},
|
||||
],
|
||||
medications: [
|
||||
{
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "cardio",
|
||||
compliance7: 100,
|
||||
compliance30: 86,
|
||||
compliance90: 88,
|
||||
streak: 12,
|
||||
missedLast7: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** A number that appears nowhere in the features payload. */
|
||||
const FABRICATED = JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Your weight is down 47.9 kg this week." },
|
||||
});
|
||||
|
||||
/** Cites the medication compliance rate the server itself computed. */
|
||||
const GROUNDED_VIA_MEDS = JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Adherence sits at 86 % over the last 30 days." },
|
||||
});
|
||||
|
||||
const PREVIOUS_PAYLOAD = JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Yesterday's grounded briefing." },
|
||||
recommendations: [],
|
||||
});
|
||||
|
||||
function cachedUser(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: [],
|
||||
// Never equal to the freshly computed hash, so a full generation runs.
|
||||
insightsSnapshotHash: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function completion(content: string) {
|
||||
return {
|
||||
result: { content, tokensUsed: 10, model: "m" },
|
||||
workingProvider: { providerType: "openai" },
|
||||
fallbackHops: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The `dailyBriefing` the generation persisted. */
|
||||
function persistedBriefing(): unknown {
|
||||
const call = userUpdate.mock.calls.at(-1);
|
||||
const text = call?.[0]?.data?.insightsCachedText as string | undefined;
|
||||
if (!text) return undefined;
|
||||
return (JSON.parse(text) as Record<string, unknown>).dailyBriefing;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: {} },
|
||||
]);
|
||||
resolveProvider.mockResolvedValue({ type: "none" });
|
||||
extractFeatures.mockResolvedValue(FEATURES);
|
||||
userUpdate.mockResolvedValue({});
|
||||
findUnique.mockResolvedValue(cachedUser());
|
||||
});
|
||||
|
||||
describe("briefing grounding — disposal", () => {
|
||||
it("keeps the previous briefing when the corrective retry fails on CONTENT", async () => {
|
||||
findUnique.mockResolvedValue(
|
||||
cachedUser({
|
||||
insightsCachedAt: new Date("2026-07-15T03:00:00.000Z"),
|
||||
insightsCachedText: PREVIOUS_PAYLOAD,
|
||||
}),
|
||||
);
|
||||
// Both passes carry the same fabricated figure: a content failure.
|
||||
runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED));
|
||||
|
||||
await generateComprehensiveInsight("u1", { locale: "en" });
|
||||
|
||||
// The fabricated text never persists...
|
||||
expect(JSON.stringify(persistedBriefing())).not.toContain("47.9");
|
||||
// ...and the reader keeps yesterday's grounded briefing rather than a hole.
|
||||
expect(persistedBriefing()).toEqual({
|
||||
paragraph: "Yesterday's grounded briefing.",
|
||||
});
|
||||
});
|
||||
|
||||
it("still strips hard when there is no previous briefing to stand in", async () => {
|
||||
runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED));
|
||||
|
||||
await generateComprehensiveInsight("u1", { locale: "en" });
|
||||
|
||||
expect(persistedBriefing()).toBeNull();
|
||||
});
|
||||
|
||||
it("admits a figure from a features block the old allow-set never listed", async () => {
|
||||
runRawCompletionWithFallback.mockResolvedValue(
|
||||
completion(GROUNDED_VIA_MEDS),
|
||||
);
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "en" });
|
||||
|
||||
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
|
||||
// No corrective retry: the compliance rate is grounded, so the gate is
|
||||
// satisfied on the first pass and the briefing survives intact.
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(persistedBriefing()).toEqual({
|
||||
paragraph: "Adherence sits at 86 % over the last 30 days.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user