From 21c46b63809475d6d7adb23cd9d1b9a28ae09651 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 07:39:00 -0400 Subject: [PATCH] test(onboarding): pin the provider hook's React wiring renderToStaticMarkup runs no effect, so the hook's own wiring was guarded by no test: the route re-read (pathname in the deps), the data-channel subscription and its cleanup, the card latch stored during render, and the order "write the preference, then update the state" of hide, show, skip and resume. React's hooks are stubbed so the hook runs as a plain function on a prepared state, its effects captured and run by hand. Resolves #396 Co-Authored-By: Claude Opus 5.5 --- .../useOnboardingChecklist.wiring.test.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/hooks/useOnboardingChecklist.wiring.test.ts 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); + }); +});