fix: check category parents and repair orphan categories (#393) #472
4 changed files with 94 additions and 3 deletions
|
|
@ -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, [], []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) || [];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) || [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue