From 7ee8bc8134ff64d87f00d1c8f81bc3338ba088e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 12:08:38 +0200 Subject: [PATCH] Fail the suite when a source enum member has no label The four raw values in the sources ladder shipped because nothing checks the catalogues against the enum they render. The new guard reads MeasurementSource and IntakeSource out of prisma/schema.prisma, not out of a copy, and fails naming every member no catalogue labels; it also asserts the key each entry names resolves in all seven bundles, since a key that resolves nowhere renders no better than the enum value. Run red against the tree before the labels landed, it reported seven unlabelled measurement sources per catalogue and one unlabelled intake source. The schema reader now carries each enum's declared members so the guard can read them. --- src/__tests__/helpers/prisma-schema-names.ts | 16 +- .../measurement-source-label-coverage.test.ts | 164 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/measurement-source-label-coverage.test.ts diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts index 310ecaaf3..fd7b5b448 100644 --- a/src/__tests__/helpers/prisma-schema-names.ts +++ b/src/__tests__/helpers/prisma-schema-names.ts @@ -29,6 +29,8 @@ export interface DeclaredEnum { mappedName: string | null; /** The name Prisma puts in the SQL — `@@map` when present, else `name`. */ physicalName: string; + /** The declared members, in declaration order. */ + values: string[]; } export interface DeclaredField { @@ -87,7 +89,19 @@ export function readDeclaredSchema( const clean = stripComments(body); const mappedName = BLOCK_MAP.exec(clean)?.[1] ?? null; if (kind === "enum") { - enums.push({ name, mappedName, physicalName: mappedName ?? name }); + // A member is a bare identifier on its own line; `@@map` and any + // trailing comment are already gone (block attributes start with `@`, + // comment lines were stripped above). + const values = clean + .split("\n") + .map((line) => line.replace(/\/\/.*$/, "").trim()) + .filter((line) => /^\w+$/.test(line)); + enums.push({ + name, + mappedName, + physicalName: mappedName ?? name, + values, + }); } else { rawModels.push({ name, body: clean }); } diff --git a/src/__tests__/measurement-source-label-coverage.test.ts b/src/__tests__/measurement-source-label-coverage.test.ts new file mode 100644 index 000000000..ce1cb991c --- /dev/null +++ b/src/__tests__/measurement-source-label-coverage.test.ts @@ -0,0 +1,164 @@ +/** + * Every provenance enum member a surface renders must have a label. + * + * Settings → Sources listed `GOOGLE_HEALTH`, `OURA`, `POLAR` and `STRAVA` by + * their internal name for a full release line: the label catalogue was + * written when the enum was seven members long, the render path falls back + * to the raw value, and nothing failed when a provider landed without its + * label. Same fall-through in the measurement list (the source facet offers + * every enum member) and on the medication intake caption, where the v1.28 + * Apple Health dose sync reads `via APPLE_HEALTH`. + * + * The member list comes from `prisma/schema.prisma` — the enum itself, not a + * copy of it — so the next provider cannot ship without its label. Each + * catalogue is checked in two steps: the map has an entry for the member, + * and the key that entry names resolves to a real string in EVERY locale + * bundle (a key that resolves nowhere renders as the key, which is no better + * than the enum value it replaced). + * + * Mutation check: drop a member from either map and this goes red naming it; + * point one at a key the bundles do not carry and the resolve arm goes red. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { + INTAKE_SOURCE_LABEL_KEYS, + INTAKE_SOURCE_VALUES, + MEASUREMENT_SOURCE_LIST_LABEL_KEYS, + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS, +} from "@/lib/i18n/source-labels"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import { deviceTypeEnum } from "@/lib/validations/source-priority"; +import { readDeclaredSchema } from "./helpers/prisma-schema-names"; + +const ROOT = join(__dirname, "../.."); +const MESSAGES_DIR = join(ROOT, "messages"); + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + locale: f.replace(/\.json$/, ""), + bundle: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as unknown, + })); + +function resolveKey(bundle: unknown, key: string): string | undefined { + let node: unknown = bundle; + for (const segment of key.split(".")) { + if (node == null || typeof node !== "object") return undefined; + node = (node as Record)[segment]; + } + return typeof node === "string" && node !== "" ? node : undefined; +} + +const declaredEnums = readDeclaredSchema().enums; + +function membersOf(enumName: string): string[] { + const declared = declaredEnums.find((e) => e.name === enumName); + if (!declared) throw new Error(`${enumName} is not declared in the schema`); + return declared.values; +} + +/** + * Assert the catalogue covers the enum and that every key it names resolves. + * `floor` fails the whole check if the schema reader stopped matching — an + * empty member list would otherwise pass every assertion below vacuously. + */ +function expectLabelled( + members: string[], + floor: number, + catalogue: Record, + catalogueName: string, +): void { + expect( + members.length, + `Read only ${members.length} members from prisma/schema.prisma — the ` + + `schema reader stopped matching, so this guard proves nothing.`, + ).toBeGreaterThanOrEqual(floor); + + const unlabelled = members.filter((member) => !catalogue[member]); + expect( + unlabelled, + `${catalogueName} has no entry for these enum members, so the surface ` + + `renders their raw enum value:\n` + + unlabelled.map((m) => ` ${m}`).join("\n"), + ).toEqual([]); + + const unresolved: string[] = []; + for (const member of members) { + const key = catalogue[member]; + if (!key) continue; + for (const { locale, bundle } of LOCALES) { + if (resolveKey(bundle, key) === undefined) { + unresolved.push(`${member} → ${key} (${locale})`); + } + } + } + expect( + unresolved, + `${catalogueName} names keys that no message bundle carries:\n` + + unresolved.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); +} + +describe("source label coverage", () => { + it("has more than one locale bundle to check against", () => { + expect(LOCALES.length).toBeGreaterThanOrEqual(7); + }); + + it("keeps the Zod source enum in step with the schema", () => { + expect([...measurementSourceEnum.options].sort()).toEqual( + [...membersOf("MeasurementSource")].sort(), + ); + }); + + it("labels every MeasurementSource in Settings → Sources", () => { + expectLabelled( + membersOf("MeasurementSource"), + 14, + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS, + "The settings source catalogue", + ); + }); + + it("labels every MeasurementSource in the measurement list", () => { + expectLabelled( + membersOf("MeasurementSource"), + 14, + MEASUREMENT_SOURCE_LIST_LABEL_KEYS, + "The measurement-list source catalogue", + ); + }); + + it("labels every IntakeSource on the intake history", () => { + const members = membersOf("IntakeSource"); + expect([...INTAKE_SOURCE_VALUES].sort()).toEqual([...members].sort()); + expectLabelled( + members, + 5, + INTAKE_SOURCE_LABEL_KEYS, + "The intake source catalogue", + ); + }); + + /** + * The other enum the sources section renders. It is already closed by its + * own type (`Record` off the Zod enum), so this arm is + * the bundle half only — the keys have to exist for the type to help. + */ + it("labels every device type in Settings → Sources", () => { + const catalogue = Object.fromEntries( + deviceTypeEnum.options.map((d) => [ + d, + `settings.sections.sources.deviceLabels.${d}`, + ]), + ); + expectLabelled( + [...deviceTypeEnum.options], + 7, + catalogue, + "The device catalogue", + ); + }); +});