test: shared React hook harness for the jsdom-less tests (#409) #434

Merged
maximus merged 3 commits from issue-409-hook-harness into main 2026-10-14 00:28:24 +00:00
9 changed files with 853 additions and 325 deletions

View file

@ -587,6 +587,36 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.) - **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code. - **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
## Tests (vitest sans jsdom)
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
```ts
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
import { hookHarness } from "../test/hookHarness";
const harness = hookHarness();
```
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
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.
## CI/CD ## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle. Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.

View file

@ -1,8 +1,7 @@
// BudgetTable — how the grid tells the page its target (#400). // BudgetTable — how the grid tells the page its target (#400, #409).
// //
// renderToStaticMarkup runs no effect, so the report would stay unguarded. // renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's `useState`, `useRef` and `useEffect` are stubbed (technique of // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the
// grid runs as a plain function, its state and refs survive from one call to // grid runs as a plain function, its state and refs survive from one call to
// the next as between two renders of a mounted component, and `commit()` runs // the next as between two renders of a mounted component, and `commit()` runs
// the effects as React does, an effect only when one of its dependencies // the effects as React does, an effect only when one of its dependencies
@ -12,42 +11,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
effects: [] as Effect[],
/** Dependencies of each effect at its last run. */
ran: new Map<number, readonly unknown[] | undefined>(),
/** 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", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
const slot = (init: () => unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) harness.slots[index] = init();
return index;
};
return {
...actual,
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
useRef: (init: unknown) => harness.slots[slot(() => ({ current: init }))],
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
})); }));
@ -55,7 +23,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows"); const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return { return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => { useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false); const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false);
return { return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed), visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed, isCollapsed,
@ -64,7 +32,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
collapseAll: () => undefined, collapseAll: () => undefined,
allExpanded: (rows: T[]) => { allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc); const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key)); return keys.length > 0 && keys.every((key) => ctx.expanded.has(key));
}, },
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length, groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
}; };
@ -72,6 +40,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
}; };
}); });
import { hookHarness } from "../../test/hookHarness";
import BudgetTable from "./BudgetTable"; import BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types"; import type { BudgetOnboardingTargetKind } from "../../shared/types";
@ -89,43 +58,31 @@ const leaf = (category_id: number, parent_id: number | null = null): BudgetYearR
}); });
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)]; const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
const harness = hookHarness();
/** One render of the mounted grid: its hooks are called in the same order each time. */ /** One render of the mounted grid: its hooks are called in the same order each time. */
function render( function render(
rows: BudgetYearRow[], rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void, onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean, onboardingTarget?: boolean,
) { ) {
harness.cursor = 0; harness.render(() =>
harness.effects = []; BudgetTable({
BudgetTable({ rows,
rows, onUpdatePlanned: () => undefined,
onUpdatePlanned: () => undefined, onSplitEvenly: () => undefined,
onSplitEvenly: () => undefined, onboardingTarget,
onboardingTarget, onOnboardingTargetChange: onChange,
onOnboardingTargetChange: onChange, }),
}); );
} }
/** After a render: run each effect whose dependencies changed since its last run, as React does. */ /** After a render: run each effect whose dependencies changed since its last run, as React does. */
function commit() { const commit = () => harness.commit();
harness.effects.forEach((effect, index) => {
const previous = harness.ran.get(index);
const changed =
!harness.ran.has(index) ||
effect.deps === undefined ||
previous === undefined ||
effect.deps.some((dep, k) => !Object.is(dep, previous[k]));
harness.ran.set(index, effect.deps);
if (changed) effect.fn();
});
}
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.setters = []; ctx.expanded = new Set();
harness.effects = [];
harness.ran = new Map();
harness.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
}); });
@ -146,7 +103,7 @@ describe("BudgetTable — the target told to the page (#400)", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows. // The user opens the group: an editable row shows.
harness.expanded = new Set(["p:10"]); ctx.expanded = new Set(["p:10"]);
render(GROUPED, onChange, true); render(GROUPED, onChange, true);
commit(); commit();
render(GROUPED, onChange, true); render(GROUPED, onChange, true);

View file

@ -1,53 +1,37 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397). // OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
// //
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same // renderToStaticMarkup runs no click, so React's hooks come from the shared
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a // harness (src/test/hookHarness.ts): the slot runs as a plain function, and
// plain function, and its state survives from one call to the next as it // its state survives from one call to the next as it would between two
// would between two renders of a mounted component. // renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({
cursor: 0,
pathname: "/reports", pathname: "/reports",
onboarding: null as unknown, onboarding: null as unknown,
})); }));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }), useLocation: () => ({ pathname: ctx.pathname }),
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children, OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding, useOnboarding: () => ctx.onboarding,
})); }));
vi.mock("./Sidebar", () => ({ default: () => null })); vi.mock("./Sidebar", () => ({ default: () => null }));
import { hookHarness } from "../../test/hookHarness";
import AppShell, { OnboardingBannerSlot } from "./AppShell"; import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner"; import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath"; import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -79,21 +63,20 @@ function onboardingValue(): OnboardingValue {
/** One render of the mounted slot: its hooks are read in the same order each time. */ /** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps { function renderSlot(): OnboardingBannerProps {
harness.cursor = 0; const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
expect(element.type).toBe(OnboardingBanner); expect(element.type).toBe(OnboardingBanner);
return element.props; return element.props;
} }
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.pathname = "/reports"; ctx.pathname = "/reports";
harness.onboarding = onboardingValue(); ctx.onboarding = onboardingValue();
}); });
describe("OnboardingBannerSlot (#397)", () => { describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => { it("hands the shared path to the banner, the list folded", () => {
const value = harness.onboarding as OnboardingValue; const value = ctx.onboarding as OnboardingValue;
const props = renderSlot(); const props = renderSlot();
expect(props.mode).toBe("banner"); expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist); expect(props.checklist).toBe(value.checklist);
@ -105,7 +88,7 @@ describe("OnboardingBannerSlot (#397)", () => {
}); });
it("passes the mode and the entitlement as they come: the banner decides what to show", () => { it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false }; ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
const props = renderSlot(); const props = renderSlot();
expect(props.mode).toBe("none"); expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false); expect(props.budgetReady).toBe(false);
@ -121,19 +104,19 @@ describe("OnboardingBannerSlot (#397)", () => {
}); });
it("AppShell mounts one slot per route, so each page starts with the list folded", () => { it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import"; ctx.pathname = "/import";
const shell = AppShell() as ReactNode; const shell = harness.render(() => AppShell() as ReactNode);
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot); const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined(); expect(slot).toBeDefined();
expect(slot.key).toBe("/import"); expect(slot.key).toBe("/import");
harness.pathname = "/transactions"; ctx.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot); const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions"); expect(next.key).toBe("/transactions");
}); });
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => { it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = AppShell() as ReactNode; const shell = harness.render(() => AppShell() as ReactNode);
const [main] = find(shell, (e) => e.type === "main"); const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined(); expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function"); const [provider] = find(main.props.children, (e) => typeof e.type === "function");

View file

@ -1,46 +1,28 @@
// OnboardingStepCard — the connected card's wiring (#398). // OnboardingStepCard — the connected card's wiring (#398, #409).
// //
// renderToStaticMarkup runs no effect, so the focus move would stay unguarded. // renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
// React's `useRef` and `useEffect` are stubbed (technique of // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card // card runs as a plain function, its refs survive from one call to the next as
// runs as a plain function, its refs survive from one call to the next as they // they would between two renders of a mounted component, and `commit()` runs
// would between two renders of a mounted component, and its effects are // its effect when one of its dependencies changed, as React does. The router
// captured and run by hand. The router and the path are faked. // and the path are faked.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
const harness = vi.hoisted(() => ({
refs: [] as Array<{ current: unknown }>,
cursor: 0,
effects: [] as Effect[],
location: { pathname: "/import", state: null as unknown, key: "arrival-1" }, location: { pathname: "/import", state: null as unknown, key: "arrival-1" },
navigate: vi.fn(), navigate: vi.fn(),
onboarding: null as unknown, onboarding: null as unknown,
})); }));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useRef: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.refs)) harness.refs[index] = { current: init };
return harness.refs[index];
},
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => harness.location, useLocation: () => ctx.location,
useNavigate: () => harness.navigate, useNavigate: () => ctx.navigate,
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
import { hookHarness } from "../../test/hookHarness";
import OnboardingStepCard from "./OnboardingStepCard"; import OnboardingStepCard from "./OnboardingStepCard";
import StepCard, { type StepCardProps } from "./StepCard"; import StepCard, { type StepCardProps } from "./StepCard";
import { computeChecklist } from "../../utils/onboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist";
@ -48,6 +30,8 @@ import { nextStep, type StepCardModel } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
import type { OnboardingCounters } from "../../shared/types"; import type { OnboardingCounters } from "../../shared/types";
const harness = hookHarness();
const ZERO: OnboardingCounters = { const ZERO: OnboardingCounters = {
sources: 0, sources: 0,
imports: 0, imports: 0,
@ -86,28 +70,27 @@ const DONE: StepCardModel = { text: null, done: true };
/** One render of the mounted card: its hooks are called in the same order each time. */ /** One render of the mounted card: its hooks are called in the same order each time. */
function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null { function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null {
harness.cursor = 0; return harness.render(() => OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null);
harness.effects = [];
return OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null;
} }
/** The card's title ref: its first hook. */
const titleRef = () => harness.slots[0] as { current: unknown };
/** The title the card's ref points at, once React would have attached it. */ /** The title the card's ref points at, once React would have attached it. */
function attachTitle(): { focus: ReturnType<typeof vi.fn> } { function attachTitle(): { focus: ReturnType<typeof vi.fn> } {
const title = { focus: vi.fn() }; const title = { focus: vi.fn() };
harness.refs[0].current = title; titleRef().current = title;
return title; return title;
} }
const runEffects = () => { /** The commit of the last render: its effect runs if one of its dependencies changed. */
for (const effect of harness.effects) effect.fn(); const commit = () => harness.commit();
};
beforeEach(() => { beforeEach(() => {
harness.refs = []; harness.clear();
harness.effects = []; ctx.location = { pathname: "/import", state: null, key: "arrival-1" };
harness.location = { pathname: "/import", state: null, key: "arrival-1" }; ctx.navigate.mockReset();
harness.navigate.mockReset(); ctx.onboarding = path(FOLDER_SET);
harness.onboarding = path(FOLDER_SET);
}); });
describe("OnboardingStepCard — when it shows (#398)", () => { describe("OnboardingStepCard — when it shows (#398)", () => {
@ -119,18 +102,18 @@ describe("OnboardingStepCard — when it shows (#398)", () => {
it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => { it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => {
for (const mode of ["none", "banner", "complete"] as const) { for (const mode of ["none", "banner", "complete"] as const) {
harness.onboarding = path(FOLDER_SET, { mode }); ctx.onboarding = path(FOLDER_SET, { mode });
expect(render(), mode).toBeNull(); expect(render(), mode).toBeNull();
} }
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" }); ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
expect(render()).toBeNull(); expect(render()).toBeNull();
harness.onboarding = path(FOLDER_SET); ctx.onboarding = path(FOLDER_SET);
expect(render(null)).toBeNull(); expect(render(null)).toBeNull();
}); });
it("stays once the page's steps are done: the latch keeps it, not the next step", () => { it("stays once the page's steps are done: the latch keeps it, not the next step", () => {
harness.onboarding = path(IMPORTED); ctx.onboarding = path(IMPORTED);
const value = harness.onboarding as OnboardingValue; const value = ctx.onboarding as OnboardingValue;
expect(value.nextStep?.to).toBe("/transactions"); expect(value.nextStep?.to).toBe("/transactions");
const card = render(DONE); const card = render(DONE);
expect(card).not.toBeNull(); expect(card).not.toBeNull();
@ -145,27 +128,27 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(props.position).toEqual({ n: 3, total: 6 }); expect(props.position).toEqual({ n: 3, total: 6 });
expect(props.text).toBe(SOURCE_LIST.text); expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false); expect(props.done).toBe(false);
expect(props.titleRef).toBe(harness.refs[0]); expect(props.titleRef).toBe(titleRef());
}); });
it("the next step once done, and a wait while the next step is still on this page", () => { it("the next step once done, and a wait while the next step is still on this page", () => {
harness.onboarding = path(IMPORTED); ctx.onboarding = path(IMPORTED);
expect(render(DONE)!.props.next).toEqual({ expect(render(DONE)!.props.next).toEqual({
kind: "step", kind: "step",
step: (harness.onboarding as OnboardingValue).nextStep, step: (ctx.onboarding as OnboardingValue).nextStep,
}); });
// Before the re-read, the next step is still the source step, on /import. // Before the re-read, the next step is still the source step, on /import.
harness.onboarding = path(FOLDER_SET); ctx.onboarding = path(FOLDER_SET);
expect(render(DONE)!.props.next).toEqual({ kind: "wait" }); expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
}); });
it("no 'Step n of N' until the Budget entitlement is known", () => { it("no 'Step n of N' until the Budget entitlement is known", () => {
harness.onboarding = path(FOLDER_SET, { budgetReady: false }); ctx.onboarding = path(FOLDER_SET, { budgetReady: false });
expect(render()!.props.position).toBeNull(); expect(render()!.props.position).toBeNull();
}); });
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => { it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = harness.onboarding as OnboardingValue; const value = ctx.onboarding as OnboardingValue;
const props = render()!.props; const props = render()!.props;
props.onSkip(); props.onSkip();
expect(value.skip).toHaveBeenCalledWith("source"); expect(value.skip).toHaveBeenCalledWith("source");
@ -179,58 +162,60 @@ describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => {
const ARRIVAL = { onboarding: { focus: true } }; const ARRIVAL = { onboarding: { focus: true } };
it("moves the focus to the title, then consumes the navigation state", () => { it("moves the focus to the title, then consumes the navigation state", () => {
harness.location = { ...harness.location, state: ARRIVAL }; ctx.location = { ...ctx.location, state: ARRIVAL };
render(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null }); expect(ctx.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan( expect(vi.mocked(ctx.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
title.focus.mock.invocationCallOrder[0], title.focus.mock.invocationCallOrder[0],
); );
}); });
it("does it once per arrival: StrictMode's second run and later renders do nothing", () => { it("does it once per arrival: StrictMode's second run and later renders do nothing", () => {
harness.location = { ...harness.location, state: ARRIVAL }; ctx.location = { ...ctx.location, state: ARRIVAL };
render(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
runEffects(); harness.replayEffects();
render(); render();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1);
// A new arrival on the same mounted page asks again. // A new arrival on the same mounted page asks again.
harness.location = { ...harness.location, key: "arrival-2" }; ctx.location = { ...ctx.location, key: "arrival-2" };
render(); render();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(2); expect(title.focus).toHaveBeenCalledTimes(2);
expect(harness.navigate).toHaveBeenCalledTimes(2); expect(ctx.navigate).toHaveBeenCalledTimes(2);
}); });
it("waits for the card to show before focusing and consuming", () => { it("waits for the card to show before focusing and consuming", () => {
harness.location = { ...harness.location, state: ARRIVAL }; ctx.location = { ...ctx.location, state: ARRIVAL };
expect(render(null)).toBeNull(); expect(render(null)).toBeNull();
runEffects(); commit();
expect(harness.navigate).not.toHaveBeenCalled(); expect(ctx.navigate).not.toHaveBeenCalled();
render(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1);
}); });
it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => { it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => {
for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) { for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) {
harness.location = { ...harness.location, state }; // A fresh mount per visit: its effect runs at its first commit.
harness.clear();
ctx.location = { ...ctx.location, state };
render(); render();
const title = attachTitle(); const title = attachTitle();
expect(() => runEffects()).not.toThrow(); expect(() => commit()).not.toThrow();
expect(title.focus).not.toHaveBeenCalled(); expect(title.focus).not.toHaveBeenCalled();
} }
expect(harness.navigate).not.toHaveBeenCalled(); expect(ctx.navigate).not.toHaveBeenCalled();
}); });
}); });

