From 88aa5ccacdb8c95ca2c9aae5817eb0c810c36936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 01:27:00 +0200 Subject: [PATCH] test(anamnesis): pin derived values, AUDIT-C wording and cut-offs, and the per-section prompt gate --- .../profile-ai-section-gating.test.ts | 85 ++++++++ .../derived/__tests__/sleep-rhythm.test.ts | 21 +- .../mental-health/__tests__/audit-c.test.ts | 113 ++++++++++ .../profile/__tests__/life-derived.test.ts | 205 ++++++++++++++++++ .../__tests__/life-circumstances.test.ts | 114 ++++++++++ 5 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/lib/mental-health/__tests__/audit-c.test.ts create mode 100644 src/lib/profile/__tests__/life-derived.test.ts create mode 100644 src/lib/validations/__tests__/life-circumstances.test.ts diff --git a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts index e78c906e3..2a3b575c1 100644 --- a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts +++ b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts @@ -169,4 +169,89 @@ describe("profile section access in AI prompt paths", () => { expect(mocks.factFindMany).toHaveBeenCalledOnce(); expect(text).toContain("Penicillin"); }); + + describe("life circumstances (v1.43)", () => { + const habitsBytes = new Uint8Array([20]); + const goalsBytes = new Uint8Array([21]); + const validFrom = new Date("2026-03-01T00:00:00.000Z"); + + beforeEach(() => { + // Answer with exactly the kinds the query asked for, the way the + // database would, so a kind left out of the query is never returned. + mocks.factFindMany.mockImplementation( + ({ where }: { where: { kind: { in: string[] } } }) => + Promise.resolve( + [ + { kind: "HABITS", valueEncrypted: habitsBytes, validFrom }, + { kind: "HEALTH_GOALS", valueEncrypted: goalsBytes, validFrom }, + ].filter((row) => where.kind.in.includes(row.kind)), + ), + ); + mocks.decrypt.mockImplementation((bytes: Uint8Array) => { + if (bytes[0] === 20) { + return JSON.stringify({ + schema: 1, + napRegularly: null, + snoringReported: true, + dietPattern: "vegan", + dietSpecifics: [], + lateMealsPerWeek: null, + fruitVegPortionsPerDay: null, + caffeineDrinksPerDay: 4, + latestCaffeine: "after_18", + }); + } + if (bytes[0] === 21) { + return JSON.stringify({ schema: 1, goals: ["sleep"] }); + } + return "unknown"; + }); + }); + + it("never decrypts a section without its switch", async () => { + mockIncluded(["GOALS"]); + const text = await getSelfContextTextForUser("user-1", "en"); + + const decrypted = mocks.decrypt.mock.calls.map(([bytes]) => bytes); + expect(decrypted).not.toContain(habitsBytes); + expect(decrypted).toContain(goalsBytes); + const query = mocks.factFindMany.mock.calls[0][0] as { + where: { kind: { in: string[] } }; + }; + expect(query.where.kind.in).not.toContain("HABITS"); + expect(text).toContain("Health goals: sleep"); + expect(text).toContain("self-report, valid since 2026-03"); + expect(text).not.toContain("vegan"); + }); + + it("adds the section once it is switched on", async () => { + mockIncluded(["HABITS"]); + const text = await getSelfContextTextForUser("user-1", "en"); + expect(mocks.decrypt.mock.calls.map(([bytes]) => bytes)).toContain( + habitsBytes, + ); + expect(text).toContain("Diet pattern: vegan"); + expect(text).toContain("Latest caffeine: after 18"); + }); + + it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => { + mocks.auditFindFirst.mockResolvedValue({ + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "de", + }); + mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" }); + + mockIncluded(["ALCOHOL_PATTERN"]); + const text = await getSelfContextTextForUser("user-1", "de"); + expect(text).toContain( + "AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht", + ); + + mocks.auditFindFirst.mockClear(); + mockIncluded(["GOALS"]); + await getSelfContextTextForUser("user-1", "de"); + expect(mocks.auditFindFirst).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts index c518ee56a..4d0e98922 100644 --- a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts @@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock the DB so the builder is hermetic — we control the rows. const findMany = vi.fn(); vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } }, + prisma: { + measurement: { findMany: (...a: unknown[]) => findMany(...a) }, + // v1.43 — the free-weekday answers; none here, so the weekend default. + healthProfileFactRevision: { findMany: vi.fn(async () => []) }, + }, })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), @@ -146,6 +150,21 @@ describe("computeSleepRhythmFromNights window cap", () => { // rhythm route (42-day read) both depend on to render identical values. expect(oneYear.chronotype).toEqual(sixWeeks.chronotype); }); + + it("counts the answered free weekdays as free nights instead of the weekend (v1.43)", () => { + const nights = trailingNights(42); + const weekend = computeSleepRhythmFromNights(nights, 420); + const tueWed = computeSleepRhythmFromNights(nights, 420, "COMPUTED", [ + { + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: null, + days: [2, 3, 4], + }, + ]); + expect(weekend.chronotype.freeNightsCounted).toBe(12); + // Three free weekdays over six weeks: 18 free nights, not the weekend 12. + expect(tueWed.chronotype.freeNightsCounted).toBe(18); + }); }); describe("computeAverageSleep", () => { diff --git a/src/lib/mental-health/__tests__/audit-c.test.ts b/src/lib/mental-health/__tests__/audit-c.test.ts new file mode 100644 index 000000000..2be2dbd59 --- /dev/null +++ b/src/lib/mental-health/__tests__/audit-c.test.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + auditCCutoff, + auditCInstrument, + auditCTotal, + isValidatedAuditCLocale, + normaliseAuditCLocale, +} from "../audit-c"; +import { AUDIT_C_ITEMS, AUDIT_C_STANDARD_DRINK } from "../instruments"; + +describe("AUDIT-C cut-offs", () => { + it("German entry, woman, sum 4: at the S3 cut-off of 4", () => { + const cutoff = auditCCutoff("de", "FEMALE", "de"); + expect(cutoff).toMatchObject({ + value: 4, + sex: "female", + source: "S3_ALCOHOL_DE", + }); + expect(cutoff?.sourceLabel).toContain("Deutschland"); + expect(auditCTotal([2, 1, 1]) >= cutoff!.value).toBe(true); + }); + + it("German men 5, Korean 5 and 4, everyone else Bradley 4 and 3", () => { + expect(auditCCutoff("de", "MALE", "de")?.value).toBe(5); + expect(auditCCutoff("ko", "MALE", "ko")).toMatchObject({ + value: 5, + source: "KNHANES_KR", + }); + expect(auditCCutoff("ko", "FEMALE", "ko")?.value).toBe(4); + expect(auditCCutoff("en", "MALE", "en")).toMatchObject({ + value: 4, + source: "BRADLEY_2007", + }); + expect(auditCCutoff("fr", "FEMALE", "fr")?.value).toBe(3); + }); + + it("has no cut-off without a binary sex in the profile", () => { + expect(auditCCutoff("de", null, "de")).toBeNull(); + expect(auditCCutoff("de", "OTHER", "de")).toBeNull(); + }); + + it("names the country in the reader's language", () => { + expect(auditCCutoff("en", "MALE", "es")?.sourceLabel).toContain( + "Estados Unidos", + ); + }); +}); + +describe("AUDIT-C instrument", () => { + it("serves the validated wording in the account language", () => { + const de = auditCInstrument("de"); + expect(de.locale).toBe("de"); + expect(de.validatedLocale).toBe(true); + expect(de.items).toHaveLength(3); + expect(de.items[0].options.map((o) => o.value)).toEqual([0, 1, 2, 3, 4]); + expect(de.attribution).toBe( + "As a WHO-approved instrument, the AUDIT is in the public domain.", + ); + }); + + it("falls back to the WHO English items, never a translation of its own", () => { + for (const locale of ["fr", "es", "it"]) { + const instrument = auditCInstrument(locale); + expect(instrument.locale).toBe("en"); + expect(instrument.validatedLocale).toBe(false); + expect(instrument.items[0].stem).toBe(AUDIT_C_ITEMS.en.items[0].stem); + // The help text is still in the account language. + expect(instrument.standardDrink).toBe(AUDIT_C_STANDARD_DRINK[locale]); + } + }); + + it("knows which languages are validated", () => { + expect(["de", "en", "ko", "pl"].every(isValidatedAuditCLocale)).toBe(true); + expect(["es", "fr", "it"].some(isValidatedAuditCLocale)).toBe(false); + expect(normaliseAuditCLocale("pt-BR")).toBe("en"); + expect(normaliseAuditCLocale("de-AT")).toBe("de"); + }); + + it("carries a standard-drink sentence for every app language", () => { + for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) { + expect(AUDIT_C_STANDARD_DRINK[locale]?.length).toBeGreaterThan(20); + } + }); + + /** + * The validated wording must not change: re-wording invalidates the score, + * and the WHO licence forbids material changes. The hashes pin the exact + * characters per language; a change here needs a new primary source, and + * the source note in `instruments.ts` changes with it. + */ + it("keeps the validated wording byte for byte", () => { + const hashes = Object.fromEntries( + Object.entries(AUDIT_C_ITEMS).map(([locale, wording]) => [ + locale, + createHash("sha256") + .update(JSON.stringify(wording.items)) + .digest("hex") + .slice(0, 16), + ]), + ); + expect(hashes).toEqual(PINNED); + }); +}); + +const PINNED: Record = { + de: "4c072c57581dfa60", + en: "7d91058682d56051", + ko: "5617030281f6f963", + pl: "1a06ca242af64e54", +}; diff --git a/src/lib/profile/__tests__/life-derived.test.ts b/src/lib/profile/__tests__/life-derived.test.ts new file mode 100644 index 000000000..efb1a04c1 --- /dev/null +++ b/src/lib/profile/__tests__/life-derived.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { + activityMinutesPerWeek, + computePackYears, + dayTypeForNight, + isReviewDue, + screeningHints, + type ScreeningInputs, +} from "../life-derived"; +import type { TobaccoDetailValue } from "@/lib/validations/life-circumstances"; + +const NOW = new Date("2026-10-16T12:00:00.000Z"); + +function tobacco(over: Partial = {}): TobaccoDetailValue { + return { + schema: 1, + products: ["cigarettes"], + cigarettesPerDay: 20, + startYear: 1990, + quitMonth: null, + secondhandSmoke: null, + ...over, + }; +} + +describe("pack-years", () => { + it("20 a day from 1990 to 2020 is 30.0", () => { + expect( + computePackYears("FORMER", tobacco({ quitMonth: "2020-06" }), NOW), + ).toEqual({ value: 30, status: "computed" }); + }); + + it("runs to the current year for a current smoker, to one decimal", () => { + expect( + computePackYears("CURRENT", tobacco({ cigarettesPerDay: 15 }), NOW), + ).toEqual({ value: 27, status: "computed" }); + expect( + computePackYears( + "CURRENT", + tobacco({ cigarettesPerDay: 7, startYear: 2010 }), + NOW, + ), + ).toEqual({ value: 5.6, status: "computed" }); + }); + + it("is missing without a start year, never estimated", () => { + expect( + computePackYears("CURRENT", tobacco({ startYear: null }), NOW), + ).toEqual({ value: null, status: "missing_inputs" }); + expect(computePackYears("CURRENT", null, NOW).status).toBe( + "missing_inputs", + ); + }); + + it("needs the quit date of a former smoker", () => { + expect(computePackYears("FORMER", tobacco(), NOW).status).toBe( + "missing_inputs", + ); + }); + + it("does not apply to someone who never smoked or smokes no cigarettes", () => { + expect(computePackYears("NEVER", tobacco(), NOW).status).toBe( + "not_applicable", + ); + expect( + computePackYears( + "CURRENT", + tobacco({ products: ["e_cigarette"], cigarettesPerDay: null }), + NOW, + ).status, + ).toBe("not_applicable"); + }); +}); + +describe("activity minutes", () => { + it("multiplies the two Exercise Vital Sign answers", () => { + expect(activityMinutesPerWeek(5, 30)).toEqual({ + value: 150, + meetsWho150: true, + }); + expect(activityMinutesPerWeek(2, 30)).toEqual({ + value: 60, + meetsWho150: false, + }); + }); + + it("stays null with either answer missing", () => { + expect(activityMinutesPerWeek(null, 30)).toEqual({ + value: null, + meetsWho150: null, + }); + }); +}); + +describe("free weekdays per night", () => { + // 2026-10-13 is a Tuesday, 2026-10-17 a Saturday. + const spans = [ + { + validFrom: new Date("2026-10-01T00:00:00.000Z"), + validUntil: null, + days: [2, 3], + }, + ]; + + it("reads a night against the answer in force: Tuesday and Wednesday free", () => { + expect(dayTypeForNight("2026-10-13", spans)).toBe("free"); + expect(dayTypeForNight("2026-10-14", spans)).toBe("free"); + expect(dayTypeForNight("2026-10-17", spans)).toBe("work"); + }); + + it("keeps the weekend default for nights before the answer", () => { + expect(dayTypeForNight("2026-09-26", spans)).toBe("free"); + expect(dayTypeForNight("2026-09-29", spans)).toBe("work"); + }); + + it("is the weekend default with no answer at all", () => { + expect(dayTypeForNight("2026-10-17", [])).toBe("free"); + expect(dayTypeForNight("2026-10-13", [])).toBe("work"); + }); +}); + +describe("review due", () => { + it("after a year, and never without an answer", () => { + expect(isReviewDue(new Date("2025-10-15T00:00:00.000Z"), NOW)).toBe(true); + expect(isReviewDue(new Date("2026-01-01T00:00:00.000Z"), NOW)).toBe(false); + expect(isReviewDue(null, NOW)).toBe(false); + }); +}); + +describe("screening hints", () => { + function input(over: Partial = {}): ScreeningInputs { + const detail = over.detail ?? tobacco({ startYear: 1985 }); + return { + ageYears: 60, + sex: "MALE", + smokingStatus: "CURRENT", + detail, + packYears: computePackYears(over.smokingStatus ?? "CURRENT", detail, NOW), + locale: "de", + now: NOW, + ...over, + }; + } + + it("suggests the German low-dose CT with the G-BA source and country", () => { + const [lung] = screeningHints(input()); + expect(lung.id).toBe("lung_ldct"); + expect(lung.status).toBe("suggested"); + expect(lung.region).toBe("DE"); + expect(lung.sourceLabel).toContain("G-BA"); + expect(lung.sourceLabel).toContain("Deutschland"); + expect(lung.sourceUrl).toMatch(/^https:\/\/www\.g-ba\.de\//); + }); + + it("names the failing criteria", () => { + const [lung] = screeningHints( + input({ + ageYears: 45, + detail: tobacco({ startYear: 2015, cigarettesPerDay: 10 }), + }), + ); + expect(lung.status).toBe("not_eligible"); + expect(lung.reasonKeys).toEqual( + expect.arrayContaining([ + "age_out_of_range", + "pack_years_below_15", + "smoked_years_below_25", + ]), + ); + }); + + it("says unknown, not no, when the birth date is missing", () => { + const [lung] = screeningHints(input({ ageYears: null })); + expect(lung.status).toBe("unknown"); + expect(lung.reasonKeys).toContain("missing_birth_date"); + }); + + it("uses the US rules outside German, with the country in the account language", () => { + const [lung, aaa] = screeningHints(input({ locale: "fr" })); + expect(lung.region).toBe("US"); + expect(lung.sourceLabel).toContain("USPSTF"); + expect(lung.sourceLabel).toContain("États-Unis"); + expect(aaa.status).toBe("not_eligible"); + expect(aaa.reasonKeys).toContain("age_out_of_range"); + }); + + it("offers the aorta ultrasound to every man from 65 in Germany, not to women", () => { + expect(screeningHints(input({ ageYears: 66 }))[1].status).toBe("suggested"); + const women = screeningHints(input({ ageYears: 66, sex: "FEMALE" }))[1]; + expect(women.status).toBe("not_eligible"); + expect(women.reasonKeys).toContain("sex_not_male"); + expect(screeningHints(input({ ageYears: 66, sex: null }))[1].status).toBe( + "unknown", + ); + }); + + it("asks the US rule for a smoking history", () => { + const never = screeningHints( + input({ ageYears: 70, locale: "en", smokingStatus: "NEVER" }), + ); + expect(never[0].reasonKeys).toEqual(["never_smoked"]); + expect(never[1].reasonKeys).toContain("never_smoked"); + }); +}); diff --git a/src/lib/validations/__tests__/life-circumstances.test.ts b/src/lib/validations/__tests__/life-circumstances.test.ts new file mode 100644 index 000000000..f04e1066e --- /dev/null +++ b/src/lib/validations/__tests__/life-circumstances.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + auditCRequestSchema, + factWriteSchema, + isStoredFactPlaintext, + lifeAiSectionsPatchSchema, +} from "../life-circumstances"; + +const tobacco = { + schema: 1, + products: ["cigarettes"], + cigarettesPerDay: 20, + startYear: 1990, + quitMonth: "2020-06", + secondhandSmoke: null, +}; + +describe("fact writes (v1.43)", () => { + it("still takes the three closed kinds with their string value", () => { + expect( + factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "FORMER" }) + .success, + ).toBe(true); + expect( + factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "ROTATING" }) + .success, + ).toBe(false); + }); + + it("takes a life kind with its object value and an optional day", () => { + const parsed = factWriteSchema.safeParse({ + kind: "TOBACCO_DETAIL", + value: tobacco, + validFrom: "2026-03-01", + }); + expect(parsed.success).toBe(true); + }); + + it("refuses cigarettes per day without cigarettes, and unknown keys", () => { + expect( + factWriteSchema.safeParse({ + kind: "TOBACCO_DETAIL", + value: { ...tobacco, products: ["e_cigarette"] }, + }).success, + ).toBe(false); + expect( + factWriteSchema.safeParse({ + kind: "TOBACCO_DETAIL", + value: { ...tobacco, packYears: 30 }, + }).success, + ).toBe(false); + }); + + it("caps the goals at three and the sitting hours at half-hour steps", () => { + expect( + factWriteSchema.safeParse({ + kind: "HEALTH_GOALS", + value: { + schema: 1, + goals: ["sleep", "weight", "fitness", "blood_sugar"], + }, + }).success, + ).toBe(false); + expect( + factWriteSchema.safeParse({ + kind: "ACTIVITY_HABIT", + value: { + schema: 1, + moderateDaysPerWeek: 3, + moderateMinutesPerDay: 30, + sittingHoursWorkday: 7.25, + }, + }).success, + ).toBe(false); + }); + + it("checks a backup's plaintext the way a write checks the value", () => { + expect(isStoredFactPlaintext("SHIFT_SCHEDULE", "ROTATING")).toBe(true); + expect( + isStoredFactPlaintext("TOBACCO_DETAIL", JSON.stringify(tobacco)), + ).toBe(true); + expect(isStoredFactPlaintext("TOBACCO_DETAIL", "{")).toBe(false); + expect(isStoredFactPlaintext("HABITS", JSON.stringify(tobacco))).toBe( + false, + ); + }); +}); + +describe("AI switches and AUDIT-C requests", () => { + it("needs at least one section", () => { + expect(lifeAiSectionsPatchSchema.safeParse({ sections: {} }).success).toBe( + false, + ); + expect( + lifeAiSectionsPatchSchema.safeParse({ sections: { HABITS: true } }) + .success, + ).toBe(true); + }); + + it("takes three items from 0 to 4", () => { + expect( + auditCRequestSchema.safeParse({ items: [0, 4, 2], source: "WEB" }) + .success, + ).toBe(true); + expect( + auditCRequestSchema.safeParse({ items: [0, 5, 2], source: "WEB" }) + .success, + ).toBe(false); + expect( + auditCRequestSchema.safeParse({ items: [0, 1], source: "WEB" }).success, + ).toBe(false); + }); +});