diff --git a/src/i18n/locales.keys.test.ts b/src/i18n/locales.keys.test.ts new file mode 100644 index 0000000..8ae7796 --- /dev/null +++ b/src/i18n/locales.keys.test.ts @@ -0,0 +1,125 @@ +// Guard: fr.json and en.json declare the same keys (#453). +// +// i18next serves fr.json, the default language, and en.json, the fallback +// (`src/i18n/config.ts`). A key written in fr.json only shows raw to an +// English user; one written in en.json only shows in English to a French +// user. Neither breaks the build. These tests hold both files to one set of +// keys. +// +// A key is the dotted path of a leaf, array indices included +// (`docs.dashboard.features.6`) and plural suffixes too +// (`import.sources.fileCount_one`). Values are never compared: a text left +// untranslated in the other file passes. A path that is an array in one file +// and an object in the other, or a leaf in one and an object or an array in +// the other, is a gap as well: `t(key, { returnObjects: true })` would hand +// the component another shape. + +import { describe, it, expect } from "vitest"; +import fr from "./locales/fr.json"; +import en from "./locales/en.json"; + +type NodeKind = "object" | "array" | "leaf"; + +/** Every node under the root of a locale tree, by dotted path (array indices included), with its kind. */ +function nodesOf(tree: unknown): Map { + const nodes = new Map(); + const visit = (value: unknown, path: string[]): void => { + const kind: NodeKind = Array.isArray(value) + ? "array" + : typeof value === "object" && value !== null + ? "object" + : "leaf"; + if (path.length > 0) nodes.set(path.join("."), kind); + if (kind === "leaf") return; + // An array's entries are its indices, as strings: `features.6`. + for (const [key, item] of Object.entries(value as object)) visit(item, [...path, key]); + }; + visit(tree, []); + return nodes; +} + +/** + * The keys of `from` that `into` lacks, sorted: the leaves of `from` with + * nothing at the same path in `into`. A path `into` holds with another kind is + * left to `kindGaps`. + */ +function missingKeys(from: Map, into: Map): string[] { + return [...from] + .filter(([path, kind]) => kind === "leaf" && !into.has(path)) + .map(([path]) => path) + .sort(); +} + +/** The paths both trees hold with different kinds, sorted, as `path: in , in `. */ +function kindGaps( + a: Map, + b: Map, + nameA: string, + nameB: string, +): string[] { + return [...a] + .filter(([path, kind]) => b.has(path) && b.get(path) !== kind) + .map(([path, kind]) => `${path}: ${kind} in ${nameA}, ${b.get(path)} in ${nameB}`) + .sort(); +} + +const FR = nodesOf(fr); +const EN = nodesOf(en); + +describe("locale keys: fr.json and en.json (#453)", () => { + it("reads both files whole, array items included", () => { + // The canary: an empty or unread file would make the comparison pass on nothing. + for (const [name, nodes] of [["fr.json", FR], ["en.json", EN]] as const) { + expect(nodes.get("common.cancel"), name).toBe("leaf"); + expect(nodes.get("dashboard.help.tips"), name).toBe("array"); + expect(nodes.get("dashboard.help.tips.0"), name).toBe("leaf"); + } + }); + + it("en.json declares every key of fr.json", () => { + expect(missingKeys(FR, EN), "keys of fr.json missing from en.json").toEqual([]); + }); + + it("fr.json declares every key of en.json", () => { + expect(missingKeys(EN, FR), "keys of en.json missing from fr.json").toEqual([]); + }); + + it("gives every path both files hold the same kind: object, array or leaf", () => { + expect(kindGaps(FR, EN, "fr.json", "en.json"), "paths of another kind").toEqual([]); + }); +}); + +describe("locale keys: the comparison (#453)", () => { + it("names, on each side apart, the keys the other side lacks, array indices included", () => { + const a = nodesOf({ list: ["one", "two"], only: { a: "x" } }); + const b = nodesOf({ list: ["one"], only: { b: "y" } }); + expect(missingKeys(a, b)).toEqual(["list.1", "only.a"]); + expect(missingKeys(b, a)).toEqual(["only.b"]); + }); + + it("holds an array against an object with the same indices as a gap", () => { + const a = nodesOf({ list: ["one"] }); + const b = nodesOf({ list: { 0: "one" } }); + expect(missingKeys(a, b)).toEqual([]); + expect(missingKeys(b, a)).toEqual([]); + expect(kindGaps(a, b, "a", "b")).toEqual(["list: array in a, object in b"]); + }); + + it("holds a leaf against an object or an array as a gap", () => { + const leaf = nodesOf({ x: "text" }); + expect(kindGaps(leaf, nodesOf({ x: { y: "text" } }), "a", "b")).toEqual([ + "x: leaf in a, object in b", + ]); + expect(kindGaps(leaf, nodesOf({ x: ["text"] }), "a", "b")).toEqual(["x: leaf in a, array in b"]); + // The leaf is not reported missing as well: `x` exists on the other side, with another kind. + expect(missingKeys(leaf, nodesOf({ x: ["text"] }))).toEqual([]); + }); + + it("never compares values", () => { + const a = nodesOf({ x: "un", list: ["a", "b"] }); + const b = nodesOf({ x: "one", list: ["c", "d"] }); + expect(missingKeys(a, b)).toEqual([]); + expect(missingKeys(b, a)).toEqual([]); + expect(kindGaps(a, b, "a", "b")).toEqual([]); + }); +});