// BudgetPage and its getting-started card (#400). No jsdom: react-dom/server // renders in vitest's node environment. The budget is a fake `useBudget()` // over its real initial state, the path a fake `useOnboarding()` built from the // real checklist, and the grid is the real BudgetTable. A static render runs // no effect, so the grid never reports its target here (the report and what // the page does with it are in BudgetPage.wiring.test.ts). What is pinned is // where the card sits and what it says before and after the step is checked. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; // The budget, collapse and preference services pull the database: stub the connection. vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock()); vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); const harness = vi.hoisted(() => ({ budget: null as unknown, onboarding: null as unknown })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../hooks/useBudget", async (importOriginal) => ({ ...(await importOriginal()), useBudget: () => harness.budget, })); import BudgetPage from "./BudgetPage"; import { initialBudgetState, type BudgetState } from "../hooks/useBudget"; import type { OnboardingValue } from "../hooks/useOnboardingChecklist"; import { computeChecklist } from "../utils/onboardingChecklist"; import { nextStep } from "../utils/onboardingPath"; import type { BudgetYearRow, OnboardingCounters } from "../shared/types"; /** 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, }; /** A first amount saved: every step done. */ const BUDGETED: OnboardingCounters = { ...BUDGET_LEFT, budgetEntries: 1 }; const ROWS: BudgetYearRow[] = [ { category_id: 10, category_name: "Logement", category_color: "#9ca3af", category_type: "expense", parent_id: null, is_parent: true, depth: 0, months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0, }, { category_id: 11, category_name: "Loyer", category_color: "#9ca3af", category_type: "expense", parent_id: 10, is_parent: false, depth: 1, months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0, }, ]; /** The budget after its first read, with `overrides`. */ function budget(overrides: Partial = {}) { const state: BudgetState = { ...initialBudgetState(), rows: ROWS, totalInDb: 41, ...overrides }; const noop = vi.fn(); return new Proxy({ state } as Record, { get: (target, key) => (key in target ? target[key as string] : noop), }); } /** 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, }; } const render = () => renderToStaticMarkup( , ); const CARD = 'aria-label="t(onboarding.banner.label)"'; const HIGHLIGHT = 'data-onboarding-highlight="true"'; const DIM = 'class="opacity-50 pointer-events-none"'; const count = (html: string, needle: string) => html.split(needle).length - 1; /** * The card's sentence: the paragraph right after the card's own title, null * without one (the empty state below has a title and a paragraph too). */ function sentence(html: string): string | null { const title = html.indexOf("", html.indexOf(CARD)); return html.slice(title).match(/^<\/h2>

]*>(.*?)<\/p>/)?.[1] ?? null; } beforeEach(() => { harness.budget = budget(); harness.onboarding = path(BUDGET_LEFT); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("BudgetPage — the step card (#400)", () => { it("renders the card first, before the page's heading, at its place in the path", () => { const html = render(); expect(count(html, CARD)).toBe(1); expect(html.indexOf(CARD)).toBeLessThan(html.indexOf(" { const html = render(); expect(sentence(html)).toBeNull(); expect(count(html, HIGHLIGHT)).toBe(0); }); it("keeps the card out of the loading dim, which still covers the page", () => { harness.budget = budget({ isLoading: true }); const html = render(); expect(count(html, DIM)).toBe(1); expect(html.indexOf("t(onboarding.card.skip)")).toBeLessThan(html.indexOf(DIM)); expect(html.indexOf(DIM)).toBeLessThan(html.indexOf(" { harness.onboarding = path(BUDGETED); const html = render(); expect(count(html, CARD)).toBe(1); expect(html).toMatch(/

]*>.*t\(onboarding\.card\.done\)/); expect(html).toContain("t(onboarding.banner.complete)"); expect(html).toContain("t(onboarding.banner.openGuide)"); expect(html).not.toContain("t(onboarding.card.skip)"); expect(count(html, HIGHLIGHT)).toBe(0); }); it("a profile without transactions: the card above the 'import first' state", () => { harness.budget = budget({ rows: [], totalInDb: 0 }); const html = render(); expect(html).toContain("t(budget.empty.title)"); expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("t(budget.empty.title)")); expect(html).toContain("t(onboarding.card.skip)"); expect(sentence(html)).toBeNull(); }); it("no card when the path is not in card mode on this page", () => { for (const overrides of [ { mode: "banner" as const, cardPage: null }, { mode: "none" as const, cardPage: null }, { mode: "card" as const, cardPage: "/transactions" }, ]) { harness.onboarding = path(BUDGET_LEFT, overrides); const html = render(); expect(html).not.toContain(CARD); expect(html).toContain("Logement"); } }); });