Simpl-Resultat/src/__integration__/i18n-mock-guard.test.ts
le king fu ac3a981f64 test(i18n): follow-ups of the review of PR #435
- renderCategoryLabel.test.ts passes markedT itself, its assertions in
  the marked form (`t(balance.category.cash[defaultValue=cash])`): the
  second fake `t` of the repository is gone.
- i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`,
  `t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks
  react-i18next must call `i18nMock(`, comments dropped before the
  search. Each new rule has its own case in the guard's tests; the
  binding rule found renderCategoryLabel.test.ts before its migration.
- docs/architecture.md: the "only fake t" sentence is scoped to test
  files and says the guard only refuses the forms it recognises.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:18 -04:00

112 lines
5.4 KiB
TypeScript

/**
* 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 hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
* function that takes `t`);
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
* alone, in a comment for instance, is not enough: comments are dropped
* before the search).
*
* Keywords rather than an analysis of the factory: less fragile. The matches
* are anchored on a word boundary, so `useLicenseContext: () =>`,
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `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*(?:\(|vi\.fn\()/;
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** 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) && !CALLS_I18NMOCK.test(withoutComments(source))) {
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 });",
"const at = (n: number) => n; const format = (v: string) => v;",
"if (t === (other as unknown)) return; const run = (t) => t;",
"const t = markedT; const set = { t: markedT };",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
});
it("recognises a fake t bound to a name or built with vi.fn", () => {
const x = resolve(SRC, "x.test.ts");
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
});
it("requires an actual call to i18nMock(, not its name in a comment", () => {
const x = resolve(SRC, "x.test.tsx");
const inComment = [
"// Not i18nMock(): a hand-written module.",
"/* i18nMock( would be the pattern */",
'vi.mock("react-i18next", () => fake);',
].join("\n");
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
expect(offences(x, called)).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([]);
});
});