Simpl-Resultat/src/components/categories/HierarchyCheckModal.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

123 lines
5 KiB
TypeScript

// HierarchyCheckModal — what the « Vérifier la hiérarchie » window shows (#393).
// No jsdom: react-dom/server renders in vitest's node environment. A closed
// CategoryCombobox renders its input only, without its portal (#429).
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import HierarchyCheckModal from "./HierarchyCheckModal";
import type { CategoryTreeNode } from "../../shared/types";
import type { OrphanCategory } from "../../utils/categoryHierarchy";
const node = (id: number, parent_id: number | null, name: string, i18n_key: string | null = null): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
// 1 is a root; 3 <-> 5 is a cycle; 20's parent is gone.
const CATEGORIES = [
node(1, null, "Logement", "categoriesSeed.housing"),
node(3, 5, "Épicerie"),
node(5, 3, "Alimentation"),
node(20, 99, "Cadeaux"),
];
const ORPHANS: OrphanCategory[] = [
{ id: 3, reason: "cycle" },
{ id: 20, reason: "missing_parent" },
];
const render = (props: { orphans: OrphanCategory[] | null; error?: string | null; isSaving?: boolean }) =>
renderToStaticMarkup(
<HierarchyCheckModal
orphans={props.orphans}
error={props.error ?? null}
categories={CATEGORIES}
isSaving={props.isSaving ?? false}
onReattach={vi.fn()}
onClose={vi.fn()}
/>,
);
const comboboxes = (html: string) => html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
describe("HierarchyCheckModal (#393)", () => {
it("is a modal dialog named after the button, with its explanation and a close button", () => {
const html = render({ orphans: [] });
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-label="t(categories.hierarchy.check)"');
expect(html).toContain("t(categories.hierarchy.intro)");
expect(html).toContain("t(common.close)</button>");
});
it("says the check runs while the orphans are not known", () => {
const html = render({ orphans: null });
expect(html).toContain("t(categories.hierarchy.checking)");
expect(html).not.toContain("t(categories.hierarchy.none)");
expect(comboboxes(html)).toHaveLength(0);
});
it("says « aucune » for a sound hierarchy, with no row", () => {
const html = render({ orphans: [] });
expect(html).toContain("t(categories.hierarchy.none)");
expect(html).not.toContain("categories.hierarchy.count");
expect(html).not.toContain("t(categories.hierarchy.reattach)");
});
it("counts the orphans, and gives each row its name, its reason, a new parent and « Rattacher »", () => {
const html = render({ orphans: ORPHANS });
expect(html).toContain("t(categories.hierarchy.count[count=2])");
expect(html).toContain(">Épicerie</p>");
expect(html).toContain(">Cadeaux</p>");
expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
expect(html.split("t(categories.hierarchy.reattach)</button>")).toHaveLength(3);
const inputs = comboboxes(html);
expect(inputs).toHaveLength(2);
expect(inputs[0]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Épicerie])"');
expect(inputs[1]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
});
it("preselects the root on every row", () => {
for (const input of comboboxes(render({ orphans: ORPHANS }))) {
expect(input).toContain('value="t(categories.hierarchy.root)"');
}
});
it("names a seed category in the app's language", () => {
const html = render({ orphans: [{ id: 1, reason: "inactive_parent" }] });
expect(html).toContain(">t(categoriesSeed.housing[defaultValue=Logement])</p>");
expect(html).toContain("t(categories.hierarchy.reasons.inactive_parent)");
});
it("shows the translated error above the list, as an alert", () => {
const html = render({ orphans: ORPHANS, error: "Ce parent a des transactions" });
expect(html).toContain('role="alert"');
expect(html.indexOf("Ce parent a des transactions")).toBeLessThan(html.indexOf("categories.hierarchy.count"));
});
it("shows a failed check alone, without the « checking » line", () => {
const html = render({ orphans: null, error: "database is locked" });
expect(html).toContain("database is locked");
expect(html).not.toContain("t(categories.hierarchy.checking)");
});
it("disables « Rattacher » while a reattachment runs", () => {
const html = render({ orphans: ORPHANS, isSaving: true });
const buttons = html.match(/<button[^>]*>t\(categories\.hierarchy\.reattach\)<\/button>/g) ?? [];
expect(buttons).toHaveLength(2);
for (const button of buttons) expect(button).toContain('disabled=""');
expect(render({ orphans: ORPHANS })).not.toContain('disabled=""');
});
});