Simpl-Resultat/src/pages/CategoriesPage.render.test.tsx
le king fu 00be2877f3 feat(onboarding): step cards of the Categories and Reports pages
The two visit steps get their card: /categories explains keywords and
points at "All keywords" (never "Reset"), /reports explains the hub and
points at the Trends card, with a padlock variant in the Free edition.
"Got it" acknowledges the step; opening the highlighted Trends card does
too (D17), since following the ring leaves the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:45:05 -04:00

152 lines
5.7 KiB
TypeScript

// CategoriesPage and its getting-started card (#432). No jsdom:
// react-dom/server renders in vitest's node environment. The categories hook
// is stubbed (the page is loading, its header shows), and the path is a fake
// `useOnboarding()` built from the real checklist. What is pinned is the
// page's wiring: where the card sits, what it says, and which control carries
// the one highlight ("All keywords", never "Reset").
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
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 }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useCategories", () => ({
useCategories: () => ({
state: {
categories: [],
tree: [],
keywords: [],
selectedCategoryId: null,
editingCategory: null,
isCreating: false,
isLoading: true,
isSaving: false,
error: null,
},
}),
}));
import CategoriesPage, { CATEGORIES_ALL_KEYWORDS_TARGET } from "./CategoriesPage";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
/** Folder, source and import done: the next step is the categories step. */
const IMPORTED: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 0,
budgetEntries: 0,
transactions: 41,
};
/** The path latched on /categories (card mode), as the provider shares it. */
function path(acknowledged: ChecklistItemKey[], overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(IMPORTED, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged,
});
return {
status: "ready",
checklist,
counters: IMPORTED,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/categories",
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 render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/categories"]}>
<CategoriesPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
beforeEach(() => {
harness.onboarding = path([]);
});
describe("CategoriesPage — the step card (#432)", () => {
it("renders the card first, before the page's heading, fifth of eight", () => {
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=5,total=8])");
expect(html).toContain("t(onboarding.checklist.items.categories)");
});
it("explains keywords, 'All keywords' named by its own label", () => {
expect(sentence(render())).toBe(
"t(onboarding.card.categories[allKeywords=t(categories.allKeywords)])",
);
});
it("offers 'Got it' and 'Skip this step'", () => {
const html = render();
expect(html).toContain("t(onboarding.card.acknowledge)");
expect(html).toContain("t(onboarding.card.skip)");
});
it("'All keywords' is the one target, never 'Reset'", () => {
const html = render();
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${CATEGORIES_ALL_KEYWORDS_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
const reset = html.indexOf("t(categories.reinitialize)</button>");
// The ring sits on the button whose label closes right after it, before "Reset".
expect(ring).toBeLessThan(allKeywords);
expect(html.lastIndexOf("<button", allKeywords)).toBeLessThan(ring);
expect(ring).toBeLessThan(reset);
});
it("the step acknowledged: 'Done.', the next step is the manual category, no ring", () => {
harness.onboarding = path(["categories"]);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
expect(html).not.toContain("t(onboarding.card.acknowledge)");
expect(html).not.toContain(HIGHLIGHT);
});
it("without the card on screen, no card and no ring", () => {
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/import" }]) {
harness.onboarding = path([], overrides);
const html = render();
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
}
});
it("opening the page acknowledges nothing", () => {
const value = harness.onboarding as OnboardingValue;
render();
expect(value.acknowledge).not.toHaveBeenCalled();
expect(value.skip).not.toHaveBeenCalled();
});
});