The budget grid opens collapsed and only its leaf rows take an amount, so the card first points at "Expand all", then at the current month's cell of the first editable row shown (D12). `budgetOnboardingTarget` (pure, beside budgetTableResults.ts) decides it from the rows each section shows, in the order it shows them; the collapse state stays BudgetTable's own and the card never unfolds the grid. BudgetTable rings that one control when the page asks (`onboardingTarget`) and reports its kind through `onOnboardingTargetChange`, in an effect, at mount and on change only: the page needs it for the card's sentence before it asks for the ring. `budgetCardModel(input, checklist)` gives the "collapsed" sentence for "Expand all", the "flat" one for a cell, no sentence and no ring before the grid says, and "Done." once the budget step is checked, which the card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders the card first, outside the loading dim the grid gets after each save, and asks for the ring only while the card shows. The Free edition never mounts the page and never latches it: its path ends at 5/5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
7.9 KiB
TypeScript
205 lines
7.9 KiB
TypeScript
// BudgetTable and the getting-started card's target (#400). No jsdom:
|
|
// react-dom/server renders in vitest's node environment. The collapse hook is
|
|
// replaced by the real pure helpers over a state the test sets (the real hook
|
|
// hydrates in an effect, which a static render never runs), and the
|
|
// subtotal-position preference is read from a stubbed localStorage. What is
|
|
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
|
// of the first editable row shown.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import type { BudgetYearRow } from "../../shared/types";
|
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
}));
|
|
|
|
const harness = vi.hoisted(() => ({
|
|
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
|
expanded: new Set<string>(),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
|
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
|
return {
|
|
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
|
|
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
|
|
return {
|
|
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
|
|
isCollapsed,
|
|
toggle: () => undefined,
|
|
expandAll: () => undefined,
|
|
collapseAll: () => undefined,
|
|
allExpanded: (rows: T[]) => {
|
|
const keys = collapsibleKeys(rows, acc);
|
|
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
|
|
},
|
|
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
import BudgetTable, { BUDGET_CELL_TARGET, BUDGET_EXPAND_ALL_TARGET } from "./BudgetTable";
|
|
|
|
const leaf = (
|
|
category_id: number,
|
|
category_name: string,
|
|
category_type: BudgetYearRow["category_type"],
|
|
parent_id: number | null = null,
|
|
): BudgetYearRow => ({
|
|
category_id,
|
|
category_name,
|
|
category_color: "#9ca3af",
|
|
category_type,
|
|
parent_id,
|
|
is_parent: false,
|
|
depth: parent_id === null ? 0 : 1,
|
|
months: Array(12).fill(0) as number[],
|
|
annual: 0,
|
|
previousYearTotal: 0,
|
|
});
|
|
const group = (
|
|
category_id: number,
|
|
category_name: string,
|
|
category_type: BudgetYearRow["category_type"],
|
|
): BudgetYearRow => ({ ...leaf(category_id, category_name, category_type), is_parent: true });
|
|
|
|
/** Two groups, in the order `buildBudgetYearRows` emits them: income first. */
|
|
const GROUPED: BudgetYearRow[] = [
|
|
group(1, "Revenus", "income"),
|
|
leaf(2, "Salaire", "income", 1),
|
|
group(10, "Logement", "expense"),
|
|
leaf(11, "Loyer", "expense", 10),
|
|
leaf(12, "Électricité", "expense", 10),
|
|
];
|
|
/** A category without sub-category stays on screen while the groups are collapsed. */
|
|
const WITH_STANDALONE: BudgetYearRow[] = [...GROUPED, leaf(30, "Divers", "expense")];
|
|
/** No group at all. */
|
|
const FLAT: BudgetYearRow[] = [leaf(30, "Divers", "expense"), leaf(31, "Cadeaux", "expense")];
|
|
|
|
const MARCH = 2;
|
|
const DECEMBER = 11;
|
|
|
|
const render = (rows: BudgetYearRow[], onboardingTarget?: boolean) =>
|
|
renderToStaticMarkup(
|
|
<BudgetTable
|
|
rows={rows}
|
|
onUpdatePlanned={() => undefined}
|
|
onSplitEvenly={() => undefined}
|
|
onboardingTarget={onboardingTarget}
|
|
/>,
|
|
);
|
|
|
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
|
|
|
/** The one highlighted control: its target key and its inner markup. */
|
|
function highlighted(html: string): { target: string; inner: string } {
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
const match = html.match(/<button [^>]*data-onboarding-target="([^"]+)" data-onboarding-highlight="true"[^>]*>(.*?)<\/button>/);
|
|
expect(match).not.toBeNull();
|
|
return { target: match![1], inner: match![2] };
|
|
}
|
|
|
|
/** The markup of the row named `name`, from its `<tr` to its `</tr>`. */
|
|
function rowOf(html: string, name: string): string {
|
|
const at = html.indexOf(`>${name}<`);
|
|
expect(at).toBeGreaterThan(-1);
|
|
return html.slice(html.lastIndexOf("<tr", at), html.indexOf("</tr>", at));
|
|
}
|
|
|
|
/** The month (0-11) of the highlighted cell in a row, -1 for none: name, last year and annual come first. */
|
|
function ringedMonth(row: string): number {
|
|
const cell = row
|
|
.split("<td")
|
|
.slice(1)
|
|
.findIndex((td) => td.includes(HIGHLIGHT));
|
|
return cell === -1 ? -1 : cell - 3;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
harness.expanded = new Set();
|
|
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(new Date(2026, MARCH, 15));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("BudgetTable — the getting-started card's target (#400)", () => {
|
|
it("groups collapsed: 'Expand all' is the one target, no cell", () => {
|
|
const html = render(GROUPED, true);
|
|
const target = highlighted(html);
|
|
expect(target.target).toBe(BUDGET_EXPAND_ALL_TARGET);
|
|
expect(target.inner).toContain("t(reports.collapse.expandAll)");
|
|
expect(html).not.toContain(BUDGET_CELL_TARGET);
|
|
});
|
|
|
|
it("a group open: this month's cell of its first editable row is the one target", () => {
|
|
harness.expanded = new Set(["p:10"]);
|
|
const html = render(GROUPED, true);
|
|
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
|
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
|
expect(ringedMonth(rowOf(html, "Électricité"))).toBe(-1);
|
|
// The group's subtotal row has no button to point at.
|
|
expect(ringedMonth(rowOf(html, "Logement"))).toBe(-1);
|
|
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
|
});
|
|
|
|
it("every group open: the first editable row of the grid, income first", () => {
|
|
harness.expanded = new Set(["p:1", "p:10"]);
|
|
const html = render(GROUPED, true);
|
|
expect(ringedMonth(rowOf(html, "Salaire"))).toBe(MARCH);
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
});
|
|
|
|
it("a category without sub-category shown beside collapsed groups: its cell, 'Expand all' stays plain", () => {
|
|
const html = render(WITH_STANDALONE, true);
|
|
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
|
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
|
expect(html).toContain("t(reports.collapse.expandAll)");
|
|
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
|
});
|
|
|
|
it("no groups: no 'Expand all' at all, the first row's cell", () => {
|
|
const html = render(FLAT, true);
|
|
expect(html).not.toContain("t(reports.collapse.expandAll)");
|
|
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
});
|
|
|
|
it("the cell follows the clock's month", () => {
|
|
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
|
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
|
});
|
|
|
|
it("subtotals at the bottom: still the first editable row shown", () => {
|
|
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
|
|
harness.expanded = new Set(["p:10"]);
|
|
const html = render(GROUPED, true);
|
|
expect(html).toContain("t(reports.subtotalsOnBottom)");
|
|
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
});
|
|
|
|
it("without `onboardingTarget`, nothing is highlighted", () => {
|
|
for (const expanded of [[], ["p:10"]]) {
|
|
harness.expanded = new Set(expanded);
|
|
for (const onboardingTarget of [false, undefined]) {
|
|
const html = render(GROUPED, onboardingTarget);
|
|
expect(count(html, HIGHLIGHT)).toBe(0);
|
|
expect(html).not.toContain("data-onboarding-target");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("no row: the grid's 'no category' box, nothing highlighted", () => {
|
|
const html = render([], true);
|
|
expect(html).toContain("t(budget.noCategories)");
|
|
expect(count(html, HIGHLIGHT)).toBe(0);
|
|
});
|
|
});
|