test(i18n): one marked fake t for the suite, and a guard (#389) #435
2 changed files with 92 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
77
src/__integration__/i18n-mock-guard.test.ts
Normal file
77
src/__integration__/i18n-mock-guard.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue