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 = { 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(), 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(["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(), 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); }); });