// 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()), 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()), 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 & { 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 { // 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, { 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 }); }); });