import { describe, it, expect } from "vitest"; import { sortHierarchical } from "./CategoryCombobox"; import type { Category } from "../../shared/types"; function cat( id: number, name: string, parentId: number | null, sortOrder: number, ): Category { return { id, name, parent_id: parentId ?? undefined, type: "expense", is_active: true, is_inputable: true, sort_order: sortOrder, created_at: "", }; } const displayName = (c: Category) => c.name; describe("sortHierarchical", () => { it("returns [] for empty input", () => { expect(sortHierarchical([], displayName)).toEqual([]); }); it("orders a single root before its children (parent-first DFS)", () => { const input = [ cat(10, "Paie", 1, 1), cat(1, "Revenus", null, 1), cat(11, "Autres revenus", 1, 2), ]; const ordered = sortHierarchical(input, displayName).map((c) => c.id); expect(ordered).toEqual([1, 10, 11]); }); it("keeps each root fully grouped with its sub-tree, roots ordered by sort_order", () => { // Reproduces the reported bug: a flat list coming back globally ordered by // (sort_order, name) would interleave roots and children that share the // same sort_order. DFS must un-scramble that. const input: Category[] = [ // Roots cat(1, "Revenus", null, 1), cat(2, "Dépenses récurrentes", null, 2), // Children of Revenus (sort_order 1 & 2 within that parent) cat(10, "Paie", 1, 1), cat(11, "Autres revenus", 1, 2), // Children of Dépenses récurrentes (sort_order 1 & 2 within that parent) cat(20, "Loyer", 2, 1), cat(21, "Électricité", 2, 2), ]; // Simulate the SQL artifact: global sort by (sort_order, name), which is // exactly what triggered the bug. const scrambled = [...input].sort((a, b) => { if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order; return a.name.localeCompare(b.name); }); const ordered = sortHierarchical(scrambled, displayName).map((c) => c.id); expect(ordered).toEqual([1, 10, 11, 2, 20, 21]); }); it("orders siblings by sort_order, then by display name as tiebreaker", () => { const input: Category[] = [ cat(1, "Root", null, 1), cat(12, "Beta", 1, 5), cat(10, "Zulu", 1, 5), // same sort_order as 12 -> name tiebreak cat(11, "Alpha", 1, 1), ]; const ordered = sortHierarchical(input, displayName).map((c) => c.id); // Under Root, order should be: Alpha(sort=1), Beta(sort=5,B intermediate -> leaf)", () => { const input: Category[] = [ cat(2, "Dépenses", null, 2), cat(31, "Assurances", 2, 12), cat(310, "Assurance-auto", 31, 1), cat(311, "Assurance-habitation", 31, 2), cat(32, "Pharmacie", 2, 13), ]; const ordered = sortHierarchical(input, displayName).map((c) => c.id); expect(ordered).toEqual([2, 31, 310, 311, 32]); }); it("appends orphans (parent filtered out / missing) at the end", () => { const input: Category[] = [ cat(1, "Revenus", null, 1), cat(10, "Paie", 1, 1), // Orphan: parent_id 999 not in the set cat(500, "Orphan", 999, 1), ]; const ordered = sortHierarchical(input, displayName).map((c) => c.id); expect(ordered).toEqual([1, 10, 500]); }); it("is stable (does not duplicate) when called with already-ordered input", () => { const input: Category[] = [ cat(1, "Revenus", null, 1), cat(10, "Paie", 1, 1), cat(2, "Dépenses", null, 2), cat(20, "Loyer", 2, 1), ]; const once = sortHierarchical(input, displayName); const twice = sortHierarchical(once, displayName); expect(twice.map((c) => c.id)).toEqual(once.map((c) => c.id)); expect(twice).toHaveLength(input.length); }); });