Simpl-Resultat/src/pages/BudgetPage.wiring.test.ts
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
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>
2026-10-13 21:32:20 -04:00

198 lines
8.2 KiB
TypeScript

// BudgetPage — what the page does with the grid's target (#400, #409).
//
// A static render runs no effect, so the grid never reports there. React's
// hooks come from the shared harness (src/test/hookHarness.ts): the page runs
// as a plain function, its state survives from one call to the next as
// between two renders of a mounted component, and its setters keep their
// identity as React's do. The test plays the grid by calling the callback the
// page hands it, then reads what the page hands the card and the grid.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
pathname: "/budget",
onboarding: null as unknown,
budget: null as unknown,
}));
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
// The page's modules pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
vi.mock("../hooks/useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
}));
vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => ctx.budget,
}));
import { hookHarness } from "../test/hookHarness";
import BudgetPage from "./BudgetPage";
import BudgetTable from "../components/budget/BudgetTable";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
/** Steps 2 to 5 done: the next step is the budget. */
const BUDGET_LEFT: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 0,
transactions: 41,
};
/** The path latched on /budget (card mode), as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/budget",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
/** One render of the mounted page: its hooks are read in the same order each time. */
function render() {
const page = harness.render(() => BudgetPage() as AnyElement);
const [card] = find(page, (el) => el.type === OnboardingStepCard);
const [grid] = find(page, (el) => el.type === BudgetTable);
return {
page,
model: card.props.model as BudgetCardModel,
highlight: grid.props.onboardingTarget as boolean,
report: grid.props.onOnboardingTargetChange as (kind: BudgetOnboardingTargetKind | null) => void,
};
}
beforeEach(() => {
harness.clear();
ctx.pathname = "/budget";
ctx.onboarding = path(BUDGET_LEFT);
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
ctx.budget = new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
});
});
describe("BudgetPage — the grid's target and the card (#400)", () => {
it("renders the card as the page's first child, ahead of its heading", () => {
const { page } = render();
const children = page.props.children as ReactNode[];
expect((children[0] as AnyElement).type).toBe(OnboardingStepCard);
expect(find(children[0], (el) => el.type === "h1")).toHaveLength(0);
expect(find(children.slice(1), (el) => el.type === "h1")).toHaveLength(1);
});
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
const checklist = (ctx.onboarding as OnboardingValue).checklist;
let view = render();
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
expect(view.highlight).toBe(false);
view.report("expandAll");
view = render();
expect(view.model.text?.key).toBe("onboarding.card.budget.collapsed");
expect(view.highlight).toBe(true);
view.report("cell");
view = render();
expect(view.model.text?.key).toBe("onboarding.card.budget.flat");
expect(view.highlight).toBe(true);
view.report(null);
view = render();
expect(view.model.text).toBeNull();
expect(view.highlight).toBe(false);
});
it("asks for no ring while the card is not on screen", () => {
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "complete" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.clear();
ctx.onboarding = path(BUDGET_LEFT, overrides);
render().report("cell");
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
}
});
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
ctx.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
render().report("cell");
const view = render();
expect(view.model).toEqual({ text: null, done: true, target: null });
expect(view.highlight).toBe(false);
});
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
expect(render().report).toBe(render().report);
});
it("a profile without transactions: the grid's last report does not outlive the grid", () => {
// Steps 3 to 5 skipped: the budget step is next on a profile without any
// transaction. The rows can land before the count (both reads queue
// behind db.ts's lock): the grid mounts, reports "Expand all", then gives
// way to the empty state (review of #400).
const counters = { ...BUDGET_LEFT, imports: 0, manualCategorized: 0, transactions: 0 };
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
skipped: ["source", "import", "manualCategory"],
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = ctx.budget as { state: BudgetState };
budget.state = { ...budget.state, totalInDb: null };
render().report("expandAll");
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
budget.state = { ...budget.state, totalInDb: 0 };
const page = harness.render(() => BudgetPage() as AnyElement);
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
const [card] = find(page, (el) => el.type === OnboardingStepCard);
expect(card.props.model).toEqual({ text: null, done: false, target: null });
});
});