Simpl-Resultat/src/utils/onboardingChecklist.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

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));
});
});