// buildParentOptions — which categories the category form offers as a parent (#423). import { describe, it, expect } from "vitest"; import { buildParentOptions } from "./parentOptions"; import type { CategoryTreeNode } from "../../shared/types"; const node = (id: number, parent_id: number | null, name = `c${id}`): 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: null, children: [], }); // Two roots. Root 1 has a child (10) with its own child (100), and a leaf child (11). // Root 2 has a leaf child (20). const TREE = [node(1, null), node(10, 1), node(100, 10), node(11, 1), node(2, null), node(20, 2)]; const ids = (list: CategoryTreeNode[]) => list.map((c) => c.id).sort((a, b) => a - b); describe("buildParentOptions — creating a category", () => { it("offers roots and level-1 categories, never level 2", () => { expect(ids(buildParentOptions(TREE))).toEqual([1, 2, 10, 11, 20]); }); it("offers roots alone when there is nothing deeper", () => { expect(ids(buildParentOptions([node(1, null), node(2, null)]))).toEqual([1, 2]); }); it("treats a category whose parent is missing as a root", () => { // 10's parent (1) is not in the list: 10 is a root, 100 a level-1 child. expect(ids(buildParentOptions([node(10, 1), node(100, 10), node(1000, 100)]))).toEqual([10, 100]); }); it("returns [] for an empty list", () => { expect(buildParentOptions([])).toEqual([]); }); }); describe("buildParentOptions — editing a category", () => { it("leaves out the edited category itself", () => { expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]); }); it("offers nothing when the edited category has grandchildren", () => { // Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3. // Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1. expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]); expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]); }); it("leaves out the whole descent of the edited category", () => { // Editing 10 (child 100): a level-1 category with a child; 100 stays out. expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100); // Editing root 2 (child 20): only roots remain, so 20 stays out. expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20); }); it("keeps only roots when the edited category has children but no grandchildren", () => { // 10 has a child (100): under a level-1 parent its child would sit at level 3. expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]); expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]); }); it("keeps level-1 categories when the edited category is a leaf", () => { expect(ids(buildParentOptions(TREE, { editingId: 20 }))).toEqual([1, 2, 10, 11]); }); it("always keeps the parent currently set on the form, even one the rules would drop", () => { // Legacy data: 1000 sits under 100 (level 3). Editing 1000, its parent 100 is // level 2 and would be dropped; it stays so the field shows what is stored. const deep = [...TREE, node(1000, 100)]; expect(ids(buildParentOptions(deep, { editingId: 1000, currentParentId: 100 }))).toEqual([ 1, 2, 10, 11, 20, 100, ]); }); it("does not re-add the edited category as its own current parent", () => { expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 11 }))).toEqual([1, 2, 10, 20]); }); it("ignores a current parent that is not in the list", () => { expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 999 }))).toEqual([1, 2, 10, 20]); }); it("terminates on a cycle and never offers the edited category", () => { // Which members of the cycle are offered is not specified (#393 repairs // cycles); the walk must end, and 3 must stay out. const cyclic = [node(1, 2), node(2, 1), node(3, null)]; const offered = ids(buildParentOptions(cyclic, { editingId: 3 })); expect(offered).not.toContain(3); expect(offered.every((id) => id === 1 || id === 2)).toBe(true); }); it("never offers a member of a cycle, nor what hangs under it: a child there would be hidden (#393)", () => { // 1 <-> 2 with 4 under 2; 3 and 5 are sound roots. Creating, then editing 5. const cyclic = [node(1, 2), node(2, 1), node(4, 2), node(3, null), node(5, null)]; expect(ids(buildParentOptions(cyclic))).toEqual([3, 5]); expect(ids(buildParentOptions(cyclic, { editingId: 5 }))).toEqual([3]); }); it("terminates when the edited category is itself a member of a cycle", () => { // 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not // unbounded). Only roots remain, so the root outside the cycle is offered. const cyclic = [node(1, 2), node(2, 1), node(3, null)]; expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]); }); });