test(categories): pin the rules the review of #393 left green (#393)

Review 25437, point 2: the loop named by its own smallest member, not by
a smaller id under it (RM1); the page hands the window every category,
not the tree (RM11); a category that is its own parent stays out of the
budget, and displaysAsRoot says so (RM15). RM7 (depth counted on active
categories at the service) is pinned in categoryService.test.ts by
1a36d4e, RM2 by be4b923.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-19 20:59:02 -04:00
parent be4b923451
commit 7f25293669
3 changed files with 43 additions and 0 deletions

View file

@ -359,6 +359,11 @@ describe("buildBudgetYearRows — a category whose parent is deleted or gone (#3
);
expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false);
});
it("hides a category that is its own parent too, a loop of one member", () => {
const rows = buildBudgetYearRows([...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 7 })], [entry(7, 40)], []);
expect(rows.some((r) => r.category_id === 7)).toBe(false);
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {

View file

@ -202,6 +202,35 @@ describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
});
it("hands the window every category, not the tree: an orphan in a loop is named", () => {
const node = (id: number, name: string, parent_id: number | null) => ({
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: [],
});
// 3 <-> 5: no root reaches them, so the tree holds root 1 (and 10 under it) only.
const categories = [node(1, "Logement", null), node(10, "Loyer", 1), node(3, "Cadeaux", 5), node(5, "Fêtes", 3)];
harness.state = {
isLoading: false,
categories,
tree: [{ ...categories[0], children: [categories[1]] }],
hierarchyCheck: { orphans: [{ id: 3, reason: "cycle" }], error: null },
};
const html = render();
expect(html).toContain(">Cadeaux</p>");
expect(html).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
expect(html).not.toContain(">—</p>");
});
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");

View file

@ -113,6 +113,10 @@ describe("displaysAsRoot", () => {
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", () => {
@ -139,6 +143,11 @@ describe("findOrphans (#393)", () => {
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)];