// 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"); }); });