test(anamnesis): pin derived values, AUDIT-C wording and cut-offs, and the per-section prompt gate

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 01:27:00 +02:00
1 parent e5860c9f41
commit 88aa5ccacd
5 files changed
+537 -1

No files matched your search

@@ -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();
});
});
});
@@ -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", () => {
@@ -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<string, string> = {
de: "4c072c57581dfa60",
en: "7d91058682d56051",
ko: "5617030281f6f963",
pl: "1a06ca242af64e54",
};
@@ -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> = {}): 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> = {}): 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");
});
});
@@ -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);
});
});