diff --git a/docs/architecture.md b/docs/architecture.md index 909c295..4463417 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.) - **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 Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle. diff --git a/src/components/budget/BudgetTable.wiring.test.ts b/src/components/budget/BudgetTable.wiring.test.ts index 567ac0c..b16be6b 100644 --- a/src/components/budget/BudgetTable.wiring.test.ts +++ b/src/components/budget/BudgetTable.wiring.test.ts @@ -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. -// React's `useState`, `useRef` and `useEffect` are stubbed (technique of -// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the +// React's hooks come from the shared harness (src/test/hookHarness.ts): the // 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 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 { CollapseAccessors } from "../../utils/collapsibleRows"; -type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; - -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(), +vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ /** Keys of the open groups (`p:`): the grid opens collapsed. */ expanded: new Set(), })); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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", () => ({ 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"); return { useCollapsibleGroups: (_key: string | null, acc: CollapseAccessors) => { - const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false); + const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false); return { visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed), isCollapsed, @@ -64,7 +32,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => { collapseAll: () => undefined, allExpanded: (rows: T[]) => { 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, }; @@ -72,6 +40,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => { }; }); +import { hookHarness } from "../../test/hookHarness"; import BudgetTable from "./BudgetTable"; 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 harness = hookHarness(); + /** One render of the mounted grid: its hooks are called in the same order each time. */ function render( rows: BudgetYearRow[], onChange: (kind: BudgetOnboardingTargetKind | null) => void, onboardingTarget?: boolean, ) { - harness.cursor = 0; - harness.effects = []; - BudgetTable({ - rows, - onUpdatePlanned: () => undefined, - onSplitEvenly: () => undefined, - onboardingTarget, - onOnboardingTargetChange: onChange, - }); + harness.render(() => + BudgetTable({ + rows, + onUpdatePlanned: () => undefined, + onSplitEvenly: () => undefined, + onboardingTarget, + onOnboardingTargetChange: onChange, + }), + ); } /** After a render: run each effect whose dependencies changed since its last run, as React does. */ -function 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(); - }); -} +const commit = () => harness.commit(); beforeEach(() => { - harness.slots = []; - harness.setters = []; - harness.effects = []; - harness.ran = new Map(); - harness.expanded = new Set(); + harness.clear(); + ctx.expanded = new Set(); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); }); @@ -146,7 +103,7 @@ describe("BudgetTable — the target told to the page (#400)", () => { expect(onChange).toHaveBeenCalledTimes(1); // 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); commit(); render(GROUPED, onChange, true); diff --git a/src/components/layout/AppShell.slot.test.tsx b/src/components/layout/AppShell.slot.test.tsx index 6b184a8..1c54adf 100644 --- a/src/components/layout/AppShell.slot.test.tsx +++ b/src/components/layout/AppShell.slot.test.tsx @@ -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 -// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a -// plain function, and its state survives from one call to the next as it -// would between two renders of a mounted component. +// renderToStaticMarkup runs no click, so React's hooks come from the shared +// harness (src/test/hookHarness.ts): the slot runs as a plain function, and +// its state survives from one call to the next as it would between two +// renders of a mounted component. import { describe, it, expect, vi, beforeEach } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { Outlet } from "react-router-dom"; -const harness = vi.hoisted(() => ({ - slots: [] as unknown[], - cursor: 0, +vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ pathname: "/reports", onboarding: null as unknown, })); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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) => ({ ...(await importOriginal()), - useLocation: () => ({ pathname: harness.pathname }), + useLocation: () => ({ pathname: ctx.pathname }), })); vi.mock("../../contexts/OnboardingContext", () => ({ OnboardingProvider: ({ children }: { children: ReactNode }) => children, - useOnboarding: () => harness.onboarding, + useOnboarding: () => ctx.onboarding, })); vi.mock("./Sidebar", () => ({ default: () => null })); +import { hookHarness } from "../../test/hookHarness"; import AppShell, { OnboardingBannerSlot } from "./AppShell"; import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner"; import { computeChecklist } from "../../utils/onboardingChecklist"; import { nextStep } from "../../utils/onboardingPath"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; +const harness = hookHarness(); + type AnyElement = ReactElement & { children?: ReactNode }>; 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. */ function renderSlot(): OnboardingBannerProps { - harness.cursor = 0; - const element = OnboardingBannerSlot() as ReactElement; + const element = harness.render(() => OnboardingBannerSlot() as ReactElement); expect(element.type).toBe(OnboardingBanner); return element.props; } beforeEach(() => { - harness.slots = []; - harness.pathname = "/reports"; - harness.onboarding = onboardingValue(); + harness.clear(); + ctx.pathname = "/reports"; + ctx.onboarding = onboardingValue(); }); describe("OnboardingBannerSlot (#397)", () => { 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(); expect(props.mode).toBe("banner"); 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", () => { - harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false }; + ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false }; const props = renderSlot(); expect(props.mode).toBe("none"); 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", () => { - harness.pathname = "/import"; - const shell = AppShell() as ReactNode; + ctx.pathname = "/import"; + const shell = harness.render(() => AppShell() as ReactNode); const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot); expect(slot).toBeDefined(); expect(slot.key).toBe("/import"); - harness.pathname = "/transactions"; - const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot); + ctx.pathname = "/transactions"; + const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot); expect(next.key).toBe("/transactions"); }); 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"); expect(main).toBeDefined(); const [provider] = find(main.props.children, (e) => typeof e.type === "function"); diff --git a/src/components/onboarding/OnboardingStepCard.test.tsx b/src/components/onboarding/OnboardingStepCard.test.tsx index 75a8d4e..7a07aff 100644 --- a/src/components/onboarding/OnboardingStepCard.test.tsx +++ b/src/components/onboarding/OnboardingStepCard.test.tsx @@ -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. -// React's `useRef` and `useEffect` are stubbed (technique of -// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card -// runs as a plain function, its refs survive from one call to the next as they -// would between two renders of a mounted component, and its effects are -// captured and run by hand. The router and the path are faked. +// React's hooks come from the shared harness (src/test/hookHarness.ts): the +// card runs as a plain function, its refs survive from one call to the next as +// they would between two renders of a mounted component, and `commit()` runs +// its effect when one of its dependencies changed, as React does. The router +// and the path are faked. import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement } from "react"; -type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; - -const harness = vi.hoisted(() => ({ - refs: [] as Array<{ current: unknown }>, - cursor: 0, - effects: [] as Effect[], +vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ location: { pathname: "/import", state: null as unknown, key: "arrival-1" }, navigate: vi.fn(), onboarding: null as unknown, })); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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) => ({ ...(await importOriginal()), - useLocation: () => harness.location, - useNavigate: () => harness.navigate, + useLocation: () => ctx.location, + 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 StepCard, { type StepCardProps } from "./StepCard"; import { computeChecklist } from "../../utils/onboardingChecklist"; @@ -48,6 +30,8 @@ import { nextStep, type StepCardModel } from "../../utils/onboardingPath"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingCounters } from "../../shared/types"; +const harness = hookHarness(); + const ZERO: OnboardingCounters = { sources: 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. */ function render(model: StepCardModel | null = SOURCE_LIST): ReactElement | null { - harness.cursor = 0; - harness.effects = []; - return OnboardingStepCard({ model }) as ReactElement | null; + return harness.render(() => OnboardingStepCard({ model }) as ReactElement | 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. */ function attachTitle(): { focus: ReturnType } { const title = { focus: vi.fn() }; - harness.refs[0].current = title; + titleRef().current = title; return title; } -const runEffects = () => { - for (const effect of harness.effects) effect.fn(); -}; +/** The commit of the last render: its effect runs if one of its dependencies changed. */ +const commit = () => harness.commit(); beforeEach(() => { - harness.refs = []; - harness.effects = []; - harness.location = { pathname: "/import", state: null, key: "arrival-1" }; - harness.navigate.mockReset(); - harness.onboarding = path(FOLDER_SET); + harness.clear(); + ctx.location = { pathname: "/import", state: null, key: "arrival-1" }; + ctx.navigate.mockReset(); + ctx.onboarding = path(FOLDER_SET); }); 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", () => { 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(); } - harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" }); + ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" }); expect(render()).toBeNull(); - harness.onboarding = path(FOLDER_SET); + ctx.onboarding = path(FOLDER_SET); expect(render(null)).toBeNull(); }); it("stays once the page's steps are done: the latch keeps it, not the next step", () => { - harness.onboarding = path(IMPORTED); - const value = harness.onboarding as OnboardingValue; + ctx.onboarding = path(IMPORTED); + const value = ctx.onboarding as OnboardingValue; expect(value.nextStep?.to).toBe("/transactions"); const card = render(DONE); 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.text).toBe(SOURCE_LIST.text); 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", () => { - harness.onboarding = path(IMPORTED); + ctx.onboarding = path(IMPORTED); expect(render(DONE)!.props.next).toEqual({ 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. - harness.onboarding = path(FOLDER_SET); + ctx.onboarding = path(FOLDER_SET); expect(render(DONE)!.props.next).toEqual({ kind: "wait" }); }); 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(); }); 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; props.onSkip(); expect(value.skip).toHaveBeenCalledWith("source"); @@ -179,58 +162,60 @@ describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => { const ARRIVAL = { onboarding: { focus: true } }; 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(); const title = attachTitle(); - runEffects(); + commit(); expect(title.focus).toHaveBeenCalledTimes(1); - expect(harness.navigate).toHaveBeenCalledTimes(1); - expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null }); - expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan( + expect(ctx.navigate).toHaveBeenCalledTimes(1); + expect(ctx.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null }); + expect(vi.mocked(ctx.navigate).mock.invocationCallOrder[0]).toBeGreaterThan( title.focus.mock.invocationCallOrder[0], ); }); 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(); const title = attachTitle(); - runEffects(); - runEffects(); + commit(); + harness.replayEffects(); render(); - runEffects(); + commit(); 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. - harness.location = { ...harness.location, key: "arrival-2" }; + ctx.location = { ...ctx.location, key: "arrival-2" }; render(); - runEffects(); + commit(); 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", () => { - harness.location = { ...harness.location, state: ARRIVAL }; + ctx.location = { ...ctx.location, state: ARRIVAL }; expect(render(null)).toBeNull(); - runEffects(); - expect(harness.navigate).not.toHaveBeenCalled(); + commit(); + expect(ctx.navigate).not.toHaveBeenCalled(); render(); const title = attachTitle(); - runEffects(); + commit(); 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", () => { 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(); const title = attachTitle(); - expect(() => runEffects()).not.toThrow(); + expect(() => commit()).not.toThrow(); expect(title.focus).not.toHaveBeenCalled(); } - expect(harness.navigate).not.toHaveBeenCalled(); + expect(ctx.navigate).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/useOnboardingChecklist.wiring.test.ts b/src/hooks/useOnboardingChecklist.wiring.test.ts index 10a4bc0..5655053 100644 --- a/src/hooks/useOnboardingChecklist.wiring.test.ts +++ b/src/hooks/useOnboardingChecklist.wiring.test.ts @@ -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 // useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what -// the hook does with React would stay unguarded. Here React's hooks are -// stubbed (same technique as useImportWizard.executeImport.test.ts): the hook -// runs as a plain function on a prepared state, its effects are captured and -// run by hand, and the onboarding service is mocked. +// the hook does with React would stay unguarded. Here React's hooks come from +// the shared harness (src/test/hookHarness.ts): the hook runs as a plain +// function, its state, memos and callbacks survive from one render to the +// 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"; -type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; - -const harness = vi.hoisted(() => ({ - state: null as unknown, - dispatch: vi.fn(), - effects: [] as Effect[], - pathname: "/", -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ pathname: "/" })); +vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) })); vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) })); vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() })); vi.mock("../services/onboardingService", () => ({ @@ -43,6 +24,7 @@ vi.mock("../services/onboardingService", () => ({ resumeStep: vi.fn(), })); +import { hookHarness } from "../test/hookHarness"; import { useOnboardingChecklist, initialOnboardingChecklistState, @@ -61,6 +43,8 @@ import { type OnboardingSnapshot, } from "../services/onboardingService"; +const harness = hookHarness(); + const snapshot: OnboardingSnapshot = { counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, importFolderSet: true, @@ -73,19 +57,25 @@ const ready: OnboardingChecklistState = [ { type: "LOAD_DONE", seq: 1, snapshot } as const, ].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. */ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }; 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(() => { - harness.state = initialOnboardingChecklistState; - harness.pathname = "/"; - harness.effects = []; - harness.dispatch.mockReset(); + harness.clear(); + ctx.pathname = "/"; vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot); vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false }); for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) { @@ -95,18 +85,15 @@ beforeEach(() => { }); afterEach(() => { + harness.unmount(); vi.useRealTimers(); }); describe("useOnboardingChecklist — wiring (#396)", () => { it("reads at mount, through rule D9, and again whenever the route changes", async () => { - harness.pathname = "/import"; - useOnboardingChecklist(); - const [reread] = harness.effects; - // The route is a dependency: React runs the read again on every new pathname. - expect(reread.deps).toContain("/import"); - - reread.fn(); + ctx.pathname = "/import"; + render(); + harness.commit(); await flush(); expect(initializeGuidedPath).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); @@ -114,15 +101,37 @@ describe("useOnboardingChecklist — wiring (#396)", () => { { type: "LOAD_START", seq: 1 }, { 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 () => { + render(); + harness.commit(); + await flush(); vi.useFakeTimers(); - useOnboardingChecklist(); - const follow = harness.effects[1]; - expect(follow.deps).not.toContain(harness.pathname); + harness.dispatch.mockReset(); + + // 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(); vi.advanceTimersByTime(149); @@ -131,28 +140,28 @@ describe("useOnboardingChecklist — wiring (#396)", () => { expect(actionsOfType("LOAD_START")).toHaveLength(1); await flush(); - cleanup(); + harness.unmount(); notifyDataChanged(); vi.advanceTimersByTime(300); expect(actionsOfType("LOAD_START")).toHaveLength(1); }); it("stores a card latch that changed during render, and only then", () => { - harness.state = { ...ready, cardPage: "/import" }; - harness.pathname = "/reports"; - useOnboardingChecklist(); - expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_CARD_PAGE", cardPage: null }); + mountWith({ ...ready, cardPage: "/import" }); + ctx.pathname = "/reports"; + render(); + 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.state = ready; - useOnboardingChecklist(); + expect(render().cardPage).toBeNull(); expect(harness.dispatch).not.toHaveBeenCalled(); }); it("shares the derived next step and mode with the state", () => { - harness.state = ready; - harness.pathname = "/reports"; - const value = useOnboardingChecklist(); + mountWith(ready); + ctx.pathname = "/reports"; + const value = render(); expect(value.status).toBe("ready"); expect(value.nextStep?.key).toBe("manualCategory"); 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 () => { - harness.state = ready; - const value = useOnboardingChecklist(); + mountWith(ready); + const value = render(); await value.hide(); await value.show(); 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 () => { - harness.state = ready; - const value = useOnboardingChecklist(); + mountWith(ready); + const value = render(); vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked")); vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked")); await value.hide(); await value.skip("source"); expect(harness.dispatch).not.toHaveBeenCalled(); expect(logError).toHaveBeenCalledTimes(2); + expect(render().hidden).toBe(false); }); it("refresh is the loader itself", async () => { - const value = useOnboardingChecklist(); + const value = render(); await value.refresh(); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(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"); + }); +}); diff --git a/src/pages/BudgetPage.wiring.test.ts b/src/pages/BudgetPage.wiring.test.ts index fd3b1e3..9791db7 100644 --- a/src/pages/BudgetPage.wiring.test.ts +++ b/src/pages/BudgetPage.wiring.test.ts @@ -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 -// `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 // 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 @@ -9,50 +9,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; -const harness = vi.hoisted(() => ({ - slots: [] as unknown[], - setters: [] as Array<(next: unknown) => void>, - cursor: 0, +vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ pathname: "/budget", onboarding: null as unknown, budget: null as unknown, })); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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) => ({ ...(await importOriginal()), - useLocation: () => ({ pathname: harness.pathname }), + useLocation: () => ({ pathname: ctx.pathname }), })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), })); // The page's modules pull the database: stub the connection. 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", () => ({ useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }), })); vi.mock("../hooks/useBudget", async (importOriginal) => ({ ...(await importOriginal()), - useBudget: () => harness.budget, + useBudget: () => ctx.budget, })); +import { hookHarness } from "../test/hookHarness"; import BudgetPage from "./BudgetPage"; import BudgetTable from "../components/budget/BudgetTable"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; @@ -62,6 +43,8 @@ import { computeChecklist } from "../utils/onboardingChecklist"; import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath"; import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types"; +const harness = hookHarness(); + type AnyElement = ReactElement & { children?: ReactNode }>; function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { @@ -103,8 +86,7 @@ function path(counters: OnboardingCounters, overrides: Partial /** One render of the mounted page: its hooks are read in the same order each time. */ function render() { - harness.cursor = 0; - const page = BudgetPage() as AnyElement; + const page = harness.render(() => BudgetPage() as AnyElement); const [card] = find(page, (el) => el.type === OnboardingStepCard); const [grid] = find(page, (el) => el.type === BudgetTable); return { @@ -116,12 +98,11 @@ function render() { } beforeEach(() => { - harness.slots = []; - harness.setters = []; - harness.pathname = "/budget"; - harness.onboarding = path(BUDGET_LEFT); + harness.clear(); + ctx.pathname = "/budget"; + ctx.onboarding = path(BUDGET_LEFT); const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 }; - harness.budget = new Proxy({ state } as Record, { + ctx.budget = new Proxy({ state } as Record, { 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", () => { - const checklist = (harness.onboarding as OnboardingValue).checklist; + const checklist = (ctx.onboarding as OnboardingValue).checklist; let view = render(); expect(view.model).toEqual(budgetCardModel({ target: null }, checklist)); 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: "card" as const, cardPage: "/transactions" }, ]) { - harness.slots = []; - harness.setters = []; - harness.onboarding = path(BUDGET_LEFT, overrides); + harness.clear(); + ctx.onboarding = path(BUDGET_LEFT, overrides); render().report("cell"); expect(render().highlight, JSON.stringify(overrides)).toBe(false); } }); 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"); const view = render(); 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, skipped: ["source", "import", "manualCategory"], }); - harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) }); - const budget = harness.budget as { state: BudgetState }; + ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) }); + const budget = ctx.budget as { state: BudgetState }; budget.state = { ...budget.state, totalInDb: null }; render().report("expandAll"); expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed"); budget.state = { ...budget.state, totalInDb: 0 }; - harness.cursor = 0; - const page = BudgetPage() as AnyElement; + const page = harness.render(() => BudgetPage() as AnyElement); expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0); const [card] = find(page, (el) => el.type === OnboardingStepCard); expect(card.props.model).toEqual({ text: null, done: false, target: null }); diff --git a/src/pages/settings/UsersSettingsPage.test.tsx b/src/pages/settings/UsersSettingsPage.test.tsx index 947cb7d..953b3a0 100644 --- a/src/pages/settings/UsersSettingsPage.test.tsx +++ b/src/pages/settings/UsersSettingsPage.test.tsx @@ -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 -// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function -// and its state survives from one call to the next, as between two renders of -// the mounted page. So "the path got hidden by the banner of this very page" -// is a new `useOnboarding()` value on the next render, without remounting. +// React's 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 between two renders of the mounted page, and `commit()` runs its +// effects as React would. So "the path got hidden by the banner of this very +// 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 { isValidElement, type ReactElement, type ReactNode } from "react"; -const harness = vi.hoisted(() => ({ - slots: [] as unknown[], - cursor: 0, - onboarding: null as unknown, -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - 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", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); +const ctx = vi.hoisted(() => ({ onboarding: null as unknown })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), })); vi.mock("../../contexts/OnboardingContext", () => ({ - useOnboarding: () => harness.onboarding, + useOnboarding: () => ctx.onboarding, })); // The page's cards are not under test, and they reach tauri. 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/onboardingService", () => ({ showChecklist: vi.fn() })); +import { hookHarness } from "../../test/hookHarness"; import UsersSettingsPage from "./UsersSettingsPage"; import { getPreference } from "../../services/userPreferenceService"; import { showChecklist } from "../../services/onboardingService"; +const harness = hookHarness(); + type AnyElement = ReactElement & { children?: ReactNode }>; function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { @@ -71,10 +54,11 @@ function path(hidden: boolean) { 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 { - harness.cursor = 0; - return UsersSettingsPage() as ReactNode; + const page = harness.render(() => UsersSettingsPage() as ReactNode); + harness.commit(); + return page; } 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)")); beforeEach(() => { - harness.slots = []; - harness.onboarding = path(false); + harness.clear(); + ctx.onboarding = path(false); vi.mocked(getPreference).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", () => { expect(showButton(renderPage())).toHaveLength(0); - harness.onboarding = path(true); + ctx.onboarding = path(true); const page = renderPage(); expect(texts(page)).toContain("t(settings.users.onboarding.label)"); 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 () => { const hiddenPath = path(true); - harness.onboarding = hiddenPath; + ctx.onboarding = hiddenPath; const [button] = showButton(renderPage()); await (button.props.onClick as () => Promise)(); expect(hiddenPath.show).toHaveBeenCalledTimes(1); expect(showChecklist).not.toHaveBeenCalled(); // The provider applied SHOW: the next render reads the path shown. - harness.onboarding = path(false); + ctx.onboarding = path(false); const page = renderPage(); expect(showButton(page)).toHaveLength(0); 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 () => { - harness.onboarding = path(true); + ctx.onboarding = path(true); const [button] = showButton(renderPage()); await (button.props.onClick as () => Promise)(); - harness.onboarding = path(false); + ctx.onboarding = path(false); expect(confirmation(renderPage())).toHaveLength(1); - harness.onboarding = path(true); + ctx.onboarding = path(true); const page = renderPage(); expect(showButton(page)).toHaveLength(1); 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 () => { const hiddenPath = path(true); - harness.onboarding = hiddenPath; + ctx.onboarding = hiddenPath; const [button] = showButton(renderPage()); await (button.props.onClick as () => Promise)(); const page = renderPage(); @@ -145,8 +129,10 @@ describe("UsersSettingsPage — showing the path again (#397)", () => { expect(confirmation(page)).toHaveLength(0); }); - it("never reads the preference itself", () => { - harness.onboarding = path(true); + it("never reads the preference itself, neither while rendering nor in an effect", () => { + ctx.onboarding = path(true); + renderPage(); + ctx.onboarding = path(false); renderPage(); expect(getPreference).not.toHaveBeenCalled(); }); diff --git a/src/test/hookHarness.test.ts b/src/test/hookHarness.test.ts new file mode 100644 index 0000000..d581f38 --- /dev/null +++ b/src/test/hookHarness.test.ts @@ -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 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 unknown> }; + expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated"); + expect(fake.default.useState).toBe((R as unknown as Record).useState); + }); +}); diff --git a/src/test/hookHarness.ts b/src/test/hookHarness.ts new file mode 100644 index 0000000..58e6193 --- /dev/null +++ b/src/test/hookHarness.ts @@ -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(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 = {}; + 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 = { ...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; +}