Compare commits
No commits in common. "issue-453-i18n-keys-guard" and "main" have entirely different histories.
issue-453-
...
main
2 changed files with 2 additions and 127 deletions
|
|
@ -138,7 +138,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm
|
||||||
|
|
||||||
### i18n et documentation
|
### i18n et documentation
|
||||||
|
|
||||||
- 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
|
- 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`
|
||||||
- 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 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 »
|
- 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
|
- **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
|
- **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) »
|
- **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`. 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
|
- **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
|
||||||
- **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
|
- **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)
|
- **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 »
|
- **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 »
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
// 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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue