Simpl-Resultat/src/components/transactions/SplitButton.test.tsx
le king fu cb7f8479e4
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
feat(gating): reserve transaction splits for the Base edition
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.

- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
  stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
  by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
  rendered once the edition resolves to Free, and stays mounted through
  a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
  architecture, entitlements header comment, both changelogs.

Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.

Fixes #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:02:47 -04:00

106 lines
4.3 KiB
TypeScript

// SplitButton — the Répartition button of a transaction row (#369). No jsdom:
// react-dom/server renders the markup in vitest's node environment, and calling
// the component as a function (UpsellCtas.render.test.tsx pattern) hands over
// the button's props, so a click runs without a DOM.
import { describe, it, expect, vi } from "vitest";
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import fr from "../../i18n/locales/fr.json";
import en from "../../i18n/locales/en.json";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SplitButton from "./SplitButton";
import type { SplitAccess } from "../../shared/splitGate";
import type { TransactionRow } from "../../shared/types";
const row = (isSplit: boolean): TransactionRow => ({
id: isSplit ? 1 : 2,
date: "2026-09-12",
description: isSplit ? "COSTCO 123" : "IGA 456",
amount: -120,
category_id: 7,
category_name: "Épicerie",
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: true,
is_split: isSplit,
});
const ACCESSES: SplitAccess[] = ["open", "locked", "pending"];
const LOCK = "lucide-lock";
const ORANGE = "text-orange-500";
const markup = (access: SplitAccess, isSplit = false) =>
renderToStaticMarkup(
<SplitButton row={row(isSplit)} access={access} onOpenSplit={vi.fn()} onOpenUpsell={vi.fn()} />,
);
/** One click on the button, through its own props: which callback ran. */
function click(access: SplitAccess, isSplit: boolean) {
const onOpenSplit = vi.fn();
const onOpenUpsell = vi.fn();
const button = SplitButton({ row: row(isSplit), access, onOpenSplit, onOpenUpsell }) as ReactElement<{
onClick: () => void;
}>;
button.props.onClick();
return { split: onOpenSplit.mock.calls.length, upsell: onOpenUpsell.mock.calls.length };
}
describe("SplitButton — what it shows (#369)", () => {
it("shows the lock in the locked state only", () => {
expect(markup("locked")).toContain(LOCK);
expect(markup("open")).not.toContain(LOCK);
expect(markup("pending")).not.toContain(LOCK);
});
it("keeps the orange of a split row in every state, and only for a split row", () => {
for (const access of ACCESSES) {
expect(markup(access, true), access).toContain(ORANGE);
expect(markup(access, false), access).not.toContain(ORANGE);
}
});
it("is disabled while the license is not ready, and only then", () => {
// The attribute itself: the class also names the `disabled:` variants.
expect(markup("pending")).toContain('disabled=""');
expect(markup("open")).not.toContain('disabled=""');
expect(markup("locked")).not.toContain('disabled=""');
});
it("names the edition in the locked title, keeps the usual title otherwise", () => {
expect(markup("locked")).toContain('title="t(transactions.splitLocked[tier=t(license.editions.base)])"');
expect(markup("open")).toContain('title="t(transactions.splitAdjustment)"');
expect(markup("pending")).toContain('title="t(transactions.splitAdjustment)"');
});
});
describe("SplitButton — what a click does (#369)", () => {
// Every row: the decision never depends on the row being split already.
it("locked: opens the upsell, never the split window, on a split row and on a plain one", () => {
expect(click("locked", true)).toEqual({ split: 0, upsell: 1 });
expect(click("locked", false)).toEqual({ split: 0, upsell: 1 });
});
it("open: opens the split window, never the upsell", () => {
expect(click("open", true)).toEqual({ split: 1, upsell: 0 });
expect(click("open", false)).toEqual({ split: 1, upsell: 0 });
});
it("pending: opens nothing", () => {
expect(click("pending", true)).toEqual({ split: 0, upsell: 0 });
expect(click("pending", false)).toEqual({ split: 0, upsell: 0 });
});
});
// The i18n mock renders any key: a key missing from one locale would only show
// up in the app (the raw key in English, or English in French).
describe("transactions.splitLocked in both locales", () => {
it("exists in fr and en and interpolates the edition", () => {
for (const messages of [fr, en]) {
expect(messages.transactions.splitLocked).toContain("{{tier}}");
expect(messages.transactions.splitLocked).not.toBe(messages.transactions.splitAdjustment);
}
});
});