FN-6791: add i18n regression guardrails
Add focused i18n regression coverage for dashboard localization guardrails. - Assert dashboard hardcoded-string lint ignores stay limited to non-shipping patterns. - Verify real locale catalogs retain key parity across supported locales and namespaces. - Cover fallback behavior for empty secondary translations, missing keys, unsupported locale tags, and Chinese locale normalization. - Document that @fusion/i18n tests now protect lint-ignore scope and catalog parity drift. Files changed: docs/i18n-contributing.md | 2 + .../i18n/src/__tests__/i18n-gate-coverage.test.ts | 65 +++++++++++++++++++ .../i18n/src/__tests__/locale-fallback.test.ts | 72 ++++++++++++++++++++++ 3 files changed, 139 insertions(+) Fusion-Task-Id: FN-6791 Fusion-Task-Lineage: 7f52113e-237a-415d-a109-51295fdc0372
This commit is contained in:
@@ -54,6 +54,8 @@ Any remaining user-facing copy must be localized with `t()` / `<Trans>` and an
|
||||
specific files or a small cluster in `lint.ignore`, includes an `FNXC` rationale,
|
||||
and has a filed follow-up task that removes the ignore. The settings sections
|
||||
cluster is no longer deferred as of FN-6771; keep those files covered by lint.
|
||||
The `@fusion/i18n` regression tests also assert the lint-ignore scope and live
|
||||
catalog key parity so those guardrails cannot silently drift.
|
||||
|
||||
## Translating an existing language
|
||||
|
||||
|
||||
65
packages/i18n/src/__tests__/i18n-gate-coverage.test.ts
Normal file
65
packages/i18n/src/__tests__/i18n-gate-coverage.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SUPPORTED_LOCALES } from "@fusion/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import config from "../../../../i18next.config.ts";
|
||||
import namespaces from "../../namespaces.json";
|
||||
import { findParityViolations, type CatalogObject, type NamespaceCatalogs } from "../parity.js";
|
||||
|
||||
/**
|
||||
* FNXC:i18n-GateRegression 2026-06-20-00:00:
|
||||
* The post-localization dashboard must stay inside both i18n guardrails: future hardcoded dashboard copy cannot be hidden in lint.ignore, and future en keys must be synced structurally across every locale/namespace.
|
||||
* This test duplicates the gate invariants in fast Vitest coverage without shelling out, so CI catches drift even before a human reruns the i18n CLI commands.
|
||||
*/
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
type Namespace = (typeof namespaces.all)[number];
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const expectedNonShippingLintIgnores = ["**/__tests__/**", "**/*.test.*", "**/*.stories.*"];
|
||||
|
||||
const representativeDashboardFiles = {
|
||||
plugin: "packages/dashboard/app/components/PiExtensionsManager.tsx",
|
||||
agent: "packages/dashboard/app/components/AgentDetailView.tsx",
|
||||
mission: "packages/dashboard/app/components/MissionManager.tsx",
|
||||
node: "packages/dashboard/app/components/AddNodeModal.tsx",
|
||||
research: "packages/dashboard/app/components/ResearchView.tsx",
|
||||
document: "packages/dashboard/app/components/DocumentsView.tsx",
|
||||
activity: "packages/dashboard/app/components/ActivityFeed.tsx",
|
||||
workflow: "packages/dashboard/app/components/WorkflowSelector.tsx",
|
||||
task: "packages/dashboard/app/components/TaskDetailModal.tsx",
|
||||
setup: "packages/dashboard/app/components/SetupWizardModal.tsx",
|
||||
pr: "packages/dashboard/app/components/PullRequestView.tsx",
|
||||
settings: "packages/dashboard/app/components/settings/sections/GeneralSection.tsx",
|
||||
} as const;
|
||||
|
||||
function readCatalog(locale: Locale, namespace: Namespace): CatalogObject {
|
||||
const path = `${repoRoot}/packages/i18n/locales/${locale}/${namespace}.json`;
|
||||
return JSON.parse(readFileSync(path, "utf8")) as CatalogObject;
|
||||
}
|
||||
|
||||
function readCatalogs(locale: Locale): NamespaceCatalogs {
|
||||
return Object.fromEntries(namespaces.all.map((namespace) => [namespace, readCatalog(locale, namespace)]));
|
||||
}
|
||||
|
||||
describe("i18n gate regression coverage", () => {
|
||||
it("keeps dashboard source files under the hardcoded-string lint gate", () => {
|
||||
expect(config.lint?.ignore).toEqual(expectedNonShippingLintIgnores);
|
||||
expect(config.lint?.ignoredTags).toEqual(["kbd"]);
|
||||
|
||||
for (const [area, file] of Object.entries(representativeDashboardFiles)) {
|
||||
expect(config.lint?.ignore, `${area} representative must not be ignored`).not.toContain(file);
|
||||
}
|
||||
expect(config.lint?.ignore?.filter((entry) => entry.includes("packages/dashboard/app/"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps real catalogs in key parity across every supported locale and namespace", () => {
|
||||
expect(config.locales).toEqual([...SUPPORTED_LOCALES]);
|
||||
expect(namespaces.all).toEqual(["common", "app", "errors", "cli"]);
|
||||
|
||||
const enCatalogs = readCatalogs("en");
|
||||
for (const locale of SUPPORTED_LOCALES.filter((locale) => locale !== "en")) {
|
||||
expect(findParityViolations(enCatalogs, readCatalogs(locale), { locale })).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
72
packages/i18n/src/__tests__/locale-fallback.test.ts
Normal file
72
packages/i18n/src/__tests__/locale-fallback.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import i18next, { type Resource } from "i18next";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { baseInitOptions, FALLBACK_LNG, normalizeToSupportedLocale } from "../config.js";
|
||||
|
||||
/**
|
||||
* FNXC:i18n-Fallback 2026-06-20-00:00:
|
||||
* Secondary locale catalogs intentionally keep untranslated entries as empty strings, so runtime fallback must prefer the en source instead of rendering blanks.
|
||||
* These in-memory fixtures exercise real i18next options for translated, empty, missing, and unsupported-locale boundary cases without loading catalogs or spawning gate commands.
|
||||
*/
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
common: {
|
||||
translated: "English translated source",
|
||||
empty: "English empty fallback",
|
||||
absent: "English absent fallback",
|
||||
defaultFallback: "English default fallback",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
common: {
|
||||
translated: "Valeur française",
|
||||
empty: "",
|
||||
},
|
||||
},
|
||||
} satisfies Resource;
|
||||
|
||||
async function createFixtureInstance(lng: string) {
|
||||
const instance = i18next.createInstance();
|
||||
await instance.init({
|
||||
...baseInitOptions(),
|
||||
resources,
|
||||
lng,
|
||||
ns: ["common"],
|
||||
defaultNS: "common",
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
describe("locale fallback boundary cases", () => {
|
||||
it("uses translated values and falls back to en for empty or absent secondary values", async () => {
|
||||
const instance = await createFixtureInstance("fr");
|
||||
|
||||
expect(instance.t("translated")).toBe("Valeur française");
|
||||
expect(instance.t("empty")).toBe("English empty fallback");
|
||||
expect(instance.t("absent")).toBe("English absent fallback");
|
||||
});
|
||||
|
||||
it("normalizes known locale tags and rejects unsupported tags", () => {
|
||||
expect(normalizeToSupportedLocale("en-US")).toBe("en");
|
||||
expect(normalizeToSupportedLocale("zh-Hant-HK")).toBe("zh-TW");
|
||||
expect(normalizeToSupportedLocale("zh-Hans")).toBe("zh-CN");
|
||||
expect(normalizeToSupportedLocale("xx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves unsupported runtime languages through the default en fallback chain", async () => {
|
||||
const instance = await createFixtureInstance("xx");
|
||||
|
||||
expect(FALLBACK_LNG.default).toEqual(["en"]);
|
||||
expect(instance.t("defaultFallback")).toBe("English default fallback");
|
||||
});
|
||||
|
||||
it("keeps Simplified and Traditional Chinese catalogs from collapsing", () => {
|
||||
const options = baseInitOptions();
|
||||
|
||||
expect(options.load).toBe("currentOnly");
|
||||
expect(options.nonExplicitSupportedLngs).toBe(false);
|
||||
expect(normalizeToSupportedLocale("zh-CN")).toBe("zh-CN");
|
||||
expect(normalizeToSupportedLocale("zh-TW")).toBe("zh-TW");
|
||||
expect(normalizeToSupportedLocale("zh")).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user