Simpl-Resultat/src/hooks/useBudget.test.ts
le king fu 9c325e274b feat(budget): adopt multi-level collapse on the Budget grid
Reverse #278's deliberate no-collapse decision for the budget grid: it now
folds/unfolds at every category level like the hierarchical reports, opening
fully collapsed with a one-click "Expand all".

- BudgetTable: wire useCollapsibleGroups (defaultExpanded: false), inline
  BUDGET_COLLAPSE_ACCESSORS + BUDGET_EXPANDED_KEY (mirrors ComparePeriodTable /
  BudgetVsActualTable — no budgetTableModel.ts). Chevron + aria-expanded +
  aria-level on every parent row; groups.visible(group) before reorderRows.
- BudgetTable: section subtotal now uses the tested sumLeavesForType on the RAW
  group (drop-in for the hand-rolled loop) so folding stays purely visual.
- BudgetTable: rename STORAGE_KEY to "budget-subtotals-position", decoupling the
  subtotals-position preference from BudgetVsActualTable (they collided).
- useBudget: extract the pure buildBudgetYearRows(); the grid's rows are
  level-order (BFS), not DFS — document the invariant and pin it in a test, since
  the #288 ancestor-walk collapse is order-independent (the v1 plan assumed DFS
  and would have broken here).
- Tests: useBudget.test.ts locks the level-order emission, the DFS-killer, the
  end-to-end multi-level collapse on real builder output, and that subtotals sum
  raw rows regardless of collapse. 828 vitest pass.

Resolves #289

Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:16:01 -04:00

130 lines
6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { BudgetYearRow, Category, BudgetEntry } from "../shared/types";
import { buildBudgetYearRows } from "./useBudget";
import { sumLeavesForType } from "../components/budget/budgetTableResults";
import {
type CollapseAccessors,
visibleRows,
isCollapsedFor,
} from "../utils/collapsibleRows";
// The Budget grid's rows are built by `useBudget` (the "3rd builder" the
// abandoned v1 collapse plan ignored). Its final sort is LEVEL-ORDER (depth
// ascending / BFS), not depth-first. These tests pin that invariant: the
// shipped ancestor-walk collapse (issue #288) is order-INDEPENDENT, but the v1
// depth-cursor algorithm assumed DFS and would have broken exactly here.
function cat(
id: number,
name: string,
type: Category["type"],
opts: { parent_id?: number; is_inputable?: boolean; sort_order?: number } = {},
): Category {
return {
id,
name,
type,
parent_id: opts.parent_id,
color: "#000",
is_active: true,
is_inputable: opts.is_inputable ?? true,
sort_order: opts.sort_order ?? 0,
created_at: "",
};
}
// income leaf + a 3-level expense group:
// Housing (root, non-inputable)
// ├─ Rent (depth-1 leaf)
// └─ Utilities (depth-1 intermediate parent)
// ├─ Hydro (depth-2 leaf)
// └─ Internet (depth-2 leaf)
const CATEGORIES: Category[] = [
cat(10, "Salary", "income", { sort_order: 0 }),
cat(1, "Housing", "expense", { is_inputable: false, sort_order: 1 }),
cat(2, "Rent", "expense", { parent_id: 1, sort_order: 0 }),
cat(3, "Utilities", "expense", { parent_id: 1, is_inputable: false, sort_order: 1 }),
cat(4, "Hydro", "expense", { parent_id: 3, sort_order: 0 }),
cat(5, "Internet", "expense", { parent_id: 3, sort_order: 1 }),
];
function entry(category_id: number, amount: number, month = 1): BudgetEntry {
return { id: 0, category_id, year: 2026, month, amount, created_at: "", updated_at: "" };
}
// Same accessors BudgetTable declares inline — duplicated here (they are 4 trivial
// lambdas the issue mandates stay inline; a .tsx import into a node-env test would
// pull in JSX/lucide/react-i18next needlessly).
const ACCESSORS: CollapseAccessors<BudgetYearRow> = {
keyOf: (row) => `p:${row.category_id}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent,
};
describe("buildBudgetYearRows — level-order emission (issue #289 invariant)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
it("emits income before expense, then each group in LEVEL order (BFS), not DFS", () => {
// Salary (income) first; then the Housing expense group depth-ascending:
// root subtotal, then the depth-1 rows (parent Utilities before leaf Rent),
// then the depth-2 grandchildren.
expect(rows.map((r) => r.category_id)).toEqual([10, 1, 3, 2, 4, 5]);
});
it("keeps depth non-decreasing within a top group — the property that would have killed the v1 (DFS-assuming) collapse algorithm", () => {
const expenseGroup = rows.filter((r) => r.category_type === "expense");
const depths = expenseGroup.map((r) => r.depth ?? 0);
for (let i = 1; i < depths.length; i++) {
expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]);
}
// Explicit DFS-killer: the depth-2 grandchildren (Hydro/Internet) come AFTER
// the depth-1 leaf sibling (Rent). A depth-first emission would interleave
// them directly under Utilities, i.e. BEFORE Rent.
const leafIdx = (id: number) => rows.findIndex((r) => r.category_id === id && !r.is_parent);
expect(leafIdx(4)).toBeGreaterThan(leafIdx(2)); // Hydro after Rent
expect(leafIdx(5)).toBeGreaterThan(leafIdx(2)); // Internet after Rent
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []);
it("collapsed-by-default (empty set) shows only the roots", () => {
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
// Salary (root leaf) + Housing (root subtotal); nothing beneath Housing.
expect(visible.map((r) => r.category_id)).toEqual([10, 1]);
});
it("expanding a root reveals its DIRECT children only — grandchildren stay folded under the still-collapsed intermediate", () => {
const flipped = new Set<string>(["p:1"]); // Housing expanded
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false);
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
// Housing's direct children: Utilities subtotal + Rent leaf. Hydro/Internet
// remain hidden under the collapsed Utilities.
expect(visible.map((r) => r.category_id)).toEqual([10, 1, 3, 2]);
});
});
describe("BudgetTable section subtotals stay on RAW rows (collapse is purely visual, issue #289)", () => {
const ENTRIES = [entry(2, 1000), entry(4, 100), entry(5, 50)];
const rows = buildBudgetYearRows(CATEGORIES, ENTRIES, []);
it("sums every expense LEAF regardless of collapse — folding a parent never moves a total", () => {
const rawExpense = sumLeavesForType(rows, "expense");
expect(rawExpense.annual).toBe(-1150); // -(Rent 1000 + Hydro 100 + Internet 50)
// If the grid mistakenly summed the collapse-visible rows, a fully-collapsed
// grid (only roots visible, and the expense root is a parent → excluded)
// would report 0. The grid feeds the RAW group to `sumLeavesForType`, so the
// section total is invariant under collapse; this asserts the two differ,
// which is exactly why raw rows must be used.
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
const collapsedVisible = visibleRows(rows, ACCESSORS, isCollapsed);
const visibleExpense = sumLeavesForType(collapsedVisible, "expense");
expect(visibleExpense.annual).toBe(0);
expect(rawExpense.annual).not.toBe(visibleExpense.annual);
});
});