test(onboarding): pin the provider hook's React wiring
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s

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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 07:39:00 -04:00
parent 68b90f5f16
commit 21c46b6380

View file

@ -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<typeof import("react")>();
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);
});
});