The by-category report combobox (`/reports/category`) was showing its full category list with scrambled indentation — parents from one sub-tree interleaved with children from another. Root cause: `getAllCategoriesWithCounts` returns rows via `ORDER BY sort_order, name`, which is a *global* sort; two different roots with sort_order=1 would be followed by their respective children in the same bucket, mixing depths together. Add a pure `sortHierarchical(categories, resolveName)` helper in `CategoryCombobox.tsx` that rebuilds the display order as a DFS walk of the tree: each parent is emitted immediately followed by its descendants, with siblings within a group sorted by `sort_order` then localized display name. Orphans (parent filtered out or missing) are appended at the end so nothing disappears. The helper runs client-side inside the combobox's `useMemo`, so the fix is scoped to this component and doesn't affect other consumers of `getAllCategoriesWithCounts`. Filtering on the input query remains unchanged. Covered by 7 unit tests on the helper (empty list, single root, the exact bug-reproducing scrambled case, sort_order + name tiebreak, 3-level hierarchy, orphans, idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
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<Z), Zulu(sort=5)
|
|
expect(ordered).toEqual([1, 11, 12, 10]);
|
|
});
|
|
|
|
it("handles 3-level hierarchies (parent -> 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);
|
|
});
|
|
});
|