// 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 { 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( , ); 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>/)?.[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(" { 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)"); const reset = html.indexOf("t(categories.reinitialize)"); // The ring sits on the button whose label closes right after it, before "Reset". expect(ring).toBeLessThan(allKeywords); expect(html.lastIndexOf(" { 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(); }); });