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", + ); + }); +});