// 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: "/", budgetReady: true })); vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) })); vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: ctx.budgetReady }), })); 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(), acknowledgeStep: vi.fn(), initializeVisitSteps: 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 { acknowledgeStep, getOnboardingSnapshot, hideChecklist, initializeGuidedPath, initializeVisitSteps, 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: [], // Stopped after the import: the visit steps are acknowledged and initialized // (rule of the templates, #425). acknowledged: ["categories", "reports"], visitStepsInitialized: true, }; 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 = "/"; ctx.budgetReady = true; vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot); vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false }); for (const write of [ hideChecklist, showChecklist, skipStep, resumeStep, acknowledgeStep, initializeVisitSteps, ]) { 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 (that it leaves the follower and its // pending re-read alone is pinned by the next test). 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("a route change during the 150 ms wait does not drop the deferred re-read", async () => { render(); harness.commit(); await flush(); vi.useFakeTimers(); harness.dispatch.mockReset(); notifyDataChanged(); vi.advanceTimersByTime(100); // The route changes while the re-read waits: the read effect runs again, // the follower (deps: [refresh]) must not be cleaned up and resubscribed, // which would drop its pending timer. ctx.pathname = "/reports"; render(); harness.commit(); await flush(); expect(actionsOfType("LOAD_START")).toHaveLength(1); vi.advanceTimersByTime(50); await flush(); // One read for the route, one for the data event. expect(actionsOfType("LOAD_START")).toHaveLength(2); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(3); }); 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(6); }); 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('"Got it" writes the acknowledgement first, then updates the state, keeping the card latch (#425)', async () => { mountWith({ ...ready, acknowledged: [], cardPage: "/import" }); ctx.pathname = "/import"; const value = render(); await value.acknowledge("categories"); expect(acknowledgeStep).toHaveBeenCalledWith("categories"); expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([ { type: "ACKNOWLEDGE", key: "categories" }, ]); expect(vi.mocked(acknowledgeStep).mock.invocationCallOrder[0]).toBeLessThan( harness.dispatch.mock.invocationCallOrder[0], ); const after = render(); expect(after.cardPage).toBe("/import"); expect(after.checklist?.items.find((i) => i.key === "categories")?.done).toBe(true); }); it('a failed "Got it" leaves the state alone and logs (#425)', async () => { mountWith({ ...ready, acknowledged: [] }); const value = render(); vi.mocked(acknowledgeStep).mockRejectedValueOnce(new Error("database is locked")); await value.acknowledge("reports"); expect(harness.dispatch).not.toHaveBeenCalled(); expect(logError).toHaveBeenCalledWith("Onboarding: failed to acknowledge a step", "database is locked"); }); 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"); }); }); describe("useOnboardingChecklist — the visit steps' initialization (#425)", () => { const sixDone = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 }; const notInitialized: OnboardingChecklistState = { ...ready, counters: sixDone, acknowledged: [], visitStepsInitialized: false, }; it("runs once, after a successful load with the entitlement known, then re-reads", async () => { mountWith(notInitialized); ctx.pathname = "/reports"; render(); harness.commit(); await flush(); expect(initializeVisitSteps).toHaveBeenCalledTimes(1); expect(initializeVisitSteps).toHaveBeenCalledWith(true); // The read of the mount plus the re-read of the initializer. expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2); // Later renders, the re-read not landed yet (flag still unset in state): no second run. harness.slots[0] = notInitialized; render(); harness.commit(); await flush(); expect(initializeVisitSteps).toHaveBeenCalledTimes(1); }); it("an incomplete path initializes without acknowledging", async () => { mountWith({ ...notInitialized, counters: { ...sixDone, budgetEntries: 0 } }); render(); harness.commit(); await flush(); expect(initializeVisitSteps).toHaveBeenCalledWith(false); }); it("does not run before budgetReady, then runs once it is known", async () => { ctx.budgetReady = false; mountWith(notInitialized); render(); harness.commit(); await flush(); expect(initializeVisitSteps).not.toHaveBeenCalled(); ctx.budgetReady = true; harness.slots[0] = notInitialized; render(); harness.commit(); await flush(); expect(initializeVisitSteps).toHaveBeenCalledTimes(1); expect(initializeVisitSteps).toHaveBeenCalledWith(true); }); it("does not run before the first successful load, nor once the flag is set", async () => { vi.mocked(getOnboardingSnapshot).mockReturnValue(new Promise(() => {})); render(); harness.commit(); await flush(); render(); harness.commit(); await flush(); expect(initializeVisitSteps).not.toHaveBeenCalled(); harness.unmount(); harness.clear(); vi.mocked(getOnboardingSnapshot).mockResolvedValue(snapshot); mountWith(ready); render(); harness.commit(); await flush(); expect(initializeVisitSteps).not.toHaveBeenCalled(); }); it("shares visitStepsInitialized; before it, a complete path shows no 'Next step' banner", () => { mountWith(notInitialized); ctx.pathname = "/reports"; const value = render(); expect(value.visitStepsInitialized).toBe(false); expect(value.nextStep).toBeNull(); expect(value.mode).toBe("none"); }); });