From 86fd496e7718f297c8e7e61c2fbf7b13a1097afd Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:44:36 -0400 Subject: [PATCH 1/3] 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 --- src/test/hookHarness.test.ts | 286 +++++++++++++++++++++++++++++++++++ src/test/hookHarness.ts | 265 ++++++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 src/test/hookHarness.test.ts create mode 100644 src/test/hookHarness.ts 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; +} -- 2.45.2 From 1ba3b8bae92610a9182371be0380355e09f35942 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:44:43 -0400 Subject: [PATCH 2/3] 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 --- .../budget/BudgetTable.wiring.test.ts | 87 +++------ src/components/layout/AppShell.slot.test.tsx | 63 +++---- .../onboarding/OnboardingStepCard.test.tsx | 133 ++++++------- .../useOnboardingChecklist.wiring.test.ts | 175 ++++++++++++------ src/pages/BudgetPage.wiring.test.ts | 65 +++---- src/pages/settings/UsersSettingsPage.test.tsx | 74 +++----- 6 files changed, 272 insertions(+), 325 deletions(-) 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(); }); -- 2.45.2 From 79ba1ed2f340f332fc5843bd38c8716dcfc6c651 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 19:44:43 -0400 Subject: [PATCH 3/3] docs(architecture): add the tests section and the hook harness Resolves #409 Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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. -- 2.45.2