Simpl-Resultat/src/utils/categoryHierarchy.test.ts
le king fu 7f25293669 test(categories): pin the rules the review of #393 left green (#393)
Review 25437, point 2: the loop named by its own smallest member, not by
a smaller id under it (RM1); the page hands the window every category,
not the tree (RM11); a category that is its own parent stays out of the
budget, and displaysAsRoot says so (RM15). RM7 (depth counted on active
categories at the service) is pinned in categoryService.test.ts by
1a36d4e, RM2 by be4b923.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:59:07 -04:00

229 lines
8.9 KiB
TypeScript

// categoryHierarchy — the rules of the category hierarchy (#393): depths,
// heights and descent, the orphan diagnostic and the validation of a new
// parent. Every walk must end on a `parent_id` cycle: a test that loops runs
// into vitest's timeout and goes red.
import { describe, it, expect } from "vitest";
import {
MAX_CATEGORY_DEPTH,
depthsOf,
descendantsOf,
displaysAsRoot,
findOrphans,
fitsUnder,
isActiveRow,
parentChangeRefusal,
subtreeHeight,
type HierarchyNode,
type HierarchyRow,
} from "./categoryHierarchy";
const n = (id: number, parent_id: number | null): HierarchyNode => ({ id, parent_id });
/** An active row, or an inactive one with `active = false`. */
const r = (id: number, parent_id: number | null, active = true): HierarchyRow => ({
id,
parent_id,
is_active: active ? 1 : 0,
});
// Two roots. 1 -> 10 -> 100 and 1 -> 11; 2 -> 20.
const TREE = [n(1, null), n(10, 1), n(100, 10), n(11, 1), n(2, null), n(20, 2)];
const depthEntries = (nodes: HierarchyNode[]) =>
[...depthsOf(nodes).entries()].sort(([a], [b]) => a - b);
describe("depthsOf", () => {
it("gives 0 to a root, 1 to its child, 2 to a grandchild", () => {
expect(depthEntries(TREE)).toEqual([
[1, 0],
[2, 0],
[10, 1],
[11, 1],
[20, 1],
[100, 2],
]);
});
it("does not depend on the order of the list", () => {
expect(depthEntries([...TREE].reverse())).toEqual(depthEntries(TREE));
});
it("treats a category whose parent is not in the list as a root", () => {
expect(depthEntries([n(10, 1), n(100, 10)])).toEqual([
[10, 0],
[100, 1],
]);
});
it("gives no depth to the members of a cycle nor to what hangs under it, and ends", () => {
// 1 <-> 2, 3 under 1, 4 under 3; 5 is a sound root.
const depths = depthsOf([n(3, 1), n(1, 2), n(2, 1), n(4, 3), n(5, null)]);
expect([...depths.entries()]).toEqual([[5, 0]]);
});
it("gives no depth to a category that is its own parent", () => {
expect(depthsOf([n(7, 7), n(8, 7)]).size).toBe(0);
});
it("ends on a three-member cycle reached from below", () => {
const depths = depthsOf([n(9, 3), n(3, 2), n(2, 1), n(1, 3)]);
expect(depths.size).toBe(0);
});
});
describe("subtreeHeight and descendantsOf", () => {
it("count the levels and list the categories under a category", () => {
expect(subtreeHeight(TREE, 1)).toBe(2);
expect(subtreeHeight(TREE, 10)).toBe(1);
expect(subtreeHeight(TREE, 100)).toBe(0);
expect([...descendantsOf(TREE, 1)].sort((a, b) => a - b)).toEqual([10, 11, 100]);
expect(descendantsOf(TREE, 100).size).toBe(0);
});
it("end on a cycle, each category once, the start never listed", () => {
// 1 <-> 2, 3 under 2.
const cyclic = [n(1, 2), n(2, 1), n(3, 2)];
expect(subtreeHeight(cyclic, 1)).toBe(2);
expect([...descendantsOf(cyclic, 1)].sort((a, b) => a - b)).toEqual([2, 3]);
expect(subtreeHeight([n(7, 7)], 7)).toBe(0);
expect(descendantsOf([n(7, 7)], 7).size).toBe(0);
});
});
describe("fitsUnder", () => {
it("allows three levels, 0 to 2, counting the sub-tree", () => {
expect(MAX_CATEGORY_DEPTH).toBe(2);
expect(fitsUnder(0, 0)).toBe(true);
expect(fitsUnder(1, 0)).toBe(true);
expect(fitsUnder(0, 1)).toBe(true);
expect(fitsUnder(2, 0)).toBe(false);
expect(fitsUnder(1, 1)).toBe(false);
expect(fitsUnder(0, 2)).toBe(false);
});
});
describe("displaysAsRoot", () => {
const ids = new Set([1, 2, 10]);
it("is a root without a parent, or with a parent missing from the list", () => {
expect(displaysAsRoot({ id: 1, parent_id: null }, ids)).toBe(true);
expect(displaysAsRoot({ id: 1 }, ids)).toBe(true);
expect(displaysAsRoot({ id: 30, parent_id: 99 }, ids)).toBe(true);
});
it("is not a root under a parent of the list, a cycle member included", () => {
expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false);
expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false);
});
it("is not a root as its own parent: that one-member loop stays hidden, like the tree hides it", () => {
expect(displaysAsRoot({ id: 10, parent_id: 10 }, ids)).toBe(false);
});
});
describe("isActiveRow", () => {
it("reads 1 (or true) as active, anything else as inactive, as `is_active = 1` does", () => {
expect(isActiveRow(r(1, null))).toBe(true);
expect(isActiveRow({ id: 1, parent_id: null, is_active: true })).toBe(true);
expect(isActiveRow(r(1, null, false))).toBe(false);
expect(isActiveRow({ id: 1, parent_id: null, is_active: false })).toBe(false);
});
});
describe("findOrphans (#393)", () => {
it("finds nothing in a sound tree", () => {
expect(findOrphans(TREE.map((c) => r(c.id, c.parent_id ?? null)))).toEqual([]);
});
it("finds a category that is its own parent", () => {
expect(findOrphans([r(1, null), r(7, 7), r(8, 7)])).toEqual([{ id: 7, reason: "cycle" }]);
});
it("lists a two-member cycle once, by its smallest id, not what hangs under it", () => {
// 5 <-> 3, 8 under 5, 9 under 8. Read from below first.
const rows = [r(9, 8), r(8, 5), r(5, 3), r(3, 5), r(1, null)];
expect(findOrphans(rows)).toEqual([{ id: 3, reason: "cycle" }]);
});
it("names the loop by its own smallest member, never by a smaller id that hangs under it", () => {
// 5 <-> 8, 2 under 5: reattaching 2 would leave 5 and 8 hidden.
expect(findOrphans([r(2, 5), r(5, 8), r(8, 5), r(1, null)])).toEqual([{ id: 5, reason: "cycle" }]);
});
it("lists a three-member cycle once, by its smallest id, whatever the order of the rows", () => {
// 6 -> 4 -> 9 -> 6, 12 under 9.
const rows = [r(6, 4), r(4, 9), r(9, 6), r(12, 9), r(1, null)];
expect(findOrphans(rows)).toEqual([{ id: 4, reason: "cycle" }]);
expect(findOrphans([...rows].reverse())).toEqual([{ id: 4, reason: "cycle" }]);
});
it("names a deactivated parent apart from a missing one, and only the head of each group", () => {
// 20's parent (2) is inactive; 21 under 20 follows it. 30's parent (99) does not exist.
const rows = [r(1, null), r(2, null, false), r(21, 20), r(20, 2), r(30, 99), r(31, 30)];
expect(findOrphans(rows)).toEqual([
{ id: 20, reason: "inactive_parent" },
{ id: 30, reason: "missing_parent" },
]);
});
it("never lists an inactive category, nor a cycle among inactive ones", () => {
const rows = [r(1, null), r(2, 3, false), r(3, 2, false), r(4, 4, false)];
expect(findOrphans(rows)).toEqual([]);
});
it("reports a loop that runs through a deactivated category as the deactivated parent", () => {
// 5 -> 6 (inactive) -> 5: the tree shows 5 at the root, its parent being inactive.
expect(findOrphans([r(5, 6), r(6, 5, false)])).toEqual([{ id: 5, reason: "inactive_parent" }]);
});
it("lists several groups, sorted by id", () => {
const rows = [r(40, 41), r(41, 40), r(15, 15), r(2, 77), r(1, null)];
expect(findOrphans(rows)).toEqual([
{ id: 2, reason: "missing_parent" },
{ id: 15, reason: "cycle" },
{ id: 40, reason: "cycle" },
]);
});
});
describe("parentChangeRefusal (#393)", () => {
it("accepts the root, always, even for a branch three levels deep or a cycle member", () => {
expect(parentChangeRefusal(TREE, 1, null)).toBeNull();
expect(parentChangeRefusal([n(1, 2), n(2, 1)], 1, null)).toBeNull();
});
it("refuses the category itself", () => {
expect(parentChangeRefusal(TREE, 11, 11)).toBe("self_parent");
});
it("refuses a child and a grandchild: the move would close a loop", () => {
expect(parentChangeRefusal(TREE, 1, 10)).toBe("cycle");
expect(parentChangeRefusal(TREE, 1, 100)).toBe("cycle");
});
it("refuses a parent caught in another cycle: the category would be hidden with it", () => {
const nodes = [...TREE, n(50, 51), n(51, 50)];
expect(parentChangeRefusal(nodes, 20, 50)).toBe("cycle");
});
it("refuses a fourth level, counting the sub-tree of the category", () => {
// 100 is level 2: a child under it would be level 3.
expect(parentChangeRefusal(TREE, 20, 100)).toBe("too_deep");
// Root 2 has a child (20): under 10 (level 1), 20 would land at level 3.
expect(parentChangeRefusal(TREE, 2, 10)).toBe("too_deep");
// Root 1 has grandchildren: under any parent, 100 would land at level 3.
expect(parentChangeRefusal(TREE, 1, 2)).toBe("too_deep");
});
it("accepts a valid move", () => {
expect(parentChangeRefusal(TREE, 20, 10)).toBeNull(); // a leaf under a level-1 parent
expect(parentChangeRefusal(TREE, 2, 1)).toBeNull(); // a root with one level under a root
expect(parentChangeRefusal(TREE, 11, 2)).toBeNull();
});
it("moves a member of a cycle under a sound parent when the branch fits, and ends", () => {
// 1 <-> 2 with nothing under them; 3 is a sound root.
const nodes = [n(1, 2), n(2, 1), n(3, null)];
expect(parentChangeRefusal(nodes, 1, 3)).toBeNull();
// ... but never under the other member.
expect(parentChangeRefusal(nodes, 1, 2)).toBe("cycle");
});
});