import { describe, it, expect } from 'vitest'; import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from './cashflowUtils'; const TIMELINE = [ { date: '2026-06-10', balance: 500, bills: [] }, { date: '2026-06-12', balance: 300, bills: [{ id: 1, name: 'Rent', amount: 200 }] }, { date: '2026-06-15', balance: 300, bills: [], payday: true }, ]; describe('buildTimelineGeometry', () => { it('returns null for empty or single-point timelines', () => { expect(buildTimelineGeometry([], 400, 96)).toBeNull(); expect(buildTimelineGeometry([TIMELINE[0]], 400, 96)).toBeNull(); expect(buildTimelineGeometry(null, 400, 96)).toBeNull(); }); it('positions points by actual day spacing, not even spacing', () => { const geo = buildTimelineGeometry(TIMELINE, 400, 96, 0); // 2026-06-12 is 2/5ths of the way from 06-10 to 06-15 expect(geo.points[1].x).toBeCloseTo(400 * (2 / 5), 5); expect(geo.points[0].x).toBe(0); expect(geo.points[2].x).toBe(400); }); it('maps balances down the Y axis and includes zero in the domain', () => { const geo = buildTimelineGeometry(TIMELINE, 400, 100, 0); // domain is [0, 500]: start sits at top, zero at bottom expect(geo.points[0].y).toBe(0); expect(geo.zeroY).toBe(100); expect(geo.points[1].y).toBeCloseTo(100 * (1 - 300 / 500), 5); }); it('handles a projection that goes negative', () => { const geo = buildTimelineGeometry([ { date: '2026-06-10', balance: 100, bills: [] }, { date: '2026-06-12', balance: -50, bills: [{ id: 1, name: 'Big', amount: 150 }] }, { date: '2026-06-15', balance: -50, bills: [], payday: true }, ], 400, 100, 0); // zero line sits inside the chart, not on an edge expect(geo.zeroY).toBeGreaterThan(0); expect(geo.zeroY).toBeLessThan(100); // negative balance plots below the zero line expect(geo.points[1].y).toBeGreaterThan(geo.zeroY); }); it('builds a step path (H then V segments) and a closed area', () => { const geo = buildTimelineGeometry(TIMELINE, 400, 96, 0); expect(geo.line).toMatch(/^M [\d.]+ [\d.]+( H [\d.]+ V [\d.-]+)+$/); expect(geo.area.endsWith('Z')).toBe(true); expect(geo.points[1].isDrop).toBe(true); expect(geo.points[2].isPayday).toBe(true); }); }); describe('daysUntilLabel / shortDate / splitUpcoming', () => { it('labels day counts naturally', () => { expect(daysUntilLabel(0)).toBe('today'); expect(daysUntilLabel(1)).toBe('tomorrow'); expect(daysUntilLabel(5)).toBe('5 days'); expect(daysUntilLabel(undefined)).toBe('today'); }); it('formats dates without Date() timezone traps', () => { expect(shortDate('2026-07-01')).toBe('Jul 1'); expect(shortDate('2026-12-15')).toBe('Dec 15'); expect(shortDate('')).toBe(''); expect(shortDate(null)).toBe(''); }); it('splits upcoming into visible + overflow', () => { const six = Array.from({ length: 6 }, (_, i) => ({ id: i })); expect(splitUpcoming(six, 4)).toEqual({ visible: six.slice(0, 4), overflow: 2 }); expect(splitUpcoming([], 4)).toEqual({ visible: [], overflow: 0 }); expect(splitUpcoming(null, 4)).toEqual({ visible: [], overflow: 0 }); }); });