From 57bd4453baa4c6421ecc1de41c3ee489de0a33d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:07:00 +0200 Subject: [PATCH] test(i18n): auto-discover locales + fallback-chain runtime guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n-locale-integrity now auto-discovers every messages/.json instead of hardcoding en + de. When W9e lands fr/es/it/pl, the parity + no-empty + no-TODO + no-duplicate-keys checks extend automatically. fallback-chain.test.tsx documents the three-level resolver contract (active locale → EN → raw key). Level 2 stays as a skipped contract note since the integrity test makes it unreachable at runtime — every key is guaranteed present in every locale before CI accepts. --- .../__tests__/i18n-locale-integrity.test.ts | 144 +++++++++--------- .../i18n/__tests__/fallback-chain.test.tsx | 46 ++++++ 2 files changed, 120 insertions(+), 70 deletions(-) create mode 100644 src/lib/i18n/__tests__/fallback-chain.test.tsx diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 486484205..324d55a47 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -1,10 +1,24 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; const ROOT = join(__dirname, "../../.."); -const EN_PATH = join(ROOT, "messages/en.json"); -const DE_PATH = join(ROOT, "messages/de.json"); +const MESSAGES_DIR = join(ROOT, "messages"); +const EN_PATH = join(MESSAGES_DIR, "en.json"); +const DE_PATH = join(MESSAGES_DIR, "de.json"); + +// Auto-discover every messages/.json. Source-of-truth is EN — +// new locales added in v1.4.25 (fr/es/it/pl) and beyond get covered +// automatically by the parity, no-empty-values, and no-TODO tests +// without having to extend this file each time. +function discoverLocales(): Array<{ locale: string; path: string }> { + return readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ locale: f.replace(/\.json$/, ""), path: join(MESSAGES_DIR, f) })); +} + +const ALL_LOCALES = discoverLocales(); +const NON_EN_LOCALES = ALL_LOCALES.filter((l) => l.locale !== "en"); // JSON.parse silently keeps the last value when an object has duplicate keys, // which lets shadowed keys hide bugs. Walk the source manually to surface them. @@ -148,70 +162,64 @@ function findDuplicateKeys( } describe("i18n locale file integrity", () => { - it("messages/en.json has no duplicate keys", () => { - const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8")); - expect( - dups, - `Duplicate keys silently shadow earlier definitions:\n` + - dups - .map( - (d) => - ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`, - ) - .join("\n"), - ).toEqual([]); - }); + it.each(ALL_LOCALES)( + "messages/$locale.json has no duplicate keys", + ({ path }) => { + const dups = findDuplicateKeys(readFileSync(path, "utf8")); + expect( + dups, + `Duplicate keys silently shadow earlier definitions:\n` + + dups + .map( + (d) => + ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`, + ) + .join("\n"), + ).toEqual([]); + }, + ); - it("messages/de.json has no duplicate keys", () => { - const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8")); - expect( - dups, - `Duplicate keys silently shadow earlier definitions:\n` + - dups - .map( - (d) => - ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`, - ) - .join("\n"), - ).toEqual([]); - }); - - it("en and de share the same key shape (locale parity)", () => { - const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record< - string, - unknown - >; - const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record< - string, - unknown - >; - - function flatten(obj: unknown, prefix: string, out: string[]) { - if (obj == null || typeof obj !== "object") return; - for (const [k, v] of Object.entries(obj)) { - const key = prefix ? `${prefix}.${k}` : k; - if (typeof v === "string") out.push(key); - else if (typeof v === "object") flatten(v, key, out); - } + function flatten(obj: unknown, prefix: string, out: string[]) { + if (obj == null || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.push(key); + else if (typeof v === "object") flatten(v, key, out); } + } - const enKeys = new Set(); - const deKeys = new Set(); - const enArr: string[] = []; - const deArr: string[] = []; - flatten(en, "", enArr); - flatten(de, "", deArr); - enArr.forEach((k) => enKeys.add(k)); - deArr.forEach((k) => deKeys.add(k)); + it.each(NON_EN_LOCALES)( + "en and $locale share the same key shape (locale parity)", + ({ locale, path }) => { + const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record< + string, + unknown + >; + const other = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; - const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort(); - const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort(); + const enKeys = new Set(); + const otherKeys = new Set(); + const enArr: string[] = []; + const otherArr: string[] = []; + flatten(en, "", enArr); + flatten(other, "", otherArr); + enArr.forEach((k) => enKeys.add(k)); + otherArr.forEach((k) => otherKeys.add(k)); - expect( - { onlyInEn, onlyInDe }, - "Locale files drifted apart — every key must exist in both locales.", - ).toEqual({ onlyInEn: [], onlyInDe: [] }); - }); + const onlyInEn = [...enKeys].filter((k) => !otherKeys.has(k)).sort(); + const onlyInOther = [...otherKeys] + .filter((k) => !enKeys.has(k)) + .sort(); + + expect( + { onlyInEn, onlyInOther }, + `Locale files drifted apart — every key in en.json must exist in ${locale}.json and vice versa.`, + ).toEqual({ onlyInEn: [], onlyInOther: [] }); + }, + ); // Flat helper used by the value-quality checks below. function flattenValues( @@ -249,10 +257,7 @@ describe("i18n locale file integrity", () => { "targets.status.fitness", ]); - it.each([ - ["en", EN_PATH], - ["de", DE_PATH], - ])("%s locale has no empty values", (_locale, path) => { + it.each(ALL_LOCALES)("$locale locale has no empty values", ({ path }) => { const data = JSON.parse(readFileSync(path, "utf8")) as Record< string, unknown @@ -307,10 +312,9 @@ describe("i18n locale file integrity", () => { ).toEqual([]); }); - it.each([ - ["en", EN_PATH], - ["de", DE_PATH], - ])("%s locale has no TODO/FIXME placeholders in values", (_locale, path) => { + it.each(ALL_LOCALES)( + "$locale locale has no TODO/FIXME placeholders in values", + ({ path }) => { const data = JSON.parse(readFileSync(path, "utf8")) as Record< string, unknown diff --git a/src/lib/i18n/__tests__/fallback-chain.test.tsx b/src/lib/i18n/__tests__/fallback-chain.test.tsx new file mode 100644 index 000000000..7153eb317 --- /dev/null +++ b/src/lib/i18n/__tests__/fallback-chain.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; + +function Read({ k }: { k: string }) { + const { t } = useTranslations(); + return {t(k)}; +} + +describe("i18n fallback chain", () => { + it("resolves a key that exists in the active locale", () => { + const html = renderToStaticMarkup( + + + , + ); + // dashboard.title is shipped in every locale; the German value should + // appear (not the EN value, not the raw key). + expect(html).toContain('data-key="dashboard.title"'); + expect(html).not.toMatch(/>dashboard\.title {}); + + it("returns the key itself only as last resort (regression guard)", () => { + const html = renderToStaticMarkup( + + + , + ); + // Last-resort fallback documents the contract: when both locales + // miss the key, t() returns the key itself. This is intentional — + // visible regression beats silent empty string. The integrity test + // (../__tests__/i18n-locale-integrity.test.ts) prevents this from + // happening in shipped code; this test just pins the runtime + // contract. + expect(html).toContain("this.key.absolutely.does.not.exist.anywhere"); + }); +});