645 lines
28 KiB
TypeScript
645 lines
28 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { checkI18nParity, collectLeafKeys, formatI18nParityReport } from "./i18n-check.ts";
|
|
|
|
async function makeFixture() {
|
|
const root = await mkdir(path.join(os.tmpdir(), `i18n-parity-${crypto.randomUUID()}`), { recursive: true });
|
|
if (!root) throw new Error("Failed to create i18n parity fixture");
|
|
|
|
return { root };
|
|
}
|
|
|
|
async function writeTranslations(root: string, locale: string, translations: unknown) {
|
|
await writeNamespace(root, locale, "translations", translations);
|
|
}
|
|
|
|
async function writeNamespace(root: string, locale: string, namespace: string, translations: unknown) {
|
|
const localeRoot = path.join(root, "apps", "web", "public", "locales", locale);
|
|
await mkdir(localeRoot, { recursive: true });
|
|
await writeFile(path.join(localeRoot, `${namespace}.json`), `${JSON.stringify(translations, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function writeSource(root: string, relativePath: string, source: string) {
|
|
const filePath = path.join(root, relativePath);
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, source, "utf8");
|
|
}
|
|
|
|
function checkFixtureParity(
|
|
root: string,
|
|
options: Partial<Parameters<typeof checkI18nParity>[0]> = {},
|
|
): ReturnType<typeof checkI18nParity> {
|
|
return checkI18nParity({ cwd: root, locales: ["de", "en"], referenceLocale: "de", namespaces: ["translations"], ...options });
|
|
}
|
|
|
|
describe("i18n parity guard", () => {
|
|
test("collects nested translation leaf keys deterministically", () => {
|
|
expect(
|
|
collectLeafKeys({
|
|
dashboard: { title: "Dashboard", stats: { empty: "No data" } },
|
|
common: { cancel: "Cancel" },
|
|
}),
|
|
).toEqual(["common.cancel", "dashboard.stats.empty", "dashboard.title"]);
|
|
});
|
|
|
|
test("passes when supported locale files share identical nested leaf keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
common: { cancel: "Abbrechen", save: "Speichern" },
|
|
dashboard: { stats: { empty: "Keine Daten" }, title: "Dashboard" },
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
common: { cancel: "Cancel", save: "Save" },
|
|
dashboard: { stats: { empty: "No data" }, title: "Dashboard" },
|
|
});
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.keyCount).toBe(4);
|
|
expect(report).toBe("PASS i18n-parity: 2 locales (de, en) share 4 translation keys across 1 namespaces");
|
|
});
|
|
|
|
test("fails with a clear report when a configured locale file is missing", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toEqual([
|
|
{
|
|
type: "missing-file",
|
|
locale: "en",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
|
|
},
|
|
]);
|
|
expect(report).toContain("FAIL i18n-parity");
|
|
expect(report).toContain(path.join("apps", "web", "public", "locales", "en", "translations.json"));
|
|
});
|
|
|
|
test("fails when the reference locale contains zero translation keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {});
|
|
await writeTranslations(root, "en", {});
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.keyCount).toBe(0);
|
|
expect(result.issues).toEqual([
|
|
{
|
|
type: "empty-reference",
|
|
locale: "de",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"),
|
|
},
|
|
]);
|
|
expect(report).toContain("empty reference namespace [de/translations]");
|
|
expect(report).toContain("expected at least one translation key");
|
|
});
|
|
|
|
test("fails with missing and extra key diagnostics when English diverges from German", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
common: { cancel: "Abbrechen", save: "Speichern" },
|
|
dashboard: { title: "Dashboard" },
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
common: { cancel: "Cancel", search: "Search" },
|
|
dashboard: { title: "Dashboard" },
|
|
});
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toEqual([
|
|
{
|
|
type: "key-divergence",
|
|
locale: "en",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
|
|
referenceLocale: "de",
|
|
missingKeys: ["common.save"],
|
|
extraKeys: ["common.search"],
|
|
},
|
|
]);
|
|
expect(report).toContain("key divergence [en/translations] vs [de/translations]");
|
|
expect(report).toContain("missing keys:\n - common.save");
|
|
expect(report).toContain("extra keys:\n - common.search");
|
|
});
|
|
|
|
test("fails when locale values are blank even when keys are present", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
common: { cancel: "Abbrechen", save: " " },
|
|
dashboard: { title: "Dashboard" },
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
common: { cancel: "Cancel", save: "Save" },
|
|
dashboard: { title: "" },
|
|
});
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "empty-translation",
|
|
locale: "de",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"),
|
|
keys: ["common.save"],
|
|
});
|
|
expect(result.issues).toContainEqual({
|
|
type: "empty-translation",
|
|
locale: "en",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
|
|
keys: ["dashboard.title"],
|
|
});
|
|
expect(report).toContain("empty translation values [de/translations]");
|
|
expect(report).toContain("keys:\n - common.save");
|
|
});
|
|
|
|
test("fails when high-confidence German UI text leaks into the English catalog", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { leads: { status: { new: "Neu" } } });
|
|
await writeTranslations(root, "en", { leads: { status: { new: "Neu" } } });
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "language-leak",
|
|
locale: "en",
|
|
namespace: "translations",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
|
|
findings: [{ key: "leads.status.new", words: ["Neu"] }],
|
|
});
|
|
expect(report).toContain("German UI text in English catalog [en/translations]");
|
|
expect(report).toContain("leads.status.new [Neu]");
|
|
});
|
|
|
|
test("does not flag lower-case technical identifiers in English examples", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { audit: { example: "z. B. kontakt.created" } });
|
|
await writeTranslations(root, "en", { audit: { example: "e.g. kontakt.created" } });
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("fails when an unconfigured locale directory is present", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeTranslations(root, "fr", { common: { save: "Enregistrer" } });
|
|
|
|
const result = checkFixtureParity(root);
|
|
|
|
expect(result.issues).toContainEqual({
|
|
type: "unexpected-locale-dir",
|
|
locale: "fr",
|
|
directoryPath: path.join(root, "apps", "web", "public", "locales", "fr"),
|
|
});
|
|
});
|
|
|
|
test("passes static source keys from packages when the reference locale contains them", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
package: { example: { title: "Paket" } },
|
|
models: { role: { viewPermissions_one: "Eine Berechtigung", viewPermissions_other: "{{count}} Berechtigungen" } },
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
package: { example: { title: "Package" } },
|
|
models: { role: { viewPermissions_one: "One permission", viewPermissions_other: "{{count}} permissions" } },
|
|
});
|
|
await writeSource(
|
|
root,
|
|
"packages/example/src/component.tsx",
|
|
'export function Component({ t, count }) { return <>{t("package.example.title")}{t("models.role.viewPermissions", { count })}</>; }\n',
|
|
);
|
|
|
|
const result = checkFixtureParity(root);
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("accepts static source keys from non-default locale namespaces", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeNamespace(root, "de", "errors", { "404": { title: "Nicht gefunden" }, notFound: "Nicht gefunden" });
|
|
await writeNamespace(root, "en", "errors", { "404": { title: "Not found" }, notFound: "Not found" });
|
|
await writeNamespace(root, "de", "mail", { welcome: { subject: "Willkommen" } });
|
|
await writeNamespace(root, "en", "mail", { welcome: { subject: "Welcome" } });
|
|
await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader({ t }) { return t("404.title") + t("notFound"); }\n');
|
|
await writeSource(root, "packages/mail/src/template.ts", 'export function mail(t) { return t("welcome.subject"); }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "errors", "mail"] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("extracts static translation keys from JSX attributes", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
common: { banner: { title: "Titel" }, description: "Beschreibung" },
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
common: { banner: { title: "Title" }, description: "Description" },
|
|
});
|
|
await writeSource(
|
|
root,
|
|
"apps/web/app/components/Banner.tsx",
|
|
'export function Banner() { return <Trans i18nKey="common.banner.title" descriptionKey="common.description" />; }\n',
|
|
);
|
|
|
|
const result = checkFixtureParity(root);
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("extracts semantic translation keys from view-model and registry fields", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", {
|
|
registry: {
|
|
breadcrumb: "Archiv",
|
|
empty: "Keine Einträge",
|
|
health: "Verbunden",
|
|
status: "Bereit",
|
|
},
|
|
});
|
|
await writeTranslations(root, "en", {
|
|
registry: {
|
|
breadcrumb: "Archive",
|
|
empty: "No entries",
|
|
health: "Connected",
|
|
status: "Ready",
|
|
},
|
|
});
|
|
await writeSource(
|
|
root,
|
|
"packages/view-models/src/registry.ts",
|
|
[
|
|
"export const vm = {",
|
|
' breadcrumbKey: "registry.breadcrumb",',
|
|
' emptyHeadlineKey: "registry.empty",',
|
|
' healthMessageKey: "registry.health",',
|
|
' statusLabelKey: "registry.status",',
|
|
"};",
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("fails for missing semantic registry keys that would otherwise render raw", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(root, "apps/web/app/services/registry.ts", 'export const result = { errorKey: "registry.errors.typo" };\n');
|
|
|
|
const result = checkFixtureParity(root);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "registry.errors.typo",
|
|
namespace: null,
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/services/registry.ts"],
|
|
});
|
|
});
|
|
|
|
test("scans feature-specific view-model key fields beyond generic labels", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(
|
|
root,
|
|
"packages/view-models/src/feature.vm.ts",
|
|
[
|
|
"export const vm = {",
|
|
' anonymousHintKey: "feature.hints.anonymous",',
|
|
' candidatesEmptyKey: "feature.candidates.empty",',
|
|
' dropzoneHeadlineKey: "feature.dropzone.headline",',
|
|
' noMailboxKey: "feature.mailbox.missing",',
|
|
' usersLoadingKey: "feature.users.loading",',
|
|
"};",
|
|
].join("\n"),
|
|
);
|
|
|
|
const missingKeys = checkFixtureParity(root).issues
|
|
.filter((issue) => issue.type === "missing-source-key")
|
|
.map((issue) => issue.key)
|
|
.sort();
|
|
expect(missingKeys).toEqual([
|
|
"feature.candidates.empty",
|
|
"feature.dropzone.headline",
|
|
"feature.hints.anonymous",
|
|
"feature.mailbox.missing",
|
|
"feature.users.loading",
|
|
]);
|
|
});
|
|
|
|
test("does not treat stable protocol registry identifiers as translation keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(
|
|
root,
|
|
"apps/web/app/services/protocol-registry.ts",
|
|
[
|
|
'export const feature = { featureFlagKey: "tenant.subscription.active" };',
|
|
'export const trigger = { triggerKey: "lead.qualified" };',
|
|
'export const template = { templateKey: "shk.order.v1" };',
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("does not treat local variables named like translation fields as translation keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(root, "apps/web/app/services/registry.ts", 'export const value = () => { const translationKey = "configTranslationKeys"; return translationKey; };\n');
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("accepts dynamic translation key families backed by catalog entries", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { workflow: { status: { active: "Aktiv", paused: "Pausiert" } } });
|
|
await writeTranslations(root, "en", { workflow: { status: { active: "Active", paused: "Paused" } } });
|
|
await writeSource(root, "apps/web/app/components/Status.tsx", "export function Status({ t, status }) { return t(`workflow.status.${status}`); }\n");
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("fails for dynamic translation key families with a misspelled catalog prefix", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { forms: { editor: { operators: { equals: "Ist gleich" } } } });
|
|
await writeTranslations(root, "en", { forms: { editor: { operators: { equals: "Equals" } } } });
|
|
await writeSource(root, "apps/web/app/components/Operator.tsx", "export function Operator({ t, op }) { return t(`forms.builder.operators.${op}`); }\n");
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "forms.builder.operators.*",
|
|
namespace: null,
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/components/Operator.tsx"],
|
|
});
|
|
expect(report).toContain("missing source translation key family (no-fallback): forms.builder.operators.*");
|
|
});
|
|
|
|
test("handles expressions with quoted fallbacks inside dynamic key families", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { dialog: { confirm: { leave: { title: "Verlassen?" } } } });
|
|
await writeTranslations(root, "en", { dialog: { confirm: { leave: { title: "Leave?" } } } });
|
|
await writeSource(root, "apps/web/app/components/Dialog.tsx", 'export function Dialog({ t, action }) { return t(`dialog.confirm.${action ?? "leave"}.title`); }\n');
|
|
|
|
expect(checkFixtureParity(root).ok).toBe(true);
|
|
});
|
|
|
|
test("fails when static package source keys are missing from the reference locale", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(root, "packages/example/src/component.tsx", 'export function Component({ t }) { return t("package.example.title"); }\n');
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "package.example.title",
|
|
namespace: null,
|
|
fallback: "no-fallback",
|
|
files: ["packages/example/src/component.tsx"],
|
|
});
|
|
expect(report).toContain("missing source translation key (no-fallback): package.example.title");
|
|
});
|
|
|
|
test("checks every configured namespace file and key parity per locale", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen", save: "Speichern" } });
|
|
await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel", search: "Search" } });
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.keyCount).toBe(3);
|
|
expect(result.issues).toContainEqual({
|
|
type: "key-divergence",
|
|
locale: "en",
|
|
namespace: "common",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"),
|
|
referenceLocale: "de",
|
|
missingKeys: ["actions.save"],
|
|
extraKeys: ["actions.search"],
|
|
});
|
|
expect(report).toContain("key divergence [en/common] vs [de/common]");
|
|
});
|
|
|
|
test("reports missing configured namespace files", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } });
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-file",
|
|
locale: "en",
|
|
namespace: "common",
|
|
filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"),
|
|
});
|
|
});
|
|
|
|
test("uses explicit source namespaces instead of accepting keys from another namespace", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { actions: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { actions: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen" } });
|
|
await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel" } });
|
|
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form({ t }) { return t("actions.save", { ns: "common" }); }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "actions.save",
|
|
namespace: "common",
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/components/Form.tsx"],
|
|
});
|
|
expect(report).toContain("missing source translation key (no-fallback): common:actions.save");
|
|
});
|
|
|
|
test("flags namespace keys that use a dot instead of the namespace colon separator", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
// settings.json nutzt flache, gepunktete Keys (erreichbar nur via "settings:tenant.theme.title").
|
|
await writeNamespace(root, "de", "settings", { "tenant.theme.title": "Darstellung" });
|
|
await writeNamespace(root, "en", "settings", { "tenant.theme.title": "Appearance" });
|
|
// Bug-Muster: Punkt statt Doppelpunkt -> adressiert den defaultNS, nicht den settings-Namespace,
|
|
// und erscheint zur Laufzeit als nackter Key. Korrekt waere t("settings:tenant.theme.title").
|
|
await writeSource(root, "apps/web/app/components/Theme.tsx", 'export function Theme({ t }) { return t("settings.tenant.theme.title"); }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "settings"] });
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "settings.tenant.theme.title",
|
|
namespace: null,
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/components/Theme.tsx"],
|
|
});
|
|
expect(report).toContain("missing source translation key (no-fallback): settings.tenant.theme.title");
|
|
});
|
|
|
|
test("classifies missing source keys with fallback separately from missing source keys without fallback", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(
|
|
root,
|
|
"apps/web/app/routes/example.tsx",
|
|
'export function Route({ t }) { return t("missing.withFallback", { defaultValue: "Fallback" }) + t("missing.withoutFallback"); }\n',
|
|
);
|
|
|
|
const result = checkFixtureParity(root);
|
|
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "missing.withFallback",
|
|
namespace: null,
|
|
fallback: "fallback",
|
|
files: ["apps/web/app/routes/example.tsx"],
|
|
});
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "missing.withoutFallback",
|
|
namespace: null,
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/routes/example.tsx"],
|
|
});
|
|
});
|
|
|
|
test("does not fail the gate when missing source keys have fallback text", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeTranslations(root, "de", { common: { save: "Speichern" } });
|
|
await writeTranslations(root, "en", { common: { save: "Save" } });
|
|
await writeSource(root, "apps/web/app/routes/example.tsx", 'export function Route({ t }) { return t("missing.withFallback", "Fallback"); }\n');
|
|
|
|
const result = checkFixtureParity(root);
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "missing.withFallback",
|
|
namespace: null,
|
|
fallback: "fallback",
|
|
files: ["apps/web/app/routes/example.tsx"],
|
|
});
|
|
expect(report).toContain("PASS i18n-parity");
|
|
expect(report).toContain("missing source translation key (fallback): missing.withFallback");
|
|
});
|
|
|
|
test("infers source namespace from namespace-specific translation loaders", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { notFound: "Nicht gefunden" });
|
|
await writeNamespace(root, "en", "translations", { notFound: "Not found" });
|
|
await writeNamespace(root, "de", "errors", { notFound: "Nicht gefunden" });
|
|
await writeNamespace(root, "en", "errors", { notFound: "Not found" });
|
|
await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader(request) { const { t } = await getTranslations(request, "errors"); return t("notFound"); }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "errors"] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("applies static key prefixes from translation hooks and fixed translators", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" }, admin: { title: "Admin" } });
|
|
await writeNamespace(root, "en", "common", { actions: { save: "Save" }, admin: { title: "Admin" } });
|
|
await writeSource(
|
|
root,
|
|
"apps/web/app/components/Form.tsx",
|
|
'export function Form() { const { t } = useTranslation("common", { keyPrefix: "actions" }); return t("save"); }\n',
|
|
);
|
|
await writeSource(
|
|
root,
|
|
"apps/web/app/routes/Admin.tsx",
|
|
'export async function loader(i18n) { const t = await i18n.getFixedT("de", "common", "admin"); return t("title"); }\n',
|
|
);
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("uses nearby JSX namespaces for Trans i18n keys instead of accepting matching default keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { shared: { cancel: "Abbrechen" } });
|
|
await writeNamespace(root, "en", "common", { shared: { cancel: "Cancel" } });
|
|
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { return <Trans ns="common" i18nKey="shared.save" />; }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
const report = formatI18nParityReport(result);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.issues).toContainEqual({
|
|
type: "missing-source-key",
|
|
key: "shared.save",
|
|
namespace: "common",
|
|
fallback: "no-fallback",
|
|
files: ["apps/web/app/components/Form.tsx"],
|
|
});
|
|
expect(report).toContain("missing source translation key (no-fallback): common:shared.save");
|
|
});
|
|
|
|
test("extracts selector-style translation keys", async () => {
|
|
const { root } = await makeFixture();
|
|
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
|
|
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } });
|
|
await writeNamespace(root, "en", "common", { actions: { save: "Save" } });
|
|
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { const { t } = useTranslation("common"); return t(($) => $.actions.save); }\n');
|
|
|
|
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|