diff --git a/src/hooks/useOnboardingChecklist.wiring.test.ts b/src/hooks/useOnboardingChecklist.wiring.test.ts new file mode 100644 index 0000000..10a4bc0 --- /dev/null +++ b/src/hooks/useOnboardingChecklist.wiring.test.ts @@ -0,0 +1,202 @@ +// useOnboardingChecklist — the hook's own wiring (#396). +// +// 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. + +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("./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 { + 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 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); + +/** 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); + +beforeEach(() => { + harness.state = initialOnboardingChecklistState; + harness.pathname = "/"; + harness.effects = []; + harness.dispatch.mockReset(); + 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(() => { + 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(); + 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 }, + ]); + }); + + it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => { + vi.useFakeTimers(); + useOnboardingChecklist(); + const follow = harness.effects[1]; + expect(follow.deps).not.toContain(harness.pathname); + + const cleanup = follow.fn() as () => void; + notifyDataChanged(); + notifyDataChanged(); + vi.advanceTimersByTime(149); + expect(actionsOfType("LOAD_START")).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(actionsOfType("LOAD_START")).toHaveLength(1); + await flush(); + + cleanup(); + 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 }); + + harness.dispatch.mockReset(); + harness.state = ready; + useOnboardingChecklist(); + 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(); + 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 () => { + harness.state = ready; + const value = useOnboardingChecklist(); + 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 () => { + harness.state = ready; + const value = useOnboardingChecklist(); + 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); + }); + + it("refresh is the loader itself", async () => { + const value = useOnboardingChecklist(); + await value.refresh(); + expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); + expect(actionsOfType("LOAD_DONE")).toHaveLength(1); + }); +});