All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Refs #423 - parentOptions.ts: say that this list is the only guard today (updateCategory checks neither depth nor cycle, #393 will), instead of pointing to a service-side validation that does not exist yet. The height walk is also bounded by the number of categories, so a missing cycle guard gives a wrong height instead of an endless loop; a test edits a member of a cycle. - AddKeywordDialog.tsx: drop `categoriesLoaded`, which only repeated what the null initial category already guarantees. - Tests: the form and the detail panel are pinned to what they hand to buildParentOptions (editingId, currentParentId), through a pass-through spy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
106 lines
4.6 KiB
TypeScript
106 lines
4.6 KiB
TypeScript
// 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("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]);
|
|
});
|
|
});
|