77 lines
3.1 KiB
JavaScript
77 lines
3.1 KiB
JavaScript
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 });
|
|
});
|
|
});
|