View file

@ -1,37 +1,18 @@
// useOnboardingChecklist — the hook's own wiring (#396). // useOnboardingChecklist — the hook's own wiring (#396, #409).
// //
// The reducer, the loader, the follower and the derivation are tested in // The reducer, the loader, the follower and the derivation are tested in
// useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what // useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what
// the hook does with React would stay unguarded. Here React's hooks are // the hook does with React would stay unguarded. Here React's hooks come from
// stubbed (same technique as useImportWizard.executeImport.test.ts): the hook // the shared harness (src/test/hookHarness.ts): the hook runs as a plain
// runs as a plain function on a prepared state, its effects are captured and // function, its state, memos and callbacks survive from one render to the
// run by hand, and the onboarding service is mocked. // next as in a mounted provider, its reducer is applied, and `commit()` runs
// its effects as React does. The onboarding service is mocked.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ pathname: "/" }));
const harness = vi.hoisted(() => ({ vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
state: null as unknown,
dispatch: vi.fn(),
effects: [] as Effect[],
pathname: "/",
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useReducer: () => [harness.state, harness.dispatch],
useState: (init: unknown) => [typeof init === "function" ? (init as () => unknown)() : init, () => undefined],
useCallback: (fn: unknown) => fn,
useMemo: (fn: () => unknown) => fn(),
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: harness.pathname }) }));
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) })); vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() })); vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../services/onboardingService", () => ({ vi.mock("../services/onboardingService", () => ({
@ -43,6 +24,7 @@ vi.mock("../services/onboardingService", () => ({
resumeStep: vi.fn(), resumeStep: vi.fn(),
})); }));
import { hookHarness } from "../test/hookHarness";
import { import {
useOnboardingChecklist, useOnboardingChecklist,
initialOnboardingChecklistState, initialOnboardingChecklistState,
@ -61,6 +43,8 @@ import {
type OnboardingSnapshot, type OnboardingSnapshot,
} from "../services/onboardingService"; } from "../services/onboardingService";
const harness = hookHarness();
const snapshot: OnboardingSnapshot = { const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true, importFolderSet: true,
@ -73,19 +57,25 @@ const ready: OnboardingChecklistState = [
{ type: "LOAD_DONE", seq: 1, snapshot } as const, { type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState); ].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** One render of the mounted provider. */
const render = () => harness.render(useOnboardingChecklist);
/** The state the provider mounts with: its `useReducer` is its first React hook. */
const mountWith = (state: OnboardingChecklistState) => {
harness.slots[0] = state;
};
/** Lets the loader's promises settle. */ /** Lets the loader's promises settle. */
const flush = async () => { const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve(); for (let i = 0; i < 10; i++) await Promise.resolve();
}; };
const actionsOfType = (type: string) => const actionsOfType = (type: string) =>
harness.dispatch.mock.calls.map((c) => c[0]).filter((a) => a.type === type); harness.dispatch.mock.calls.map((c) => c[0] as { type: string }).filter((a) => a.type === type);
beforeEach(() => { beforeEach(() => {
harness.state = initialOnboardingChecklistState; harness.clear();
harness.pathname = "/"; ctx.pathname = "/";
harness.effects = [];
harness.dispatch.mockReset();
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot); vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false }); vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) { for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
@ -95,18 +85,15 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
harness.unmount();
vi.useRealTimers(); vi.useRealTimers();
}); });
describe("useOnboardingChecklist — wiring (#396)", () => { describe("useOnboardingChecklist — wiring (#396)", () => {
it("reads at mount, through rule D9, and again whenever the route changes", async () => { it("reads at mount, through rule D9, and again whenever the route changes", async () => {
harness.pathname = "/import"; ctx.pathname = "/import";
useOnboardingChecklist(); render();
const [reread] = harness.effects; harness.commit();
// The route is a dependency: React runs the read again on every new pathname.
expect(reread.deps).toContain("/import");
reread.fn();
await flush(); await flush();
expect(initializeGuidedPath).toHaveBeenCalledTimes(1); expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
@ -114,15 +101,37 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
{ type: "LOAD_START", seq: 1 }, { type: "LOAD_START", seq: 1 },
{ type: "LOAD_DONE", seq: 1, snapshot }, { type: "LOAD_DONE", seq: 1, snapshot },
]); ]);
// The loaded state renders: same route, no new read.
const loaded = render();
harness.commit();
await flush();
expect(loaded.status).toBe("ready");
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
// A new route: React runs the read again.
ctx.pathname = "/transactions";
render();
harness.commit();
await flush();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
}); });
it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => { it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers(); vi.useFakeTimers();
useOnboardingChecklist(); harness.dispatch.mockReset();
const follow = harness.effects[1];
expect(follow.deps).not.toContain(harness.pathname); // A route change re-runs the read, not the follower: one subscription only.
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
harness.dispatch.mockReset();
const cleanup = follow.fn() as () => void;
notifyDataChanged(); notifyDataChanged();
notifyDataChanged(); notifyDataChanged();
vi.advanceTimersByTime(149); vi.advanceTimersByTime(149);
@ -131,28 +140,28 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(actionsOfType("LOAD_START")).toHaveLength(1); expect(actionsOfType("LOAD_START")).toHaveLength(1);
await flush(); await flush();
cleanup(); harness.unmount();
notifyDataChanged(); notifyDataChanged();
vi.advanceTimersByTime(300); vi.advanceTimersByTime(300);
expect(actionsOfType("LOAD_START")).toHaveLength(1); expect(actionsOfType("LOAD_START")).toHaveLength(1);
}); });
it("stores a card latch that changed during render, and only then", () => { it("stores a card latch that changed during render, and only then", () => {
harness.state = { ...ready, cardPage: "/import" }; mountWith({ ...ready, cardPage: "/import" });
harness.pathname = "/reports"; ctx.pathname = "/reports";
useOnboardingChecklist(); render();
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_CARD_PAGE", cardPage: null }); expect(harness.dispatch.mock.calls).toEqual([[{ type: "SET_CARD_PAGE", cardPage: null }]]);
// The reducer applied it: the next render finds the latch stored.
harness.dispatch.mockReset(); harness.dispatch.mockReset();
harness.state = ready; expect(render().cardPage).toBeNull();
useOnboardingChecklist();
expect(harness.dispatch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled();
}); });
it("shares the derived next step and mode with the state", () => { it("shares the derived next step and mode with the state", () => {
harness.state = ready; mountWith(ready);
harness.pathname = "/reports"; ctx.pathname = "/reports";
const value = useOnboardingChecklist(); const value = render();
expect(value.status).toBe("ready"); expect(value.status).toBe("ready");
expect(value.nextStep?.key).toBe("manualCategory"); expect(value.nextStep?.key).toBe("manualCategory");
expect(value.mode).toBe("banner"); expect(value.mode).toBe("banner");
@ -161,8 +170,8 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
}); });
it("each action writes its preference first, then updates the state", async () => { it("each action writes its preference first, then updates the state", async () => {
harness.state = ready; mountWith(ready);
const value = useOnboardingChecklist(); const value = render();
await value.hide(); await value.hide();
await value.show(); await value.show();
await value.skip("budget"); await value.skip("budget");
@ -183,20 +192,68 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
}); });
it("an action whose write fails leaves the state alone and logs", async () => { it("an action whose write fails leaves the state alone and logs", async () => {
harness.state = ready; mountWith(ready);
const value = useOnboardingChecklist(); const value = render();
vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked")); vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked"));
vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked")); vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked"));
await value.hide(); await value.hide();
await value.skip("source"); await value.skip("source");
expect(harness.dispatch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledTimes(2); expect(logError).toHaveBeenCalledTimes(2);
expect(render().hidden).toBe(false);
}); });
it("refresh is the loader itself", async () => { it("refresh is the loader itself", async () => {
const value = useOnboardingChecklist(); const value = render();
await value.refresh(); await value.refresh();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
expect(actionsOfType("LOAD_DONE")).toHaveLength(1); expect(actionsOfType("LOAD_DONE")).toHaveLength(1);
}); });
}); });
describe("useOnboardingChecklist — what survives a render (#409)", () => {
it("keeps one loader for the provider's life: refresh, the value and the reads stay put", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const first = render();
harness.commit();
await flush();
const reads = vi.mocked(getOnboardingSnapshot).mock.calls.length;
const second = render();
harness.commit();
await flush();
expect(second.refresh).toBe(first.refresh);
// Nothing changed: the shared value keeps its identity, so no consumer re-renders.
expect(second).toBe(first);
// A new loader would re-run the read effect at every render: a loop of reads.
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(reads);
});
it("'Skip' moves the next step on the next render, without a re-read", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const before = render();
expect(before.nextStep?.key).toBe("manualCategory");
await before.skip("manualCategory");
const after = render();
expect(getOnboardingSnapshot).not.toHaveBeenCalled();
expect(after.checklist).not.toBe(before.checklist);
expect(after.nextStep?.key).toBe("budget");
});
it("a change of mode alone reaches the consumers", () => {
// An empty profile: the dashboard shows its own empty state, other pages the banner.
mountWith({ ...ready, counters: { ...snapshot.counters, transactions: 0 } });
ctx.pathname = "/";
const dashboard = render();
ctx.pathname = "/reports";
const reports = render();
expect(reports.checklist).toBe(dashboard.checklist);
expect(reports.nextStep).toBe(dashboard.nextStep);
expect(reports.cardPage).toBe(dashboard.cardPage);
expect(reports.mode).not.toBe(dashboard.mode);
expect(reports.mode).toBe("banner");
});
});

