// TransactionsPage and its getting-started card (#399). No jsdom: // react-dom/server renders in vitest's node environment. The page runs the // real `useTransactions` (its reducer and initial state; no effect runs, so // nothing is read) with the rows a first read would have brought, and the path // is a fake `useOnboarding()` built from the real checklist. What is pinned is // the page's wiring: the guided entry's start filters, where the card sits, // what it says, and which control carries the one highlight. import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; import type { TransactionFilters, TransactionRow } from "../shared/types"; // The page's 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(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[], /** Filters a later dispatch would have set (the fallback, a filter picked by hand). */ filters: null as Partial | null, })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../hooks/useTransactions", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTransactions: (...args: Parameters) => { const hook = actual.useTransactions(...args); const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters; return { ...hook, state: { ...hook.state, rows: harness.rows, filters } }; }, }; }); import TransactionsPage from "./TransactionsPage"; import { TRANSACTION_CATEGORY_TARGET } from "../components/transactions/TransactionTable"; import type { OnboardingValue } from "../hooks/useOnboardingChecklist"; import { computeChecklist } from "../utils/onboardingChecklist"; import { nextStep, stepNavState } from "../utils/onboardingPath"; import type { OnboardingCounters } from "../shared/types"; /** Folder, source and import done: the next step is the manual category. */ const IMPORTED: OnboardingCounters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 41, }; /** A transaction categorized by hand: the step is checked. */ const CATEGORIZED: OnboardingCounters = { ...IMPORTED, manualCategorized: 1 }; /** The path latched on /transactions (card mode), as the provider shares it. */ function path( counters: OnboardingCounters, overrides: Partial = {}, budgetAllowed = true, ): OnboardingValue { // Stopped after the import: the visit steps are acknowledged (rule of the templates, #425). const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed, visitStepsInitialized: true, acknowledged: ["categories", "reports"], }); return { status: "ready", checklist, counters, hidden: false, budgetReady: true, visitStepsInitialized: true, nextStep: nextStep(checklist), mode: "card", cardPage: "/transactions", 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, }; } const row = (id: number, description: string): TransactionRow => ({ id, date: "2025-12-18", description, amount: -42.5, category_id: null, category_name: null, category_color: null, source_name: "Exemple Desjardins", notes: null, is_manually_categorized: false, is_split: false, }); /** What the link of the "categorize by hand" step carries. */ const GUIDED = stepNavState("manualCategory"); const render = (state: unknown = null) => renderToStaticMarkup( , ); const CARD = 'aria-label="t(onboarding.banner.label)"'; const HIGHLIGHT = 'data-onboarding-highlight="true"'; const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`; const count = (html: string, needle: string) => html.split(needle).length - 1; /** * The card's sentence: the paragraph that follows its title. The card always * hands `t` its interpolations, so a sentence without any reads `t(key[])`. */ const sentence = (html: string) => html.match(/<\/h2>

]*>(.*?)<\/p>/)?.[1] ?? null; /** The filter bar's start date, "" without a bound (React writes `value` last). */ const dateFrom = (html: string) => html.match(/]* value="([^"]*)"/)?.[1]; /** The period button shown as active. */ const activePeriod = (html: string) => html.match(/