Simpl-Resultat/src/components/categories/parentOptions.test.ts
le king fu 8898213e02
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
refactor(categories): follow-ups of the review of PR #428
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>
2026-10-12 18:44:54 -04:00

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]);
});
});