import { describe, it, expect } from 'vitest'; import { aggregate, buildHierarchy, buildChartSeries } from '@/lib/stats/aggregate'; import { workEntryValue } from '@/types'; import type { WorkEntry, Payment, Expense } from '@/types'; const mkWork = (date: string, amount?: number, hours?: number, rate?: number): WorkEntry => ({ id: `w-${date}-${amount ?? hours}`, date, description: 'work', amount, hours, rate, createdAt: 0, updatedAt: 0, }); const mkPayment = (date: string, amount: number): Payment => ({ id: `p-${date}-${amount}`, date, amount, payer: 'X', createdAt: 0, updatedAt: 0, }); const mkExpense = (date: string, amount: number, deductible = true): Expense => ({ id: `e-${date}-${amount}`, date, amount, description: 'exp', deductible, createdAt: 0, updatedAt: 0, }); describe('workEntryValue', () => { it('returns amount when set', () => { expect(workEntryValue(mkWork('2024-01-01', 150))).toBe(150); }); it('returns hours * rate when amount not set', () => { expect(workEntryValue(mkWork('2024-01-01', undefined, 4, 50))).toBe(200); }); it('amount takes precedence over hours/rate', () => { const e = mkWork('2024-01-01', 100, 10, 50); expect(workEntryValue(e)).toBe(100); }); it('returns 0 when neither set', () => { expect(workEntryValue(mkWork('2024-01-01'))).toBe(0); }); }); describe('aggregate', () => { it('rolls up days → months → years', () => { const agg = aggregate( [], [mkPayment('2024-01-15', 1000), mkPayment('2024-01-20', 500), mkPayment('2024-02-10', 300)], [], ); expect(agg.days.get('2024-01-15')?.payments).toBe(1000); expect(agg.months.get('2024-01')?.payments).toBe(1500); expect(agg.months.get('2024-02')?.payments).toBe(300); expect(agg.years.find((y) => y.label === '2024')?.payments).toBe(1800); }); it('computes net = payments − expenses', () => { const agg = aggregate( [], [mkPayment('2024-03-01', 5000)], [mkExpense('2024-03-01', 1200)], ); expect(agg.days.get('2024-03-01')?.net).toBe(3800); expect(agg.months.get('2024-03')?.net).toBe(3800); }); it('separates deductible from total expenses', () => { const agg = aggregate( [], [], [mkExpense('2024-03-01', 100, true), mkExpense('2024-03-01', 50, false)], ); const d = agg.days.get('2024-03-01')!; expect(d.expenses).toBe(150); expect(d.deductibleExpenses).toBe(100); }); it('computes monthly average per active day', () => { const agg = aggregate( [], [mkPayment('2024-03-01', 100), mkPayment('2024-03-10', 200)], [], ); // 2 active days, 300 total → 150 avg expect(agg.months.get('2024-03')?.avgPerChild).toBe(150); expect(agg.months.get('2024-03')?.childCount).toBe(2); }); it('computes yearly average per active month', () => { const agg = aggregate( [], [mkPayment('2024-01-05', 1000), mkPayment('2024-03-05', 2000)], [], ); const y = agg.years.find((y) => y.label === '2024')!; // 2 months active, 3000 net → 1500 avg/month expect(y.childCount).toBe(2); expect(y.avgPerChild).toBe(1500); }); it('projects year-end from YTD (halfway through year)', () => { const agg = aggregate( [], [mkPayment('2024-01-01', 50000)], [], new Date('2024-07-01'), // ~halfway ); const y = agg.years.find((y) => y.label === '2024')!; expect(y.projected).toBeGreaterThan(90000); expect(y.projected).toBeLessThan(110000); }); it('does not project completed years', () => { const agg = aggregate([], [mkPayment('2023-06-01', 1000)], [], new Date('2024-06-01')); const y = agg.years.find((y) => y.label === '2023')!; expect(y.projected).toBeNull(); }); it('projects month-end from current day', () => { const agg = aggregate( [], [mkPayment('2024-06-10', 1000)], [], new Date('2024-06-15'), // halfway through June ); const m = agg.months.get('2024-06')!; expect(m.projected).toBeGreaterThan(1500); expect(m.projected).toBeLessThan(2500); }); it('sorts years descending', () => { const agg = aggregate( [], [mkPayment('2022-01-01', 1), mkPayment('2024-01-01', 1), mkPayment('2023-01-01', 1)], [], ); expect(agg.years.map((y) => y.label)).toEqual(['2024', '2023', '2022']); }); }); describe('buildHierarchy', () => { it('builds year→month→day→item tree', () => { const work = [ mkWork('2024-03-15', 100), mkWork('2024-03-15', 50), mkWork('2024-04-01', 200), ]; const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), (e) => (e as WorkEntry).description); expect(tree).toHaveLength(1); // one year expect(tree[0].level).toBe('year'); expect(tree[0].value).toBe(350); expect(tree[0].children).toHaveLength(2); // two months const march = tree[0].children.find((m) => m.key === '2024-03')!; expect(march.value).toBe(150); expect(march.children).toHaveLength(1); // one day expect(march.children[0].children).toHaveLength(2); // two items }); it('attaches entry to item leaves', () => { const work = [mkWork('2024-01-01', 100)]; const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => 'label'); const item = tree[0].children[0].children[0].children[0]; expect(item.level).toBe('item'); expect(item.entry).toBe(work[0]); }); it('sorts years, months, days descending (newest first)', () => { const work = [mkWork('2023-01-01', 1), mkWork('2024-01-01', 1)]; const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => ''); expect(tree[0].key).toBe('2024'); expect(tree[1].key).toBe('2023'); }); }); describe('buildChartSeries', () => { it('produces points at requested granularity', () => { const series = buildChartSeries( [], [mkPayment('2024-01-15', 100), mkPayment('2024-02-10', 200)], [], ['payments'], 'month', null, null, ); expect(series).toHaveLength(2); expect(series[0].payments).toBe(100); expect(series[1].payments).toBe(200); }); it('filters by date range', () => { const series = buildChartSeries( [], [mkPayment('2024-01-01', 1), mkPayment('2024-06-01', 1), mkPayment('2024-12-01', 1)], [], ['payments'], 'month', '2024-03', '2024-09', ); expect(series).toHaveLength(1); expect(series[0].label).toBe('2024-06'); }); it('computes cumulative metrics', () => { const series = buildChartSeries( [], [mkPayment('2024-01-01', 100), mkPayment('2024-02-01', 200), mkPayment('2024-03-01', 300)], [], ['cumulativePayments'], 'month', null, null, ); expect(series[0].cumulativePayments).toBe(100); expect(series[1].cumulativePayments).toBe(300); expect(series[2].cumulativePayments).toBe(600); }); it('sorts ascending for time-series display', () => { const series = buildChartSeries( [], [mkPayment('2024-03-01', 1), mkPayment('2024-01-01', 1)], [], ['payments'], 'month', null, null, ); expect(series[0].label < series[1].label).toBe(true); }); it('groups by week when requested', () => { const series = buildChartSeries( [], // Monday and Tuesday of same week [mkPayment('2024-06-03', 100), mkPayment('2024-06-04', 50)], [], ['payments'], 'week', null, null, ); expect(series).toHaveLength(1); expect(series[0].payments).toBe(150); }); it('computes netIncome metric', () => { const series = buildChartSeries( [], [mkPayment('2024-01-01', 1000)], [mkExpense('2024-01-01', 300)], ['netIncome'], 'month', null, null, ); expect(series[0].netIncome).toBe(700); }); });