Simpl-Resultat/src/hooks/useOnboardingChecklist.test.ts
le king fu dcf3b013dc feat(onboarding): share the getting-started path through a provider
The checklist state moves from DashboardPage to OnboardingProvider, mounted
in AppShell around the routed page; pages read it through useOnboarding().
The reducer stays in useOnboardingChecklist:

- loads are numbered and a stale answer is ignored;
- HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started
  before them (#384);
- a refresh keeps the counters and the ready status, and a failed re-read
  after a first success keeps the last state; only the start of a failure
  streak is logged;
- re-read at mount, on every route change and 150 ms after a burst of
  data events.

onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and
is a pure read (skipped steps included, one preference per step, filtered on
the six keys and the value "1"); initializeGuidedPath applies rule D9 once
per profile in one transaction (hidden first, then the flag, ROLLBACK on
failure), awaited once by the first load through a promise shared by
StrictMode's double mount; skipStep / resumeStep are idempotent.

computeChecklist counts skipped rows; the new pure onboardingPath module
holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch,
bannerMode and readOnboardingNavState. The dashboard reads the context and
keeps its compact bar for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:33:57 -04:00

732 lines
26 KiB
TypeScript

// useOnboardingChecklist — reducer, loader and data-channel follower (#351, #396).
//
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
// the exported reducer, loader and follower are driven directly.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// The hook module imports the onboarding service (→ db → tauri plugins) and
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
// so importing the module never touches a real connection.
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
import {
onboardingChecklistReducer,
initialOnboardingChecklistState,
createOnboardingLoader,
deriveOnboardingView,
followDataChanges,
DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction,
type OnboardingChecklistState,
type OnboardingLoaderDeps,
} from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents";
import { computeChecklist } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService";
import type { OnboardingCounters } from "../shared/types";
const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true,
hidden: false,
skipped: [],
};
const reduce = (
actions: OnboardingChecklistAction[],
from: OnboardingChecklistState = initialOnboardingChecklistState,
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
/** A first successful load (seq 1). */
const ready = (snap: OnboardingSnapshot = snapshot): OnboardingChecklistState =>
reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_DONE", seq: 1, snapshot: snap },
]);
describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
it("starts loading with no counters, nothing hidden or skipped, and no card", () => {
expect(initialOnboardingChecklistState).toEqual({
status: "loading",
counters: null,
importFolderSet: false,
hidden: false,
skipped: [],
loadSeq: 0,
hiddenSetAt: 0,
skippedSetAt: 0,
cardPage: null,
});
});
it("LOAD_DONE stores the snapshot and sets ready", () => {
const s = ready({ ...snapshot, skipped: ["budget"] });
expect(s.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters);
expect(s.importFolderSet).toBe(true);
expect(s.hidden).toBe(false);
expect(s.skipped).toEqual(["budget"]);
});
it("LOAD_DONE applies the persisted hidden flag", () => {
expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
});
it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: true } },
]);
expect(s.status).toBe("loading");
expect(s.counters).toBeNull();
expect(s.hidden).toBe(false);
const answered = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], s);
expect(answered.status).toBe("ready");
expect(answered.counters).toEqual(snapshot.counters);
});
it("a stale LOAD_DONE arriving after the newer one does not overwrite it", () => {
const newer = { ...snapshot, counters: { ...snapshot.counters, imports: 4 } };
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_DONE", seq: 2, snapshot: newer },
{ type: "LOAD_DONE", seq: 1, snapshot },
]);
expect(s.counters?.imports).toBe(4);
});
it("a refresh keeps ready and the counters while it runs", () => {
const s = reduce([{ type: "LOAD_START", seq: 2 }], ready());
expect(s.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters);
expect(s.importFolderSet).toBe(true);
expect(s.loadSeq).toBe(2);
});
it("a failed re-read after a first success keeps ready and the last state", () => {
const hiddenReady = ready({ ...snapshot, hidden: true, skipped: ["source"] });
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_ERROR", seq: 2 },
],
hiddenReady,
);
expect(s.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters);
expect(s.hidden).toBe(true);
expect(s.skipped).toEqual(["source"]);
});
it("a first load that fails sets status=error and keeps hidden=false", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
expect(s.status).toBe("error");
expect(s.hidden).toBe(false);
expect(s.counters).toBeNull();
});
it("a stale LOAD_ERROR is ignored", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_ERROR", seq: 1 },
]);
expect(s.status).toBe("loading");
});
it("a retry after a failed first load goes back to loading, then ready", () => {
const errored = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
const loading = reduce([{ type: "LOAD_START", seq: 2 }], errored);
expect(loading.status).toBe("loading");
const done = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], loading);
expect(done.status).toBe("ready");
expect(done.counters).toEqual(snapshot.counters);
});
it("never mutates the previous state", () => {
const before = ready();
const frozen = Object.freeze({ ...before });
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
expect(frozen.hidden).toBe(false);
expect(frozen.skipped).toEqual([]);
});
});
describe("onboardingChecklistReducer — local actions win over an older snapshot (#384, #396)", () => {
it("HIDE during a load wins over that load's snapshot", () => {
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "HIDE" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
],
ready(),
);
expect(s.hidden).toBe(true);
// The rest of the snapshot still lands.
expect(s.status).toBe("ready");
});
it("SHOW during a load wins over that load's snapshot", () => {
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "SHOW" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
],
ready({ ...snapshot, hidden: true }),
);
expect(s.hidden).toBe(false);
});
it("SKIP during a load wins over that load's snapshot", () => {
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "SKIP", key: "budget" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: [] } },
],
ready(),
);
expect(s.skipped).toEqual(["budget"]);
});
it("RESUME during a load wins over that load's snapshot", () => {
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "RESUME", key: "budget" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["budget"] } },
],
ready({ ...snapshot, skipped: ["budget"] }),
);
expect(s.skipped).toEqual([]);
});
it("HIDE during the very first load wins too", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "HIDE" },
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: false } },
]);
expect(s.status).toBe("ready");
expect(s.hidden).toBe(true);
});
it("a load started after the action brings the stored value back", () => {
// The user hid the path, then showed it again from Settings → Users:
// a later load reads the preference, which is the truth again.
const s = reduce(
[
{ type: "HIDE" },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
],
ready(),
);
expect(s.hidden).toBe(false);
});
it("HIDE does not shield the skipped steps, nor SKIP the hidden flag", () => {
const afterHide = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "HIDE" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["source"] } },
],
ready(),
);
expect(afterHide.skipped).toEqual(["source"]);
const afterSkip = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "SKIP", key: "budget" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
],
ready(),
);
expect(afterSkip.hidden).toBe(true);
});
it("HIDE and SHOW do not change the load state", () => {
const hidden = reduce([{ type: "HIDE" }], ready());
expect(hidden.hidden).toBe(true);
expect(hidden.status).toBe("ready");
expect(hidden.counters).toEqual(snapshot.counters);
const shown = reduce([{ type: "SHOW" }], hidden);
expect(shown.hidden).toBe(false);
expect(shown.status).toBe("ready");
});
it("HIDE while the first load runs keeps status loading", () => {
const s = reduce([{ type: "LOAD_START", seq: 1 }, { type: "HIDE" }]);
expect(s.status).toBe("loading");
expect(s.hidden).toBe(true);
});
});
describe("onboardingChecklistReducer — skipped steps and the card latch (#396)", () => {
it("SKIP keeps checklist order and never duplicates a step", () => {
const s = reduce(
[
{ type: "SKIP", key: "budget" },
{ type: "SKIP", key: "source" },
{ type: "SKIP", key: "budget" },
],
ready(),
);
expect(s.skipped).toEqual(["source", "budget"]);
});
it("RESUME removes only its step", () => {
const s = reduce([{ type: "RESUME", key: "source" }], ready({ ...snapshot, skipped: ["source", "budget"] }));
expect(s.skipped).toEqual(["budget"]);
});
it("SKIP releases the card latch; RESUME, HIDE and a load do not touch it", () => {
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
expect(latched.cardPage).toBe("/import");
expect(reduce([{ type: "SKIP", key: "importFolder" }], latched).cardPage).toBeNull();
expect(reduce([{ type: "RESUME", key: "importFolder" }], latched).cardPage).toBe("/import");
expect(
reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_DONE", seq: 2, snapshot },
],
latched,
).cardPage,
).toBe("/import");
});
it("SET_CARD_PAGE with the same page returns the same state object", () => {
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
latched,
);
});
});
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
/** A promise whose resolution the test controls. */
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function loaderDeps(overrides: Partial<OnboardingLoaderDeps> = {}) {
const actions: OnboardingChecklistAction[] = [];
const deps: OnboardingLoaderDeps = {
initializeGuidedPath: vi.fn(async () => ({ hidden: false })),
getOnboardingSnapshot: vi.fn(async () => snapshot),
dispatch: (action) => {
actions.push(action);
},
logError: vi.fn(),
...overrides,
};
return { deps, actions };
}
describe("createOnboardingLoader (#396)", () => {
it("numbers its loads and dispatches LOAD_START then LOAD_DONE with the same number", async () => {
const { deps, actions } = loaderDeps();
const load = createOnboardingLoader(deps);
await load();
await load();
expect(actions).toEqual([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_DONE", seq: 1, snapshot },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_DONE", seq: 2, snapshot },
]);
});
it("the first load reads the snapshot only once rule D9 is applied", async () => {
const init = deferred<{ hidden: boolean }>();
const { deps } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
const load = createOnboardingLoader(deps);
const pending = load();
await Promise.resolve();
await Promise.resolve();
expect(deps.getOnboardingSnapshot).not.toHaveBeenCalled();
init.resolve({ hidden: true });
await pending;
expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(1);
});
it("two concurrent first loads (StrictMode double mount) run D9 once and share it", async () => {
const init = deferred<{ hidden: boolean }>();
const { deps, actions } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
const load = createOnboardingLoader(deps);
const first = load();
const second = load();
init.resolve({ hidden: false });
await Promise.all([first, second]);
expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(2);
// Both answers are dispatched; the reducer keeps the one of load 2.
const final = actions.reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
expect(final.status).toBe("ready");
expect(final.loadSeq).toBe(2);
});
it("later loads never run D9 again", async () => {
const { deps } = loaderDeps();
const load = createOnboardingLoader(deps);
await load();
await load();
await load();
expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
});
it("a failed D9 is logged and the load still reads the snapshot", async () => {
const { deps, actions } = loaderDeps({
initializeGuidedPath: vi.fn(async () => {
throw new Error("database is locked");
}),
});
await createOnboardingLoader(deps)();
expect(deps.logError).toHaveBeenCalledTimes(1);
expect(vi.mocked(deps.logError).mock.calls[0][1]).toBe("database is locked");
expect(actions[actions.length - 1]).toEqual({ type: "LOAD_DONE", seq: 1, snapshot });
});
it("a failed snapshot dispatches LOAD_ERROR with its number", async () => {
const { deps, actions } = loaderDeps({
getOnboardingSnapshot: vi.fn(async () => {
throw new Error("boom");
}),
});
await createOnboardingLoader(deps)();
expect(actions).toEqual([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
});
it("logs a failure only when a failure streak starts", async () => {
const results: Array<"ok" | "fail"> = ["fail", "fail", "fail", "ok", "fail"];
const { deps } = loaderDeps({
getOnboardingSnapshot: vi.fn(async () => {
if (results.shift() === "fail") throw new Error("database is locked");
return snapshot;
}),
});
const load = createOnboardingLoader(deps);
for (let i = 0; i < 5; i++) await load();
// Streak 1 (three failures) then streak 2 (one): two lines, not four.
expect(deps.logError).toHaveBeenCalledTimes(2);
expect(vi.mocked(deps.logError).mock.calls[0]).toEqual([
"Onboarding: failed to load the getting-started path",
"database is locked",
]);
});
it("the failure of a stale load is not logged", async () => {
const first = deferred<OnboardingSnapshot>();
let calls = 0;
const { deps } = loaderDeps({
getOnboardingSnapshot: vi.fn(() => (++calls === 1 ? first.promise : Promise.resolve(snapshot))),
});
const load = createOnboardingLoader(deps);
const stale = load();
await load();
first.reject(new Error("late failure"));
await stale;
expect(deps.logError).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Provider view: the path end to end, without rendering
// ---------------------------------------------------------------------------
describe("deriveOnboardingView — the path end to end (#396)", () => {
// Every card page of the milestone, so the latch is exercised before the
// card issues add their pages to CARD_PAGES.
const cardPages = new Set(["/import", "/transactions", "/budget"]);
const empty: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** One render of the provider: derive, then store a latch that changed, as the hook does. */
function render(
state: OnboardingChecklistState,
pathname: string,
{ budgetAllowed = true, budgetReady = true } = {},
) {
const checklist = state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
})
: null;
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
const settled =
view.cardPage === state.cardPage
? state
: onboardingChecklistReducer(state, { type: "SET_CARD_PAGE", cardPage: view.cardPage });
// The latch settles in one pass (React re-renders once after the update).
const again = deriveOnboardingView({ state: settled, checklist, pathname, budgetReady, cardPages });
expect(again.cardPage).toBe(settled.cardPage);
return { state: settled, view, checklist };
}
/** A load numbered after every previous one, answered with `snap`. */
function load(state: OnboardingChecklistState, snap: OnboardingSnapshot): OnboardingChecklistState {
const seq = state.loadSeq + 1;
return reduce(
[
{ type: "LOAD_START", seq },
{ type: "LOAD_DONE", seq, snapshot: snap },
],
state,
);
}
const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
it("shows nothing before the first load", () => {
const { view } = render(initialOnboardingChecklistState, "/import");
expect(view.mode).toBe("none");
expect(view.nextStep).toBeNull();
expect(view.cardPage).toBeNull();
});
it("walks a new profile from the folder to the budget, the card staying when its step gets done", () => {
let s = load(initialOnboardingChecklistState, fresh);
// Empty dashboard: the welcome card holds the list (D11).
let r = render(s, "/");
expect(r.view.mode).toBe("none");
expect(r.view.nextStep?.key).toBe("importFolder");
// On /import: the card of the folder step.
s = load(r.state, fresh);
r = render(s, "/import");
expect(r.view.mode).toBe("card");
expect(r.state.cardPage).toBe("/import");
// Folder chosen, page open: next step on the same page, same card.
s = load(r.state, { ...fresh, importFolderSet: true });
r = render(s, "/import");
expect(r.view.nextStep?.key).toBe("source");
expect(r.view.mode).toBe("card");
// Import done, page open: the next step lives on /transactions, the card
// stays on /import to say "Done." and offer it.
const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
r = render(s, "/import");
expect(r.view.nextStep?.key).toBe("manualCategory");
expect(r.view.mode).toBe("card");
expect(r.state.cardPage).toBe("/import");
// Following "Next step": the latch moves to /transactions.
s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
r = render(s, "/transactions");
expect(r.view.mode).toBe("card");
expect(r.state.cardPage).toBe("/transactions");
// A category changed by hand, page open: the card stays.
const categorized = { ...imported, manualCategorized: 1 };
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
r = render(s, "/transactions");
expect(r.view.nextStep?.key).toBe("budget");
expect(r.view.mode).toBe("card");
// Elsewhere: the banner.
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
r = render(s, "/reports");
expect(r.view.mode).toBe("banner");
expect(r.state.cardPage).toBeNull();
// The budget page, then a first amount: the card stays, in its end mode.
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
r = render(s, "/budget");
expect(r.view.mode).toBe("card");
s = load(r.state, {
...fresh,
importFolderSet: true,
counters: { ...categorized, budgetEntries: 1 },
});
r = render(s, "/budget");
expect(r.view.nextStep).toBeNull();
expect(r.checklist?.complete).toBe(true);
expect(r.view.mode).toBe("card");
// Anywhere else: the end message (D4).
r = render(r.state, "/");
expect(r.view.mode).toBe("complete");
});
it('"Skip" towards a step of the same page keeps the card', () => {
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
expect(r.state.cardPage).toBe("/import");
const skipped = reduce([{ type: "SKIP", key: "importFolder" }], r.state);
expect(skipped.cardPage).toBeNull();
r = render(skipped, "/import");
expect(r.view.nextStep?.key).toBe("source");
expect(r.view.mode).toBe("card");
expect(r.state.cardPage).toBe("/import");
});
it('"Skip" towards a step of another page releases the card for the banner', () => {
const snap = {
...fresh,
importFolderSet: true,
counters: { ...empty, sources: 1, transactions: 20 },
};
let r = render(load(initialOnboardingChecklistState, snap), "/import");
expect(r.view.nextStep?.key).toBe("import");
expect(r.view.mode).toBe("card");
r = render(reduce([{ type: "SKIP", key: "import" }], r.state), "/import");
expect(r.view.nextStep?.key).toBe("manualCategory");
expect(r.view.mode).toBe("banner");
expect(r.state.cardPage).toBeNull();
});
it('"Skip" of the last step on its page ends the path with the end message', () => {
const snap = {
...fresh,
importFolderSet: true,
counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
};
let r = render(load(initialOnboardingChecklistState, snap), "/budget");
expect(r.view.mode).toBe("card");
r = render(reduce([{ type: "SKIP", key: "budget" }], r.state), "/budget");
expect(r.view.mode).toBe("complete");
});
it("hiding releases the card and shows nothing; showing again brings it back", () => {
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
expect(r.view.mode).toBe("card");
r = render(reduce([{ type: "HIDE" }], r.state), "/import");
expect(r.view.mode).toBe("none");
expect(r.state.cardPage).toBeNull();
r = render(reduce([{ type: "SHOW" }], r.state), "/import");
expect(r.view.mode).toBe("card");
});
it("a profile hidden by rule D9 shows nothing on any page", () => {
const s = load(initialOnboardingChecklistState, {
...fresh,
hidden: true,
counters: { ...empty, transactions: 40 },
});
for (const page of ["/", "/import", "/transactions", "/budget", "/reports"]) {
expect(render(s, page).view.mode).toBe("none");
}
});
it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
const snap = {
...fresh,
importFolderSet: true,
counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
};
const s = load(initialOnboardingChecklistState, snap);
const r = render(s, "/budget", { budgetAllowed: false });
expect(r.checklist?.total).toBe(5);
expect(r.view.nextStep).toBeNull();
expect(r.state.cardPage).toBeNull();
expect(r.view.mode).toBe("complete");
});
it("a failed re-read keeps the card and the counters on screen", () => {
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
const s = reduce(
[
{ type: "LOAD_START", seq: r.state.loadSeq + 1 },
{ type: "LOAD_ERROR", seq: r.state.loadSeq + 1 },
],
r.state,
);
r = render(s, "/import");
expect(r.view.mode).toBe("card");
expect(r.checklist?.total).toBe(6);
});
});
// ---------------------------------------------------------------------------
// Data-channel follower
// ---------------------------------------------------------------------------
describe("followDataChanges (#396, ADR 0022)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("waits 150 ms after an event before re-reading", () => {
const run = vi.fn();
const stop = followDataChanges(run);
notifyDataChanged();
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
expect(run).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(run).toHaveBeenCalledTimes(1);
stop();
});
it("groups a burst of events into one re-read, 150 ms after the last one", () => {
const run = vi.fn();
const stop = followDataChanges(run);
notifyDataChanged();
vi.advanceTimersByTime(100);
notifyDataChanged();
vi.advanceTimersByTime(100);
notifyDataChanged();
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
expect(run).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(run).toHaveBeenCalledTimes(1);
stop();
});
it("the cleanup drops a pending re-read and unsubscribes", () => {
const run = vi.fn();
const stop = followDataChanges(run);
notifyDataChanged();
stop();
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
notifyDataChanged();
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
expect(run).not.toHaveBeenCalled();
});
});