Simpl-Resultat/src/pages/CategoriesPage.render.test.tsx
le king fu db5ee65720 fix: refuse a looping or too deep parent, and reattach orphan categories (#393)
updateCategory checks a new parent before writing: the category itself,
one of its descendants or a category caught in a cycle (cycle), a fourth
level counting its sub-tree (too_deep), a parent holding transactions of
its own (parent_has_transactions). The refusal is a CategoryHierarchyError
that the Categories page shows by the translation of its code. The reads
and the writes, the new parent's is_inputable included, run in one
transaction through its handle; an unchanged parent is not checked, so a
category stored before these rules can still be renamed.

getCategoryDepth keeps the ids it has seen and ends on a cycle.

A "Check hierarchy" button, between "All keywords" and "Re-initialize",
opens a window that lists the orphan categories (caught in a parent loop,
parent deleted, parent gone), one per group, with a new parent for each:
the root by default, or a parent of the category form's list.
reattachCategory applies the same rules; after it, the list and the tree
reload and the Journal gets one line with the reason and the number of
categories put back, no name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:16:21 -04:00

211 lines
8.3 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,
/** Overrides of the categories state, for the tests of #393. */
state: {} as Record<string, unknown>,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useCategories", async (importOriginal) => ({
// The real error text helper: the page must translate a refusal's key.
categoriesErrorText: (await importOriginal<typeof import("../hooks/useCategories")>()).categoriesErrorText,
useCategories: () => ({
state: {
categories: [],
tree: [],
keywords: [],
selectedCategoryId: null,
editingCategory: null,
isCreating: false,
isLoading: true,
isSaving: false,
error: null,
hierarchyCheck: null,
...harness.state,
},
}),
}));
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([]);
harness.state = {};
});
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();
});
});
describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
/** The opening tag of the header button whose label closes right after it. */
const buttonTag = (html: string, label: string) => {
const end = html.indexOf(`${label}</button>`);
expect(end).toBeGreaterThan(-1);
const start = html.lastIndexOf("<button", end);
return html.slice(start, html.indexOf(">", start) + 1);
};
it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
const html = render();
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
const check = html.indexOf("t(categories.hierarchy.check)</button>");
const reset = html.indexOf("t(categories.reinitialize)</button>");
expect(allKeywords).toBeLessThan(check);
expect(check).toBeLessThan(reset);
const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
classOf(buttonTag(html, "t(categories.reinitialize)")),
);
});
it("is disabled while a save runs, as « Reset »", () => {
harness.state = { isSaving: true };
expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
});
it("opens the window only while the check is open", () => {
expect(render()).not.toContain('role="dialog"');
harness.state = { hierarchyCheck: { orphans: [], error: null } };
const html = render();
expect(html).toContain('role="dialog"');
expect(html).toContain("t(categories.hierarchy.none)");
});
it("translates a refusal in the window by its key", () => {
harness.state = {
hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
};
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
});
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");
harness.state = { error: { message: "database is locked" } };
expect(render()).toContain(">database is locked</div>");
});
});