test(i18n): auto-discover locales + fallback-chain runtime guard

i18n-locale-integrity now auto-discovers every messages/<locale>.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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:07:00 +02:00
1 parent 38bbba110a
commit 57bd4453ba
2 files changed
+120 -70

No files matched your search

+74 -70
View File
@@ -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/<locale>.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<string>();
const deKeys = new Set<string>();
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<string>();
const otherKeys = new Set<string>();
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
@@ -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 <span data-key={k}>{t(k)}</span>;
}
describe("i18n fallback chain", () => {
it("resolves a key that exists in the active locale", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<Read k="dashboard.title" />
</I18nProvider>,
);
// 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</);
});
// Level-2 of the fallback chain (active locale missing → EN) is
// unreachable in production by design: the locale-integrity parity
// test (../__tests__/i18n-locale-integrity.test.ts) enforces that
// every key in en.json exists in every other locale and vice versa,
// so a "missing in fr but present in en" state can never reach the
// runtime. If it does, the integrity test catches it before CI
// accepts the PR, NOT at runtime. That layering is intentional.
it.skip("level-2 EN fallback (unreachable — parity test guards this)", () => {});
it("returns the key itself only as last resort (regression guard)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<Read k="this.key.absolutely.does.not.exist.anywhere" />
</I18nProvider>,
);
// 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");
});
});