ten99timecard/src/__tests__/stats.test.ts

256 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});