View file

@ -1,7 +1,7 @@
// BudgetPage — what the page does with the grid's target (#400). // BudgetPage — what the page does with the grid's target (#400, #409).
// //
// A static render runs no effect, so the grid never reports there. React's // A static render runs no effect, so the grid never reports there. React's
// `useState` is stubbed (technique of AppShell.slot.test.tsx): the page runs // hooks come from the shared harness (src/test/hookHarness.ts): the page runs
// as a plain function, its state survives from one call to the next as // as a plain function, its state survives from one call to the next as
// between two renders of a mounted component, and its setters keep their // between two renders of a mounted component, and its setters keep their
// identity as React's do. The test plays the grid by calling the callback the // identity as React's do. The test plays the grid by calling the callback the
@ -9,50 +9,31 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
pathname: "/budget", pathname: "/budget",
onboarding: null as unknown, onboarding: null as unknown,
budget: null as unknown, budget: null as unknown,
})); }));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }), useLocation: () => ({ pathname: ctx.pathname }),
})); }));
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), 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: () => harness.onboarding })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
vi.mock("../hooks/useReportsPeriod", () => ({ vi.mock("../hooks/useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }), useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
})); }));
vi.mock("../hooks/useBudget", async (importOriginal) => ({ vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()), ...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => harness.budget, useBudget: () => ctx.budget,
})); }));
import { hookHarness } from "../test/hookHarness";
import BudgetPage from "./BudgetPage"; import BudgetPage from "./BudgetPage";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
@ -62,6 +43,8 @@ import { computeChecklist } from "../utils/onboardingChecklist";
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath"; import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types"; import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -103,8 +86,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
/** One render of the mounted page: its hooks are read in the same order each time. */ /** One render of the mounted page: its hooks are read in the same order each time. */
function render() { function render() {
harness.cursor = 0; const page = harness.render(() => BudgetPage() as AnyElement);
const page = BudgetPage() as AnyElement;
const [card] = find(page, (el) => el.type === OnboardingStepCard); const [card] = find(page, (el) => el.type === OnboardingStepCard);
const [grid] = find(page, (el) => el.type === BudgetTable); const [grid] = find(page, (el) => el.type === BudgetTable);
return { return {
@ -116,12 +98,11 @@ function render() {
} }
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.setters = []; ctx.pathname = "/budget";
harness.pathname = "/budget"; ctx.onboarding = path(BUDGET_LEFT);
harness.onboarding = path(BUDGET_LEFT);
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 }; const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
harness.budget = new Proxy({ state } as Record<string, unknown>, { ctx.budget = new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : vi.fn()), get: (target, key) => (key in target ? target[key as string] : vi.fn()),
}); });
}); });
@ -136,7 +117,7 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
}); });
it("follows what the grid reports: its sentence, and the ring once a target is known", () => { it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
const checklist = (harness.onboarding as OnboardingValue).checklist; const checklist = (ctx.onboarding as OnboardingValue).checklist;
let view = render(); let view = render();
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist)); expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
expect(view.highlight).toBe(false); expect(view.highlight).toBe(false);
@ -164,16 +145,15 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
{ mode: "complete" as const, cardPage: null }, { mode: "complete" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" }, { mode: "card" as const, cardPage: "/transactions" },
]) { ]) {
harness.slots = []; harness.clear();
harness.setters = []; ctx.onboarding = path(BUDGET_LEFT, overrides);
harness.onboarding = path(BUDGET_LEFT, overrides);
render().report("cell"); render().report("cell");
expect(render().highlight, JSON.stringify(overrides)).toBe(false); expect(render().highlight, JSON.stringify(overrides)).toBe(false);
} }
}); });
it("asks for no ring once the step is checked: the card says 'Done.'", () => { it("asks for no ring once the step is checked: the card says 'Done.'", () => {
harness.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 }); ctx.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
render().report("cell"); render().report("cell");
const view = render(); const view = render();
expect(view.model).toEqual({ text: null, done: true, target: null }); expect(view.model).toEqual({ text: null, done: true, target: null });
@ -195,15 +175,14 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
budgetAllowed: true, budgetAllowed: true,
skipped: ["source", "import", "manualCategory"], skipped: ["source", "import", "manualCategory"],
}); });
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) }); ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = harness.budget as { state: BudgetState }; const budget = ctx.budget as { state: BudgetState };
budget.state = { ...budget.state, totalInDb: null }; budget.state = { ...budget.state, totalInDb: null };
render().report("expandAll"); render().report("expandAll");
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed"); expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
budget.state = { ...budget.state, totalInDb: 0 }; budget.state = { ...budget.state, totalInDb: 0 };
harness.cursor = 0; const page = harness.render(() => BudgetPage() as AnyElement);
const page = BudgetPage() as AnyElement;
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0); expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
const [card] = find(page, (el) => el.type === OnboardingStepCard); const [card] = find(page, (el) => el.type === OnboardingStepCard);
expect(card.props.model).toEqual({ text: null, done: false, target: null }); expect(card.props.model).toEqual({ text: null, done: false, target: null });

