feat(profiles): name the copy of a stopped catch-up, one copy per profile, startup error cleared (#395) #436

Merged
maximus merged 8 commits from issue-395-catch-up-followups into main 2026-10-14 00:28:51 +00:00
3 changed files with 57 additions and 23 deletions
Showing only changes of commit ac3a981f64 - Show all commits

View file

@ -619,7 +619,7 @@ Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389) ### 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 : **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. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
```ts ```ts
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
@ -629,7 +629,7 @@ Options de la fabrique : `language` (« fr » par défaut ; une fonction est rel
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. 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`. Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
## CI/CD ## CI/CD

View file

@ -7,13 +7,17 @@
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the * does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
* module itself that: * module itself that:
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module); * 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
* 2. declares a `t: (` property (a hand-written fake `t`); * 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
* 3. mocks `react-i18next` without going through `i18nMock`. * `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 * Keywords rather than an analysis of the factory: less fragile. The matches
* are anchored on a word boundary, so `useLicenseContext: () =>` or * are anchored on a word boundary, so `useLicenseContext: () =>`,
* `get: (target, key)` are not a `t: (`. This file names the patterns only * `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
* inside regular expressions and excludes itself. * file names the patterns only inside regular expressions and excludes itself.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@ -25,8 +29,14 @@ const MODULE = resolve(SRC, "test", "i18nMock.ts");
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts"); const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
const LOCAL_MODULE = /\buseTranslation\s*:/; const LOCAL_MODULE = /\buseTranslation\s*:/;
const LOCAL_T = /\bt\s*:\s*\(/; const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/; 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`. */ /** The offending lines of one source, as `path:line: text`. */
function offences(path: string, source: string): string[] { function offences(path: string, source: string): string[] {
@ -36,7 +46,7 @@ function offences(path: string, source: string): string[] {
.flatMap((line, i) => .flatMap((line, i) =>
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [], LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
); );
if (MOCKS_I18NEXT.test(source) && !source.includes("i18nMock")) { if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
found.push(`${name}: mocks react-i18next without i18nMock`); found.push(`${name}: mocks react-i18next without i18nMock`);
} }
return found; return found;
@ -66,10 +76,35 @@ describe("i18n mock guard (#389)", () => {
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());', 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };", "const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
"await view.removeKey({ confirm, t: markedT });", "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"); ].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]); 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", () => { 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"))); const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]); expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);

View file

@ -1,17 +1,16 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { markedT as t } from "../test/i18nMock";
import { import {
renderCategoryLabel, renderCategoryLabel,
renderCategoryLabelFromCategory, renderCategoryLabelFromCategory,
renderCategoryLabelFromAccount, renderCategoryLabelFromAccount,
} from "./renderCategoryLabel"; } from "./renderCategoryLabel";
// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys // `t` is the marked one of src/test/i18nMock.ts (#389): a translation reads
// the way react-i18next does. We model "known" keys explicitly. // `t(key[defaultValue=…])`, so a label that skipped `t` fails. Falling back to
const known: Record<string, string> = { // `defaultValue` for an unknown key is i18next's job; the helper's part is to
"balance.category.cash": "Liquidités", // pass the raw key as that default, which the mark shows.
}; const CASH = "t(balance.category.cash[defaultValue=cash])";
const t = (key: string, options?: { defaultValue?: string }) =>
known[key] ?? options?.defaultValue ?? key;
describe("renderCategoryLabel", () => { describe("renderCategoryLabel", () => {
it("prefers a non-empty custom_label over the i18n translation", () => { it("prefers a non-empty custom_label over the i18n translation", () => {
@ -23,25 +22,25 @@ describe("renderCategoryLabel", () => {
it("trims custom_label and treats whitespace-only as absent", () => { it("trims custom_label and treats whitespace-only as absent", () => {
expect( expect(
renderCategoryLabel(" ", "balance.category.cash", "cash", t) renderCategoryLabel(" ", "balance.category.cash", "cash", t)
).toBe("Liquidités"); ).toBe(CASH);
}); });
it("falls back to the i18n translation when custom_label is null", () => { it("falls back to the i18n translation when custom_label is null", () => {
expect( expect(
renderCategoryLabel(null, "balance.category.cash", "cash", t) renderCategoryLabel(null, "balance.category.cash", "cash", t)
).toBe("Liquidités"); ).toBe(CASH);
}); });
it("falls back to the i18n translation when custom_label is undefined", () => { it("falls back to the i18n translation when custom_label is undefined", () => {
expect( expect(
renderCategoryLabel(undefined, "balance.category.cash", "cash", t) renderCategoryLabel(undefined, "balance.category.cash", "cash", t)
).toBe("Liquidités"); ).toBe(CASH);
}); });
it("falls back to the raw key when no translation exists", () => { it("passes the raw key as the default i18next falls back to", () => {
expect( expect(
renderCategoryLabel(null, "balance.category.unknown", "unknown", t) renderCategoryLabel(null, "balance.category.unknown", "unknown", t)
).toBe("unknown"); ).toBe("t(balance.category.unknown[defaultValue=unknown])");
}); });
it("returns a trimmed custom_label (leading/trailing spaces removed)", () => { it("returns a trimmed custom_label (leading/trailing spaces removed)", () => {
@ -58,7 +57,7 @@ describe("renderCategoryLabelFromCategory", () => {
{ custom_label: null, i18n_key: "balance.category.cash", key: "cash" }, { custom_label: null, i18n_key: "balance.category.cash", key: "cash" },
t t
) )
).toBe("Liquidités"); ).toBe(CASH);
expect( expect(
renderCategoryLabelFromCategory( renderCategoryLabelFromCategory(
{ custom_label: "X", i18n_key: "balance.category.cash", key: "cash" }, { custom_label: "X", i18n_key: "balance.category.cash", key: "cash" },
@ -79,7 +78,7 @@ describe("renderCategoryLabelFromAccount", () => {
}, },
t t
) )
).toBe("Liquidités"); ).toBe(CASH);
expect( expect(
renderCategoryLabelFromAccount( renderCategoryLabelFromAccount(
{ {