test(analytics): guard the three unguarded Priority 0 behaviours (#155)
docs/ui-information-architecture-review.md claimed Priority 0 was guarded by analytics-sql.test.ts. That file only ever covered the three SQL *fragments* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3 and 6 of the Priority 0 list had nothing, and all three fail silently — no error, no empty screen, just a number that quietly disagrees with the one beside it. New: src/__tests__/integration/analytics-priority0.test.ts, 18 tests. - **Item 2, pace/headline parity.** /api/analytics/daily summed over a month must equal /api/analytics/monthly's headline to the cent, per category, and across every month in the window. Cents rather than exact equality is the assertion because the two routes group at different grains (month+category vs month+day+category) and both carry 4dp — the displayed grain is what has to agree, which is precisely the bug (the chart ending the month a few cents off the headline above it). Fixtures are only shapes where a naive gross debit sum disagrees: a 50% split, a refund, fee and interest rows, an itemised loan repayment, a reconciled source row, family-tagged spend, transfers/investment/income, and a row someone else paid that I hold a split on. A fourth test asserts the naive sum does NOT match — without it, parity between two queries identical by construction would pass on fixtures too plain to discriminate. - **Item 3, fees date range.** Default 12 months with the window reported (from=2025-09-01, not 08-31 — toDateStr, not toISOString); the older tail excluded; months=0 all-time with a null range rather than a period claim the number does not make; a narrower months=3; the transaction-level fee lines bounded by the same window as the statement totals, since a bounded total beside an unbounded list is the same defect wearing the fix; and a nonsense months param clamped rather than producing an Invalid Date window. - **Item 6, partial-month comparisons.** The in-progress month never enters a baseline; a partial month is compared through the same day of each prior month; a complete month keeps full totals; top movers truncate both sides; year boundary and leap February. Item 6 was untestable by construction, which is why it had no test: the rules were closures in budget/page.tsx reading `new Date()` inline. Extracted to src/lib/partial-month.ts with an injectable clock, and budget/page.tsx now imports them. The extraction also de-duplicates the baseline filter, which was written out identically in two places. The file freezes the clock (vi.useFakeTimers, toFake: ["Date"] only — pg needs real timers for connection handling). Without it the fixtures drift out of the routes' rolling windows over time and the assertions go vacuous, which is the same class of silent failure the file exists to catch. Mutation-checked, all three: breaking daily's spend semantics fails 3 of the item-2 tests (the placebo correctly still passes); forcing the fees window to all-time fails 5; letting the current month into the baseline fails 1; and dropping the elapsed-day truncation fails 3.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Partial-month comparison rules (Priority 0, item 6).
|
||||
*
|
||||
* A month in progress is not comparable to a complete one, and getting that
|
||||
* wrong is silent: nothing fails, the current month simply looks thrifty
|
||||
* because most of it has not happened yet. Two distinct rules follow from it,
|
||||
* and both are applied in three places on the analytics page:
|
||||
*
|
||||
* 1. **The in-progress month is never part of a baseline.** Averaging it in
|
||||
* drags the "typical" figure down by however much of the month is still in
|
||||
* the future.
|
||||
* 2. **When the in-progress month is the one being looked at, every month it
|
||||
* is compared against is truncated to the same day.** Its running total is
|
||||
* measured against the same slice of each prior month, not their full
|
||||
* totals.
|
||||
*
|
||||
* This module exists so those rules can be tested. They previously lived as
|
||||
* closures inside `src/app/budget/page.tsx` reading `new Date()` directly, so
|
||||
* "is this month in progress" depended on the wall clock at run time and no
|
||||
* test could assert either rule without waiting for the calendar. Every
|
||||
* function here takes `now` explicitly, defaulting to the real clock, which is
|
||||
* the whole reason the behaviour is now guardable.
|
||||
*
|
||||
* Pair with `/api/analytics/daily`: rule 2 needs day-of-month granularity,
|
||||
* which is why that route exists.
|
||||
*/
|
||||
|
||||
/** `YYYY-MM` for a Date, in local time. */
|
||||
export function monthStrOf(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The month currently in progress. */
|
||||
export function currentMonthStr(now: Date = new Date()): string {
|
||||
return monthStrOf(now);
|
||||
}
|
||||
|
||||
/** The calendar month before `m`, as `YYYY-MM`. */
|
||||
export function prevMonth(m: string): string {
|
||||
const [year, month] = m.split("-").map(Number);
|
||||
return monthStrOf(new Date(year, month - 2, 1));
|
||||
}
|
||||
|
||||
/** Total days in month `m`. */
|
||||
export function daysInMonthOf(m: string): number {
|
||||
const [year, month] = m.split("-").map(Number);
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of `m` has actually happened. A month in progress is complete only
|
||||
* up to today; every earlier month is complete.
|
||||
*/
|
||||
export function elapsedDays(m: string, now: Date = new Date()): number {
|
||||
return m === currentMonthStr(now) ? now.getDate() : daysInMonthOf(m);
|
||||
}
|
||||
|
||||
/** Spend in a month from day 1 through `throughDay` inclusive. */
|
||||
export function spendThrough(
|
||||
days: Record<number, number> | undefined,
|
||||
throughDay: number
|
||||
): number {
|
||||
if (!days) return 0;
|
||||
let sum = 0;
|
||||
for (const [d, v] of Object.entries(days)) {
|
||||
if (Number(d) <= throughDay) sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `selectedMonth` is still in progress, and the day both sides of a
|
||||
* comparison must be truncated to.
|
||||
*
|
||||
* For a complete month the day is its last, which makes `spendThrough` a no-op
|
||||
* — so callers can use the same code path either way.
|
||||
*/
|
||||
export function comparisonBasis(
|
||||
selectedMonth: string,
|
||||
now: Date = new Date()
|
||||
): { isPartial: boolean; compareDay: number } {
|
||||
return {
|
||||
isPartial: selectedMonth === currentMonthStr(now),
|
||||
compareDay: elapsedDays(selectedMonth, now),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The months that may form a baseline for `selectedMonth`.
|
||||
*
|
||||
* Excludes the selected month itself, the month in progress (rule 1), and any
|
||||
* month with no spend recorded — an empty month is absence of data, not a
|
||||
* thrifty month, and averaging zeroes in understates the baseline.
|
||||
*/
|
||||
export function baselineMonths(
|
||||
months: readonly string[],
|
||||
selectedMonth: string,
|
||||
totals: Record<string, { spent: number }>,
|
||||
now: Date = new Date()
|
||||
): string[] {
|
||||
const current = currentMonthStr(now);
|
||||
return months.filter(
|
||||
(m) => m !== selectedMonth && m !== current && (totals[m]?.spent || 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean spend across `baseline`, truncated to `compareDay` when the selected
|
||||
* month is partial.
|
||||
*
|
||||
* `daily` is the sparse `{ [month]: { [day]: amount } }` map from
|
||||
* `/api/analytics/daily`; `totals` supplies whole-month figures for the
|
||||
* complete-month case.
|
||||
*/
|
||||
export function baselineAverage(
|
||||
baseline: readonly string[],
|
||||
{
|
||||
isPartial,
|
||||
compareDay,
|
||||
daily,
|
||||
totals,
|
||||
}: {
|
||||
isPartial: boolean;
|
||||
compareDay: number;
|
||||
daily: Record<string, Record<number, number>> | undefined;
|
||||
totals: Record<string, { spent: number }>;
|
||||
}
|
||||
): number {
|
||||
if (!baseline.length) return 0;
|
||||
const sum = baseline.reduce(
|
||||
(s, m) => s + (isPartial ? spendThrough(daily?.[m], compareDay) : totals[m]?.spent || 0),
|
||||
0
|
||||
);
|
||||
return sum / baseline.length;
|
||||
}
|
||||
Reference in New Issue
Block a user