Critical (X1): confirming a "Coming up" row records TODAY against a future slot, and occurrencesOf throws — permanently, since the purchase persists — if that pairing can't advance the series. Reproduced with the demo's own Car insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks every month route, yearView, summaryView and PostYearRoute. `expectedGroups` now omits a coming-up row outright (never disabled-with-an-explanation) unless `wouldAdvancePast` says recording it today would leave the walk able to advance past that slot. Important (X3): a cost with an outstanding occurrence in the displayed month or any earlier foldable month never gets a coming-up row either — that was the double-counting path into X1 (Car insurance listed under both "Expected" 2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier stayed pending would double-count the bill). Settle the earlier bill first. Important (X2): extracted the this-month-vs-coming-up recorded-date decision into `recordedDateFor(group, today)`, previously inlined as `row(occurrence, null)` vs `row(occurrence, today)` with nothing testing that the two aren't swapped. Tested directly. Minor: the "Coming up" label is now an `<h3>` (matching MonthRoute's own group-heading convention) instead of a `<p>`, so assistive tech gets the section divider. New tests in ExpectedBand.test.ts cover all of the above, including a regression built from the exact reported shape: confirming everything expectedGroups offers as "coming up" and then folding must never throw. Pre-fix, that reproduction throws: Recurring cost "Car insurance" did not advance past 2026-10-13 (produced 2026-10-04). Purchase <id> claims that slot but is dated 2026-09-04, which does not move the series past 2026-10-13 — correct its date so the series advances.
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx";
|
|
import { occurrencesByMonth, wouldAdvancePast } from "../../domain/occurrences.ts";
|
|
import { confirmOccurrence } from "../../store/actions.ts";
|
|
import { createSeedDataset } from "../../domain/seed.ts";
|
|
import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts";
|
|
|
|
const DKK_USD = [
|
|
{ code: "DKK", symbol: "kr" },
|
|
{ code: "USD", symbol: "$" },
|
|
];
|
|
|
|
// I5: an unrecognised currency must not silently confirm at the cost's own
|
|
// amount. `resolveExpectedAmount` is the logic ExpectedBand's Confirm button
|
|
// uses to decide whether to write a purchase at all.
|
|
describe("resolveExpectedAmount", () => {
|
|
test("untouched (typed undefined) is not invalid, and parses to nothing — the fast path", () => {
|
|
const result = resolveExpectedAmount(undefined, DKK_USD, "DKK");
|
|
expect(result.invalid).toBe(false);
|
|
expect(result.parsed).toBeNull();
|
|
});
|
|
|
|
test("edited text that parses is not invalid", () => {
|
|
const result = resolveExpectedAmount("450", DKK_USD, "DKK");
|
|
expect(result.invalid).toBe(false);
|
|
expect(result.parsed).toEqual({ amount: 450, currency: "DKK" });
|
|
});
|
|
|
|
test("edited text naming a currency the dataset doesn't have is invalid, not a silent fallback", () => {
|
|
// This is the exact bug: "30 GBP" in a DKK/USD dataset used to fall back
|
|
// to the cost's own amount via `parsed ?? undefined`.
|
|
const result = resolveExpectedAmount("30 GBP", DKK_USD, "DKK");
|
|
expect(result.invalid).toBe(true);
|
|
expect(result.parsed).toBeNull();
|
|
});
|
|
|
|
test("edited text that is empty or otherwise unparseable is invalid", () => {
|
|
expect(resolveExpectedAmount("", DKK_USD, "DKK").invalid).toBe(true);
|
|
expect(resolveExpectedAmount("thirty", DKK_USD, "DKK").invalid).toBe(true);
|
|
});
|
|
});
|
|
|
|
// X2: the load-bearing decision behind the phone-bill behaviour is which date
|
|
// a Confirm click records. Nothing else in the suite would catch the two
|
|
// cases being swapped — this test must FAIL if `recordedDateFor`'s two arms
|
|
// are exchanged (verified by hand: swapping them and re-running does fail).
|
|
describe("recordedDateFor", () => {
|
|
test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => {
|
|
expect(recordedDateFor("pending", "2026-09-04")).toBeNull();
|
|
});
|
|
|
|
test("a 'coming up' row records at today — paying early is the whole point", () => {
|
|
expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04");
|
|
});
|
|
});
|
|
|
|
function baseDataset(): Dataset {
|
|
const data = createSeedDataset("2026-02");
|
|
data.posts[0]!.id = "housing";
|
|
return data;
|
|
}
|
|
|
|
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
|
return {
|
|
id: "r1",
|
|
name: "Rent",
|
|
order: 0,
|
|
archived: false,
|
|
amount: { amount: 8000, currency: "DKK" },
|
|
startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function confirmed(recurringId: string, occurrenceDate: string, id: string): Purchase {
|
|
return {
|
|
id,
|
|
date: occurrenceDate,
|
|
description: "x",
|
|
total: { amount: 8000, currency: "DKK" },
|
|
splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
schedule: null,
|
|
source: { recurringId, occurrenceDate },
|
|
};
|
|
}
|
|
|
|
// A fixed "today" for tests where its exact value doesn't matter (any
|
|
// calendar-anchored cost advances regardless of it).
|
|
const TODAY = "2026-02-15";
|
|
|
|
// Gap 1 (spec §5): the band offers confirmation on the next pending
|
|
// occurrence, not only ones landing in the displayed month.
|
|
describe("expectedGroups (Coming up)", () => {
|
|
test("a dataset with no recurring costs shows nothing — the component must return null", () => {
|
|
const data = baseDataset();
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.overdue).toBe(0);
|
|
expect(groups.comingUp).toEqual([]);
|
|
});
|
|
|
|
test("a lastCharge bill's next occurrence next month is offered as 'coming up', not hidden until you navigate", () => {
|
|
// The flagship scenario: displayed month is February, the pending slot
|
|
// sits in March because rent-like accounting isn't in play here — a
|
|
// monthly cost's only occurrence for a month IS next month once this
|
|
// month's is confirmed.
|
|
const data = baseDataset();
|
|
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
|
data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done
|
|
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.comingUp.length).toBe(1);
|
|
expect(groups.comingUp[0]!.recurringId).toBe("phone");
|
|
expect(groups.comingUp[0]!.date).toBe("2026-03");
|
|
});
|
|
|
|
test("at most one row per cost, even when several future occurrences fall within the horizon", () => {
|
|
// A weekly cost produces many unconfirmed occurrences across the next
|
|
// several months. Only the soonest one past the displayed month may
|
|
// appear — never a list of everything the future holds. February's own
|
|
// occurrences are confirmed first so X3 doesn't suppress the row this
|
|
// test exists to check (a cost with an outstanding slot this month must
|
|
// settle it before "coming up" offers a later one — covered separately
|
|
// below).
|
|
const data = baseDataset();
|
|
data.recurring = [
|
|
cost({
|
|
id: "gym",
|
|
startDate: "2026-02-02",
|
|
recurrence: { kind: "everyNDays", n: 7 },
|
|
}),
|
|
];
|
|
data.purchases = [
|
|
confirmed("gym", "2026-02-02", "p1"),
|
|
confirmed("gym", "2026-02-09", "p2"),
|
|
confirmed("gym", "2026-02-16", "p3"),
|
|
confirmed("gym", "2026-02-23", "p4"),
|
|
];
|
|
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
// Many more unconfirmed occurrences exist in March, April, ... within the
|
|
// horizon — but comingUp holds exactly one.
|
|
const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym");
|
|
expect(forThisCost.length).toBe(1);
|
|
expect(forThisCost[0]!.date).toBe("2026-03-02"); // the soonest one past February
|
|
});
|
|
|
|
test("the horizon is bounded: a cost whose next occurrence lands beyond it does not appear", () => {
|
|
const data = baseDataset();
|
|
// Interval bigger than the default horizon: confirmed this month, the
|
|
// next occurrence is 15 months out — past the 12-month default.
|
|
data.recurring = [
|
|
cost({
|
|
id: "insurance",
|
|
startDate: "2026-02",
|
|
recurrence: { kind: "everyNMonths", n: 15 },
|
|
}),
|
|
];
|
|
data.purchases = [confirmed("insurance", "2026-02", "p1")];
|
|
|
|
expect(expectedGroups(data, "2026-02", TODAY).comingUp).toEqual([]); // default (12) horizon
|
|
// Raising the horizon far enough reveals the same occurrence, proving the
|
|
// emptiness above is the bound, not a bug elsewhere.
|
|
const wider = expectedGroups(data, "2026-02", TODAY, 16).comingUp;
|
|
expect(wider.length).toBe(1);
|
|
expect(wider[0]!.date).toBe("2027-05");
|
|
});
|
|
|
|
test("a confirmed future occurrence never appears in comingUp", () => {
|
|
const data = baseDataset();
|
|
// Ends right after March, so April onward is never generated at all —
|
|
// isolating the point of the test (a confirmed slot is excluded) from an
|
|
// unrelated, genuinely-unconfirmed one the walk would otherwise reach.
|
|
data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })];
|
|
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
|
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.comingUp).toEqual([]);
|
|
});
|
|
|
|
test("overdue counts only earlier, foldable months and is unaffected by extending the walk for comingUp", () => {
|
|
const data = baseDataset();
|
|
data.settings.foldStartMonth = "2026-01";
|
|
data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed
|
|
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.overdue).toBe(1);
|
|
});
|
|
});
|
|
|
|
// X3: a cost must never appear in both "Expected" and "Coming up" at once —
|
|
// that is the path into X1 (confirming the later row while the earlier one
|
|
// is still outstanding writes a real purchase AND leaves the earlier
|
|
// projection standing, double-counting the bill).
|
|
describe("expectedGroups — X3 (a cost with an outstanding slot never also shows a coming-up row)", () => {
|
|
test("this month's slot still pending suppresses the coming-up row for the SAME cost", () => {
|
|
const data = baseDataset();
|
|
data.recurring = [
|
|
cost({
|
|
id: "car",
|
|
name: "Car insurance",
|
|
amount: { amount: 349, currency: "DKK" },
|
|
startDate: "2026-08-14",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
}),
|
|
];
|
|
// Matches the reported demo shape exactly: no purchases at all, viewed
|
|
// 2026-09. 2026-09-13 is pending; 2026-10-13 would otherwise be the
|
|
// soonest future occurrence.
|
|
data.settings.foldStartMonth = "2026-01";
|
|
|
|
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
|
expect(groups.pending.map((o) => o.date)).toEqual(["2026-09-13"]);
|
|
expect(groups.comingUp).toEqual([]); // suppressed — settle September first
|
|
});
|
|
|
|
test("once the pending slot is settled, a coming-up row reappears — for a pairing that is itself safe", () => {
|
|
// A monthly lastCharge cost, unlike the 30-day Car insurance above, is
|
|
// always safe to confirm early (X1 never suppresses `everyNMonths`), so
|
|
// this isolates X3's own effect: suppressed while this month is
|
|
// outstanding, offered again once it is settled.
|
|
const data = baseDataset();
|
|
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
|
|
|
let groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]);
|
|
expect(groups.comingUp).toEqual([]);
|
|
|
|
confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" });
|
|
groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]);
|
|
});
|
|
|
|
test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => {
|
|
const data = baseDataset();
|
|
data.settings.foldStartMonth = "2026-01";
|
|
data.recurring = [
|
|
cost({ id: "rent", startDate: "2026-01", endedFrom: "2026-04" }),
|
|
];
|
|
// January is unconfirmed; February (displayed month) and March are
|
|
// confirmed, so nothing is `pending` and the only reason a coming-up row
|
|
// would be suppressed is the outstanding January slot.
|
|
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
|
|
|
const groups = expectedGroups(data, "2026-02", TODAY);
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.comingUp).toEqual([]); // January is still outstanding
|
|
});
|
|
});
|
|
|
|
// X1: a coming-up row confirms at today's date rather than its own slot, and
|
|
// `occurrencesOf` throws — permanently, since the purchase persists — if that
|
|
// pairing cannot advance the series. The band must never OFFER such a row.
|
|
describe("expectedGroups — X1 (a coming-up row that cannot be confirmed coherently is never offered)", () => {
|
|
test("wouldAdvancePast identifies the exact reported pairing as unsafe", () => {
|
|
const car = cost({
|
|
id: "car",
|
|
name: "Car insurance",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
});
|
|
// 39 days between "today" and the slot — more than one 30-day cycle.
|
|
expect(wouldAdvancePast(car, "2026-10-13", "2026-09-04")).toBe(false);
|
|
// A slot close enough to today (a few days, the "paid early" case) is fine.
|
|
expect(wouldAdvancePast(car, "2026-09-13", "2026-09-04")).toBe(true);
|
|
});
|
|
|
|
test("stays suppressed even once X3 no longer applies — settling this month doesn't make a too-early payment safe", () => {
|
|
// Same Car insurance shape, but September's slot is settled ON TIME
|
|
// first, so X3 no longer has anything to suppress. The October row is
|
|
// still 39 days from `today` — more than one 30-day cycle — so X1 alone
|
|
// must keep it off the list.
|
|
const data = baseDataset();
|
|
data.recurring = [
|
|
cost({
|
|
id: "car",
|
|
name: "Car insurance",
|
|
amount: { amount: 349, currency: "DKK" },
|
|
startDate: "2026-08-14",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
}),
|
|
];
|
|
confirmOccurrence(data, "car", "2026-09-13", { date: "2026-09-13" });
|
|
|
|
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
|
expect(groups.pending).toEqual([]);
|
|
expect(groups.comingUp).toEqual([]);
|
|
});
|
|
|
|
test("confirming an unsafe pairing directly still throws — the domain guard is untouched", () => {
|
|
const data = baseDataset();
|
|
data.recurring = [
|
|
cost({
|
|
id: "car",
|
|
name: "Car insurance",
|
|
amount: { amount: 349, currency: "DKK" },
|
|
startDate: "2026-08-14",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
}),
|
|
];
|
|
confirmOccurrence(data, "car", "2026-10-13", { date: "2026-09-04" });
|
|
expect(() => occurrencesByMonth(data, "2026-10")).toThrow(
|
|
/did not advance past 2026-10-13 \(produced 2026-10-04\)/,
|
|
);
|
|
});
|
|
|
|
test("regression: whatever expectedGroups offers as 'coming up', confirming it all today and folding never throws (demo Car insurance shape)", () => {
|
|
const data = baseDataset();
|
|
data.recurring = [
|
|
cost({
|
|
id: "car",
|
|
name: "Car insurance",
|
|
amount: { amount: 349, currency: "DKK" },
|
|
startDate: "2026-08-14",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
}),
|
|
cost({
|
|
id: "phone",
|
|
name: "Phone",
|
|
startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "lastCharge",
|
|
}),
|
|
];
|
|
data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car
|
|
const today = "2026-09-04";
|
|
|
|
const groups = expectedGroups(data, "2026-09", today);
|
|
// Car insurance is suppressed entirely (X3: 2026-09-13 is still pending),
|
|
// and even confirming everything else offered must never break the fold.
|
|
expect(groups.comingUp.some((o) => o.recurringId === "car")).toBe(false);
|
|
|
|
for (const occurrence of groups.comingUp) {
|
|
confirmOccurrence(data, occurrence.recurringId, occurrence.date, { date: today });
|
|
}
|
|
|
|
expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow();
|
|
});
|
|
});
|