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>
188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { computeChecklist } from "./onboardingChecklist";
|
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
|
|
|
const zero: OnboardingCounters = {
|
|
sources: 0,
|
|
imports: 0,
|
|
manualCategorized: 0,
|
|
budgetEntries: 0,
|
|
transactions: 0,
|
|
};
|
|
|
|
const open = { importFolderSet: false, budgetAllowed: true };
|
|
|
|
function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
|
|
return computeChecklist(counters, opts).items.map((i) => i.key);
|
|
}
|
|
|
|
function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
|
|
return computeChecklist(counters, opts)
|
|
.items.filter((i) => i.done)
|
|
.map((i) => i.key);
|
|
}
|
|
|
|
describe("computeChecklist — rows (#351)", () => {
|
|
it("lists the six rows in order", () => {
|
|
expect(keys(zero)).toEqual([
|
|
"profile",
|
|
"importFolder",
|
|
"source",
|
|
"import",
|
|
"manualCategory",
|
|
"budget",
|
|
]);
|
|
});
|
|
|
|
it("zero counters → only profile is done (1/6)", () => {
|
|
const list = computeChecklist(zero, open);
|
|
expect(doneKeys(zero)).toEqual(["profile"]);
|
|
expect(list.done).toBe(1);
|
|
expect(list.total).toBe(6);
|
|
expect(list.complete).toBe(false);
|
|
});
|
|
|
|
it("import folder chosen without any source → done=2", () => {
|
|
const list = computeChecklist(zero, { ...open, importFolderSet: true });
|
|
expect(doneKeys(zero, { ...open, importFolderSet: true })).toEqual([
|
|
"profile",
|
|
"importFolder",
|
|
]);
|
|
expect(list.done).toBe(2);
|
|
});
|
|
|
|
it("source row follows sources ≥ 1", () => {
|
|
expect(doneKeys({ ...zero, sources: 1 })).toContain("source");
|
|
expect(doneKeys({ ...zero, sources: 1 })).not.toContain("import");
|
|
});
|
|
|
|
it("import row follows imports ≥ 1", () => {
|
|
expect(doneKeys({ ...zero, imports: 1 })).toContain("import");
|
|
expect(doneKeys({ ...zero, imports: 1 })).not.toContain("source");
|
|
});
|
|
|
|
it("manualCategory row follows manualCategorized ≥ 1 (not transactions)", () => {
|
|
expect(doneKeys({ ...zero, transactions: 50 })).not.toContain("manualCategory");
|
|
expect(doneKeys({ ...zero, manualCategorized: 1 })).toContain("manualCategory");
|
|
});
|
|
|
|
it("budget row follows budgetEntries ≥ 1", () => {
|
|
expect(doneKeys({ ...zero, budgetEntries: 1 })).toContain("budget");
|
|
});
|
|
|
|
it("links each row to its page", () => {
|
|
const to = Object.fromEntries(
|
|
computeChecklist(zero, open).items.map((i) => [i.key, i.to]),
|
|
);
|
|
expect(to).toEqual({
|
|
profile: "/settings/users",
|
|
importFolder: "/import",
|
|
source: "/import",
|
|
import: "/import",
|
|
manualCategory: "/transactions",
|
|
budget: "/budget",
|
|
});
|
|
});
|
|
|
|
it("no row is locked when Budget is allowed", () => {
|
|
expect(computeChecklist(zero, open).items.every((i) => !i.locked)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("computeChecklist — budget gating (#351)", () => {
|
|
const locked = { importFolderSet: false, budgetAllowed: false };
|
|
|
|
it("budgetAllowed=false → total=5 and the budget row is locked", () => {
|
|
const list = computeChecklist(zero, locked);
|
|
expect(list.total).toBe(5);
|
|
const budget = list.items.find((i) => i.key === "budget")!;
|
|
expect(budget.locked).toBe(true);
|
|
expect(budget.to).toBe("/settings/users");
|
|
expect(list.items.filter((i) => i.locked)).toHaveLength(1);
|
|
});
|
|
|
|
it("a locked budget row never counts as done, even with entries", () => {
|
|
const list = computeChecklist({ ...zero, budgetEntries: 3 }, locked);
|
|
expect(list.done).toBe(1);
|
|
});
|
|
|
|
it("complete is true at 5/5 when Budget is locked", () => {
|
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
|
const list = computeChecklist(all, { ...locked, importFolderSet: true });
|
|
expect(list.done).toBe(5);
|
|
expect(list.total).toBe(5);
|
|
expect(list.complete).toBe(true);
|
|
});
|
|
|
|
it("complete is false at 5/6 when Budget is open but empty", () => {
|
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
|
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
|
expect(list.done).toBe(5);
|
|
expect(list.total).toBe(6);
|
|
expect(list.complete).toBe(false);
|
|
});
|
|
|
|
it("complete is true at 6/6 when Budget is open", () => {
|
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
|
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
|
expect(list.done).toBe(6);
|
|
expect(list.total).toBe(6);
|
|
expect(list.complete).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("computeChecklist — skipped steps (#396, D3)", () => {
|
|
const fiveDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
|
|
|
it("no row is skipped when the option is omitted or empty", () => {
|
|
expect(computeChecklist(zero, open).items.every((i) => !i.skipped)).toBe(true);
|
|
expect(computeChecklist(zero, { ...open, skipped: [] }).items.every((i) => !i.skipped)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("a skipped row is marked skipped, not done, and counts in the counter", () => {
|
|
const list = computeChecklist(zero, { ...open, skipped: ["importFolder"] });
|
|
const row = list.items.find((i) => i.key === "importFolder")!;
|
|
expect(row.skipped).toBe(true);
|
|
expect(row.done).toBe(false);
|
|
expect(list.done).toBe(2); // profile + the skipped row
|
|
expect(list.total).toBe(6);
|
|
});
|
|
|
|
it("complete with done and skipped rows", () => {
|
|
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
|
|
expect(list.done).toBe(6);
|
|
expect(list.total).toBe(6);
|
|
expect(list.complete).toBe(true);
|
|
});
|
|
|
|
it("a locked row stays out of the total, even skipped", () => {
|
|
const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
|
|
const budget = list.items.find((i) => i.key === "budget")!;
|
|
expect(budget.locked).toBe(true);
|
|
expect(list.total).toBe(5);
|
|
expect(list.done).toBe(1);
|
|
expect(list.complete).toBe(false);
|
|
});
|
|
|
|
it("a row both done and skipped counts once", () => {
|
|
const list = computeChecklist(fiveDone, {
|
|
...open,
|
|
importFolderSet: true,
|
|
skipped: ["source", "import", "budget"],
|
|
});
|
|
const source = list.items.find((i) => i.key === "source")!;
|
|
expect(source.done).toBe(true);
|
|
expect(source.skipped).toBe(true);
|
|
expect(list.done).toBe(6);
|
|
expect(list.total).toBe(6);
|
|
});
|
|
|
|
it("skipping does not change the rows' links", () => {
|
|
const to = computeChecklist(zero, { ...open, skipped: ["manualCategory", "budget"] }).items.map(
|
|
(i) => i.to,
|
|
);
|
|
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
|
});
|
|
});
|