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>
123 lines
5 KiB
TypeScript
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=""');
|
|
});
|
|
});
|