// BudgetPage — what the page does with the grid's target (#400). // // A static render runs no effect, so the grid never reports there. React's // `useState` is stubbed (technique of AppShell.slot.test.tsx): 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"; const harness = vi.hoisted(() => ({ slots: [] as unknown[], setters: [] as Array<(next: unknown) => void>, cursor: 0, pathname: "/budget", onboarding: null as unknown, budget: null as unknown, })); vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useState: (init: unknown) => { const index = harness.cursor++; if (!(index in harness.slots)) { harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init; } harness.setters[index] ??= (next: unknown) => { harness.slots[index] = typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next; }; return [harness.slots[index], harness.setters[index]]; }, }; }); vi.mock("react-router-dom", async (importOriginal) => ({ ...(await importOriginal()), useLocation: () => ({ pathname: harness.pathname }), })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), })); // 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: () => harness.onboarding })); vi.mock("../hooks/useReportsPeriod", () => ({ useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }), })); vi.mock("../hooks/useBudget", async (importOriginal) => ({ ...(await importOriginal()), useBudget: () => harness.budget, })); 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"; 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 { const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }); return { status: "ready", checklist, counters, hidden: false, budgetReady: 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), refresh: vi.fn(async () => undefined), ...overrides, }; } /** One render of the mounted page: its hooks are read in the same order each time. */ function render() { harness.cursor = 0; const page = 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.slots = []; harness.setters = []; harness.pathname = "/budget"; harness.onboarding = path(BUDGET_LEFT); const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 }; harness.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 = (harness.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.slots = []; harness.setters = []; harness.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.'", () => { harness.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); }); });