Simpl-Resultat/src/hooks/useOnboardingChecklist.wiring.test.ts
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00

411 lines
14 KiB
TypeScript

// useOnboardingChecklist — the hook's own wiring (#396, #409).
//
// 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 come from
// the shared harness (src/test/hookHarness.ts): the hook runs as a plain
// function, its state, memos and callbacks survive from one render to the
// next as in a mounted provider, its reducer is applied, and `commit()` runs
// its effects as React does. The onboarding service is mocked.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ pathname: "/", budgetReady: true }));
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
vi.mock("./useEntitlement", () => ({
useEntitlement: () => ({ allowed: true, ready: ctx.budgetReady }),
}));
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(),
acknowledgeStep: vi.fn(),
initializeVisitSteps: vi.fn(),
}));
import { hookHarness } from "../test/hookHarness";
import {
useOnboardingChecklist,
initialOnboardingChecklistState,
onboardingChecklistReducer,
type OnboardingChecklistState,
} from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService";
import {
acknowledgeStep,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
initializeVisitSteps,
resumeStep,
showChecklist,
skipStep,
type OnboardingSnapshot,
} from "../services/onboardingService";
const harness = hookHarness();
const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true,
hidden: false,
skipped: [],
// Stopped after the import: the visit steps are acknowledged and initialized
// (rule of the templates, #425).
acknowledged: ["categories", "reports"],
visitStepsInitialized: true,
};
const ready: OnboardingChecklistState = [
{ type: "LOAD_START", seq: 1 } as const,
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** One render of the mounted provider. */
const render = () => harness.render(useOnboardingChecklist);
/** The state the provider mounts with: its `useReducer` is its first React hook. */
const mountWith = (state: OnboardingChecklistState) => {
harness.slots[0] = state;
};
/** 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] as { type: string }).filter((a) => a.type === type);
beforeEach(() => {
harness.clear();
ctx.pathname = "/";
ctx.budgetReady = true;
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
for (const write of [
hideChecklist,
showChecklist,
skipStep,
resumeStep,
acknowledgeStep,
initializeVisitSteps,
]) {
vi.mocked(write).mockReset().mockResolvedValue(undefined);
}
vi.mocked(logError).mockReset();
});
afterEach(() => {
harness.unmount();
vi.useRealTimers();
});
describe("useOnboardingChecklist — wiring (#396)", () => {
it("reads at mount, through rule D9, and again whenever the route changes", async () => {
ctx.pathname = "/import";
render();
harness.commit();
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 },
]);
// The loaded state renders: same route, no new read.
const loaded = render();
harness.commit();
await flush();
expect(loaded.status).toBe("ready");
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
// A new route: React runs the read again.
ctx.pathname = "/transactions";
render();
harness.commit();
await flush();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
});
it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers();
harness.dispatch.mockReset();
// A route change re-runs the read (that it leaves the follower and its
// pending re-read alone is pinned by the next test).
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
harness.dispatch.mockReset();
notifyDataChanged();
notifyDataChanged();
vi.advanceTimersByTime(149);
expect(actionsOfType("LOAD_START")).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(actionsOfType("LOAD_START")).toHaveLength(1);
await flush();
harness.unmount();
notifyDataChanged();
vi.advanceTimersByTime(300);
expect(actionsOfType("LOAD_START")).toHaveLength(1);
});
it("a route change during the 150 ms wait does not drop the deferred re-read", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers();
harness.dispatch.mockReset();
notifyDataChanged();
vi.advanceTimersByTime(100);
// The route changes while the re-read waits: the read effect runs again,
// the follower (deps: [refresh]) must not be cleaned up and resubscribed,
// which would drop its pending timer.
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
expect(actionsOfType("LOAD_START")).toHaveLength(1);
vi.advanceTimersByTime(50);
await flush();
// One read for the route, one for the data event.
expect(actionsOfType("LOAD_START")).toHaveLength(2);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(3);
});
it("stores a card latch that changed during render, and only then", () => {
mountWith({ ...ready, cardPage: "/import" });
ctx.pathname = "/reports";
render();
expect(harness.dispatch.mock.calls).toEqual([[{ type: "SET_CARD_PAGE", cardPage: null }]]);
// The reducer applied it: the next render finds the latch stored.
harness.dispatch.mockReset();
expect(render().cardPage).toBeNull();
expect(harness.dispatch).not.toHaveBeenCalled();
});
it("shares the derived next step and mode with the state", () => {
mountWith(ready);
ctx.pathname = "/reports";
const value = render();
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(6);
});
it("each action writes its preference first, then updates the state", async () => {
mountWith(ready);
const value = render();
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('"Got it" writes the acknowledgement first, then updates the state, keeping the card latch (#425)', async () => {
mountWith({ ...ready, acknowledged: [], cardPage: "/import" });
ctx.pathname = "/import";
const value = render();
await value.acknowledge("categories");
expect(acknowledgeStep).toHaveBeenCalledWith("categories");
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
{ type: "ACKNOWLEDGE", key: "categories" },
]);
expect(vi.mocked(acknowledgeStep).mock.invocationCallOrder[0]).toBeLessThan(
harness.dispatch.mock.invocationCallOrder[0],
);
const after = render();
expect(after.cardPage).toBe("/import");
expect(after.checklist?.items.find((i) => i.key === "categories")?.done).toBe(true);
});
it('a failed "Got it" leaves the state alone and logs (#425)', async () => {
mountWith({ ...ready, acknowledged: [] });
const value = render();
vi.mocked(acknowledgeStep).mockRejectedValueOnce(new Error("database is locked"));
await value.acknowledge("reports");
expect(harness.dispatch).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledWith("Onboarding: failed to acknowledge a step", "database is locked");
});
it("an action whose write fails leaves the state alone and logs", async () => {
mountWith(ready);
const value = render();
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);
expect(render().hidden).toBe(false);
});
it("refresh is the loader itself", async () => {
const value = render();
await value.refresh();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
expect(actionsOfType("LOAD_DONE")).toHaveLength(1);
});
});
describe("useOnboardingChecklist — what survives a render (#409)", () => {
it("keeps one loader for the provider's life: refresh, the value and the reads stay put", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const first = render();
harness.commit();
await flush();
const reads = vi.mocked(getOnboardingSnapshot).mock.calls.length;
const second = render();
harness.commit();
await flush();
expect(second.refresh).toBe(first.refresh);
// Nothing changed: the shared value keeps its identity, so no consumer re-renders.
expect(second).toBe(first);
// A new loader would re-run the read effect at every render: a loop of reads.
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(reads);
});
it("'Skip' moves the next step on the next render, without a re-read", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const before = render();
expect(before.nextStep?.key).toBe("manualCategory");
await before.skip("manualCategory");
const after = render();
expect(getOnboardingSnapshot).not.toHaveBeenCalled();
expect(after.checklist).not.toBe(before.checklist);
expect(after.nextStep?.key).toBe("budget");
});
it("a change of mode alone reaches the consumers", () => {
// An empty profile: the dashboard shows its own empty state, other pages the banner.
mountWith({ ...ready, counters: { ...snapshot.counters, transactions: 0 } });
ctx.pathname = "/";
const dashboard = render();
ctx.pathname = "/reports";
const reports = render();
expect(reports.checklist).toBe(dashboard.checklist);
expect(reports.nextStep).toBe(dashboard.nextStep);
expect(reports.cardPage).toBe(dashboard.cardPage);
expect(reports.mode).not.toBe(dashboard.mode);
expect(reports.mode).toBe("banner");
});
});
describe("useOnboardingChecklist — the visit steps' initialization (#425)", () => {
const sixDone = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
const notInitialized: OnboardingChecklistState = {
...ready,
counters: sixDone,
acknowledged: [],
visitStepsInitialized: false,
};
it("runs once, after a successful load with the entitlement known, then re-reads", async () => {
mountWith(notInitialized);
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
// The read of the mount plus the re-read of the initializer.
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
// Later renders, the re-read not landed yet (flag still unset in state): no second run.
harness.slots[0] = notInitialized;
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
});
it("an incomplete path initializes without acknowledging", async () => {
mountWith({ ...notInitialized, counters: { ...sixDone, budgetEntries: 0 } });
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledWith(false);
});
it("does not run before budgetReady, then runs once it is known", async () => {
ctx.budgetReady = false;
mountWith(notInitialized);
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
ctx.budgetReady = true;
harness.slots[0] = notInitialized;
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
});
it("does not run before the first successful load, nor once the flag is set", async () => {
vi.mocked(getOnboardingSnapshot).mockReturnValue(new Promise(() => {}));
render();
harness.commit();
await flush();
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
harness.unmount();
harness.clear();
vi.mocked(getOnboardingSnapshot).mockResolvedValue(snapshot);
mountWith(ready);
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
});
it("shares visitStepsInitialized; before it, a complete path shows no 'Next step' banner", () => {
mountWith(notInitialized);
ctx.pathname = "/reports";
const value = render();
expect(value.visitStepsInitialized).toBe(false);
expect(value.nextStep).toBeNull();
expect(value.mode).toBe("none");
});
});