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)", () => {
|
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
|
||||||
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
deleteTemplate as deleteTemplateSvc,
|
deleteTemplate as deleteTemplateSvc,
|
||||||
} from "../services/budgetService";
|
} from "../services/budgetService";
|
||||||
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
||||||
|
import { displaysAsRoot } from "../utils/categoryHierarchy";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export interface BudgetState {
|
export interface BudgetState {
|
||||||
|
|
@ -241,8 +242,11 @@ export function buildBudgetYearRows(
|
||||||
return [subtotal, ...gcRows];
|
return [subtotal, ...gcRows];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify top-level parents and standalone leaves
|
// Identify top-level parents and standalone leaves. A category whose parent
|
||||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
// 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) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
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
|
// getActualTotalsForYear is the previous-year actuals reference column
|
||||||
// consumed by useBudget (Issue #276) — a thin wrapper over the same
|
// consumed by useBudget (Issue #276) — a thin wrapper over the same
|
||||||
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
|
// 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 { getDb } from "./db";
|
||||||
import { notifyDataChanged } from "./dataEvents";
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import { inPlaceholders } from "../utils/sqlFilters";
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
|
import { displaysAsRoot } from "../utils/categoryHierarchy";
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
|
|
@ -409,7 +410,10 @@ export async function getBudgetVsActualData(
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: BudgetVsActualRow[] = [];
|
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) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue