// 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(
,
);
const comboboxes = (html: string) => html.match(/]*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)");
});
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
");
expect(html).toContain(">Cadeaux");
expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
expect(html.split("t(categories.hierarchy.reattach)")).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])");
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(/