From 69ef92e65fc8e6b5827ae7c521fb8e953caf5924 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:49:44 -0400 Subject: [PATCH 1/4] test(i18n): add the shared marked fake t and use it in useLicenseMachines src/test/i18nMock.ts holds the one fake t of the suite: markedT renders t(key) or t(key[a=1,b=2]), and i18nMock({ language?, fixture? }) builds the react-i18next module for vi.mock. The local echo of useLicenseMachines.test.ts is replaced by markedT. Refs #389 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useLicenseMachines.test.ts | 15 +++---- src/test/i18nMock.test.ts | 41 ++++++++++++++++++++ src/test/i18nMock.ts | 58 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 src/test/i18nMock.test.ts create mode 100644 src/test/i18nMock.ts diff --git a/src/hooks/useLicenseMachines.test.ts b/src/hooks/useLicenseMachines.test.ts index 892d3a8..4a7f120 100644 --- a/src/hooks/useLicenseMachines.test.ts +++ b/src/hooks/useLicenseMachines.test.ts @@ -35,6 +35,7 @@ import { type MachineList, type TokenDropped, } from "../services/licenseService"; +import { markedT } from "../test/i18nMock"; // The hook itself reads the LicenseContext; the pure functions below never do. const context = { refresh: vi.fn(async () => {}), revoked: false }; @@ -640,7 +641,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => { const view = renderHook(); expect(view.canRemoveKey).toBe(true); - await view.removeKey({ confirm, t: echo }); + await view.removeKey({ confirm, t: markedT }); expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmRevoked)"); expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false }); @@ -652,21 +653,15 @@ describe("useLicenseMachines wiring to the LicenseContext", () => { vi.mocked(invoke).mockResolvedValue(null); const confirm = vi.fn(() => false); const view = renderHook(); - await view.removeKey({ confirm, t: echo }); + await view.removeKey({ confirm, t: markedT }); expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)"); expect(invoke).not.toHaveBeenCalled(); }); }); -// The removal of the stored key (#370). The fake `t` marks what it translates, `t(key[options])`, +// The removal of the stored key (#370). `markedT` marks what it translates, `t(key[options])`, // so each assertion names the message the user would read, and a string that skipped `t` (a raw // key) fails it (#387 review). -function echo(key: string, options?: Record): string { - const params = options - ? `[${Object.entries(options).map(([name, value]) => `${name}=${String(value)}`).join(",")}]` - : ""; - return `t(${key}${params})`; -} describe("licenseMachinesReducer — removal of the key", () => { it("marks the removal running, clearing the previous failure", () => { @@ -753,7 +748,7 @@ describe("performRemoveLicense", () => { events.push(`confirm ${message}`); return confirmations.shift() ?? false; }), - t: echo, + t: markedT, removeLicense: vi.fn(async (releaseSlot: boolean) => { events.push(`remove(${releaseSlot})`); return outcomes.shift() ?? removed; diff --git a/src/test/i18nMock.test.ts b/src/test/i18nMock.test.ts new file mode 100644 index 0000000..876c4df --- /dev/null +++ b/src/test/i18nMock.test.ts @@ -0,0 +1,41 @@ +// The shared fake `t` (#389): its mark is the convention every assertion reads. +import { describe, it, expect } from "vitest"; +import { i18nMock, markedT } from "./i18nMock"; + +describe("markedT", () => { + it("marks a key without options", () => { + expect(markedT("license.removeKey.confirmValid")).toBe("t(license.removeKey.confirmValid)"); + }); + + it("marks a key with its options, in their order", () => { + expect(markedT("budget.count", { count: 2, name: "x" })).toBe("t(budget.count[count=2,name=x])"); + }); + + it("never returns the bare key", () => { + expect(markedT("a.b")).not.toBe("a.b"); + }); +}); + +describe("i18nMock", () => { + it("hands out the marked t and the French language by default", () => { + const { t, i18n } = i18nMock().useTranslation(); + expect(t("a.b", { n: 1 })).toBe("t(a.b[n=1])"); + expect(i18n.language).toBe("fr"); + }); + + it("reads a language function at each access", () => { + let language = "fr"; + const { i18n } = i18nMock({ language: () => language }).useTranslation(); + expect(i18n.language).toBe("fr"); + language = "en"; + expect(i18n.language).toBe("en"); + }); + + it("returns the fixture value for a fixture key, the mark for any other", () => { + const value = [{ id: "x" }]; + const { t } = i18nMock({ fixture: { "docs.a.subsections": value } }).useTranslation(); + expect(t("docs.a.subsections", { returnObjects: true })).toBe(value); + expect(t("docs.b.subsections", { returnObjects: true })).toBe("t(docs.b.subsections[returnObjects=true])"); + expect(t("toString")).toBe("t(toString)"); + }); +}); diff --git a/src/test/i18nMock.ts b/src/test/i18nMock.ts new file mode 100644 index 0000000..4738c5f --- /dev/null +++ b/src/test/i18nMock.ts @@ -0,0 +1,58 @@ +// i18nMock — the one fake `t` of the test suite (#389). +// +// A fake `t` that returns the key as is hides a forgotten call to `t`: a +// component that displays `license.removeKey.confirmValid` instead of the +// translated text passes every test (#387 review, S1). This fake MARKS what it +// translates, `t(key)` or `t(key[a=1,b=2])`, so an assertion written in that +// form fails on a string that skipped `t`. A substring assertion on a bare key +// still reads the key inside the mark. +// +// USE — one pattern, at the head of the test module, before the imports of +// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic +// import inside it): +// +// vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); +// +// Options of the factory: +// - `language`: the `i18n.language` the component reads, "fr" by default. A +// function is read at each access, for a test that changes the language +// between two renders: `i18nMock({ language: () => language })` with a +// `let language` of the test module. +// - `fixture`: values `t` returns for some keys, as i18next returns what the +// locale declares under them (an object with `returnObjects`, for instance). +// Every other key goes through `markedT`. +// +// A test that calls a function taking `t` as a parameter passes `markedT` +// itself. `src/__integration__/i18n-mock-guard.test.ts` refuses any other +// fake `t` in a test file. + +/** The marked translation: `t(key)`, or `t(key[a=1,b=2])` with options. */ +export function markedT(key: string, options?: Record): string { + const params = options + ? `[${Object.entries(options) + .map(([name, value]) => `${name}=${String(value)}`) + .join(",")}]` + : ""; + return `t(${key}${params})`; +} + +export interface I18nMockOptions { + /** `i18n.language`, "fr" by default; a function is read at each access. */ + language?: string | (() => string); + /** Values returned as is for these keys, instead of the mark. */ + fixture?: Record; +} + +/** The module `react-i18next` for `vi.mock`: `useTranslation` with the marked `t`. */ +export function i18nMock({ language = "fr", fixture }: I18nMockOptions = {}) { + const t = (key: string, options?: Record): unknown => + fixture && Object.prototype.hasOwnProperty.call(fixture, key) + ? fixture[key] + : markedT(key, options); + const i18n = { + get language(): string { + return typeof language === "function" ? language() : language; + }, + }; + return { useTranslation: () => ({ t, i18n }) }; +} -- 2.45.2 From f5e5897df158bd84fa53835efc758549cee85103 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:56:27 -0400 Subject: [PATCH 2/4] test(i18n): move the raw fake t files to the marked i18nMock The 15 test files whose fake t returned the bare key (the 12 of the issue, plus the three category render tests added by #423) now mock react-i18next with i18nMock. Assertions naming a displayed text are rewritten in the marked form, t(key[options]), so a text that skipped t fails them. Components without such an assertion get one: PriceFetchControl, PriceFetchConsentToggle (rendered through the hook harness, its loading slot preset), CategoryDetailPanel, SourceList and SourceCard, and the region label of OnboardingChecklist. Each component was proved by a mutation removing one t call: every one turns at least one test red (exit 1). Refs #389 Co-Authored-By: Claude Opus 5.5 --- .../balance/PriceFetchControl.test.tsx | 46 +++++++++++----- .../AddKeywordDialog.render.test.tsx | 12 ++--- .../CategoryDetailPanel.render.test.tsx | 19 ++++--- .../categories/CategoryForm.render.test.tsx | 14 ++--- .../dashboard/OnboardingChecklist.test.tsx | 49 ++++++++--------- .../import/ImportEmptyState.test.tsx | 36 ++++++------- src/components/import/SourceList.test.tsx | 32 ++++++++--- .../import/WizardNavigation.test.tsx | 8 ++- src/components/reports/FilterPanel.test.tsx | 11 ++-- .../settings/DocsSubsections.test.tsx | 19 ++++--- .../settings/LicenseKeyForm.render.test.tsx | 13 ++--- .../settings/PriceFetchConsentToggle.test.tsx | 51 ++++++++++++++++-- .../shared/UpsellCtas.render.test.tsx | 54 +++++++++---------- .../TransactionTable.render.test.tsx | 16 ++---- src/pages/DashboardPage.render.test.tsx | 6 +-- 15 files changed, 209 insertions(+), 177 deletions(-) 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/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(/