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>
188 lines
7.1 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|