diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts index 7b5a539d9..2d9614684 100644 --- a/src/__tests__/dynamic-key-exhaustiveness.test.ts +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -18,6 +18,8 @@ import { allergyStatusEnum, } from "@/lib/validations/allergy"; import { familyRelationshipEnum } from "@/lib/validations/family-history"; +import { encounterKindEnum } from "@/lib/validations/encounters"; +import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; /** @@ -131,6 +133,32 @@ const REGISTRY: readonly KeySpace[] = [ { prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS }, ]; +/** + * The second arm: key spaces whose member → key mapping is NOT `prefix + + * member`, so the registry above cannot express them. The mapping function + * itself is the source, and the guard runs the enum through it. + * + * `encounters.kind` is why this arm exists. The enum member is `ROUTINE`, the + * bundle leaf is `routine`, and three surfaces — the doctor-report PDF's visit + * table, the daily digest's upcoming-visit line and the clinician share view — + * built the key by interpolation. All three printed `encounters.kind.ROUTINE` + * verbatim: into a clinical document, onto a lock screen, and onto a page a + * doctor opens. The prefix-plus-member form of this guard would have caught it + * on the day the interpolation was written, but only if the mapping had been + * expressible; it is not, so it was never registered and nothing else looked. + */ +const RESOLVED_REGISTRY: readonly { + /** What the space is, for the failure message. */ + name: string; + /** Every key the resolver can return, over the whole source enum. */ + keys: readonly string[]; +}[] = [ + { + name: "encounters.kind via encounterKindLabelKey", + keys: encounterKindEnum.options.map(encounterKindLabelKey), + }, +]; + describe("dynamic-key exhaustiveness (enum-derived)", () => { // Six shipped locales. An empty read of `messages/` would register zero // per-locale cases and the suite would report green over nothing. @@ -158,6 +186,22 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { } }); + it("every resolver-mapped key space returns a distinct key per member", () => { + for (const space of RESOLVED_REGISTRY) { + expect( + space.keys.length, + `${space.name} resolved to an empty key set — source import broke`, + ).toBeGreaterThan(0); + // A resolver whose default arm swallowed a member would collapse two + // enum values onto one key and the per-locale checks below would still + // pass, because the surviving key resolves fine. + expect( + new Set(space.keys).size, + `${space.name} maps two enum members onto one key`, + ).toBe(space.keys.length); + } + }); + for (const { locale, messages } of LOCALES) { for (const space of REGISTRY) { for (const member of space.members) { @@ -171,5 +215,16 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { }); } } + for (const space of RESOLVED_REGISTRY) { + for (const key of space.keys) { + it(`resolves ${key} in ${locale} (${space.name})`, () => { + const value = resolveKey(messages, key); + expect(value, `${key} missing in ${locale}.json`).toBeTypeOf( + "string", + ); + expect((value ?? "").trim().length).toBeGreaterThan(0); + }); + } + } } }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 9d14cc933..f453d1f54 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -19,8 +19,10 @@ * provisional→final refresh (sleep-arrival debounce) is S4's work; it will * populate the same two fields the DTO already carries, so no consumer changes. */ +import type { EncounterKind } from "@/generated/prisma/client"; import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; import type { ArrivalKind } from "@/lib/arrivals/types"; +import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; @@ -560,7 +562,12 @@ function buildUpcomingVisitItem( // Today vs tomorrow is decided on the SAME instants the read window used, so // a visit admitted by the query can never be described as neither. const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000); - const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`); + // Through the key resolver, never `encounters.kind.${kind}`: the enum is + // `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put + // raw dot notation on a lock screen. + const what = + visit.practitionerName ?? + t(encounterKindLabelKey(visit.kind as EncounterKind)); return { kind: "upcoming_visit", title: t("daily.item.upcomingVisit.title"), diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index a082cf282..6c1087ead 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -1,5 +1,7 @@ import type { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import type { EncounterKind } from "@/generated/prisma/client"; +import { encounterKindLabelKey } from "../encounters/kind-label"; import { classifyReferenceRange, formatReferenceRange, @@ -239,7 +241,10 @@ export function buildClinicalRecordsNotesSection( [visit.practitionerName, visit.practitionerSpecialty] .filter(Boolean) .join(" · ") || "—", - t(`encounters.kind.${visit.kind}`), + // Through the key resolver: the enum is `ROUTINE` and the bundle leaf is + // `routine`, so the interpolated key printed raw dot notation into the + // clinical document. + t(encounterKindLabelKey(visit.kind as EncounterKind)), visit.reason ?? "—", visit.outcome ?? "—", visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—", diff --git a/src/lib/encounters/kind-label.ts b/src/lib/encounters/kind-label.ts index ed31a78d2..3e664ad08 100644 --- a/src/lib/encounters/kind-label.ts +++ b/src/lib/encounters/kind-label.ts @@ -45,3 +45,35 @@ export function encounterKindLabel( return t("encounters.kind.other"); } } + +/** + * The same mapping as a KEY, for callers that already hold a translator and + * cannot reach for a second one — the doctor-report PDF, the daily digest and + * the clinician share view. + * + * Those three built the key by interpolation (`encounters.kind.${kind}`), and + * the enum is `ROUTINE` while the bundle leaf is `routine`, so all three + * printed raw dot notation into a clinical document. The interpolated form was + * invisible to `i18n-call-site-coverage`, which is the reason it survived: the + * key it asked for never existed and nothing said so. + */ +export function encounterKindLabelKey(kind: EncounterKind): string { + switch (kind) { + case "ROUTINE": + return "encounters.kind.routine"; + case "ACUTE": + return "encounters.kind.acute"; + case "SPECIALIST": + return "encounters.kind.specialist"; + case "PREVENTIVE": + return "encounters.kind.preventive"; + case "EMERGENCY": + return "encounters.kind.emergency"; + case "HOSPITAL": + return "encounters.kind.hospital"; + case "THERAPY": + return "encounters.kind.therapy"; + default: + return "encounters.kind.other"; + } +}