256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
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);
|
||
});
|
||
});
|