feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402
1 changed files with 202 additions and 0 deletions
202
src/hooks/useOnboardingChecklist.wiring.test.ts
Normal file
202
src/hooks/useOnboardingChecklist.wiring.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue