321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
AccountInfo,
|
|
computeDeltaSeries,
|
|
computeMoMPercent,
|
|
computeNetWorthSeries,
|
|
formatPercentValue,
|
|
getAccountSign,
|
|
isLiabilityType,
|
|
MonthDataPoint,
|
|
} from './chart-calculations';
|
|
|
|
describe('isLiabilityType', () => {
|
|
it('returns true for credit_card', () => {
|
|
expect(isLiabilityType('credit_card')).toBe(true);
|
|
});
|
|
|
|
it('returns true for loan', () => {
|
|
expect(isLiabilityType('loan')).toBe(true);
|
|
});
|
|
|
|
it('returns false for checking', () => {
|
|
expect(isLiabilityType('checking')).toBe(false);
|
|
});
|
|
|
|
it('returns false for savings', () => {
|
|
expect(isLiabilityType('savings')).toBe(false);
|
|
});
|
|
|
|
it('returns false for investment', () => {
|
|
expect(isLiabilityType('investment')).toBe(false);
|
|
});
|
|
|
|
it('returns false for retirement', () => {
|
|
expect(isLiabilityType('retirement')).toBe(false);
|
|
});
|
|
|
|
it('returns false for others', () => {
|
|
expect(isLiabilityType('others')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getAccountSign', () => {
|
|
it('returns -1 for liabilities', () => {
|
|
expect(getAccountSign('credit_card')).toBe(-1);
|
|
expect(getAccountSign('loan')).toBe(-1);
|
|
});
|
|
|
|
it('returns 1 for assets', () => {
|
|
expect(getAccountSign('checking')).toBe(1);
|
|
expect(getAccountSign('savings')).toBe(1);
|
|
expect(getAccountSign('investment')).toBe(1);
|
|
expect(getAccountSign('retirement')).toBe(1);
|
|
expect(getAccountSign('others')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('computeNetWorthSeries', () => {
|
|
const createAccounts = (
|
|
types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>,
|
|
): Record<string, AccountInfo> => {
|
|
const accounts: Record<string, AccountInfo> = {};
|
|
for (const [id, type] of Object.entries(types)) {
|
|
accounts[id] = { id, type, currency_code: 'EUR' };
|
|
}
|
|
return accounts;
|
|
};
|
|
|
|
it('returns empty array for empty data', () => {
|
|
const result = computeNetWorthSeries([], {});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('sums assets correctly', () => {
|
|
const data = [
|
|
{ month: '2025-01', acc1: 10000, acc2: 20000 },
|
|
{ month: '2025-02', acc1: 15000, acc2: 25000 },
|
|
];
|
|
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({ month: '2025-01', value: 30000 });
|
|
expect(result[1]).toEqual({ month: '2025-02', value: 40000 });
|
|
});
|
|
|
|
it('subtracts liabilities from net worth', () => {
|
|
const data = [
|
|
{ month: '2025-01', checking: 50000, credit_card: 10000 },
|
|
];
|
|
const accounts = createAccounts({
|
|
checking: 'checking',
|
|
credit_card: 'credit_card',
|
|
});
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
expect(result[0].value).toBe(40000); // 50000 - 10000
|
|
});
|
|
|
|
it('handles mixed assets and liabilities', () => {
|
|
const data = [
|
|
{
|
|
month: '2025-01',
|
|
savings: 100000,
|
|
checking: 20000,
|
|
credit_card: 5000,
|
|
loan: 30000,
|
|
},
|
|
];
|
|
const accounts = createAccounts({
|
|
savings: 'savings',
|
|
checking: 'checking',
|
|
credit_card: 'credit_card',
|
|
loan: 'loan',
|
|
});
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
// Net worth = 100000 + 20000 - 5000 - 30000 = 85000
|
|
expect(result[0].value).toBe(85000);
|
|
});
|
|
|
|
it('handles negative net worth (more liabilities than assets)', () => {
|
|
const data = [{ month: '2025-01', checking: 10000, loan: 50000 }];
|
|
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
expect(result[0].value).toBe(-40000); // 10000 - 50000
|
|
});
|
|
|
|
it('preserves timestamp if present', () => {
|
|
const data = [{ month: '2025-01', timestamp: 1704067200, acc1: 10000 }];
|
|
const accounts = createAccounts({ acc1: 'checking' });
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
expect(result[0].timestamp).toBe(1704067200);
|
|
});
|
|
|
|
it('skips non-numeric balance values', () => {
|
|
const data = [{ month: '2025-01', acc1: 10000, acc2: 'invalid' }];
|
|
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
|
|
|
|
const result = computeNetWorthSeries(data, accounts);
|
|
|
|
expect(result[0].value).toBe(10000);
|
|
});
|
|
});
|
|
|
|
describe('computeDeltaSeries', () => {
|
|
it('returns null change for first month', () => {
|
|
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
|
|
|
|
const result = computeDeltaSeries(series);
|
|
|
|
expect(result[0].change).toBeNull();
|
|
});
|
|
|
|
it('computes correct positive delta', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: 10000 },
|
|
{ month: '2025-02', value: 15000 },
|
|
];
|
|
|
|
const result = computeDeltaSeries(series);
|
|
|
|
expect(result[1].change).toBe(5000);
|
|
});
|
|
|
|
it('computes correct negative delta', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: 15000 },
|
|
{ month: '2025-02', value: 10000 },
|
|
];
|
|
|
|
const result = computeDeltaSeries(series);
|
|
|
|
expect(result[1].change).toBe(-5000);
|
|
});
|
|
});
|
|
|
|
describe('computeMoMPercent', () => {
|
|
it('returns null for first month', () => {
|
|
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
|
|
|
|
const result = computeMoMPercent(series);
|
|
|
|
expect(result[0].percent).toBeNull();
|
|
});
|
|
|
|
it('computes correct positive percentage', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: 10000 },
|
|
{ month: '2025-02', value: 11000 },
|
|
];
|
|
|
|
const result = computeMoMPercent(series);
|
|
|
|
expect(result[1].percent).toBe(10); // +10%
|
|
});
|
|
|
|
it('computes correct negative percentage', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: 10000 },
|
|
{ month: '2025-02', value: 9000 },
|
|
];
|
|
|
|
const result = computeMoMPercent(series);
|
|
|
|
expect(result[1].percent).toBe(-10); // -10%
|
|
});
|
|
|
|
it('returns null when previous value is zero (division by zero guard)', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: 0 },
|
|
{ month: '2025-02', value: 10000 },
|
|
];
|
|
|
|
const result = computeMoMPercent(series);
|
|
|
|
expect(result[1].percent).toBeNull();
|
|
});
|
|
|
|
it('handles negative to positive transition correctly', () => {
|
|
const series: MonthDataPoint[] = [
|
|
{ month: '2025-01', value: -10000 },
|
|
{ month: '2025-02', value: 5000 },
|
|
];
|
|
|
|
const result = computeMoMPercent(series);
|
|
|
|
// Change of 15000 relative to absolute value of -10000 = 150%
|
|
expect(result[1].percent).toBe(150);
|
|
});
|
|
});
|
|
|
|
describe('edge cases for slice(1) in useChartViews hook', () => {
|
|
// The hook slices off the first month (needed for MoM calculation baseline)
|
|
// These tests document expected behavior for edge cases
|
|
|
|
it('single month data results in empty series after slice', () => {
|
|
const data = [{ month: '2025-01', acc1: 10000 }];
|
|
const accounts: Record<string, AccountInfo> = {
|
|
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
|
|
};
|
|
|
|
const netWorth = computeNetWorthSeries(data, accounts);
|
|
const delta = computeDeltaSeries(netWorth);
|
|
const momPercent = computeMoMPercent(netWorth);
|
|
|
|
// All series have 1 element before slicing
|
|
expect(netWorth).toHaveLength(1);
|
|
expect(delta).toHaveLength(1);
|
|
expect(momPercent).toHaveLength(1);
|
|
|
|
// After slice(1), all become empty
|
|
expect(netWorth.slice(1)).toHaveLength(0);
|
|
expect(delta.slice(1)).toHaveLength(0);
|
|
expect(momPercent.slice(1)).toHaveLength(0);
|
|
});
|
|
|
|
it('two months data results in single data point after slice', () => {
|
|
const data = [
|
|
{ month: '2025-01', acc1: 10000 },
|
|
{ month: '2025-02', acc1: 15000 },
|
|
];
|
|
const accounts: Record<string, AccountInfo> = {
|
|
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
|
|
};
|
|
|
|
const netWorth = computeNetWorthSeries(data, accounts);
|
|
const delta = computeDeltaSeries(netWorth);
|
|
const momPercent = computeMoMPercent(netWorth);
|
|
|
|
// After slice(1), we have 1 displayable data point
|
|
expect(netWorth.slice(1)).toHaveLength(1);
|
|
expect(delta.slice(1)).toHaveLength(1);
|
|
expect(momPercent.slice(1)).toHaveLength(1);
|
|
|
|
// And the values are computed correctly
|
|
expect(netWorth.slice(1)[0].value).toBe(15000);
|
|
expect(delta.slice(1)[0].change).toBe(5000);
|
|
expect(momPercent.slice(1)[0].percent).toBe(50);
|
|
});
|
|
|
|
it('empty data results in empty series', () => {
|
|
const netWorth = computeNetWorthSeries([], {});
|
|
const delta = computeDeltaSeries(netWorth);
|
|
const momPercent = computeMoMPercent(netWorth);
|
|
|
|
expect(netWorth.slice(1)).toHaveLength(0);
|
|
expect(delta.slice(1)).toHaveLength(0);
|
|
expect(momPercent.slice(1)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('formatPercentValue', () => {
|
|
it('returns dash for null values', () => {
|
|
expect(formatPercentValue(null)).toBe('—');
|
|
});
|
|
|
|
it('adds plus sign for positive values', () => {
|
|
expect(formatPercentValue(10.5)).toBe('+10.5%');
|
|
});
|
|
|
|
it('does not add sign for negative values', () => {
|
|
expect(formatPercentValue(-5.3)).toBe('-5.3%');
|
|
});
|
|
|
|
it('formats zero without sign', () => {
|
|
expect(formatPercentValue(0)).toBe('0.0%');
|
|
});
|
|
|
|
it('respects custom decimal places', () => {
|
|
expect(formatPercentValue(10.567, { decimals: 2 })).toBe('+10.57%');
|
|
});
|
|
});
|