Compare commits

...

2 commits

Author SHA1 Message Date
le king fu
25a1fdea84 docs: name the i18n key guard in REVIEW.md (#453)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m9s
The "Faux t" review pitfall said that until #453 no guard caught a key
present in one language only. It now names src/i18n/locales.keys.test.ts
and what the guard does not see: a value left in the other language, and a
key the code calls that neither file declares. The first rule of the "i18n
et documentation" zone names the guard too, as the migration and release
zones name theirs. The "Niveaux" section is untouched
(scripts/review-levels.py --check still passes).

Resolves #453

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:16:13 -04:00
le king fu
8a250b98b5 test: guard the key parity of fr.json and en.json (#453)
No test compared the key trees of the two locale files: a key added to one
language only passed the build and the suite, then showed raw to an English
user (missing from en.json) or in English to a French user (missing from
fr.json, English being the fallback).

src/i18n/locales.keys.test.ts imports both files statically, flattens every
node to a dotted path, array indices included (docs.dashboard.features.6),
and fails with the keys missing from each language in a test of its own,
plus a third test for a path held with another kind in each file (an array
against an object, a leaf against an object or an array). Values are never
compared. A canary checks both files were read whole, array items included,
and four tests on small fixtures pin the rules of the comparison.

Run on the base (6e8e157): 1712 leaves on each side, no gap, so no
translation to add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:12:32 -04:00
2 changed files with 127 additions and 2 deletions

View file

@ -138,7 +138,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm
### i18n et documentation
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois (mêmes clés, vérifiées par `src/i18n/locales.keys.test.ts`) ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`, #453
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
@ -183,7 +183,7 @@ Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dép
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Une clé présente dans une seule langue fait échouer la garde `src/i18n/locales.keys.test.ts`, qui ne compare que les clés des deux fichiers : un texte resté dans l'autre langue (du français dans `en.json`) la passe, et une clé que le code appelle sans qu'aucun des deux fichiers la déclare lui échappe aussi. — `docs/architecture.md` « Faux `t` marqué », #389, #453
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »

View file

@ -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<string, NodeKind> {
const nodes = new Map<string, NodeKind>();
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<string, NodeKind>, into: Map<string, NodeKind>): 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: <kind> in <a>, <kind> in <b>`. */
function kindGaps(
a: Map<string, NodeKind>,
b: Map<string, NodeKind>,
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([]);
});
});