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:
gsxdsm
2026-06-20 11:02:55 -07:00
parent bdf95f8d65
commit b2f564c2be
3 changed files with 139 additions and 0 deletions

View File

@@ -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

View 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([]);
}
});
});

View 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");
});
});