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()); 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()); 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"); }); });