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(); });