Finding I3 (Critical): PurchaseDialog calls offerableCosts(dataset, draft.date)
unconditionally in its render body. The native <input type="date"> lets the
owner clear it, setting draft.date to "" — monthOf("") throws, uncaught,
during render. Unlike an event-handler throw an error boundary can react to,
this crashes the dialog with no interaction to undo.
monthOf itself stays strict per AGENTS.md (no softening a domain throw into a
fallback): offerableCosts now wraps it in a UI-layer isParseableMonth check
and returns no costs for an empty (or otherwise unparseable) date, rather than
reporting an error for a form the owner simply hasn't finished.
Checked the other recurringForm.ts helpers that take a date for the same
hole:
- newCostOptionIsSelectable had it too — "everyNMonths"+"calendar" read as
selectable for ANY date length, including empty, though seedCostFromDraft
still hands that combo's date to monthOf. Fixed the same way, so every
"+ New recurring cost..." option is disabled while the date is empty.
- sourceFor and seedCostFromDraft are only ever called at save time, inside
the guarded store.mutate, never from render — left strict, since a bad
date there already surfaces as a caught save-time error exactly like any
other bad-date save (ensureMonth's monthOf in addPurchase). Documented and
tested that determination rather than inventing a second rule for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
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 <input type="date"> 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 <option disabled>.
|
|
describe("newCostOptionIsSelectable", () => {
|
|
test("everyNMonths + calendar is selectable at either granularity — rent occurs IN a month", () => {
|
|
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09")).toBe(true);
|
|
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09-20")).toBe(true);
|
|
});
|
|
|
|
test("everyNMonths + lastCharge needs a day-granular date", () => {
|
|
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09-20")).toBe(true);
|
|
});
|
|
|
|
test("everyNDays always needs a day-granular date, whichever anchoring", () => {
|
|
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09-20")).toBe(true);
|
|
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09-20")).toBe(true);
|
|
});
|
|
|
|
test("everyNWeeks always needs a day-granular date, whichever anchoring", () => {
|
|
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09-20")).toBe(true);
|
|
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09-20")).toBe(true);
|
|
});
|
|
|
|
// I3 follow-up: an empty date fails EVERY pairing, including
|
|
// "everyNMonths" + "calendar" — the one combination that is otherwise
|
|
// always fine. That combination still hands the date to `monthOf` inside
|
|
// `seedCostFromDraft`, and an empty string does not parse as a month
|
|
// either, so it must not be offered as selectable while the date is empty.
|
|
test("an empty date is never selectable, not even everyNMonths + calendar", () => {
|
|
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNDays", "calendar", "")).toBe(false);
|
|
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("seedCostFromDraft", () => {
|
|
test("carries the purchase's amount, currency, splits and date", () => {
|
|
const draft = {
|
|
date: "2026-09-20",
|
|
description: "Gym",
|
|
note: "",
|
|
amount: 349,
|
|
currency: "DKK" as const,
|
|
splitMode: "percent" as const,
|
|
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
|
|
plan: null,
|
|
};
|
|
const seeded = seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar");
|
|
expect(seeded.name).toBe("Gym");
|
|
expect(seeded.amount).toEqual({ amount: 349, currency: "DKK" });
|
|
expect(seeded.splits).toEqual([{ postId: "p1", value: 100, absorbsRemainder: true }]);
|
|
expect(seeded.anchoring).toBe("calendar");
|
|
});
|
|
|
|
test("a calendar monthly cost starts in the purchase's MONTH", () => {
|
|
const draft = {
|
|
date: "2026-09-20",
|
|
description: "Rent",
|
|
note: "",
|
|
amount: 8200,
|
|
currency: "DKK" as const,
|
|
splitMode: "percent" as const,
|
|
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
|
|
plan: null,
|
|
};
|
|
expect(
|
|
seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar").startDate,
|
|
).toBe("2026-09");
|
|
});
|
|
|
|
test("an anchored monthly cost starts on the purchase's DAY", () => {
|
|
// Day precision: the cycle runs from the charge, so it needs a real day.
|
|
const draft = {
|
|
date: "2026-09-20",
|
|
description: "Phone",
|
|
note: "",
|
|
amount: 300,
|
|
currency: "DKK" as const,
|
|
splitMode: "percent" as const,
|
|
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
|
|
plan: null,
|
|
};
|
|
expect(
|
|
seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "lastCharge").startDate,
|
|
).toBe("2026-09-20");
|
|
});
|
|
|
|
// I3 follow-up: like `sourceFor`, this is only ever called at save time
|
|
// (inside store.mutate), never from a render body — `newCostOptionIsSelectable`
|
|
// already disables every "+ New" option for an empty date, so reaching
|
|
// this with one would mean the combo was chosen before the date was
|
|
// cleared. It stays strict and throws, exactly like any other bad-date
|
|
// save (ensureMonth's monthOf in addPurchase) — a caught save-time error,
|
|
// not an uncaught render crash.
|
|
test("a month-granular pairing throws on an empty date, same as any other bad-date save", () => {
|
|
const draft = {
|
|
date: "",
|
|
description: "Rent",
|
|
note: "",
|
|
amount: 8200,
|
|
currency: "DKK" as const,
|
|
splitMode: "percent" as const,
|
|
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
|
|
plan: null,
|
|
};
|
|
expect(() => seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar")).toThrow();
|
|
});
|
|
|
|
test("a day-granular pairing carries an empty date straight through, unvalidated here", () => {
|
|
// seedCostFromDraft itself does not call monthOf for this pairing — the
|
|
// store's own requireStartDateGranularity is what would refuse it, at
|
|
// save time, inside the same guarded mutate call.
|
|
const draft = {
|
|
date: "",
|
|
description: "Phone",
|
|
note: "",
|
|
amount: 300,
|
|
currency: "DKK" as const,
|
|
splitMode: "percent" as const,
|
|
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
|
|
plan: null,
|
|
};
|
|
expect(
|
|
seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "lastCharge").startDate,
|
|
).toBe("");
|
|
});
|
|
});
|