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>
411 lines
14 KiB
TypeScript
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");
|
|
});
|
|
});
|