feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402

Merged
maximus merged 4 commits from issue-396-onboarding-shared-state into main 2026-10-09 01:10:05 +00:00
Showing only changes of commit 21c46b6380 - Show all commits

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);
});
});