|
| 1 | +import { describe, expect, test } from "bun:test"; |
| 2 | +import { calculateTrend, formatChange, formatDelta, type Observation } from "./GenerateAnalysis"; |
| 3 | + |
| 4 | +// All fixtures use fixed dates so the tests never depend on the wall clock. |
| 5 | + |
| 6 | +/** Observations on the 1st of every `stepMonths` months from `from` to `to` (inclusive), skipping `skip` dates. */ |
| 7 | +function periodic(from: string, to: string, valueAt: (i: number) => number, skip: string[] = [], stepMonths = 1): Observation[] { |
| 8 | + const out: Observation[] = []; |
| 9 | + let [y, m] = from.split("-").map(Number); |
| 10 | + const [ty, tm] = to.split("-").map(Number); |
| 11 | + for (let i = 0; y < ty || (y === ty && m <= tm); i++) { |
| 12 | + const date = `${y}-${String(m).padStart(2, "0")}-01`; |
| 13 | + if (!skip.includes(date)) out.push({ date, value: valueAt(i) }); |
| 14 | + m += stepMonths; |
| 15 | + while (m > 12) { m -= 12; y++; } |
| 16 | + } |
| 17 | + return out; |
| 18 | +} |
| 19 | + |
| 20 | +/** Business-day observations (Mon-Fri) from `from` to `to`, inclusive. */ |
| 21 | +function businessDaily(from: string, to: string, valueAt: (i: number) => number): Observation[] { |
| 22 | + const out: Observation[] = []; |
| 23 | + for (let d = new Date(from), i = 0; d <= new Date(to); d.setUTCDate(d.getUTCDate() + 1), i++) { |
| 24 | + const dow = d.getUTCDay(); |
| 25 | + if (dow !== 0 && dow !== 6) out.push({ date: d.toISOString().slice(0, 10), value: valueAt(i) }); |
| 26 | + } |
| 27 | + return out; |
| 28 | +} |
| 29 | + |
| 30 | +describe("calculateTrend anchors the window to the latest observation", () => { |
| 31 | + // Values rise 1.0 per observation, so the change equals the number of periods spanned. |
| 32 | + const monthly = periodic("2015-08", "2026-08", i => 100 + i); |
| 33 | + |
| 34 | + test("monthly series ending Aug 2026: 1Y start is Aug 2025 regardless of today", () => { |
| 35 | + const t = calculateTrend(monthly, 1)!; |
| 36 | + expect(t.endDate).toBe("2026-08-01"); |
| 37 | + expect(t.startDate).toBe("2025-08-01"); |
| 38 | + expect(t.change).toBe(12); |
| 39 | + }); |
| 40 | + |
| 41 | + test("10Y window lands exactly ten years before the latest observation", () => { |
| 42 | + const t = calculateTrend(monthly, 10)!; |
| 43 | + expect(t.startDate).toBe("2016-08-01"); |
| 44 | + expect(t.change).toBe(120); |
| 45 | + }); |
| 46 | + |
| 47 | + test("quarterly series ending Q2 2026: 1Y start is Q2 2025", () => { |
| 48 | + const quarterly = periodic("2016-01", "2026-04", i => i, [], 3); |
| 49 | + const t = calculateTrend(quarterly, 1)!; |
| 50 | + expect(t.endDate).toBe("2026-04-01"); |
| 51 | + expect(t.startDate).toBe("2025-04-01"); |
| 52 | + expect(t.change).toBe(4); |
| 53 | + }); |
| 54 | + |
| 55 | + test("missing observations next to the target do not shift the start", () => { |
| 56 | + // FRED returns "." for some periods; the caller drops them before windowing. |
| 57 | + const t = calculateTrend(periodic("2015-08", "2026-08", i => 100 + i, ["2025-07-01", "2025-09-01", "2025-10-01"]), 1)!; |
| 58 | + expect(t.startDate).toBe("2025-08-01"); |
| 59 | + expect(t.change).toBe(12); |
| 60 | + }); |
| 61 | + |
| 62 | + test("a missing target month returns null rather than a mislabelled 13-month window", () => { |
| 63 | + expect(calculateTrend(periodic("2015-08", "2026-08", i => 100 + i, ["2025-08-01"]), 1)).toBeNull(); |
| 64 | + }); |
| 65 | + |
| 66 | + test("quarterly series with a hole at the target returns null", () => { |
| 67 | + const quarterly = periodic("2016-01", "2026-04", i => i, ["2025-04-01"], 3); |
| 68 | + expect(calculateTrend(quarterly, 1)).toBeNull(); |
| 69 | + }); |
| 70 | + |
| 71 | + test("annual series: 1Y start is the prior year's observation", () => { |
| 72 | + const annual = periodic("2014-01", "2024-01", i => 40 + i, [], 12); |
| 73 | + const t = calculateTrend(annual, 1)!; |
| 74 | + expect(t.startDate).toBe("2023-01-01"); |
| 75 | + expect(t.change).toBe(1); |
| 76 | + }); |
| 77 | + |
| 78 | + test("sparse annual series with no point near the target returns null", () => { |
| 79 | + const obs: Observation[] = [ |
| 80 | + { date: "2020-01-01", value: 40 }, |
| 81 | + { date: "2024-01-01", value: 42 }, |
| 82 | + ]; |
| 83 | + expect(calculateTrend(obs, 1)).toBeNull(); // target 2023-01-01, nearest is 3 years away |
| 84 | + }); |
| 85 | + |
| 86 | + test("daily series whose target falls on a weekend uses the nearest business day", () => { |
| 87 | + const daily = businessDaily("2025-06-02", "2026-08-31", i => i); // 2026-08-31 is a Monday |
| 88 | + const t = calculateTrend(daily, 1)!; |
| 89 | + expect(t.endDate).toBe("2026-08-31"); |
| 90 | + expect(t.startDate).toBe("2025-09-01"); // 2025-08-31 is a Sunday; Monday (1 day) beats Friday (2 days) |
| 91 | + }); |
| 92 | + |
| 93 | + test("fewer than two observations returns null", () => { |
| 94 | + expect(calculateTrend([{ date: "2026-08-01", value: 1 }], 1)).toBeNull(); |
| 95 | + expect(calculateTrend([], 1)).toBeNull(); |
| 96 | + }); |
| 97 | +}); |
| 98 | + |
| 99 | +describe("rate series are reported in percentage points", () => { |
| 100 | + test("change is in pp and direction uses a 0.1pp dead band", () => { |
| 101 | + const rate = periodic("2024-08", "2026-08", i => 4.5 - i * 0.05); // falls 0.05pp per month |
| 102 | + const t = calculateTrend(rate, 1, true)!; |
| 103 | + expect(t.unit).toBe("pp"); |
| 104 | + expect(t.delta).toBeCloseTo(-0.6, 6); |
| 105 | + expect(t.direction).toBe("↓"); |
| 106 | + expect(formatChange(t)).toBe("-0.60pp"); |
| 107 | + }); |
| 108 | + |
| 109 | + test("a flat rate series is '→' even though its percent change is not tiny", () => { |
| 110 | + const flat = periodic("2024-08", "2026-08", i => 0.5 + (i % 2) * 0.05); // wobbles 0.05pp around 0.5% |
| 111 | + expect(calculateTrend(flat, 1, true)!.direction).toBe("→"); |
| 112 | + }); |
| 113 | + |
| 114 | + test("level series report percent change", () => { |
| 115 | + const t = calculateTrend(periodic("2024-08", "2026-08", i => 300 + i), 1)!; |
| 116 | + expect(t.unit).toBe("%"); |
| 117 | + expect(t.startDate).toBe("2025-08-01"); |
| 118 | + expect(formatChange(t)).toBe("+3.8%"); // 312 -> 324 = +3.846% |
| 119 | + }); |
| 120 | + |
| 121 | + test("formatChange and formatDelta handle missing or non-finite values", () => { |
| 122 | + expect(formatChange(null)).toBe("—"); |
| 123 | + expect(formatDelta(Infinity, "%")).toBe("—"); |
| 124 | + expect(formatDelta(0.734, "pp")).toBe("+0.73pp"); |
| 125 | + }); |
| 126 | +}); |
0 commit comments