View file

@ -1,41 +1,21 @@
// UsersSettingsPage — the re-show button reads the shared path (#397). // UsersSettingsPage — the re-show button reads the shared path (#397, #409).
// //
// React's `useState` is stubbed (same technique as // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function // page runs as a plain function, its state survives from one call to the
// and its state survives from one call to the next, as between two renders of // next, as between two renders of the mounted page, and `commit()` runs its
// the mounted page. So "the path got hidden by the banner of this very page" // effects as React would. So "the path got hidden by the banner of this very
// is a new `useOnboarding()` value on the next render, without remounting. // page" is a new `useOnboarding()` value on the next render, without
// remounting, and an effect of the page that read the preference would run.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({ onboarding: null as unknown }));
cursor: 0,
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ vi.mock("../../contexts/OnboardingContext", () => ({
useOnboarding: () => harness.onboarding, useOnboarding: () => ctx.onboarding,
})); }));
// The page's cards are not under test, and they reach tauri. // The page's cards are not under test, and they reach tauri.
vi.mock("../../components/settings/AccountCard", () => ({ default: () => null })); vi.mock("../../components/settings/AccountCard", () => ({ default: () => null }));
@ -46,10 +26,13 @@ vi.mock("../../components/settings/DocsContent", () => ({ default: () => null })
vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() })); vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() }));
vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() })); vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() }));
import { hookHarness } from "../../test/hookHarness";
import UsersSettingsPage from "./UsersSettingsPage"; import UsersSettingsPage from "./UsersSettingsPage";
import { getPreference } from "../../services/userPreferenceService"; import { getPreference } from "../../services/userPreferenceService";
import { showChecklist } from "../../services/onboardingService"; import { showChecklist } from "../../services/onboardingService";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -71,10 +54,11 @@ function path(hidden: boolean) {
return { hidden, show: vi.fn(async () => undefined) }; return { hidden, show: vi.fn(async () => undefined) };
} }
/** One render of the mounted page. */ /** One render of the mounted page, then its commit. */
function renderPage(): ReactNode { function renderPage(): ReactNode {
harness.cursor = 0; const page = harness.render(() => UsersSettingsPage() as ReactNode);
return UsersSettingsPage() as ReactNode; harness.commit();
return page;
} }
const showButton = (page: ReactNode) => const showButton = (page: ReactNode) =>
@ -83,8 +67,8 @@ const confirmation = (page: ReactNode) =>
find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)")); find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)"));
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.onboarding = path(false); ctx.onboarding = path(false);
vi.mocked(getPreference).mockReset(); vi.mocked(getPreference).mockReset();
vi.mocked(showChecklist).mockReset(); vi.mocked(showChecklist).mockReset();
}); });
@ -99,7 +83,7 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => { it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => {
expect(showButton(renderPage())).toHaveLength(0); expect(showButton(renderPage())).toHaveLength(0);
harness.onboarding = path(true); ctx.onboarding = path(true);
const page = renderPage(); const page = renderPage();
expect(texts(page)).toContain("t(settings.users.onboarding.label)"); expect(texts(page)).toContain("t(settings.users.onboarding.label)");
expect(showButton(page)).toHaveLength(1); expect(showButton(page)).toHaveLength(1);
@ -108,14 +92,14 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("the button shows the path through the provider, then confirms in place", async () => { it("the button shows the path through the provider, then confirms in place", async () => {
const hiddenPath = path(true); const hiddenPath = path(true);
harness.onboarding = hiddenPath; ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
expect(hiddenPath.show).toHaveBeenCalledTimes(1); expect(hiddenPath.show).toHaveBeenCalledTimes(1);
expect(showChecklist).not.toHaveBeenCalled(); expect(showChecklist).not.toHaveBeenCalled();
// The provider applied SHOW: the next render reads the path shown. // The provider applied SHOW: the next render reads the path shown.
harness.onboarding = path(false); ctx.onboarding = path(false);
const page = renderPage(); const page = renderPage();
expect(showButton(page)).toHaveLength(0); expect(showButton(page)).toHaveLength(0);
expect(confirmation(page)).toHaveLength(1); expect(confirmation(page)).toHaveLength(1);
@ -123,13 +107,13 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
}); });
it("hidden again after a restore, it offers the button again", async () => { it("hidden again after a restore, it offers the button again", async () => {
harness.onboarding = path(true); ctx.onboarding = path(true);
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
harness.onboarding = path(false); ctx.onboarding = path(false);
expect(confirmation(renderPage())).toHaveLength(1); expect(confirmation(renderPage())).toHaveLength(1);
harness.onboarding = path(true); ctx.onboarding = path(true);
const page = renderPage(); const page = renderPage();
expect(showButton(page)).toHaveLength(1); expect(showButton(page)).toHaveLength(1);
expect(confirmation(page)).toHaveLength(0); expect(confirmation(page)).toHaveLength(0);
@ -137,7 +121,7 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("keeps the button when showing failed: the provider left the path hidden", async () => { it("keeps the button when showing failed: the provider left the path hidden", async () => {
const hiddenPath = path(true); const hiddenPath = path(true);
harness.onboarding = hiddenPath; ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
const page = renderPage(); const page = renderPage();
@ -145,8 +129,10 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
expect(confirmation(page)).toHaveLength(0); expect(confirmation(page)).toHaveLength(0);
}); });
it("never reads the preference itself", () => { it("never reads the preference itself, neither while rendering nor in an effect", () => {
harness.onboarding = path(true); ctx.onboarding = path(true);
renderPage();
ctx.onboarding = path(false);
renderPage(); renderPage();
expect(getPreference).not.toHaveBeenCalled(); expect(getPreference).not.toHaveBeenCalled();
}); });

View file

@ -0,0 +1,286 @@
// hookHarness — each simulated semantic proved by its own test (#409).
//
// The tests that rely on the harness can only be as true as it is. Here it is
// driven directly: a harness of its own, a fake React built over the real one,
// and small components written for the test.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { readFileSync } from "node:fs";
import * as actualReact from "react";
import { createHookHarness, fakeReact, hookHarness, type HookHarness } from "./hookHarness";
let harness: HookHarness;
let R: typeof actualReact;
beforeEach(() => {
harness = createHookHarness();
R = fakeReact(actualReact, harness);
});
describe("hookHarness — the module itself", () => {
it("imports React only as a type: a value import would load the mock it builds", () => {
// The code only: the header comment shows the pattern of use.
const source = readFileSync(new URL("./hookHarness.ts", import.meta.url), "utf8")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
const reactImports = source.split("\n").filter((line) => /from\s+["']react["']/.test(line));
expect(reactImports.length).toBeGreaterThan(0);
for (const line of reactImports) expect(line).toMatch(/^import type /);
expect(source).not.toMatch(/require\(["']react["']\)/);
expect(source).not.toMatch(/import\(["']react["']\)/);
});
it("shares one harness through its accessor", () => {
expect(hookHarness()).toBe(hookHarness());
});
it("leaves the rest of React real", () => {
expect(R.isValidElement).toBe(actualReact.isValidElement);
expect(R.Fragment).toBe(actualReact.Fragment);
expect(R.createElement).toBe(actualReact.createElement);
});
});
describe("hookHarness — useState", () => {
it("calls the initializer once, at the first render", () => {
const init = vi.fn(() => 1);
const Counter = () => R.useState(init)[0];
expect(harness.render(Counter)).toBe(1);
expect(harness.render(Counter)).toBe(1);
expect(init).toHaveBeenCalledTimes(1);
});
it("keeps a stable setter, applies functional updates, and does not re-render", () => {
let renders = 0;
const Counter = () => {
renders++;
return R.useState(0);
};
const [, set] = harness.render(Counter);
set(5);
set((n: number) => n + 1);
expect(renders).toBe(1);
const [value, again] = harness.render(Counter);
expect(value).toBe(6);
expect(again).toBe(set);
});
it("keeps each slot apart, by call order", () => {
const Pair = () => [R.useState("a")[0], R.useState("b")[0]];
expect(harness.render(Pair)).toEqual(["a", "b"]);
});
it("reads a slot the test preset before the first render", () => {
harness.slots[0] = "preset";
expect(harness.render(() => R.useState("init")[0])).toBe("preset");
});
});
describe("hookHarness — useReducer", () => {
const reducer = (state: number, action: { by: number }) => state + action.by;
it("applies the reducer, keeps a stable dispatch, and records every action in the spy", () => {
const Sum = () => R.useReducer(reducer, 10);
const [state, dispatch] = harness.render(Sum);
expect(state).toBe(10);
dispatch({ by: 2 });
dispatch({ by: 3 });
const [next, again] = harness.render(Sum);
expect(next).toBe(15);
expect(again).toBe(dispatch);
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([{ by: 2 }, { by: 3 }]);
});
it("calls the init function once", () => {
const init = vi.fn((n: number) => n * 2);
const Sum = () => R.useReducer(reducer, 4, init)[0];
expect(harness.render(Sum)).toBe(8);
expect(harness.render(Sum)).toBe(8);
expect(init).toHaveBeenCalledTimes(1);
});
});
describe("hookHarness — useRef", () => {
it("keeps one object from render to render", () => {
const Ref = () => R.useRef(0);
const first = harness.render(Ref);
first.current = 7;
const second = harness.render(Ref);
expect(second).toBe(first);
expect(second.current).toBe(7);
});
});
describe("hookHarness — useMemo and useCallback", () => {
it("useMemo keeps its value while the dependencies are equal, recomputes when one changes", () => {
const compute = vi.fn((n: number) => ({ n }));
let dep = 1;
const Memo = () => R.useMemo(() => compute(dep), [dep]);
const first = harness.render(Memo);
expect(harness.render(Memo)).toBe(first);
expect(compute).toHaveBeenCalledTimes(1);
dep = 2;
const second = harness.render(Memo);
expect(second).not.toBe(first);
expect(second).toEqual({ n: 2 });
expect(compute).toHaveBeenCalledTimes(2);
});
it("compares the dependencies with Object.is", () => {
let dep: unknown = NaN;
const compute = vi.fn(() => ({}));
const Memo = () => R.useMemo(compute, [dep]);
harness.render(Memo);
harness.render(Memo);
expect(compute).toHaveBeenCalledTimes(1);
dep = { same: true };
harness.render(Memo);
dep = { same: true };
harness.render(Memo);
expect(compute).toHaveBeenCalledTimes(3);
});
it("useMemo without dependencies recomputes at every render", () => {
const compute = vi.fn(() => ({}));
const Memo = () => R.useMemo(compute, undefined as unknown as []);
harness.render(Memo);
harness.render(Memo);
expect(compute).toHaveBeenCalledTimes(2);
});
it("useCallback keeps the first function while the dependencies are equal", () => {
let dep = "a";
const Callback = () => R.useCallback(() => dep, [dep]);
const first = harness.render(Callback);
expect(harness.render(Callback)).toBe(first);
dep = "b";
const second = harness.render(Callback);
expect(second).not.toBe(first);
expect(second()).toBe("b");
});
});
describe("hookHarness — useEffect", () => {
const log: string[] = [];
let dep = 1;
const Effects = () => {
const d = dep;
R.useEffect(() => {
log.push(`run ${d}`);
return () => {
log.push(`cleanup ${d}`);
};
}, [d]);
R.useEffect(() => {
log.push("once");
}, []);
};
beforeEach(() => {
log.length = 0;
dep = 1;
});
it("captures the effects during the render, without running them", () => {
harness.render(Effects);
expect(log).toEqual([]);
});
it("commit runs them all at the first commit, then only those whose dependencies changed", () => {
harness.render(Effects);
harness.commit();
expect(log).toEqual(["run 1", "once"]);
harness.render(Effects);
harness.commit();
expect(log).toEqual(["run 1", "once"]);
dep = 2;
harness.render(Effects);
harness.commit();
// The cleanup of the previous run, with its own closure, before the new run.
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 2"]);
});
it("commits a render once", () => {
const fn = vi.fn();
const Always = () => R.useEffect(fn);
harness.render(Always);
harness.commit();
harness.commit();
expect(fn).toHaveBeenCalledTimes(1);
harness.render(Always);
harness.commit();
expect(fn).toHaveBeenCalledTimes(2);
});
it("runs every cleanup before any effect of the same commit", () => {
const order: string[] = [];
let n = 0;
const Two = () => {
const k = n;
R.useEffect(() => {
order.push(`a${k}`);
return () => {
order.push(`~a${k}`);
};
}, [k]);
R.useEffect(() => {
order.push(`b${k}`);
return () => {
order.push(`~b${k}`);
};
}, [k]);
};
harness.render(Two);
harness.commit();
n = 1;
harness.render(Two);
harness.commit();
expect(order).toEqual(["a0", "b0", "~a0", "~b0", "a1", "b1"]);
});
it("replayEffects plays StrictMode's double run: every cleanup, then every effect again", () => {
harness.render(Effects);
harness.commit();
harness.replayEffects();
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 1", "once"]);
});
it("unmount runs every cleanup, once", () => {
harness.render(Effects);
harness.commit();
harness.unmount();
harness.unmount();
expect(log).toEqual(["run 1", "once", "cleanup 1"]);
});
it("clear starts a fresh mount", () => {
harness.render(Effects);
harness.commit();
harness.dispatch("x");
harness.clear();
expect(harness.slots).toEqual([]);
expect(harness.dispatch).not.toHaveBeenCalled();
harness.render(Effects);
harness.commit();
expect(log).toEqual(["run 1", "once", "run 1", "once"]);
});
});
describe("hookHarness — hooks it does not simulate", () => {
it.each(["useContext", "useLayoutEffect", "useSyncExternalStore", "useId", "useTransition", "use"])(
"%s throws instead of reaching the real React",
(name) => {
const hook = (R as unknown as Record<string, () => unknown>)[name];
expect(hook).toBeTypeOf("function");
expect(() => hook()).toThrow(`hookHarness: ${name} is not simulated`);
},
);
it("on the default export too", () => {
const fake = R as unknown as { default: Record<string, (...args: unknown[]) => unknown> };
expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated");
expect(fake.default.useState).toBe((R as unknown as Record<string, unknown>).useState);
});
});

265
src/test/hookHarness.ts Normal file
View file

@ -0,0 +1,265 @@
// hookHarness — React's hooks with their semantics, for tests without jsdom (#409).
//
// The repository has no jsdom: tests call hooks and components as plain
// functions, under `vi.mock("react")`. A fake hook that drops React's
// semantics (an initializer called at every render, a memo recomputed every
// time) leaves one-line mutations green. This module is the one harness those
// tests share, so the semantics are written, and proved, once
// (hookHarness.test.ts).
//
// 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", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
// import { hookHarness } from "../test/hookHarness";
// const harness = hookHarness();
//
// then, per test: `harness.clear()` in `beforeEach` (a fresh mount),
// `harness.render(() => Component(props))` for one render of the mounted
// component, `harness.commit()` to run its effects. Every test module gets its
// own copy of this module, so the singleton is shared by the mock and the test
// of one file, never across files. Values the test changes between renders
// (a pathname, a context value) live in a `vi.hoisted` object of the test.
//
// SEMANTICS
// - Slots by call order, as React: `render()` resets the cursor, so a
// component must call its hooks in the same order at every render. A test
// may preset the slot of a `useState` or `useReducer` (its value) before the
// first render.
// - `useState`: initializer called once (at the first render), stable setter,
// functional updates applied to the latest value. A setter does NOT
// re-render: the test renders again when it wants to read the new state.
// - `useReducer`: the reducer IS applied (the slot holds the new state for the
// next render), its `dispatch` is stable, and every action is also recorded
// by `harness.dispatch`, a `vi.fn()` spy shared by all the reducers.
// - `useRef`: one object per slot, kept from render to render.
// - `useMemo` / `useCallback`: recomputed only when a dependency changed
// (`Object.is`, as React); without a dependency array, at every render.
// - `useEffect`: captured during the render, never run by it. `commit()` runs
// the effects of the last render whose dependencies changed since their last
// run (all of them at the first commit, every time without an array): first
// the cleanups of those effects, then the effects, in call order, as React.
// A render is committed once. `replayEffects()` plays StrictMode's double
// run at mount (every cleanup, then every effect again); `unmount()` runs
// every cleanup.
// - Any other hook of React (`useContext`, `useLayoutEffect`,
// `useSyncExternalStore`, `useId`, `use`…) THROWS: it would otherwise reach
// the real React outside a render ("Invalid hook call"), and a test would go
// red by accident rather than on its assertion. Mock the module that calls
// it, or cover the hook here with its own test.
// - Everything else (`isValidElement`, `Fragment`, `createElement`…) is the
// real React, on the named exports and on the default export.
//
// NOT SIMULATED: re-rendering (on a setter, a dispatch, a context change),
// children (a component in the returned tree is an element, never called),
// the DOM, concurrent features, the bail-out of a setter called with the same
// value, the render-phase update loop React runs for a dispatch during render
// (the next `render()` reads it).
//
// React is imported here only as a type: a value import, from inside the
// factory that builds the mock of "react", would load that very mock.
import type * as React from "react";
import { vi, type Mock } from "vitest";
type Deps = readonly unknown[] | undefined;
type EffectFn = () => void | (() => void);
const EFFECT = Symbol("hookHarness.effect");
interface EffectSlot {
[EFFECT]: true;
/** Dependencies at the last run, `null` before the first one. */
ran: Deps | null;
cleanup: (() => void) | undefined;
}
interface PendingEffect {
index: number;
fn: EffectFn;
deps: Deps;
}
export interface HookHarness {
/** The slots of the mounted component, by call order; a test may preset a state slot before the first render. */
slots: unknown[];
/** Every action dispatched to any `useReducer` of the component, in order. */
dispatch: Mock<(action: unknown) => void>;
/** One render of the mounted component: resets the cursor and the captured effects, then calls `fn`. */
render<T>(fn: () => T): T;
/** Starts a render by hand (cursor at 0, no captured effect), for a test that calls the component itself. */
reset(): void;
/** Runs the effects of the last render whose dependencies changed, cleanups first; once per render. */
commit(): void;
/** StrictMode at mount: every cleanup of the last render's effects, then every effect again. */
replayEffects(): void;
/** Runs every cleanup, as React does when the component goes away. */
unmount(): void;
/** A fresh mount: no slot, no effect, no recorded action. */
clear(): void;
}
type Internal = HookHarness & {
writers: Array<((arg: unknown) => void) | undefined>;
next(): number;
capture(effect: PendingEffect): void;
};
function depsChanged(previous: Deps | null, next: Deps): boolean {
if (previous === null || previous === undefined || next === undefined) return true;
if (previous.length !== next.length) return true;
return next.some((dep, k) => !Object.is(dep, previous[k]));
}
const isEffectSlot = (slot: unknown): slot is EffectSlot =>
typeof slot === "object" && slot !== null && EFFECT in slot;
/** A harness of its own, for the harness's tests; the mock uses the shared one (`hookHarness()`). */
export function createHookHarness(): HookHarness {
let cursor = 0;
let pending: PendingEffect[] = [];
/** A render is committed once, as React does. */
let committed = false;
const run = (effects: PendingEffect[]) => {
for (const { index } of effects) {
const slot = harness.slots[index] as EffectSlot;
const cleanup = slot.cleanup;
slot.cleanup = undefined;
cleanup?.();
}
for (const { index, fn, deps } of effects) {
const slot = harness.slots[index] as EffectSlot;
slot.ran = deps;
const cleanup = fn();
slot.cleanup = typeof cleanup === "function" ? cleanup : undefined;
}
};
const harness: Internal = {
slots: [],
writers: [],
dispatch: vi.fn(),
render(fn) {
harness.reset();
return fn();
},
reset() {
cursor = 0;
pending = [];
committed = false;
},
commit() {
if (committed) return;
committed = true;
run(pending.filter(({ index, deps }) => depsChanged((harness.slots[index] as EffectSlot).ran, deps)));
},
replayEffects() {
run(pending);
},
unmount() {
for (const slot of harness.slots) {
if (!isEffectSlot(slot)) continue;
const cleanup = slot.cleanup;
slot.cleanup = undefined;
cleanup?.();
}
},
clear() {
harness.slots = [];
harness.writers = [];
harness.dispatch.mockReset();
harness.reset();
},
next: () => cursor++,
capture: (effect) => {
pending.push(effect);
},
};
return harness;
}
const shared = createHookHarness();
/** The harness the mock of "react" writes to, shared with the test of the same module. */
export function hookHarness(): HookHarness {
return shared;
}
const COVERED = new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect"]);
/**
* The module that replaces "react": the real one, with the covered hooks
* backed by `harness` (the shared one by default) and every other hook
* throwing.
*/
export function fakeReact(actual: typeof React, harness: HookHarness = shared): typeof React {
const h = harness as Internal;
/** Index of this hook's slot, filled by `init` at the first render only. */
const slot = (init: () => unknown): number => {
const index = h.next();
if (!(index in h.slots)) h.slots[index] = init();
return index;
};
const memo = (compute: () => unknown, deps: Deps): unknown => {
const index = slot(() => ({ deps: null as Deps | null, value: undefined as unknown }));
const entry = h.slots[index] as { deps: Deps | null; value: unknown };
if (depsChanged(entry.deps, deps)) {
entry.value = compute();
entry.deps = deps;
}
return entry.value;
};
/** Stable setter or dispatch of a state slot, created at its first use. */
const writer = (index: number, make: () => (arg: unknown) => void) => (h.writers[index] ??= make());
const covered = {
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
const set = writer(index, () => (next: unknown) => {
h.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(h.slots[index]) : next;
});
return [h.slots[index], set];
},
useReducer: (
reducer: (state: unknown, action: unknown) => unknown,
initialArg: unknown,
init?: (arg: unknown) => unknown,
) => {
const index = slot(() => (init ? init(initialArg) : initialArg));
const dispatch = writer(index, () => (action: unknown) => {
h.dispatch(action);
h.slots[index] = reducer(h.slots[index], action);
});
return [h.slots[index], dispatch];
},
useRef: (init: unknown) => h.slots[slot(() => ({ current: init }))],
useMemo: (compute: () => unknown, deps: Deps) => memo(compute, deps),
useCallback: (fn: unknown, deps: Deps) => memo(() => fn, deps),
useEffect: (fn: EffectFn, deps?: Deps) => {
const index = slot((): EffectSlot => ({ [EFFECT]: true, ran: null, cleanup: undefined }));
h.capture({ index, fn, deps });
},
};
const uncovered: Record<string, unknown> = {};
for (const key of Object.keys(actual)) {
if ((key === "use" || /^use[A-Z]/.test(key)) && !COVERED.has(key)) {
uncovered[key] = () => {
throw new Error(
`hookHarness: ${key} is not simulated; mock the module that calls it, or cover it in src/test/hookHarness.ts`,
);
};
}
}
const fake: Record<string, unknown> = { ...actual, ...uncovered, ...covered };
// `import React from "react"` then `React.useState`: the default export too.
const original = (actual as { default?: unknown }).default;
if (original && typeof original === "object") fake.default = { ...original, ...uncovered, ...covered };
return fake as unknown as typeof React;
}