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([]); + }); +}); diff --git a/src/components/balance/PriceFetchControl.test.tsx b/src/components/balance/PriceFetchControl.test.tsx index ad30af2..2090daf 100644 --- a/src/components/balance/PriceFetchControl.test.tsx +++ b/src/components/balance/PriceFetchControl.test.tsx @@ -27,19 +27,8 @@ vi.mock("../../services/userPreferenceService", () => ({ setPreference: vi.fn(), })); -// react-i18next: return the key as-is for tests -vi.mock("react-i18next", () => ({ - useTranslation: vi.fn(() => ({ - t: (key: string, opts?: Record) => { - // Include interpolation values in the returned string for assertions - if (opts) { - return `${key}(${JSON.stringify(opts)})`; - } - return key; - }, - i18n: { language: "fr" }, - })), -})); +// react-i18next: the shared marked `t` (#389), so a text that skipped `t` fails +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); // lucide-react: return simple stubs vi.mock("lucide-react", () => ({ @@ -58,7 +47,8 @@ import { getPreference, setPreference, } from "../../services/userPreferenceService"; -import { +import { renderToStaticMarkup } from "react-dom/server"; +import PriceFetchControl, { __resetBestEffortDismissForTests, } from "./PriceFetchControl"; @@ -363,3 +353,31 @@ describe("PriceFetchControl — fetchPrice invocation args", () => { expect(mockSetPreference).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Test: what the control displays (#389) +// --------------------------------------------------------------------------- + +describe("PriceFetchControl — displayed texts", () => { + beforeEach(() => { + __resetBestEffortDismissForTests(); + vi.resetAllMocks(); + setPremium(true); + }); + + it("names the fetch button and the stock best-effort notice through t", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="t(balance.priceFetching.button)"'); + expect(html).toContain(">t(balance.priceFetching.button)<"); + expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<"); + expect(html).toContain('aria-label="t(common.close)"'); + }); +}); diff --git a/src/components/budget/BudgetTable.render.test.tsx b/src/components/budget/BudgetTable.render.test.tsx index f747d00..4650eee 100644 --- a/src/components/budget/BudgetTable.render.test.tsx +++ b/src/components/budget/BudgetTable.render.test.tsx @@ -10,9 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { BudgetYearRow } from "../../shared/types"; import type { CollapseAccessors } from "../../utils/collapsibleRows"; -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), -})); +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); const harness = vi.hoisted(() => ({ /** Keys of the open groups (`p:`): the grid opens collapsed. */ diff --git a/src/components/budget/BudgetTable.wiring.test.ts b/src/components/budget/BudgetTable.wiring.test.ts index b16be6b..b5a31c2 100644 --- a/src/components/budget/BudgetTable.wiring.test.ts +++ b/src/components/budget/BudgetTable.wiring.test.ts @@ -16,9 +16,7 @@ const ctx = vi.hoisted(() => ({ /** Keys of the open groups (`p:`): the grid opens collapsed. */ expanded: new Set(), })); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), -})); +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); vi.mock("../../hooks/useCollapsibleGroups", async () => { const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows"); return { diff --git a/src/components/categories/AddKeywordDialog.render.test.tsx b/src/components/categories/AddKeywordDialog.render.test.tsx index 002351d..e09e897 100644 --- a/src/components/categories/AddKeywordDialog.render.test.tsx +++ b/src/components/categories/AddKeywordDialog.render.test.tsx @@ -4,13 +4,7 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (k: string, o?: Record) => - o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k, - i18n: { language: "fr" }, - }), -})); +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); vi.mock("./keywordCategory", () => ({ loadKeywordCategories: vi.fn(() => new Promise(() => undefined)), @@ -51,12 +45,12 @@ describe("AddKeywordDialog — category field (#423)", () => { it("starts empty, with the 'no category' placeholder, until the list is in", () => { const [input] = markup(7).match(/]*role="combobox"[^>]*>/g) ?? []; expect(input).toContain('value=""'); - expect(input).toContain('placeholder="transactions.table.noCategory"'); + expect(input).toContain('placeholder="t(transactions.table.noCategory)"'); }); it("keeps the apply button disabled before the list is in", () => { const html = markup(7); - const [apply] = html.match(/