From 2bd347b4a05bd5a22e351357a10d242204c51ce5 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:58:30 -0400 Subject: [PATCH] test(i18n): guard the test sources against a local fake t src/__integration__/i18n-mock-guard.test.ts reads every test file of src and fails on a useTranslation: or t: ( property (word-bounded) outside src/test/i18nMock.ts, or on a react-i18next mock that does not go through i18nMock. docs/architecture.md gets the convention: never a raw t, always i18nMock. Resolves #389 Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 16 ++++- src/__integration__/i18n-mock-guard.test.ts | 77 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/__integration__/i18n-mock-guard.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 4463417..6960a91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -615,7 +615,21 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` : Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit). -Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`. Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants. +Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants. + +### Faux `t` marqué — `src/test/i18nMock.ts` (#389) + +**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Le seul faux `t` du dépôt est `markedT` : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. Motif unique, en tête du fichier de test : + +```ts +vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); +``` + +Options de la fabrique : `language` (« fr » par défaut ; une fonction est relue à chaque accès, pour un test qui change de langue entre deux rendus : `i18nMock({ language: () => language })`) et `fixture` (valeurs rendues telles quelles pour certaines clés, comme ce que déclare la locale, un objet sous `returnObjects` par exemple ; une valeur lue par la fabrique au chargement du module se déclare par `vi.hoisted`). Une fonction qui prend `t` en paramètre reçoit `markedT` lui-même. + +Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque. + +Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue sur une propriété `useTranslation:` ou `t: (` (ancrées sur une frontière de mot) hors de `src/test/i18nMock.ts`, ou sur un `vi.mock("react-i18next", …)` qui ne passe pas par `i18nMock`. ## CI/CD diff --git a/src/__integration__/i18n-mock-guard.test.ts b/src/__integration__/i18n-mock-guard.test.ts new file mode 100644 index 0000000..b167140 --- /dev/null +++ b/src/__integration__/i18n-mock-guard.test.ts @@ -0,0 +1,77 @@ +/** + * Guard: one fake `t` in the test suite, the marked one of `src/test/i18nMock.ts` (#389). + * + * A local fake `t` that returns the key as is hides a forgotten call to `t` + * (#387 review). The migration moved every test file to `i18nMock`; this guard + * keeps it that way. It reads the sources, as `transactions-transfer-icon.test.ts` + * does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the + * module itself that: + * 1. declares a `useTranslation:` property (a hand-written `react-i18next` module); + * 2. declares a `t: (` property (a hand-written fake `t`); + * 3. mocks `react-i18next` without going through `i18nMock`. + * + * Keywords rather than an analysis of the factory: less fragile. The matches + * are anchored on a word boundary, so `useLicenseContext: () =>` or + * `get: (target, key)` are not a `t: (`. This file names the patterns only + * inside regular expressions and excludes itself. + */ + +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "fs"; +import { relative, resolve, sep } from "path"; + +const SRC = resolve(import.meta.dirname, ".."); +const MODULE = resolve(SRC, "test", "i18nMock.ts"); +const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts"); + +const LOCAL_MODULE = /\buseTranslation\s*:/; +const LOCAL_T = /\bt\s*:\s*\(/; +const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/; + +/** The offending lines of one source, as `path:line: text`. */ +function offences(path: string, source: string): string[] { + const name = relative(SRC, path).split(sep).join("/"); + const found = source + .split("\n") + .flatMap((line, i) => + LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [], + ); + if (MOCKS_I18NEXT.test(source) && !source.includes("i18nMock")) { + found.push(`${name}: mocks react-i18next without i18nMock`); + } + return found; +} + +const TEST_FILES = (readdirSync(SRC, { recursive: true }) as string[]) + .filter((entry) => /\.test\.tsx?$/.test(entry)) + .map((entry) => resolve(SRC, entry)) + .filter((path) => path !== MODULE && path !== SELF); + +describe("i18n mock guard (#389)", () => { + it("scans the test files of src, recursively", () => { + expect(TEST_FILES.length).toBeGreaterThan(100); + expect(TEST_FILES).toContain(resolve(SRC, "components", "settings", "LicenseCard.render.test.tsx")); + expect(TEST_FILES).toContain(resolve(SRC, "pages", "settings", "UsersSettingsPage.test.tsx")); + }); + + it("recognises a local fake, and only that", () => { + const local = [ + 'vi.mock("react-i18next", () => ({', + " useTranslation: () => ({ t: (k: string) => k }),", + "}));", + ].join("\n"); + expect(offences(resolve(SRC, "x.test.tsx"), local)).toHaveLength(2); + expect(offences(resolve(SRC, "x.test.tsx"), 'vi.mock("react-i18next", () => fake);')).toHaveLength(1); + const clean = [ + 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());', + "const context = { useLicenseContext: () => ctx, get: (target: object) => target };", + "await view.removeKey({ confirm, t: markedT });", + ].join("\n"); + expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]); + }); + + it("finds no other fake t than the one of src/test/i18nMock.ts", () => { + const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8"))); + expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]); + }); +});