mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
illness.vital.FUNCTIONAL_IMPACT (the recovery-driver track that wins ties in the illness insights card's driver selection) was referenced but absent from all six locale bundles, rendering its raw dotted key on a live surface — the same dynamic-key seam that let the WHO-5/SCI reminder labels ship untranslated. Added the label in de/en/es/fr/it/pl. Closed the class structurally: a new enum-driven dynamic-key-exhaustiveness test imports the source enum/const for each high-value dynamic key space (illness scan types + functional-impact, illness type/lifecycle, allergy + family-relationship records enums, the mental-health instrument slugs — the WHO-5/SCI seam) and asserts every member's built key resolves non-empty in all six locales, so a missing translation fails CI instead of reaching a user. Fallback-protected spaces (Strava raw sport_type) and structurally-unreachable keys are documented as excluded. Removed one confirmed-orphan key (illness.vital.SKIN_TEMPERATURE, zero call sites).
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { readFileSync, readdirSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
|
||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||
import {
|
||
ILLNESS_SCAN_TYPES,
|
||
FUNCTIONAL_IMPACT_RETURN_KEY,
|
||
} from "@/lib/illness/correlation";
|
||
import {
|
||
illnessTypeEnum,
|
||
illnessLifecycleEnum,
|
||
} from "@/lib/validations/illness";
|
||
import {
|
||
allergyCategoryEnum,
|
||
allergyTypeEnum,
|
||
allergySeverityEnum,
|
||
allergyStatusEnum,
|
||
} from "@/lib/validations/allergy";
|
||
import { familyRelationshipEnum } from "@/lib/validations/family-history";
|
||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||
|
||
/**
|
||
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
|
||
*
|
||
* The three existing i18n guards each see one face of the problem and miss the
|
||
* fourth:
|
||
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
|
||
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
|
||
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
|
||
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
|
||
* from the subtree is invisible.
|
||
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
|
||
* fires when a key is present in SOME locale but not all.
|
||
*
|
||
* None of them assert the class that actually ships broken: "for enum E, every
|
||
* member's dynamically-built key resolves, non-empty, in every locale." Because
|
||
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
|
||
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
|
||
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
|
||
* (v1.28.41) ship absent from every bundle.
|
||
*
|
||
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
|
||
* every `prefix + member` resolves. Adding an enum member without a bundle key
|
||
* fails CI by construction — no hand-maintained floor to drift.
|
||
*
|
||
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
|
||
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
|
||
* - illness.type.* illnessTypeEnum
|
||
* - illness.lifecycle.* illnessLifecycleEnum
|
||
* - records.allergies.category.* allergyCategoryEnum
|
||
* - records.allergies.type.* allergyTypeEnum
|
||
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
|
||
* - records.allergies.status.* allergyStatusEnum
|
||
* - records.family.relationship.* familyRelationshipEnum
|
||
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
|
||
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
|
||
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
|
||
*
|
||
* ── Deliberately EXCLUDED (would produce false positives) ──
|
||
* - `insights.workouts.sport.*` — the Strava render path stores a raw
|
||
* `sport_type` and the render site legitimately falls back to that raw
|
||
* string, so a "missing" key is a graceful degradation, not a broken token
|
||
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
|
||
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
|
||
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
|
||
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
|
||
* Registering them would demand bundle keys for code paths users never hit.
|
||
* - `measurementReminders.types.*` — already exhaustively covered by
|
||
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
|
||
* const lives in a client component, not a lib-level export.
|
||
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
|
||
* enums the audit flags as candidates; left for a follow-up extension. Add
|
||
* them here once their exact prefix→member mapping is confirmed 1:1 (the
|
||
* cycle cervix* prefixes and catalog slug maps need per-space verification
|
||
* to stay false-positive-free).
|
||
*/
|
||
|
||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||
|
||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||
.filter((f) => f.endsWith(".json"))
|
||
.map((f) => ({
|
||
locale: f.replace(/\.json$/, ""),
|
||
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
|
||
string,
|
||
unknown
|
||
>,
|
||
}));
|
||
|
||
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
|
||
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
|
||
|
||
interface KeySpace {
|
||
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
|
||
prefix: string;
|
||
/** The finite member set, derived from the SOURCE enum/const. */
|
||
members: readonly string[];
|
||
}
|
||
|
||
const REGISTRY: readonly KeySpace[] = [
|
||
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
|
||
{
|
||
prefix: "illness.vital",
|
||
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
|
||
},
|
||
{ prefix: "illness.type", members: illnessTypeEnum.options },
|
||
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
|
||
|
||
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
|
||
{
|
||
prefix: "records.allergies.category",
|
||
members: allergyCategoryEnum.options,
|
||
},
|
||
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
|
||
{
|
||
prefix: "records.allergies.severity",
|
||
members: [...allergySeverityEnum.options, "NONE"],
|
||
},
|
||
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
|
||
|
||
// records.family.relationship.*
|
||
{
|
||
prefix: "records.family.relationship",
|
||
members: familyRelationshipEnum.options,
|
||
},
|
||
|
||
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
|
||
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
|
||
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
|
||
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
|
||
];
|
||
|
||
describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||
it("discovers all six shipped locales", () => {
|
||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||
"de",
|
||
"en",
|
||
"es",
|
||
"fr",
|
||
"it",
|
||
"pl",
|
||
]);
|
||
});
|
||
|
||
it("every registered key space has a non-empty member set", () => {
|
||
for (const space of REGISTRY) {
|
||
expect(
|
||
space.members.length,
|
||
`${space.prefix} resolved to an empty member set — source import broke`,
|
||
).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
for (const { locale, messages } of LOCALES) {
|
||
for (const space of REGISTRY) {
|
||
for (const member of space.members) {
|
||
const key = `${space.prefix}.${member}`;
|
||
it(`resolves ${key} in ${locale}`, () => {
|
||
const value = resolveKey(messages, key);
|
||
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
|
||
"string",
|
||
);
|
||
expect((value ?? "").trim().length).toBeGreaterThan(0);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|