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>
152 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
});
|