import { describe, expect, test } from "bun:test"; import { newCostOptionIsSelectable, offerableCosts, seedCostFromDraft, sourceFor, } from "./recurringForm.ts"; import { createSeedDataset } from "../domain/seed.ts"; import * as actions from "../store/actions.ts"; import type { Dataset } from "../domain/types.ts"; function withCosts(): Dataset { const d = createSeedDataset("2026-09"); const post = d.posts[0]!; actions.addRecurringCost(d, { name: "Rent", archived: false, amount: { amount: 8200, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], }); actions.addRecurringCost(d, { name: "Phone", archived: false, amount: { amount: 300, currency: "DKK" }, startDate: "2026-09-01", recurrence: { kind: "everyNDays", n: 30 }, anchoring: "lastCharge", splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], }); return d; } describe("offerableCosts", () => { test("an anchored cost is offerable on any date", () => { const names = offerableCosts(withCosts(), "2027-05-11").map((c) => c.name); expect(names).toContain("Phone"); }); test("a calendar cost is offerable only when its grid has a slot in that month", () => { const d = withCosts(); expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).toContain("Rent"); // Before the cost started, its grid has no slot to settle. expect(offerableCosts(d, "2026-07-20").map((c) => c.name)).not.toContain("Rent"); }); test("an archived cost is not offered", () => { const d = withCosts(); actions.endRecurringCost(d, d.recurring[0]!.id, "2026-09"); expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).not.toContain("Rent"); }); // I3 (Critical): the purchase dialog calls this unconditionally on every // render, and the native lets the owner clear it, // making `date` "". `monthOf("")` throws — uncaught, that took the render // down with no interaction to undo. An empty date is a half-finished form, // not an error, so this returns no costs rather than throwing. test("an empty date offers no costs, rather than throwing", () => { const d = withCosts(); expect(offerableCosts(d, "")).toEqual([]); }); test("any other unparseable date also offers no costs, rather than throwing", () => { const d = withCosts(); expect(offerableCosts(d, "not-a-date")).toEqual([]); }); }); describe("sourceFor", () => { test("an anchored cost's source carries no slot", () => { const d = withCosts(); const phone = d.recurring.find((c) => c.name === "Phone")!; expect(sourceFor(d, phone, "2026-09-20")).toEqual({ recurringId: phone.id }); }); test("a calendar cost's source names the slot in the purchase's own month", () => { const d = withCosts(); const rent = d.recurring.find((c) => c.name === "Rent")!; expect(sourceFor(d, rent, "2026-10-03")).toEqual({ recurringId: rent.id, occurrenceDate: "2026-10", }); }); test("throws when the cost has no occurrence in the purchase's month", () => { const d = withCosts(); const rent = d.recurring.find((c) => c.name === "Rent")!; // Before the cost started: its grid has no slot in July. expect(() => sourceFor(d, rent, "2026-07-20")).toThrow(); }); // CONTROLLER RULING: everyNDays + calendar is a legal combination whose // slots are specific DAYS, not months. Deriving the slot from // monthOf(purchaseDate) alone would name a slot ("2026-09") that does not // exist on this grid — the real slot is a day like "2026-09-01". Resolving // by lookup (via occurrencesOf) gets the real slot; deriving it would not. test("a day-granular calendar cost's source names the REAL slot date, not the bare month", () => { const d = createSeedDataset("2026-09"); const post = d.posts[0]!; const cost = actions.addRecurringCost(d, { name: "Groceries plan", archived: false, amount: { amount: 500, currency: "DKK" }, startDate: "2026-09-01", recurrence: { kind: "everyNDays", n: 30 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], }); // Occurrences from 2026-09-01 stepping by 30 days: 09-01, then 10-01 — // only one slot, 2026-09-01, falls in September. expect(sourceFor(d, cost, "2026-09-20")).toEqual({ recurringId: cost.id, occurrenceDate: "2026-09-01", }); }); // I3 follow-up: unlike `offerableCosts`, `sourceFor` is only ever called at // save time (inside store.mutate), never from a render body — so it is NOT // required to tolerate an empty date the way `offerableCosts` is. It stays // strict and throws, exactly like every other bad-date save already does // (ensureMonth's monthOf in addPurchase) — a caught save-time error, not an // uncaught render crash. An anchored cost carries no slot at all and so // never touches monthOf, and is unaffected by an empty date. test("a calendar cost throws on an empty date, same as any other bad-date save", () => { const d = withCosts(); const rent = d.recurring.find((c) => c.name === "Rent")!; expect(() => sourceFor(d, rent, "")).toThrow(); }); test("an anchored cost is unaffected by an empty date — it carries no slot", () => { const d = withCosts(); const phone = d.recurring.find((c) => c.name === "Phone")!; expect(sourceFor(d, phone, "")).toEqual({ recurringId: phone.id }); }); }); // I2 fix: a month-only purchase date can only ever seed "everyNMonths" + // "calendar" (rent occurs IN a month). Every other pairing hands the date // straight to day-precise arithmetic and would throw a store-level error // naming a field ("startDate") the purchase dialog never shows — so the // dialog must not OFFER those options while the date is month-only, rather // than reword the throw. This predicate is what decides which options are // offered; it is tested directly, since a DOM test cannot exercise a // predicate the way it can a rendered