test(i18n): one marked fake t for the suite, and a guard (#389) #435
32 changed files with 420 additions and 259 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).
|
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
|
## 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,19 +27,8 @@ vi.mock("../../services/userPreferenceService", () => ({
|
||||||
setPreference: vi.fn(),
|
setPreference: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// react-i18next: return the key as-is for tests
|
// react-i18next: the shared marked `t` (#389), so a text that skipped `t` fails
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: vi.fn(() => ({
|
|
||||||
t: (key: string, opts?: Record<string, unknown>) => {
|
|
||||||
// Include interpolation values in the returned string for assertions
|
|
||||||
if (opts) {
|
|
||||||
return `${key}(${JSON.stringify(opts)})`;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// lucide-react: return simple stubs
|
// lucide-react: return simple stubs
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
|
|
@ -58,7 +47,8 @@ import {
|
||||||
getPreference,
|
getPreference,
|
||||||
setPreference,
|
setPreference,
|
||||||
} from "../../services/userPreferenceService";
|
} from "../../services/userPreferenceService";
|
||||||
import {
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import PriceFetchControl, {
|
||||||
__resetBestEffortDismissForTests,
|
__resetBestEffortDismissForTests,
|
||||||
} from "./PriceFetchControl";
|
} from "./PriceFetchControl";
|
||||||
|
|
||||||
|
|
@ -363,3 +353,31 @@ describe("PriceFetchControl — fetchPrice invocation args", () => {
|
||||||
expect(mockSetPreference).not.toHaveBeenCalled();
|
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(
|
||||||
|
<PriceFetchControl
|
||||||
|
symbol="AAPL"
|
||||||
|
date="2026-04-25"
|
||||||
|
categoryKind="priced"
|
||||||
|
assetType="stock"
|
||||||
|
onPriceFetched={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
const harness = vi.hoisted(() => ({
|
||||||
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ const ctx = vi.hoisted(() => ({
|
||||||
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
||||||
expanded: new Set<string>(),
|
expanded: new Set<string>(),
|
||||||
}));
|
}));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
||||||
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./keywordCategory", () => ({
|
vi.mock("./keywordCategory", () => ({
|
||||||
loadKeywordCategories: vi.fn(() => new Promise(() => undefined)),
|
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", () => {
|
it("starts empty, with the 'no category' placeholder, until the list is in", () => {
|
||||||
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
|
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
|
||||||
expect(input).toContain('value=""');
|
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", () => {
|
it("keeps the apply button disabled before the list is in", () => {
|
||||||
const html = markup(7);
|
const html = markup(7);
|
||||||
const [apply] = html.match(/<button [^>]*>reports\.keyword\.applyAndRecategorize<\/button>/g) ?? [];
|
const [apply] = html.match(/<button [^>]*>t\(reports\.keyword\.applyAndRecategorize\)<\/button>/g) ?? [];
|
||||||
expect(apply).toBeDefined();
|
expect(apply).toBeDefined();
|
||||||
expect(apply).toContain("disabled");
|
expect(apply).toContain("disabled");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./parentOptions", async (importOriginal) => {
|
vi.mock("./parentOptions", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./parentOptions")>();
|
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||||
|
|
@ -82,3 +76,14 @@ describe("CategoryDetailPanel — editingId wiring (#423)", () => {
|
||||||
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CategoryDetailPanel — displayed texts (#389)", () => {
|
||||||
|
it("titles the edit view and the create view through t", () => {
|
||||||
|
expect(markup(RENT, formOf(RENT), false)).toContain(">t(categories.editCategory)<");
|
||||||
|
expect(markup(null, formOf(node(0, null, "")), true)).toContain(">t(categories.addCategory)<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a selection through t when nothing is selected", () => {
|
||||||
|
expect(markup(null, null, false)).toContain(">t(categories.selectCategory)<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Pass-through spy on the pure module, to pin what the form hands it.
|
// Pass-through spy on the pure module, to pin what the form hands it.
|
||||||
vi.mock("./parentOptions", async (importOriginal) => {
|
vi.mock("./parentOptions", async (importOriginal) => {
|
||||||
|
|
@ -79,20 +73,20 @@ describe("CategoryForm — parent field (#423)", () => {
|
||||||
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
|
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
|
||||||
const html = markup(form(null));
|
const html = markup(form(null));
|
||||||
const input = comboboxInput(html);
|
const input = comboboxInput(html);
|
||||||
expect(input).toContain('aria-label="categories.parent"');
|
expect(input).toContain('aria-label="t(categories.parent)"');
|
||||||
// Only the type select remains.
|
// Only the type select remains.
|
||||||
expect(html.match(/<select/g) ?? []).toHaveLength(1);
|
expect(html.match(/<select/g) ?? []).toHaveLength(1);
|
||||||
expect(html).not.toContain("categories.noParent</option>");
|
expect(html).not.toContain("categories.noParent</option>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the 'no parent' option as the value when creating without a parent", () => {
|
it("shows the 'no parent' option as the value when creating without a parent", () => {
|
||||||
expect(comboboxInput(markup(form(null)))).toContain('value="categories.noParent"');
|
expect(comboboxInput(markup(form(null)))).toContain('value="t(categories.noParent)"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the parent's translated name as the value when editing a category with a parent", () => {
|
it("shows the parent's translated name as the value when editing a category with a parent", () => {
|
||||||
// Seed rows translate through their i18n_key with the name as default value.
|
// Seed rows translate through their i18n_key with the name as default value.
|
||||||
expect(comboboxInput(markup(form(1), 10))).toContain(
|
expect(comboboxInput(markup(form(1), 10))).toContain(
|
||||||
'value="categoriesSeed.housing[defaultValue=Logement]"',
|
'value="t(categoriesSeed.housing[defaultValue=Logement])"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,7 @@ import type { ReactElement, ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import DashboardEmptyState from "./DashboardEmptyState";
|
import DashboardEmptyState from "./DashboardEmptyState";
|
||||||
import OnboardingChecklist from "./OnboardingChecklist";
|
import OnboardingChecklist from "./OnboardingChecklist";
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
CheckCircle2: () => null,
|
CheckCircle2: () => null,
|
||||||
|
|
@ -106,11 +100,12 @@ describe("OnboardingChecklist", () => {
|
||||||
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
||||||
const { element, onHide } = render();
|
const { element, onHide } = render();
|
||||||
const text = texts(element);
|
const text = texts(element);
|
||||||
expect(text).toContain("onboarding.checklist.title");
|
expect(text).toContain("t(onboarding.checklist.title)");
|
||||||
expect(text).toContain("onboarding.checklist.counter[done=1,total=6]");
|
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
|
||||||
|
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
||||||
const [hide] = find(element, (e) => e.type === "button");
|
const [hide] = find(element, (e) => e.type === "button");
|
||||||
expect(hide.props.type).toBe("button");
|
expect(hide.props.type).toBe("button");
|
||||||
expect(texts(hide.props.children)).toContain("onboarding.checklist.hide");
|
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
|
||||||
(hide.props.onClick as () => void)();
|
(hide.props.onClick as () => void)();
|
||||||
expect(onHide).toHaveBeenCalledTimes(1);
|
expect(onHide).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
@ -120,7 +115,7 @@ describe("OnboardingChecklist", () => {
|
||||||
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
||||||
const text = texts(element);
|
const text = texts(element);
|
||||||
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
||||||
expect(text).toContain(`onboarding.checklist.items.${key}`);
|
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -154,12 +149,12 @@ describe("OnboardingChecklist", () => {
|
||||||
const { element } = render({}, ZERO, false);
|
const { element } = render({}, ZERO, false);
|
||||||
const budget = rowOf(element, "budget");
|
const budget = rowOf(element, "budget");
|
||||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||||
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
|
expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)");
|
||||||
const [link] = find(budget, (e) => e.type === Link);
|
const [link] = find(budget, (e) => e.type === Link);
|
||||||
expect(link.props.to).toBe("/settings/users");
|
expect(link.props.to).toBe("/settings/users");
|
||||||
// The license card carries no step card: nothing to focus there.
|
// The license card carries no step card: nothing to focus there.
|
||||||
expect(link.props.state).toBeUndefined();
|
expect(link.props.state).toBeUndefined();
|
||||||
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
|
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=5])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
||||||
|
|
@ -169,35 +164,35 @@ describe("OnboardingChecklist", () => {
|
||||||
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
|
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
|
||||||
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
|
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
|
||||||
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
|
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
|
||||||
expect(texts(budget)).not.toContain("onboarding.checklist.lockedBudget");
|
expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)");
|
||||||
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
|
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OnboardingChecklist — states read out (#384, #397)", () => {
|
describe("OnboardingChecklist — states read out (#384, #397)", () => {
|
||||||
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
|
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
|
||||||
const { element } = render({}, ZERO, false, ["source"]);
|
const { element } = render({}, ZERO, false, ["source"]);
|
||||||
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]);
|
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]);
|
||||||
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]);
|
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]);
|
||||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]);
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
|
||||||
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
|
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
|
||||||
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
|
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the budget row as pending while the entitlement is unknown", () => {
|
it("reads the budget row as pending while the entitlement is unknown", () => {
|
||||||
const { element } = render({ budgetReady: false }, ZERO, false);
|
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]);
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts the state text right after the label, inside the same element", () => {
|
it("puts the state text right after the label, inside the same element", () => {
|
||||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||||
const [labelled] = find(
|
const [labelled] = find(
|
||||||
rowOf(element, "source"),
|
rowOf(element, "source"),
|
||||||
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"),
|
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"),
|
||||||
);
|
);
|
||||||
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
|
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
|
||||||
"onboarding.checklist.items.source",
|
"t(onboarding.checklist.items.source)",
|
||||||
"onboarding.state.done",
|
"t(onboarding.state.done)",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -210,11 +205,11 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
|
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
|
||||||
const [badge] = find(
|
const [badge] = find(
|
||||||
source,
|
source,
|
||||||
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"),
|
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"),
|
||||||
);
|
);
|
||||||
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||||
expect(badge.props["aria-hidden"]).toBe("true");
|
expect(badge.props["aria-hidden"]).toBe("true");
|
||||||
expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]");
|
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=6])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||||
|
|
@ -223,7 +218,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
// The label is no link: only "Resume" leads to the page, after taking the step back.
|
// The label is no link: only "Resume" leads to the page, after taking the step back.
|
||||||
expect(sourceLinks).toHaveLength(1);
|
expect(sourceLinks).toHaveLength(1);
|
||||||
const [resume] = sourceLinks;
|
const [resume] = sourceLinks;
|
||||||
expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]);
|
expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]);
|
||||||
expect(resume.props.to).toBe("/import");
|
expect(resume.props.to).toBe("/import");
|
||||||
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
|
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
|
||||||
(resume.props.onClick as () => void)();
|
(resume.props.onClick as () => void)();
|
||||||
|
|
@ -242,7 +237,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||||
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
|
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||||
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||||
expect(texts(source)).not.toContain("onboarding.checklist.resume");
|
expect(texts(source)).not.toContain("t(onboarding.checklist.resume)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a locked row stays locked even when skipped", () => {
|
it("a locked row stays locked even when skipped", () => {
|
||||||
|
|
@ -250,7 +245,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
const budget = rowOf(element, "budget");
|
const budget = rowOf(element, "budget");
|
||||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||||
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
|
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||||
expect(texts(budget)).not.toContain("onboarding.checklist.resume");
|
expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)");
|
||||||
const [link] = find(budget, (e) => e.type === Link);
|
const [link] = find(budget, (e) => e.type === Link);
|
||||||
expect(link.props.to).toBe("/settings/users");
|
expect(link.props.to).toBe("/settings/users");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
FolderOpen: () => null,
|
FolderOpen: () => null,
|
||||||
|
|
@ -83,11 +77,11 @@ describe("ImportEmptyState — no folder (#356)", () => {
|
||||||
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
|
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
|
||||||
const { props, tree, buttons, links } = render({ hasFolder: false });
|
const { props, tree, buttons, links } = render({ hasFolder: false });
|
||||||
|
|
||||||
expect(texts(tree)).toContain("import.empty.noFolder.title");
|
expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
|
||||||
expect(texts(tree)).toContain("import.empty.noFolder.description");
|
expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
|
||||||
|
|
||||||
expect(buttons).toHaveLength(1);
|
expect(buttons).toHaveLength(1);
|
||||||
expect(texts(buttons[0].props.children)).toContain("import.empty.noFolder.cta");
|
expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
||||||
expect(buttons[0].props.disabled).toBe(false);
|
expect(buttons[0].props.disabled).toBe(false);
|
||||||
buttons[0].props.onClick();
|
buttons[0].props.onClick();
|
||||||
expect(props.onBrowse).toHaveBeenCalledTimes(1);
|
expect(props.onBrowse).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -96,13 +90,13 @@ describe("ImportEmptyState — no folder (#356)", () => {
|
||||||
expect(links).toHaveLength(1);
|
expect(links).toHaveLength(1);
|
||||||
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
|
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
|
||||||
expect(links[0].props.to).toBe("/settings/users#import-export");
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||||
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
|
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never shows the sample or refresh actions without a folder", () => {
|
it("never shows the sample or refresh actions without a folder", () => {
|
||||||
const { tree } = render({ hasFolder: false });
|
const { tree } = render({ hasFolder: false });
|
||||||
expect(texts(tree)).not.toContain("import.empty.createSample");
|
expect(texts(tree)).not.toContain("t(import.empty.createSample)");
|
||||||
expect(texts(tree)).not.toContain("import.empty.refresh");
|
expect(texts(tree)).not.toContain("t(import.empty.refresh)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,22 +104,22 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
|
||||||
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
|
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
|
||||||
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
|
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
|
||||||
|
|
||||||
expect(texts(tree)).toContain("import.empty.noSources.title");
|
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
|
||||||
|
|
||||||
expect(buttons).toHaveLength(2);
|
expect(buttons).toHaveLength(2);
|
||||||
expect(texts(buttons[0].props.children)).toContain("import.empty.createSample");
|
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
|
||||||
expect(buttons[0].props.disabled).toBe(false);
|
expect(buttons[0].props.disabled).toBe(false);
|
||||||
buttons[0].props.onClick();
|
buttons[0].props.onClick();
|
||||||
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
|
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(texts(buttons[1].props.children)).toContain("import.empty.refresh");
|
expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)");
|
||||||
buttons[1].props.onClick();
|
buttons[1].props.onClick();
|
||||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||||
expect(props.onBrowse).not.toHaveBeenCalled();
|
expect(props.onBrowse).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(links).toHaveLength(1);
|
expect(links).toHaveLength(1);
|
||||||
expect(links[0].props.to).toBe("/settings/users#import-export");
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||||
expect(texts(links[0].props.children)).toContain("import.empty.guideLink");
|
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables the create button while the sample folders are being written", () => {
|
it("disables the create button while the sample folders are being written", () => {
|
||||||
|
|
@ -138,9 +132,9 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
|
||||||
it("names the folder in the description, never the full path", () => {
|
it("names the folder in the description, never the full path", () => {
|
||||||
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
|
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
|
||||||
const { tree } = render({ hasFolder: true, folderName });
|
const { tree } = render({ hasFolder: true, folderName });
|
||||||
const description = texts(tree).find((s) => s.startsWith("import.empty.noSources.description"));
|
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
|
||||||
|
|
||||||
expect(description).toBe("import.empty.noSources.description[folderName=Relevés]");
|
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
|
||||||
expect(description).not.toContain("Users");
|
expect(description).not.toContain("Users");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -154,14 +148,14 @@ describe("ImportEmptyState — getting-started target (#398)", () => {
|
||||||
const targets = highlighted(tree);
|
const targets = highlighted(tree);
|
||||||
expect(targets).toHaveLength(1);
|
expect(targets).toHaveLength(1);
|
||||||
expect(targets[0].type).toBe("button");
|
expect(targets[0].type).toBe("button");
|
||||||
expect(texts(targets[0].props.children)).toContain("import.empty.noFolder.cta");
|
expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
|
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
|
||||||
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
||||||
const targets = highlighted(tree);
|
const targets = highlighted(tree);
|
||||||
expect(targets).toHaveLength(1);
|
expect(targets).toHaveLength(1);
|
||||||
expect(texts(targets[0].props.children)).toContain("import.empty.createSample");
|
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights nothing without the property", () => {
|
it("highlights nothing without the property", () => {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import SourceList from "./SourceList";
|
import SourceList from "./SourceList";
|
||||||
import { SOURCE_CARD_TARGET } from "./SourceCard";
|
import { SOURCE_CARD_TARGET } from "./SourceCard";
|
||||||
|
|
@ -61,3 +55,27 @@ describe("SourceList — getting-started target (#398)", () => {
|
||||||
expect(markup(false)).not.toContain("data-onboarding");
|
expect(markup(false)).not.toContain("data-onboarding");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SourceList and SourceCard — displayed texts (#389)", () => {
|
||||||
|
it("titles the list and counts each source's files through t", () => {
|
||||||
|
const html = markup();
|
||||||
|
expect(html).toContain(">t(import.sources.title)<");
|
||||||
|
expect(cards(html)[0]).toContain("t(import.sources.fileCount[count=1])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the files not yet imported through t", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<SourceList
|
||||||
|
sources={[source("Desjardins")]}
|
||||||
|
configuredSourceNames={new Set()}
|
||||||
|
importedFileNames={new Map([["Desjardins", new Set<string>()]])}
|
||||||
|
onSelectSource={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("t(import.sources.new)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the folder is empty through t", () => {
|
||||||
|
expect(markup(false, [])).toContain(">t(import.sources.empty)<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
|
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
|
||||||
|
|
||||||
|
|
@ -36,9 +34,9 @@ describe("WizardNavigation — getting-started target (#398)", () => {
|
||||||
expect(next).toContain("NEXT_LABEL");
|
expect(next).toContain("NEXT_LABEL");
|
||||||
expect(next).toContain(HIGHLIGHT);
|
expect(next).toContain(HIGHLIGHT);
|
||||||
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
|
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
|
||||||
expect(cancel).toContain("common.cancel");
|
expect(cancel).toContain("t(common.cancel)");
|
||||||
expect(cancel).not.toContain("data-onboarding");
|
expect(cancel).not.toContain("data-onboarding");
|
||||||
expect(back).toContain("import.wizard.back");
|
expect(back).toContain("t(import.wizard.back)");
|
||||||
expect(back).not.toContain("data-onboarding");
|
expect(back).not.toContain("data-onboarding");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
// plugins) and useEntitlement (→ LicenseContext): stub both.
|
// plugins) and useEntitlement (→ LicenseContext): stub both.
|
||||||
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
||||||
|
|
||||||
let onboarding: OnboardingValue;
|
let onboarding: OnboardingValue;
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,9 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { Link, MemoryRouter } from "react-router-dom";
|
import { Link, MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
// The fake `t` marks what it translates, `t(key[options])`: a string that
|
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
|
||||||
// skipped `t` would show unmarked.
|
// skipped `t` would show unmarked.
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import OnboardingBanner, {
|
import OnboardingBanner, {
|
||||||
GETTING_STARTED_GUIDE,
|
GETTING_STARTED_GUIDE,
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,9 @@ import { createRef, isValidElement, type ReactElement, type ReactNode } from "re
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { Link, MemoryRouter } from "react-router-dom";
|
import { Link, MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
// The fake `t` marks what it translates, `t(key[options])`: a string that
|
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
|
||||||
// skipped `t` would show unmarked.
|
// skipped `t` would show unmarked.
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import StepCard, { type StepCardProps } from "./StepCard";
|
import StepCard, { type StepCardProps } from "./StepCard";
|
||||||
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
|
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,7 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: vi.fn(() => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Inbox: () => null,
|
Inbox: () => null,
|
||||||
|
|
@ -164,8 +159,8 @@ describe("FilterPanel — render", () => {
|
||||||
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
|
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
|
||||||
const { element } = renderPanel();
|
const { element } = renderPanel();
|
||||||
const text = flatten(element);
|
const text = flatten(element);
|
||||||
expect(text).toContain("reports.filters.accounts.label");
|
expect(text).toContain("t(reports.filters.accounts.label)");
|
||||||
expect(text).toContain("reports.filters.accounts.hint");
|
expect(text).toContain("t(reports.filters.accounts.hint)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
// DocsSubsections — unit tests (Issue #355).
|
// DocsSubsections — unit tests (Issue #355).
|
||||||
//
|
//
|
||||||
// Same approach as FilterPanel.test.tsx: no jsdom in this project, and the
|
// Same approach as FilterPanel.test.tsx: no jsdom in this project, and the
|
||||||
// component's only hook is `useTranslation` (mocked into a lookup over a
|
// component's only hook is `useTranslation` (mocked by `i18nMock` over a
|
||||||
// fixture), so it is invoked directly as a function and its React-element
|
// fixture), so it is invoked directly as a function and its React-element
|
||||||
// tree is walked without a renderer.
|
// tree is walked without a renderer.
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
const FIXTURE: Record<string, unknown> = {
|
// Hoisted: the factory of `vi.mock` below reads it when the module loads.
|
||||||
|
const FIXTURE = vi.hoisted((): Record<string, unknown> => ({
|
||||||
"docs.import.subsections": [
|
"docs.import.subsections": [
|
||||||
{
|
{
|
||||||
id: "import-export",
|
id: "import-export",
|
||||||
|
|
@ -22,16 +23,14 @@ const FIXTURE: Record<string, unknown> = {
|
||||||
steps: ["Expected header"],
|
steps: ["Expected header"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
|
||||||
useTranslation: vi.fn(() => ({
|
|
||||||
// i18next returns the key itself when nothing is declared under it.
|
|
||||||
t: (key: string) => (key in FIXTURE ? FIXTURE[key] : key),
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The fixture stands for what the locale declares; every other key goes
|
||||||
|
// through the marked `t` (#389), as i18next returns a string for a missing key.
|
||||||
|
vi.mock("react-i18next", async () =>
|
||||||
|
(await import("../../test/i18nMock")).i18nMock({ fixture: FIXTURE }),
|
||||||
|
);
|
||||||
|
|
||||||
import DocsSubsections, { readSubsections } from "./DocsSubsections";
|
import DocsSubsections, { readSubsections } from "./DocsSubsections";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,10 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||||
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
||||||
|
|
||||||
let language = "fr";
|
let language = "fr";
|
||||||
// The fake `t` marks what it translates, `t(key[options])`: the assertions written in that form
|
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: the assertions written in that form
|
||||||
// fail on a string that skipped `t` (#387 review). Substring assertions on a bare key still read
|
// fail on a string that skipped `t` (#387 review). Substring assertions on a bare key still read
|
||||||
// the key inside the mark.
|
// the key inside the mark.
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock({ language: () => language }));
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let context: Record<string, unknown>;
|
let context: Record<string, unknown>;
|
||||||
vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({
|
vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import LicenseKeyForm from "./LicenseKeyForm";
|
import LicenseKeyForm from "./LicenseKeyForm";
|
||||||
|
|
||||||
|
|
@ -30,18 +25,18 @@ function render(validationError: string | null, busy = false): string {
|
||||||
describe("LicenseKeyForm", () => {
|
describe("LicenseKeyForm", () => {
|
||||||
it("names a refused key in the app language", () => {
|
it("names a refused key in the app language", () => {
|
||||||
const html = render('{"code":"invalid_prefix"}');
|
const html = render('{"code":"invalid_prefix"}');
|
||||||
expect(html).toContain("license.errors.invalidPrefix");
|
expect(html).toContain("t(license.errors.invalidPrefix)");
|
||||||
expect(html).not.toContain("{"code"");
|
expect(html).not.toContain("{"code"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never prints a raw backend string", () => {
|
it("never prints a raw backend string", () => {
|
||||||
const html = render("License key must start with SR-BASE- or SR-PREMIUM-");
|
const html = render("License key must start with SR-BASE- or SR-PREMIUM-");
|
||||||
expect(html).toContain("license.errors.unknown");
|
expect(html).toContain("t(license.errors.unknown)");
|
||||||
expect(html).not.toContain("License key must start");
|
expect(html).not.toContain("License key must start");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no error line before a submission is refused", () => {
|
it("shows no error line before a submission is refused", () => {
|
||||||
expect(render(null)).not.toContain("license.errors.");
|
expect(render(null)).not.toContain("t(license.errors.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks the submission while a machine activation runs", () => {
|
it("blocks the submission while a machine activation runs", () => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
// Mocks — declared before imports to satisfy vi.mock hoisting
|
// Mocks — declared before imports to satisfy vi.mock hoisting
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// React's hooks from the shared harness (#409): the toggle renders nothing while
|
||||||
|
// it loads, so the render test below presets its `loading` slot.
|
||||||
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
|
||||||
vi.mock("../../hooks/useIsPremium", () => ({
|
vi.mock("../../hooks/useIsPremium", () => ({
|
||||||
useIsPremium: vi.fn(),
|
useIsPremium: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -21,16 +25,16 @@ vi.mock("../../services/userPreferenceService", () => ({
|
||||||
deletePreference: vi.fn(),
|
deletePreference: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: vi.fn(() => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Imports (after mock declarations)
|
// Imports (after mock declarations)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
|
import { PriceFetchConsentToggle } from "./PriceFetchConsentToggle";
|
||||||
import { useIsPremium } from "../../hooks/useIsPremium";
|
import { useIsPremium } from "../../hooks/useIsPremium";
|
||||||
import {
|
import {
|
||||||
getPreference,
|
getPreference,
|
||||||
|
|
@ -42,6 +46,7 @@ import {
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const harness = hookHarness();
|
||||||
const mockUseIsPremium = vi.mocked(useIsPremium);
|
const mockUseIsPremium = vi.mocked(useIsPremium);
|
||||||
const mockGetPreference = vi.mocked(getPreference);
|
const mockGetPreference = vi.mocked(getPreference);
|
||||||
const mockSetPreference = vi.mocked(setPreference);
|
const mockSetPreference = vi.mocked(setPreference);
|
||||||
|
|
@ -210,3 +215,39 @@ describe("PriceFetchConsentToggle — premium guard", () => {
|
||||||
expect(buttonDisabled).toBe(false);
|
expect(buttonDisabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: what the toggle displays (#389)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — displayed texts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
harness.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One render once loaded: slots by call order, hasConsent, showConfirm, loading. */
|
||||||
|
function markup(premium: boolean, consent: boolean, confirming: boolean): string {
|
||||||
|
setPremium(premium);
|
||||||
|
harness.slots[0] = consent;
|
||||||
|
harness.slots[1] = confirming;
|
||||||
|
harness.slots[2] = false;
|
||||||
|
return renderToStaticMarkup(harness.render(() => PriceFetchConsentToggle() as ReactNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("names the section, the consent row and the not-premium tooltip through t", () => {
|
||||||
|
const html = markup(false, false, false);
|
||||||
|
expect(html).toContain(">t(settings.privacy.title)<");
|
||||||
|
expect(html).toContain(">t(settings.privacy.priceFetchConsent.label)<");
|
||||||
|
expect(html).toContain(">t(settings.privacy.priceFetchConsent.description)<");
|
||||||
|
expect(html).toContain('title="t(settings.privacy.priceFetchConsent.notPremium)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the revoke confirmation through t", () => {
|
||||||
|
const html = markup(true, true, true);
|
||||||
|
expect(html).toContain('aria-label="t(settings.privacy.priceFetchConsent.revokeButton)"');
|
||||||
|
expect(html).toContain(">t(settings.privacy.priceFetchConsent.confirmRevoke)<");
|
||||||
|
expect(html).toContain(">t(common.cancel)<");
|
||||||
|
expect(html).toContain(">t(settings.privacy.priceFetchConsent.revokeButton)<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,7 @@ vi.mock("../../services/logService", async (importOriginal) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let language = "fr";
|
let language = "fr";
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock({ language: () => language }));
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The locked screen and the Updates card read one thing from the license context:
|
// The locked screen and the Updates card read one thing from the license context:
|
||||||
// whether the stored key was revoked (#365).
|
// whether the stored key was revoked (#365).
|
||||||
|
|
@ -69,19 +63,19 @@ describe("UpsellGate with a revoked key (#365)", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
it("says nothing about a revocation for a key that is not revoked", () => {
|
it("says nothing about a revocation for a key that is not revoked", () => {
|
||||||
expect(gate()).not.toContain("upsell.revoked");
|
expect(gate()).not.toContain("t(upsell.revoked)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the revocation between the feature description and the CTAs", () => {
|
it("names the revocation between the feature description and the CTAs", () => {
|
||||||
licenseRevoked = true;
|
licenseRevoked = true;
|
||||||
const html = gate();
|
const html = gate();
|
||||||
const line = html.indexOf("upsell.revoked");
|
const line = html.indexOf("t(upsell.revoked)");
|
||||||
expect(line).toBeGreaterThan(html.indexOf("upsell.features.budget"));
|
expect(line).toBeGreaterThan(html.indexOf("t(upsell.features.budget)"));
|
||||||
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
|
expect(line).toBeLessThan(html.indexOf("t(upsell.ctaGet["));
|
||||||
expect(html.split("upsell.revoked")).toHaveLength(2);
|
expect(html.split("t(upsell.revoked)")).toHaveLength(2);
|
||||||
// The way out stays: buying, or entering another key.
|
// The way out stays: buying, or entering another key.
|
||||||
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
||||||
expect(html).toContain("upsell.ctaHaveKey");
|
expect(html).toContain("t(upsell.ctaHaveKey)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,10 +86,10 @@ describe("UpsellGate wiring", () => {
|
||||||
<UpsellGate feature="budget" requiredTier="base" />
|
<UpsellGate feature="budget" requiredTier="base" />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
||||||
expect(html).not.toContain("disabled");
|
expect(html).not.toContain("disabled");
|
||||||
expect(html).not.toContain("upsell.premiumNotForSale");
|
expect(html).not.toContain("t(upsell.premiumNotForSale)");
|
||||||
expect(html).toContain("upsell.ctaHaveKey");
|
expect(html).toContain("t(upsell.ctaHaveKey)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Premium screen: disabled button with the not-for-sale note", () => {
|
it("Premium screen: disabled button with the not-for-sale note", () => {
|
||||||
|
|
@ -105,14 +99,14 @@ describe("UpsellGate wiring", () => {
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(html).toContain('disabled=""');
|
expect(html).toContain('disabled=""');
|
||||||
expect(html).toContain("upsell.ctaGet[tier=license.editions.premium]");
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.premium)])");
|
||||||
expect(html).toContain("upsell.premiumNotForSale");
|
expect(html).toContain("t(upsell.premiumNotForSale)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("outside any router (ProfileSelectionPage, ErrorPage): renders, without I already have a key", () => {
|
it("outside any router (ProfileSelectionPage, ErrorPage): renders, without I already have a key", () => {
|
||||||
const html = renderToStaticMarkup(<UpsellGate feature="multi-profile" requiredTier="base" />);
|
const html = renderToStaticMarkup(<UpsellGate feature="multi-profile" requiredTier="base" />);
|
||||||
expect(html).toContain("upsell.ctaGet");
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
||||||
expect(html).not.toContain("upsell.ctaHaveKey");
|
expect(html).not.toContain("t(upsell.ctaHaveKey)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards onNavigate to I already have a key only", () => {
|
it("forwards onNavigate to I already have a key only", () => {
|
||||||
|
|
@ -157,11 +151,11 @@ describe("UpdateCard in the free edition", () => {
|
||||||
<UpdateCard />
|
<UpdateCard />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(html).toContain("settings.updates.notEntitled");
|
expect(html).toContain("t(settings.updates.notEntitled)");
|
||||||
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
||||||
expect(html).toContain("upsell.ctaHaveKey");
|
expect(html).toContain("t(upsell.ctaHaveKey)");
|
||||||
expect(html).toContain("settings.updates.downloadLatest");
|
expect(html).toContain("t(settings.updates.downloadLatest)");
|
||||||
expect(html).not.toContain("upsell.revoked");
|
expect(html).not.toContain("t(upsell.revoked)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names a revoked key between the message and the CTAs (#365)", () => {
|
it("names a revoked key between the message and the CTAs (#365)", () => {
|
||||||
|
|
@ -171,9 +165,9 @@ describe("UpdateCard in the free edition", () => {
|
||||||
<UpdateCard />
|
<UpdateCard />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
const line = html.indexOf("upsell.revoked");
|
const line = html.indexOf("t(upsell.revoked)");
|
||||||
expect(line).toBeGreaterThan(html.indexOf("settings.updates.notEntitled"));
|
expect(line).toBeGreaterThan(html.indexOf("t(settings.updates.notEntitled)"));
|
||||||
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
|
expect(line).toBeLessThan(html.indexOf("t(upsell.ctaGet["));
|
||||||
expect(html.split("upsell.revoked")).toHaveLength(2);
|
expect(html.split("t(upsell.revoked)")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
|
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
|
||||||
import type { Category, TransactionRow } from "../../shared/types";
|
import type { Category, TransactionRow } from "../../shared/types";
|
||||||
|
|
@ -66,11 +60,11 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
|
||||||
/** The rows of the table's body, in order. */
|
/** The rows of the table's body, in order. */
|
||||||
const bodyRows = (html: string) => html.split("<tbody")[1].split("<tr").slice(1);
|
const bodyRows = (html: string) => html.split("<tbody")[1].split("<tr").slice(1);
|
||||||
|
|
||||||
/** The highlighted wrapper around a closed combobox, whose input shows `value`. */
|
/** The highlighted wrapper around a closed combobox, whose input shows `value` (escaped). */
|
||||||
const ringedCategory = (value: string) =>
|
const ringedCategory = (value: string) =>
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
|
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
|
||||||
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value}"[^>]*/></div></div>`,
|
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value.replace(/[()[\]]/g, "\\$&")}"[^>]*/></div></div>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("TransactionTable — getting-started target (#399)", () => {
|
describe("TransactionTable — getting-started target (#399)", () => {
|
||||||
|
|
@ -79,7 +73,7 @@ describe("TransactionTable — getting-started target (#399)", () => {
|
||||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
const [first, ...others] = bodyRows(html);
|
const [first, ...others] = bodyRows(html);
|
||||||
expect(first).toContain("IGA 123");
|
expect(first).toContain("IGA 123");
|
||||||
expect(first).toMatch(ringedCategory("transactions.table.noCategory"));
|
expect(first).toMatch(ringedCategory("t(transactions.table.noCategory)"));
|
||||||
expect(others).toHaveLength(2);
|
expect(others).toHaveLength(2);
|
||||||
for (const other of others) {
|
for (const other of others) {
|
||||||
expect(other).not.toContain("data-onboarding");
|
expect(other).not.toContain("data-onboarding");
|
||||||
|
|
@ -102,7 +96,7 @@ describe("TransactionTable — getting-started target (#399)", () => {
|
||||||
|
|
||||||
it("highlights nothing without a row: the card stays, without a ring", () => {
|
it("highlights nothing without a row: the card stays, without a ring", () => {
|
||||||
const html = markup([], true);
|
const html = markup([], true);
|
||||||
expect(html).toContain("transactions.noTransactions");
|
expect(html).toContain("t(transactions.noTransactions)");
|
||||||
expect(html).not.toContain("data-onboarding");
|
expect(html).not.toContain("data-onboarding");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
type MachineList,
|
type MachineList,
|
||||||
type TokenDropped,
|
type TokenDropped,
|
||||||
} from "../services/licenseService";
|
} from "../services/licenseService";
|
||||||
|
import { markedT } from "../test/i18nMock";
|
||||||
|
|
||||||
// The hook itself reads the LicenseContext; the pure functions below never do.
|
// The hook itself reads the LicenseContext; the pure functions below never do.
|
||||||
const context = { refresh: vi.fn(async () => {}), revoked: false };
|
const context = { refresh: vi.fn(async () => {}), revoked: false };
|
||||||
|
|
@ -640,7 +641,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
||||||
const view = renderHook();
|
const view = renderHook();
|
||||||
expect(view.canRemoveKey).toBe(true);
|
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(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmRevoked)");
|
||||||
expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false });
|
expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false });
|
||||||
|
|
@ -652,21 +653,15 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
||||||
vi.mocked(invoke).mockResolvedValue(null);
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
const confirm = vi.fn(() => false);
|
const confirm = vi.fn(() => false);
|
||||||
const view = renderHook();
|
const view = renderHook();
|
||||||
await view.removeKey({ confirm, t: echo });
|
await view.removeKey({ confirm, t: markedT });
|
||||||
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)");
|
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)");
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
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
|
// so each assertion names the message the user would read, and a string that skipped `t` (a raw
|
||||||
// key) fails it (#387 review).
|
// key) fails it (#387 review).
|
||||||
function echo(key: string, options?: Record<string, unknown>): string {
|
|
||||||
const params = options
|
|
||||||
? `[${Object.entries(options).map(([name, value]) => `${name}=${String(value)}`).join(",")}]`
|
|
||||||
: "";
|
|
||||||
return `t(${key}${params})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("licenseMachinesReducer — removal of the key", () => {
|
describe("licenseMachinesReducer — removal of the key", () => {
|
||||||
it("marks the removal running, clearing the previous failure", () => {
|
it("marks the removal running, clearing the previous failure", () => {
|
||||||
|
|
@ -753,7 +748,7 @@ describe("performRemoveLicense", () => {
|
||||||
events.push(`confirm ${message}`);
|
events.push(`confirm ${message}`);
|
||||||
return confirmations.shift() ?? false;
|
return confirmations.shift() ?? false;
|
||||||
}),
|
}),
|
||||||
t: echo,
|
t: markedT,
|
||||||
removeLicense: vi.fn(async (releaseSlot: boolean) => {
|
removeLicense: vi.fn(async (releaseSlot: boolean) => {
|
||||||
events.push(`remove(${releaseSlot})`);
|
events.push(`remove(${releaseSlot})`);
|
||||||
return outcomes.shift() ?? removed;
|
return outcomes.shift() ?? removed;
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
// The budget, collapse and preference services pull the database: stub the connection.
|
// The budget, collapse and preference services pull the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({ budget: null as unknown, onboarding: null as unknown }));
|
const harness = vi.hoisted(() => ({ budget: null as unknown, onboarding: null as unknown }));
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("react-router-dom")>()),
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
||||||
useLocation: () => ({ pathname: ctx.pathname }),
|
useLocation: () => ({ pathname: ctx.pathname }),
|
||||||
}));
|
}));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
// The page's modules pull the database: stub the connection.
|
// The page's modules pull the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let onboarding: OnboardingValue;
|
let onboarding: OnboardingValue;
|
||||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
|
||||||
|
|
@ -116,7 +114,7 @@ describe("DashboardPage on the shared path (#352, #396, #397)", () => {
|
||||||
it("renders only its header while the path first loads", () => {
|
it("renders only its header while the path first loads", () => {
|
||||||
onboarding = path({ status: "loading", checklist: null, counters: null });
|
onboarding = path({ status: "loading", checklist: null, counters: null });
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toContain("dashboard.title");
|
expect(html).toContain("t(dashboard.title)");
|
||||||
expect(html).not.toContain('data-marker="empty-state"');
|
expect(html).not.toContain('data-marker="empty-state"');
|
||||||
expect(html).not.toContain('data-marker="filters"');
|
expect(html).not.toContain('data-marker="filters"');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||||
// Importing the real wizard module (for its initial state) pulls the import
|
// Importing the real wizard module (for its initial state) pulls the import
|
||||||
// services, then the database: stub the connection.
|
// services, then the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown }));
|
const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown }));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,7 @@ import type { TransactionFilters, TransactionRow } from "../shared/types";
|
||||||
|
|
||||||
// The page's services pull the database: stub the connection.
|
// The page's services pull the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({
|
|
||||||
t: (k: string, o?: Record<string, unknown>) =>
|
|
||||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
|
||||||
i18n: { language: "fr" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
const harness = vi.hoisted(() => ({
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
|
||||||
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||||
const ctx = vi.hoisted(() => ({ onboarding: null as unknown }));
|
const ctx = vi.hoisted(() => ({ onboarding: null as unknown }));
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
||||||
}));
|
|
||||||
vi.mock("../../contexts/OnboardingContext", () => ({
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
||||||
useOnboarding: () => ctx.onboarding,
|
useOnboarding: () => ctx.onboarding,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
41
src/test/i18nMock.test.ts
Normal file
41
src/test/i18nMock.test.ts
Normal file
|
|
@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
58
src/test/i18nMock.ts
Normal file
58
src/test/i18nMock.ts
Normal file
|
|
@ -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, unknown>): 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string, unknown>): 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 }) };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue