Compare commits

...

3 commits

Author SHA1 Message Date
le king fu
79ba1ed2f3 docs(architecture): add the tests section and the hook harness
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Resolves #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
1ba3b8bae9 test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold:
one loader for the provider's life (refresh and the shared value keep their
identity, no re-read), 'Skip' moving the next step on the next render, and a
change of mode alone reaching the consumers. UsersSettingsPage now commits
its effects, so an effect that read the preference goes red on the
assertion rather than on 'Invalid hook call'. BudgetTable.wiring,
BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local
slot copies; OnboardingStepCard plays StrictMode through replayEffects and
runs its effect only on a changed dependency, which now pins location.key.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
86fd496e77 test: add a shared React hook harness with its own proofs
src/test/hookHarness.ts gives the tests that call hooks and components as
plain functions one fake React with React's semantics: state initialized
once with stable setters, the reducer applied with a dispatch spy, refs kept,
memos and callbacks recomputed only on a changed dependency, effects run by
commit() only when a dependency changed (cleanups first), and every hook it
does not cover throws instead of reaching the real React. Each semantic is
proved by hookHarness.test.ts, which also checks that the module imports
React only as a type.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:36 -04: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;
}