// TransactionTable — what the Répartition button opens (#369). // // A static render never shows a window: they open on a click, through the // table's own state. React's hooks come from the shared harness // (src/test/hookHarness.ts, #409): the table runs as a plain function, its // state survives from one call to the next as between two renders of a mounted // component, and the test clicks through the props the table hands to each // SplitButton, then renders again to read what opened. import { describe, it, expect, vi, beforeEach } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); import { hookHarness } from "../../test/hookHarness"; import TransactionTable from "./TransactionTable"; import SplitButton from "./SplitButton"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; import UpsellDialog from "../shared/UpsellDialog"; import type { SplitAccess } from "../../shared/splitGate"; import type { Category, TransactionRow } from "../../shared/types"; const harness = hookHarness(); type Props = Record & { children?: ReactNode }; function find(node: ReactNode, type: unknown): ReactElement[] { if (Array.isArray(node)) return node.flatMap((n) => find(n, type)); if (!isValidElement(node)) return []; const el = node as ReactElement; return [...(el.type === type ? [el] : []), ...find(el.props.children, type)]; } const GROCERIES: Category = { id: 7, name: "Épicerie", type: "expense", is_active: true, is_inputable: true, sort_order: 1, created_at: "", }; const row = (id: number, isSplit: boolean): TransactionRow => ({ id, date: "2026-09-12", description: `TX ${id}`, amount: -42.5, category_id: GROCERIES.id, category_name: GROCERIES.name, category_color: null, source_name: "Exemple Desjardins", notes: null, is_manually_categorized: true, is_split: isSplit, }); /** A split row and a plain one: the button must behave the same on both. */ const ROWS = [row(1, true), row(2, false)]; /** One render of the mounted table, under the given access. */ const render = (splitAccess: SplitAccess) => harness.render(() => TransactionTable({ rows: ROWS, sort: { column: "date", direction: "desc" }, categories: [GROCERIES], onSort: vi.fn(), onCategoryChange: vi.fn(), onNotesChange: vi.fn(), onAddKeyword: vi.fn(async () => undefined), onLoadSplitChildren: vi.fn(async () => []), onSaveSplit: vi.fn(async () => undefined), onDeleteSplit: vi.fn(async () => undefined), splitAccess, }), ); const splitButtons = (tree: ReactNode) => find(tree, SplitButton); const upsells = (tree: ReactNode) => find(tree, UpsellDialog); const splitWindows = (tree: ReactNode) => find(tree, SplitAdjustmentModal); beforeEach(() => harness.clear()); describe("TransactionTable — the Répartition button's wiring (#369)", () => { it("hands the table's access to every row's button, and opens nothing before a click", () => { for (const access of ["open", "locked", "pending"] as const) { harness.clear(); const tree = render(access); const buttons = splitButtons(tree); expect(buttons.map((b) => b.props.access)).toEqual([access, access]); expect(buttons.map((b) => b.props.row)).toEqual(ROWS); expect(upsells(tree)).toHaveLength(0); expect(splitWindows(tree)).toHaveLength(0); } }); it("Free: the click opens the upsell for 'adjustments', never the split window, on any row", () => { for (const index of [0, 1]) { harness.clear(); (splitButtons(render("locked"))[index].props.onOpenUpsell as () => void)(); const opened = render("locked"); const dialogs = upsells(opened); expect(dialogs, `row ${index}`).toHaveLength(1); expect(dialogs[0].props.feature).toBe("adjustments"); expect(splitWindows(opened)).toHaveLength(0); // The dialog's close hands control back to the table. (dialogs[0].props.onClose as () => void)(); expect(upsells(render("locked"))).toHaveLength(0); } }); it("Base and Premium: the click opens the split window on its own row, no upsell", () => { (splitButtons(render("open"))[1].props.onOpenSplit as () => void)(); const opened = render("open"); const windows = splitWindows(opened); expect(windows).toHaveLength(1); expect(windows[0].props.transaction).toBe(ROWS[1]); expect(upsells(opened)).toHaveLength(0); }); // One click arms one window: an edition change never brings up the other one. it("an open split window survives a license refresh and closes once the edition is Free", () => { (splitButtons(render("open"))[0].props.onOpenSplit as () => void)(); const refreshing = render("pending"); expect(splitWindows(refreshing)).toHaveLength(1); expect(upsells(refreshing)).toHaveLength(0); const downgraded = render("locked"); expect(splitWindows(downgraded)).toHaveLength(0); expect(upsells(downgraded)).toHaveLength(0); }); it("an open upsell survives a license refresh and goes away once the edition is Base", () => { (splitButtons(render("locked"))[0].props.onOpenUpsell as () => void)(); const refreshing = render("pending"); expect(upsells(refreshing)).toHaveLength(1); expect(splitWindows(refreshing)).toHaveLength(0); const upgraded = render("open"); expect(upsells(upgraded)).toHaveLength(0); expect(splitWindows(upgraded)).toHaveLength(0); }); });