Simpl-Resultat/src/pages/BudgetPage.render.test.tsx
le king fu 063f6c3d7d test(i18n): replace the local marked fake t copies with i18nMock
The 12 test files that already marked their output each carried a copy
of the same fake t. They now use the shared i18nMock; no assertion
changes. LicenseCard keeps its per-test language through a getter.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:51 -04:00

188 lines
7.1 KiB
TypeScript

// 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<typeof import("../hooks/useBudget")>()),
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<BudgetState> = {}) {
const state: BudgetState = { ...initialBudgetState(), rows: ROWS, totalInDb: 41, ...overrides };
const noop = vi.fn();
return new Proxy({ state } as Record<string, unknown>, {
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> = {}): 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(
<MemoryRouter initialEntries={["/budget"]}>
<BudgetPage />
</MemoryRouter>,
);
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("</h2>", html.indexOf(CARD));
return html.slice(title).match(/^<\/h2><p [^>]*>(.*?)<\/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("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=6])");
expect(html).toContain("t(onboarding.checklist.items.budget)");
expect(html).toContain("t(onboarding.card.skip)");
// The grid is there, below the card.
expect(html.indexOf("Logement")).toBeGreaterThan(html.indexOf(CARD));
});
it("before the grid reports its target: the title and 'Skip this step', no sentence, no ring", () => {
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("<h1"));
});
it("a first amount saved: the card stays, says 'Done.' and ends the path", () => {
harness.onboarding = path(BUDGETED);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toMatch(/<p role="status"[^>]*>.*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");
}
});
});