mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
38bbba110a
commit
57bd4453ba
2 files changed
+120
-70
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user