Files
budget2.0/src/ui/routes/settings/RecurringSection.test.tsx
T

173 lines
8.5 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { addRecurringCost } from "../../../store/actions.ts";
import { RecurringSection } from "./RecurringSection.tsx";
test("bill requires an explicit destination", async () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { name.focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Phone", inputType: "insertText" })); });
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
expect(add.disabled).toBe(true);
const chosen = data.posts[1]!;
const destination = container.querySelector('select[aria-label="Destination post"]') as HTMLSelectElement;
expect(destination.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { destination.value = chosen.id; destination.dispatchEvent(new Event("change", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30$"); amount.dispatchEvent(new InputEvent("input", { bubbles: true, data: "30$", inputType: "insertText" })); });
expect(add.disabled).toBe(false);
await act(async () => { add.click(); });
const saved = data.recurring[0]!;
expect(saved.splits[0]!.postId).toBe(chosen.id);
expect(saved.amount).toEqual({ amount: 30, currency: "USD" });
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
test("failed destination edit keeps its editor and error visible", async () => {
const data = createSeedDataset("2026-09");
const cost = addRecurringCost(data, {
name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async () => { throw new Error("Disk full"); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const edit = container.querySelector(`#bill-${cost.id}`) as HTMLButtonElement;
expect(edit).not.toBeNull();
act(() => edit.click());
await act(async () => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Save destinations")!).click(); });
expect(container.textContent).toContain("Save destinations");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(cost.splits[0]!.postId).toBe(data.posts[0]!.id);
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
// to decide whether to call `mutate` at all.
describe("isValidStartDate", () => {
test("an empty string is never valid, for any kind", () => {
expect(isValidStartDate("", "everyNMonths")).toBe(false);
expect(isValidStartDate("", "everyNDays")).toBe(false);
expect(isValidStartDate("", "everyNWeeks")).toBe(false);
});
test("everyNMonths accepts either granularity", () => {
expect(isValidStartDate("2026-09", "everyNMonths")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNMonths")).toBe(true);
});
test("everyNMonths still rejects garbage", () => {
expect(isValidStartDate("not-a-date", "everyNMonths")).toBe(false);
expect(isValidStartDate("2026-13", "everyNMonths")).toBe(false);
});
test("a day-granular kind rejects a month-only value", () => {
// This is the exact C1/C2 crash: monthOf() would happily hand this back,
// but occurrencesOf hands it to addDays, which throws.
expect(isValidStartDate("2026-09", "everyNDays")).toBe(false);
expect(isValidStartDate("2026-09", "everyNWeeks")).toBe(false);
});
test("a day-granular kind accepts a day-granular value", () => {
expect(isValidStartDate("2026-09-17", "everyNDays")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNWeeks")).toBe(true);
});
test("a day-granular kind rejects a malformed day-granular-shaped value", () => {
expect(isValidStartDate("2026-02-30", "everyNDays")).toBe(false); // no Feb 30
});
// Residual C1: `monthOf` only extracts the month and never checks the day,
// so a day-SHAPED but calendar-impossible value ("2026-09-31" — September
// has 30 days) used to pass here even under everyNMonths, and could then be
// stored, only for foldBalances to throw three months later.
test("everyNMonths still rejects a day-granular value with an impossible day", () => {
expect(isValidStartDate("2026-09-31", "everyNMonths")).toBe(false);
});
test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => {
expect(isValidStartDate("2024-02-29", "everyNMonths")).toBe(true); // 2024 is a leap year
expect(isValidStartDate("2026-02-29", "everyNMonths")).toBe(false); // 2026 is not
expect(isValidStartDate("2026-02-28", "everyNMonths")).toBe(true);
});
});
// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but
// still has to be a real calendar date, and empty must be accepted since it
// is how the field un-ends a cost.
describe("isValidEndedFrom", () => {
test("an empty string is valid — it means 'not ended'", () => {
expect(isValidEndedFrom("")).toBe(true);
});
test("both granularities are valid, unconditionally — no recurrence kind is involved", () => {
expect(isValidEndedFrom("2026-09")).toBe(true);
expect(isValidEndedFrom("2026-09-17")).toBe(true);
});
test("garbage is rejected", () => {
expect(isValidEndedFrom("not-a-date")).toBe(false);
expect(isValidEndedFrom("2026-13")).toBe(false);
});
test("a calendar-impossible date is rejected, day- or month-shaped", () => {
expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30
expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year
});
test("a real leap day is accepted", () => {
expect(isValidEndedFrom("2024-02-29")).toBe(true);
});
});
// C1: switching the unit dropdown to a day-granular kind must expand a
// month-only startDate in the same mutate call, never leaving the pairing
// unvalidated for even one render.
describe("normalizedStartDateFor", () => {
test("expands a month-only date when switching to a day-granular kind", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09")).toBe("2026-09-01");
expect(normalizedStartDateFor("everyNWeeks", "2026-09")).toBe("2026-09-01");
});
test("leaves an already day-granular date untouched", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09-17")).toBe("2026-09-17");
});
test("leaves the date untouched when switching to everyNMonths", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09")).toBe("2026-09");
expect(normalizedStartDateFor("everyNMonths", "2026-09-17")).toBe("2026-09-17");
});
});