// 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 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"; 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", () => ({ getOnboardingSnapshot: vi.fn(), initializeGuidedPath: vi.fn(), hideChecklist: vi.fn(), showChecklist: vi.fn(), skipStep: vi.fn(), resumeStep: vi.fn(), })); import { hookHarness } from "../test/hookHarness"; import { useOnboardingChecklist, initialOnboardingChecklistState, onboardingChecklistReducer, type OnboardingChecklistState, } from "./useOnboardingChecklist"; import { notifyDataChanged } from "../services/dataEvents"; import { logError } from "../services/logService"; import { getOnboardingSnapshot, hideChecklist, initializeGuidedPath, resumeStep, showChecklist, skipStep, type OnboardingSnapshot, } from "../services/onboardingService"; const harness = hookHarness(); const snapshot: OnboardingSnapshot = { counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, importFolderSet: true, hidden: false, skipped: [], }; const ready: OnboardingChecklistState = [ { type: "LOAD_START", seq: 1 } as const, { 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] as { type: string }).filter((a) => a.type === type); beforeEach(() => { 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]) { vi.mocked(write).mockReset().mockResolvedValue(undefined); } vi.mocked(logError).mockReset(); }); afterEach(() => { harness.unmount(); vi.useRealTimers(); }); describe("useOnboardingChecklist — wiring (#396)", () => { it("reads at mount, through rule D9, and again whenever the route changes", async () => { ctx.pathname = "/import"; render(); harness.commit(); await flush(); expect(initializeGuidedPath).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([ { 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(); 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(); notifyDataChanged(); notifyDataChanged(); vi.advanceTimersByTime(149); expect(actionsOfType("LOAD_START")).toHaveLength(0); vi.advanceTimersByTime(1); expect(actionsOfType("LOAD_START")).toHaveLength(1); await flush(); harness.unmount(); notifyDataChanged(); vi.advanceTimersByTime(300); expect(actionsOfType("LOAD_START")).toHaveLength(1); }); it("stores a card latch that changed during render, and only then", () => { 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(); expect(render().cardPage).toBeNull(); expect(harness.dispatch).not.toHaveBeenCalled(); }); it("shares the derived next step and mode with the state", () => { mountWith(ready); ctx.pathname = "/reports"; const value = render(); expect(value.status).toBe("ready"); expect(value.nextStep?.key).toBe("manualCategory"); expect(value.mode).toBe("banner"); expect(value.cardPage).toBeNull(); expect(value.checklist?.done).toBe(4); }); it("each action writes its preference first, then updates the state", async () => { mountWith(ready); const value = render(); await value.hide(); await value.show(); await value.skip("budget"); await value.resume("budget"); expect(hideChecklist).toHaveBeenCalledTimes(1); expect(showChecklist).toHaveBeenCalledTimes(1); expect(skipStep).toHaveBeenCalledWith("budget"); expect(resumeStep).toHaveBeenCalledWith("budget"); expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([ { type: "HIDE" }, { type: "SHOW" }, { type: "SKIP", key: "budget" }, { type: "RESUME", key: "budget" }, ]); const hideDispatch = harness.dispatch.mock.invocationCallOrder[0]; expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch); }); it("an action whose write fails leaves the state alone and logs", async () => { 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 = 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"); }); });