fix: check category parents and repair orphan categories (#393) #472

Open
maximus wants to merge 7 commits from issue-393-category-hierarchy into issue-429-category-combobox-portal
4 changed files with 94 additions and 3 deletions
Showing only changes of commit 21da7a3827 - Show all commits

View file

@ -322,6 +322,45 @@ describe("buildBudgetYearRows — depth-first emission (issue #424)", () => {
});
});
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, [], []);

View file

@ -12,6 +12,7 @@ import {
deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService";
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import { useReportsPeriod } from "./useReportsPeriod";
export interface BudgetState {
@ -241,8 +242,11 @@ export function buildBudgetYearRows(
return [subtotal, ...gcRows];
}
// Identify top-level parents and standalone leaves
const topLevel = allCategories.filter((c) => !c.parent_id);
// Identify top-level parents and standalone leaves. A category whose parent
// is deactivated or gone (not in the active list) stands at the top level,
// as in the tree and the pickers, instead of vanishing from the grid (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];

View file

@ -94,6 +94,50 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
});
});
// The categories query keeps active rows only: a deleted (inactive) parent is
// absent from the list, like a parent that no longer exists (#393).
describe("getBudgetVsActualData — a category whose parent is deleted or gone (#393)", () => {
const answer = (categories: unknown[]) =>
mockSelect.mockImplementation((sql: string) => {
if (sql.includes("FROM categories")) return Promise.resolve(categories);
if (sql.includes("FROM budget_entries")) {
return Promise.resolve([{ id: 1, category_id: 7, year: 2026, month: 3, amount: 50 }]);
}
if (sql.includes("FROM transactions")) return Promise.resolve([{ category_id: 7, actual: -30 }]);
return Promise.resolve([]);
});
const category = (id: number, name: string, parent_id: number | null, is_inputable = 1) => ({
id,
name,
type: "expense",
parent_id,
is_active: 1,
is_inputable,
sort_order: id,
});
it("shows it as a root row, with its budget and its actuals", async () => {
answer([category(1, "Logement", null, 0), category(2, "Loyer", 1), category(7, "Cadeaux", 99)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.find((r) => r.category_id === 7)).toMatchObject({
parent_id: null,
depth: 0,
is_parent: false,
monthBudget: -50,
monthActual: -30,
});
});
it("shows a parent whose own parent is absent as a root group", async () => {
answer([category(6, "Fêtes", 99, 0), category(7, "Cadeaux", 6)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.map((r) => [r.category_id, r.parent_id, r.depth, r.is_parent])).toEqual([
[6, null, 0, true],
[7, 6, 1, false],
]);
});
});
// getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.

View file

@ -2,6 +2,7 @@ import type Database from "@tauri-apps/plugin-sql";
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { inPlaceholders } from "../utils/sqlFilters";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import type {
Category,
BudgetEntry,
@ -409,7 +410,10 @@ export async function getBudgetVsActualData(
}
const rows: BudgetVsActualRow[] = [];
const topLevel = allCategories.filter((c) => !c.parent_id);
// A category whose parent is deactivated or gone (not in the active list)
// stands at the top level, as in the tree and the pickers (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];