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 { reorderRows } from "../utils/reorderRows"; 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). Since issue #424 they are emitted // DEPTH-FIRST: each parent row is followed directly by its whole subtree, so an // expanded subcategory shows under its parent and `reorderRows` (subtotals at // the bottom) attaches each subtotal to its own rows. The collapse (ancestor // walk, issue #288) does not depend on the order. 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, }; /** * Depth-first invariant: every non-root row comes after its parent row, and * every row between a parent row and the end of its subtree is one of its * descendants. A "(direct)" leaf carries its own category as parent: it is * attached to the subtotal row of that category (the `p:` key of the grid). */ function expectDepthFirst(rows: BudgetYearRow[]) { const parentIndex = new Map(); rows.forEach((r, i) => { if (r.is_parent) parentIndex.set(r.category_id, i); }); // Ancestor ids of a row, climbing the parent subtotal rows. const ancestorsOf = (r: BudgetYearRow): number[] => { const out: number[] = []; let pid = r.parent_id; while (pid != null && !out.includes(pid)) { out.push(pid); const idx = parentIndex.get(pid); if (idx === undefined) break; pid = rows[idx].parent_id; } return out; }; rows.forEach((r, i) => { if (r.parent_id != null) { const p = parentIndex.get(r.parent_id); expect(p, `${r.category_name} has a parent row`).toBeDefined(); expect(p!, `${r.category_name} comes after its parent`).toBeLessThan(i); } }); rows.forEach((r, i) => { if (!r.is_parent) return; const depth = r.depth ?? 0; // The subtree runs until the next row of this depth or shallower. let j = i + 1; while (j < rows.length && (rows[j].depth ?? 0) > depth) { expect(ancestorsOf(rows[j]), `${rows[j].category_name} is under ${r.category_name}`).toContain(r.category_id); j++; } // ...and every descendant of the parent lies inside that range. rows.forEach((d, k) => { if (k === i) return; if (ancestorsOf(d).includes(r.category_id)) { expect(k, `${d.category_name} lies inside the subtree of ${r.category_name}`).toBeGreaterThan(i); expect(k, `${d.category_name} lies inside the subtree of ${r.category_name}`).toBeLessThan(j); } }); }); } const names = (rows: BudgetYearRow[]) => rows.map((r) => r.category_name); // Shape of the "Revenus" group of a new profile (consolidated_schema.sql): one // depth-1 leaf ("Autres revenus") and three intermediate parents. const REVENUS: Category[] = [ cat(1000, "Revenus", "income", { is_inputable: false, sort_order: 1 }), cat(1010, "Emploi", "income", { parent_id: 1000, is_inputable: false, sort_order: 1 }), cat(1011, "Paie régulière", "income", { parent_id: 1010, sort_order: 1 }), cat(1012, "Primes & bonus", "income", { parent_id: 1010, sort_order: 2 }), cat(1013, "Travail autonome", "income", { parent_id: 1010, sort_order: 3 }), cat(1020, "Gouvernemental", "income", { parent_id: 1000, is_inputable: false, sort_order: 2 }), cat(1021, "Remboursement impôt", "income", { parent_id: 1020, sort_order: 1 }), cat(1022, "Allocations familiales", "income", { parent_id: 1020, sort_order: 2 }), cat(1023, "Crédits TPS/TVQ", "income", { parent_id: 1020, sort_order: 3 }), cat(1024, "Assurance-emploi / RQAP", "income", { parent_id: 1020, sort_order: 4 }), cat(1030, "Revenus de placement", "income", { parent_id: 1000, is_inputable: false, sort_order: 3 }), cat(1031, "Intérêts & dividendes", "income", { parent_id: 1030, sort_order: 1 }), cat(1032, "Gains en capital", "income", { parent_id: 1030, sort_order: 2 }), cat(1033, "Revenus locatifs", "income", { parent_id: 1030, sort_order: 3 }), cat(1090, "Autres revenus", "income", { parent_id: 1000, sort_order: 9 }), ]; // Shape of "Finances & obligations" of a new profile: leaves before AND after // intermediate parents ("Retrait cash" sorts after "Impôts & taxes"). const FINANCES: Category[] = [ cat(1900, "Finances & obligations", "expense", { is_inputable: false, sort_order: 10 }), cat(1910, "Frais bancaires", "expense", { parent_id: 1900, is_inputable: false, sort_order: 1 }), cat(1911, "Frais de compte", "expense", { parent_id: 1910, sort_order: 1 }), cat(1912, "Intérêts & frais de crédit", "expense", { parent_id: 1910, sort_order: 2 }), cat(1913, "Frais de change", "expense", { parent_id: 1910, sort_order: 3 }), cat(1920, "Impôts & taxes", "expense", { parent_id: 1900, is_inputable: false, sort_order: 2 }), cat(1921, "Impôt fédéral", "expense", { parent_id: 1920, sort_order: 1 }), cat(1922, "Impôt provincial", "expense", { parent_id: 1920, sort_order: 2 }), cat(1923, "Acomptes provisionnels", "expense", { parent_id: 1920, sort_order: 3 }), cat(1930, "Dons & cotisations", "expense", { parent_id: 1900, is_inputable: false, sort_order: 3 }), cat(1931, "Dons de charité", "expense", { parent_id: 1930, sort_order: 1 }), cat(1932, "Cotisations professionnelles", "expense", { parent_id: 1930, sort_order: 2 }), cat(1933, "Cotisations syndicales", "expense", { parent_id: 1930, sort_order: 3 }), cat(1940, "Cadeaux", "expense", { parent_id: 1900, sort_order: 4 }), cat(1945, "Retrait cash", "expense", { parent_id: 1900, sort_order: 5 }), cat(1946, "Achats divers non catégorisés", "expense", { parent_id: 1900, sort_order: 6 }), ]; // Shape of the older taxonomy (default profile): an inputable root, so a // "(direct)" row, and one intermediate parent (Assurances, migration v7). const RECURRENTES: Category[] = [ cat(2, "Dépenses récurrentes", "expense", { sort_order: 2 }), cat(20, "Loyer", "expense", { parent_id: 2, sort_order: 1 }), cat(21, "Électricité", "expense", { parent_id: 2, sort_order: 2 }), cat(31, "Assurances", "expense", { parent_id: 2, is_inputable: false, sort_order: 12 }), cat(310, "Assurance-auto", "expense", { parent_id: 31, sort_order: 1 }), cat(311, "Assurance-habitation", "expense", { parent_id: 31, sort_order: 2 }), cat(312, "Assurance-vie", "expense", { parent_id: 31, sort_order: 3 }), cat(32, "Pharmacie", "expense", { parent_id: 2, sort_order: 13 }), cat(33, "Taxes municipales", "expense", { parent_id: 2, sort_order: 14 }), ]; describe("buildBudgetYearRows — depth-first emission (issue #424)", () => { it("emits income before expense, each group depth-first: Utilities is followed by its own leaves", () => { const rows = buildBudgetYearRows(CATEGORIES, [], []); // Salary (income) first; then Housing, its children sorted by name with // leaves and sub-groups mixed: Rent, then Utilities and its leaves. expect(rows.map((r) => r.category_id)).toEqual([10, 1, 2, 3, 4, 5]); expectDepthFirst(rows); }); it("Revenus: the depth-1 leaf sorts first by name, each sub-group carries its own leaves", () => { const rows = buildBudgetYearRows(REVENUS, [], []); expect(names(rows)).toEqual([ "Revenus", "Autres revenus", "Emploi", "Paie régulière", "Primes & bonus", "Travail autonome", "Gouvernemental", "Allocations familiales", "Assurance-emploi / RQAP", "Crédits TPS/TVQ", "Remboursement impôt", "Revenus de placement", "Gains en capital", "Intérêts & dividendes", "Revenus locatifs", ]); expectDepthFirst(rows); }); it("Finances & obligations: leaves before and after sub-groups, sorted by name together", () => { const rows = buildBudgetYearRows(FINANCES, [], []); expect(names(rows)).toEqual([ "Finances & obligations", "Achats divers non catégorisés", "Cadeaux", "Dons & cotisations", "Cotisations professionnelles", "Cotisations syndicales", "Dons de charité", "Frais bancaires", "Frais de change", "Frais de compte", "Intérêts & frais de crédit", "Impôts & taxes", "Acomptes provisionnels", "Impôt fédéral", "Impôt provincial", "Retrait cash", ]); expectDepthFirst(rows); }); it("older taxonomy: the (direct) row leads, Assurances is followed by its three leaves", () => { const rows = buildBudgetYearRows(RECURRENTES, [], []); expect(names(rows)).toEqual([ "Dépenses récurrentes", "Dépenses récurrentes (direct)", "Assurances", "Assurance-auto", "Assurance-habitation", "Assurance-vie", "Électricité", "Loyer", "Pharmacie", "Taxes municipales", ]); expectDepthFirst(rows); }); it("top groups are sorted by type, then sort_order, then name — whatever the input order", () => { const all = [ ...FINANCES, ...RECURRENTES, ...REVENUS, cat(9000, "Divers", "expense", { sort_order: 2 }), cat(9100, "Virements", "transfer", { sort_order: 0 }), ]; const rows = buildBudgetYearRows([...all].reverse(), [], []); const roots = rows.filter((r) => (r.depth ?? 0) === 0).map((r) => r.category_name); expect(roots).toEqual([ "Revenus", // income "Dépenses récurrentes", // expense, sort_order 2, name before "Divers" "Divers", // expense, sort_order 2 (lone leaf) "Finances & obligations", // expense, sort_order 10 "Virements", // transfer, last despite sort_order 0 ]); expectDepthFirst(rows); }); it("subtotals at the bottom: each subtotal follows its own rows (reorderRows on the builder output)", () => { const rows = reorderRows(buildBudgetYearRows([...FINANCES, ...RECURRENTES], [], []), false); expect(names(rows)).toEqual([ "Dépenses récurrentes (direct)", "Assurance-auto", "Assurance-habitation", "Assurance-vie", "Assurances", "Électricité", "Loyer", "Pharmacie", "Taxes municipales", "Dépenses récurrentes", "Achats divers non catégorisés", "Cadeaux", "Cotisations professionnelles", "Cotisations syndicales", "Dons de charité", "Dons & cotisations", "Frais de change", "Frais de compte", "Intérêts & frais de crédit", "Frais bancaires", "Acomptes provisionnels", "Impôt fédéral", "Impôt provincial", "Impôts & taxes", "Retrait cash", "Finances & obligations", ]); }); it("expanding a single intermediate parent shows its leaves right under it", () => { const rows = buildBudgetYearRows(REVENUS, [], []); const flipped = new Set(["p:1000", "p:1010"]); // Revenus + Emploi expanded const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false); const visible = visibleRows(rows, ACCESSORS, isCollapsed); expect(names(visible)).toEqual([ "Revenus", "Autres revenus", "Emploi", "Paie régulière", "Primes & bonus", "Travail autonome", "Gouvernemental", "Revenus de placement", ]); }); it("subtotals are unchanged by the order: each subtotal sums its own leaves", () => { const ENTRIES = [entry(1011, 100), entry(1012, 20), entry(1021, 7), entry(1033, 3), entry(1090, 1000)]; const rows = buildBudgetYearRows(REVENUS, ENTRIES, []); const annualOf = (id: number) => rows.find((r) => r.category_id === id && r.is_parent)!.annual; expect(annualOf(1000)).toBe(1130); expect(annualOf(1010)).toBe(120); expect(annualOf(1020)).toBe(7); expect(annualOf(1030)).toBe(3); }); }); describe("buildBudgetYearRows — a category whose parent is deleted or gone (#393)", () => { // The budget reads active categories only: a deleted (inactive) parent is // simply absent from the list, like a parent that no longer exists. it("shows a leaf whose parent is absent as a root, with its budget amounts", () => { const rows = buildBudgetYearRows( [...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 99, sort_order: 5 })], [entry(7, 40, 1), entry(7, 60, 2)], [{ category_id: 7, actual: -80 }], ); const gifts = rows.find((r) => r.category_id === 7); expect(gifts).toMatchObject({ parent_id: null, depth: 0, is_parent: false, annual: 100, previousYearTotal: -80 }); expectDepthFirst(rows); }); it("shows a parent whose own parent is absent as a root group, its children under it", () => { const rows = buildBudgetYearRows( [ ...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 99, is_inputable: false, sort_order: 5 }), cat(8, "Flowers", "expense", { parent_id: 7, sort_order: 1 }), ], [entry(8, 25)], [], ); expect(rows.find((r) => r.category_id === 7)).toMatchObject({ parent_id: null, depth: 0, is_parent: true, annual: 25 }); expect(rows.find((r) => r.category_id === 8)).toMatchObject({ parent_id: 7, depth: 1, annual: 25 }); expectDepthFirst(rows); }); it("still hides the members of a parent_id cycle, which « Vérifier la hiérarchie » repairs", () => { const rows = buildBudgetYearRows( [...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 8 }), cat(8, "Flowers", "expense", { parent_id: 7 })], [], [], ); expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false); }); }); 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, by name: Rent leaf + Utilities subtotal. // Hydro/Internet remain hidden under the collapsed Utilities. expect(visible.map((r) => r.category_id)).toEqual([10, 1, 2, 3]); }); }); 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); }); }); // --------------------------------------------------------------------------- // Issue #353 — the profile-wide transaction count (`totalInDb`) drives the // "import first" empty state of the Budget page. The exported reducer is // exercised directly (no renderHook harness in this repo). import { budgetReducer, initialBudgetState, showsEmptyState } from "./useBudget"; describe("budgetReducer — SET_TOTAL_IN_DB (issue #353)", () => { it("starts unknown: totalInDb is null, which keeps the grid", () => { const state = initialBudgetState(); expect(state.totalInDb).toBeNull(); expect(showsEmptyState(state)).toBe(false); }); it("stores the count without touching year, rows, templates or accounts", () => { const before = budgetReducer( budgetReducer(initialBudgetState(), { type: "SET_YEAR", payload: 2024 }), { type: "SET_DATA", payload: { rows: buildBudgetYearRows(CATEGORIES, [entry(2, 1000)], []), templates: [] } }, ); const after = budgetReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 }); expect(after.totalInDb).toBe(0); expect(after.year).toBe(2024); expect(after.rows).toBe(before.rows); expect(after.templates).toBe(before.templates); expect(after.accounts).toBe(before.accounts); expect({ ...after, totalInDb: before.totalInDb }).toEqual(before); }); it("0 shows the empty state, then N > 0 renders the grid again", () => { const empty = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 0 }); expect(showsEmptyState(empty)).toBe(true); const filled = budgetReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 12 }); expect(filled.totalInDb).toBe(12); expect(showsEmptyState(filled)).toBe(false); }); it("the other actions preserve totalInDb", () => { const known = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 3 }); const after = budgetReducer( budgetReducer(known, { type: "SET_YEAR", payload: 2025 }), { type: "SET_ERROR", payload: "boom" }, ); expect(after.totalInDb).toBe(3); }); });