fix: check category parents and repair orphan categories (#393) #472
5 changed files with 490 additions and 64 deletions
|
|
@ -97,6 +97,13 @@ describe("buildParentOptions — editing a category", () => {
|
||||||
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
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", () => {
|
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
|
// 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.
|
// unbounded). Only roots remain, so the root outside the cycle is offered.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { CategoryTreeNode } from "../../shared/types";
|
import type { CategoryTreeNode } from "../../shared/types";
|
||||||
|
import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy";
|
||||||
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
|
|
||||||
const MAX_PARENT_DEPTH = 1;
|
|
||||||
|
|
||||||
export interface ParentOptionsContext {
|
export interface ParentOptionsContext {
|
||||||
/** Id of the category being edited; absent or null when creating. */
|
/** Id of the category being edited; absent or null when creating. */
|
||||||
|
|
@ -14,60 +12,9 @@ export interface ParentOptionsContext {
|
||||||
currentParentId?: number | null;
|
currentParentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
|
|
||||||
const byId = new Map<number, CategoryTreeNode>();
|
|
||||||
for (const c of categories) byId.set(c.id, c);
|
|
||||||
const depths = new Map<number, number>();
|
|
||||||
const depthOf = (id: number, seen: Set<number>): number => {
|
|
||||||
const known = depths.get(id);
|
|
||||||
if (known !== undefined) return known;
|
|
||||||
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
|
|
||||||
seen.add(id);
|
|
||||||
const cat = byId.get(id);
|
|
||||||
const parentId = cat?.parent_id ?? null;
|
|
||||||
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
|
|
||||||
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
|
|
||||||
depths.set(id, d);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
for (const c of categories) depthOf(c.id, new Set());
|
|
||||||
return depths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
|
* Categories that may be chosen as the parent in the category form, and in the
|
||||||
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
|
* « Vérifier la hiérarchie » window (#393).
|
||||||
* walk can never take more steps than there are categories.
|
|
||||||
*/
|
|
||||||
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
|
||||||
const childrenOf = new Map<number, number[]>();
|
|
||||||
for (const c of categories) {
|
|
||||||
if (c.parent_id === null) continue;
|
|
||||||
const bucket = childrenOf.get(c.parent_id) ?? [];
|
|
||||||
bucket.push(c.id);
|
|
||||||
childrenOf.set(c.parent_id, bucket);
|
|
||||||
}
|
|
||||||
let height = 0;
|
|
||||||
const seen = new Set<number>([rootId]);
|
|
||||||
let frontier = [rootId];
|
|
||||||
while (frontier.length > 0 && height < categories.length) {
|
|
||||||
const next: number[] = [];
|
|
||||||
for (const id of frontier) {
|
|
||||||
for (const child of childrenOf.get(id) ?? []) {
|
|
||||||
if (seen.has(child)) continue;
|
|
||||||
seen.add(child);
|
|
||||||
next.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next.length === 0) break;
|
|
||||||
height += 1;
|
|
||||||
frontier = next;
|
|
||||||
}
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Categories that may be chosen as the parent in the category form.
|
|
||||||
*
|
*
|
||||||
* - Roots and level-1 categories only: a level-2 parent would create a fourth
|
* - Roots and level-1 categories only: a level-2 parent would create a fourth
|
||||||
* level, which nothing else in the app knows how to display.
|
* level, which nothing else in the app knows how to display.
|
||||||
|
|
@ -78,11 +25,15 @@ function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||||
* whole descent, which would create a cycle that hides the branch everywhere
|
* whole descent, which would create a cycle that hides the branch everywhere
|
||||||
* (#393): a descendant sits at least one level below the edited category,
|
* (#393): a descendant sits at least one level below the edited category,
|
||||||
* deeper than any depth still allowed.
|
* deeper than any depth still allowed.
|
||||||
|
* - A category caught in a `parent_id` cycle, or under one, has no depth and is
|
||||||
|
* never offered: a child placed under it would be hidden with it.
|
||||||
* - The parent currently set on the form is always kept when it exists.
|
* - The parent currently set on the form is always kept when it exists.
|
||||||
*
|
*
|
||||||
* This list is the only guard: `updateCategory` writes any `parent_id` it is
|
* The depth and height rules are those of `src/utils/categoryHierarchy.ts`,
|
||||||
* given, without a depth or cycle check (#393 will add one). The form therefore
|
* which `updateCategory` and `reattachCategory` apply again before writing
|
||||||
* never offers a new parent that the rules above reject.
|
* (#393): the list only spares a refusal at save time. It does not leave out
|
||||||
|
* a parent that holds transactions; the service refuses it with a translated
|
||||||
|
* message.
|
||||||
*
|
*
|
||||||
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
||||||
*/
|
*/
|
||||||
|
|
@ -95,15 +46,13 @@ export function buildParentOptions(
|
||||||
const depths = depthsOf(categories);
|
const depths = depthsOf(categories);
|
||||||
|
|
||||||
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
|
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
|
||||||
// Negative when the edited category has grandchildren: only the current parent survives.
|
|
||||||
const maxDepth = MAX_PARENT_DEPTH - height;
|
|
||||||
|
|
||||||
return categories.filter((c) => {
|
return categories.filter((c) => {
|
||||||
if (c.id === editingId) return false;
|
if (c.id === editingId) return false;
|
||||||
// Kept even when the depth rule would drop it: the field must show what is
|
// Kept even when the depth rule would drop it: the field must show what is
|
||||||
// stored. Keeping it changes nothing on save (it is already the parent);
|
// stored. Keeping it changes nothing on save (it is already the parent).
|
||||||
// repairing such data is the job of #393.
|
|
||||||
if (c.id === currentParentId) return true;
|
if (c.id === currentParentId) return true;
|
||||||
return (depths.get(c.id) ?? 0) <= maxDepth;
|
const depth = depths.get(c.id);
|
||||||
|
return depth !== undefined && fitsUnder(depth, height);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,19 @@ describe("sortHierarchical", () => {
|
||||||
expect(ordered).toEqual([1, 10, 500]);
|
expect(ordered).toEqual([1, 10, 500]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves out the members of a parent_id cycle and what hangs under it, and ends (#393)", () => {
|
||||||
|
// 3 <-> 5, 8 under 5: no root leads to them, and their parents are in the
|
||||||
|
// list, so they are no orphans of it either. This is why a cycle hides a
|
||||||
|
// category from every picker; « Vérifier la hiérarchie » repairs it.
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(3, "Épicerie", 5, 1),
|
||||||
|
cat(5, "Alimentation", 3, 1),
|
||||||
|
cat(8, "Boulangerie", 5, 2),
|
||||||
|
];
|
||||||
|
expect(sortHierarchical(input, displayName).map((c) => c.id)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
it("is stable (does not duplicate) when called with already-ordered input", () => {
|
it("is stable (does not duplicate) when called with already-ordered input", () => {
|
||||||
const input: Category[] = [
|
const input: Category[] = [
|
||||||
cat(1, "Revenus", null, 1),
|
cat(1, "Revenus", null, 1),
|
||||||
|
|
|
||||||
220
src/utils/categoryHierarchy.test.ts
Normal file
220
src/utils/categoryHierarchy.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
237
src/utils/categoryHierarchy.ts
Normal file
237
src/utils/categoryHierarchy.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
// categoryHierarchy — the rules of the category hierarchy, in one place (#393).
|
||||||
|
//
|
||||||
|
// Three consumers read them: the parent list of the category form
|
||||||
|
// (`buildParentOptions`, #423), the orphan diagnostic of the Categories page
|
||||||
|
// (`findOrphans`) and the validation of a parent change in `updateCategory`
|
||||||
|
// and `reattachCategory` (`parentChangeRefusal`). Every walk, up a parent chain
|
||||||
|
// or down the children, keeps a set of the categories it has seen: a
|
||||||
|
// `parent_id` cycle stored in the profile ends the walk instead of looping.
|
||||||
|
//
|
||||||
|
// Levels: a root sits at depth 0, its children at 1, their children at 2.
|
||||||
|
// Three levels at most: the budget grid stops at the grandchildren, so a
|
||||||
|
// fourth level would drop out of it.
|
||||||
|
|
||||||
|
/** What the hierarchy rules read of a category. */
|
||||||
|
export interface HierarchyNode {
|
||||||
|
id: number;
|
||||||
|
/** `null` (or absent) for a root. */
|
||||||
|
parent_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of `SELECT id, parent_id, is_active FROM categories`, active or not. */
|
||||||
|
export interface HierarchyRow extends HierarchyNode {
|
||||||
|
/** `1` for an active category (SQLite's INTEGER flag). */
|
||||||
|
is_active: number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepest depth a category may sit at: three levels, 0 to 2. */
|
||||||
|
export const MAX_CATEGORY_DEPTH = 2;
|
||||||
|
|
||||||
|
/** Whether the row is active, as the queries' `WHERE is_active = 1` reads it. */
|
||||||
|
export function isActiveRow(row: HierarchyRow): boolean {
|
||||||
|
return row.is_active === 1 || row.is_active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the category shows as a root among the categories `ids`: it has no
|
||||||
|
* parent, or a parent that is not in the list (deactivated, or gone). The
|
||||||
|
* tree, the category pickers and the budget all follow this rule (#393).
|
||||||
|
*/
|
||||||
|
export function displaysAsRoot(category: HierarchyNode, ids: ReadonlySet<number>): boolean {
|
||||||
|
return category.parent_id == null || !ids.has(category.parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth of each category among `categories`: 0 for a root, 1 for its child, 2
|
||||||
|
* for a grandchild. A category whose parent is not in the list counts as a
|
||||||
|
* root, as in the tree. A category caught in a `parent_id` cycle, or under
|
||||||
|
* one, has no depth: no root leads to it, and it is left out of the map.
|
||||||
|
*/
|
||||||
|
export function depthsOf(categories: readonly HierarchyNode[]): Map<number, number> {
|
||||||
|
const byId = new Map<number, HierarchyNode>();
|
||||||
|
for (const c of categories) byId.set(c.id, c);
|
||||||
|
const depths = new Map<number, number>();
|
||||||
|
/** Categories whose depth is known, or known to be none. */
|
||||||
|
const settled = new Set<number>();
|
||||||
|
|
||||||
|
for (const start of categories) {
|
||||||
|
if (settled.has(start.id)) continue;
|
||||||
|
// Walk up until a root, a settled category, or a category already seen on
|
||||||
|
// this walk: a cycle.
|
||||||
|
const path: number[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let node = start;
|
||||||
|
let topDepth: number | undefined;
|
||||||
|
for (;;) {
|
||||||
|
path.push(node.id);
|
||||||
|
seen.add(node.id);
|
||||||
|
const parent = node.parent_id == null ? undefined : byId.get(node.parent_id);
|
||||||
|
if (parent === undefined) {
|
||||||
|
topDepth = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (settled.has(parent.id)) {
|
||||||
|
const above = depths.get(parent.id);
|
||||||
|
topDepth = above === undefined ? undefined : above + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seen.has(parent.id)) {
|
||||||
|
topDepth = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
// The last category of the path sits at `topDepth`, each one before it a
|
||||||
|
// level deeper; none has a depth when the walk ended in a cycle.
|
||||||
|
for (let i = path.length - 1, below = 0; i >= 0; i -= 1, below += 1) {
|
||||||
|
settled.add(path[i]);
|
||||||
|
if (topDepth !== undefined) depths.set(path[i], topDepth + below);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories under `rootId`, level by level. Each category is taken once,
|
||||||
|
* so a cycle ends the walk, and `rootId` itself is never listed.
|
||||||
|
*/
|
||||||
|
function levelsBelow(categories: readonly HierarchyNode[], rootId: number): number[][] {
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id == null) continue;
|
||||||
|
const bucket = childrenOf.get(c.parent_id) ?? [];
|
||||||
|
bucket.push(c.id);
|
||||||
|
childrenOf.set(c.parent_id, bucket);
|
||||||
|
}
|
||||||
|
const levels: number[][] = [];
|
||||||
|
const seen = new Set<number>([rootId]);
|
||||||
|
let frontier = [rootId];
|
||||||
|
for (;;) {
|
||||||
|
const next: number[] = [];
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const child of childrenOf.get(id) ?? []) {
|
||||||
|
if (seen.has(child)) continue;
|
||||||
|
seen.add(child);
|
||||||
|
next.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length === 0) return levels;
|
||||||
|
levels.push(next);
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with grandchildren. */
|
||||||
|
export function subtreeHeight(categories: readonly HierarchyNode[], rootId: number): number {
|
||||||
|
return levelsBelow(categories, rootId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every category under `rootId`, at any depth; never `rootId` itself, even inside a cycle. */
|
||||||
|
export function descendantsOf(categories: readonly HierarchyNode[], rootId: number): Set<number> {
|
||||||
|
return new Set(levelsBelow(categories, rootId).flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a category whose sub-tree is `height` levels deep may sit under a
|
||||||
|
* parent at `parentDepth`: its deepest descendant must stay within the three
|
||||||
|
* levels.
|
||||||
|
*/
|
||||||
|
export function fitsUnder(parentDepth: number, height: number): boolean {
|
||||||
|
return parentDepth + 1 + height <= MAX_CATEGORY_DEPTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a parent change is refused (the service adds `parent_has_transactions`). */
|
||||||
|
export type HierarchyRefusal = "self_parent" | "cycle" | "too_deep";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why category `id` may not move under `newParentId`, or `null` when it may.
|
||||||
|
* `active` are the active categories: depth and descent are counted on them,
|
||||||
|
* as the tree shows them. The caller has checked that `newParentId` is one of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* - The root (`null`) is always accepted: no place is shallower, and it is the
|
||||||
|
* repair of last resort for a category caught in a cycle.
|
||||||
|
* - `self_parent`: the category itself.
|
||||||
|
* - `cycle`: a category under it (the move would close a loop), or a category
|
||||||
|
* with no depth, caught in a cycle or under one (the move would hide it).
|
||||||
|
* - `too_deep`: its deepest descendant would land beyond the third level.
|
||||||
|
*/
|
||||||
|
export function parentChangeRefusal(
|
||||||
|
active: readonly HierarchyNode[],
|
||||||
|
id: number,
|
||||||
|
newParentId: number | null,
|
||||||
|
): HierarchyRefusal | null {
|
||||||
|
if (newParentId === null) return null;
|
||||||
|
if (newParentId === id) return "self_parent";
|
||||||
|
if (descendantsOf(active, id).has(newParentId)) return "cycle";
|
||||||
|
const parentDepth = depthsOf(active).get(newParentId);
|
||||||
|
if (parentDepth === undefined) return "cycle";
|
||||||
|
if (!fitsUnder(parentDepth, subtreeHeight(active, id))) return "too_deep";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an active category is out of the hierarchy (#393). */
|
||||||
|
export type OrphanReason = "cycle" | "inactive_parent" | "missing_parent";
|
||||||
|
|
||||||
|
export interface OrphanCategory {
|
||||||
|
/** The category to reattach; the categories under it follow it. */
|
||||||
|
id: number;
|
||||||
|
reason: OrphanReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active categories the tree cannot place, one per group to reattach
|
||||||
|
* (#393), sorted by id:
|
||||||
|
* - `inactive_parent`, `missing_parent`: an active category whose parent is
|
||||||
|
* deactivated, or gone. The tree shows it as a root, out of its place.
|
||||||
|
* - `cycle`: for each `parent_id` cycle among active categories, its member
|
||||||
|
* with the smallest id. The cycle and everything under it are out of reach
|
||||||
|
* from the roots: hidden from the tree, the pickers, the budget and the
|
||||||
|
* reports. Reattaching that member breaks the cycle; the other members and
|
||||||
|
* their descendants come back under it.
|
||||||
|
* Only the head of each group is listed: a category under it follows it.
|
||||||
|
*
|
||||||
|
* `rows` are all the categories, inactive ones included: without them, a
|
||||||
|
* deactivated parent could not be told from a missing one.
|
||||||
|
*/
|
||||||
|
export function findOrphans(rows: readonly HierarchyRow[]): OrphanCategory[] {
|
||||||
|
const byId = new Map<number, HierarchyRow>();
|
||||||
|
for (const r of rows) byId.set(r.id, r);
|
||||||
|
const orphans: OrphanCategory[] = [];
|
||||||
|
/** Active categories whose group is known: visible, an orphan, or under one. */
|
||||||
|
const settled = new Set<number>();
|
||||||
|
|
||||||
|
for (const start of rows) {
|
||||||
|
if (!isActiveRow(start) || settled.has(start.id)) continue;
|
||||||
|
// Walk up the active parents until a root, a parent that is inactive or
|
||||||
|
// gone, a settled category, or a category already seen on this walk.
|
||||||
|
const path: number[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let node = start;
|
||||||
|
for (;;) {
|
||||||
|
path.push(node.id);
|
||||||
|
seen.add(node.id);
|
||||||
|
if (node.parent_id == null) break;
|
||||||
|
const parent = byId.get(node.parent_id);
|
||||||
|
if (parent === undefined) {
|
||||||
|
orphans.push({ id: node.id, reason: "missing_parent" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!isActiveRow(parent)) {
|
||||||
|
orphans.push({ id: node.id, reason: "inactive_parent" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (settled.has(parent.id)) break;
|
||||||
|
if (seen.has(parent.id)) {
|
||||||
|
// The cycle runs from the parent to the end of the path; what comes
|
||||||
|
// before it on the path hangs under the cycle.
|
||||||
|
const members = path.slice(path.indexOf(parent.id));
|
||||||
|
orphans.push({ id: Math.min(...members), reason: "cycle" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
for (const id of path) settled.add(id);
|
||||||
|
}
|
||||||
|
return orphans.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue