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>
732 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|