147 lines
4.7 KiB
JavaScript
147 lines
4.7 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
rowThreshold,
|
|
rowEffectiveStatus,
|
|
rowIsPaid,
|
|
rowIsDebt,
|
|
sortTrackerRows,
|
|
moveInArray,
|
|
paymentSummary,
|
|
amountSearchText,
|
|
normalizeTrackerSortKey,
|
|
normalizeTrackerSortDir,
|
|
TRACKER_SORT_DEFAULT,
|
|
TRACKER_SORT_ASC,
|
|
TRACKER_SORT_DESC,
|
|
} from './trackerUtils';
|
|
|
|
const row = (overrides = {}) => ({
|
|
id: 1,
|
|
name: 'Bill',
|
|
due_date: '2026-06-12',
|
|
due_day: 12,
|
|
bucket: '1st',
|
|
status: 'upcoming',
|
|
expected_amount: 100,
|
|
actual_amount: null,
|
|
total_paid: 0,
|
|
is_skipped: false,
|
|
...overrides,
|
|
});
|
|
|
|
describe('rowThreshold / rowEffectiveStatus / rowIsPaid', () => {
|
|
it('uses the monthly override over the bill default', () => {
|
|
expect(rowThreshold(row())).toBe(100);
|
|
expect(rowThreshold(row({ actual_amount: 80 }))).toBe(80);
|
|
expect(rowThreshold(row({ actual_amount: 0 }))).toBe(0); // 0 is a real override
|
|
});
|
|
|
|
it('promotes to paid when payments meet the threshold', () => {
|
|
expect(rowEffectiveStatus(row({ total_paid: 100, status: 'upcoming' }))).toBe('paid');
|
|
expect(rowEffectiveStatus(row({ total_paid: 99.99, status: 'upcoming' }))).toBe('upcoming');
|
|
// the override lowers the bar
|
|
expect(rowEffectiveStatus(row({ total_paid: 80, actual_amount: 80, status: 'late' }))).toBe(
|
|
'paid',
|
|
);
|
|
});
|
|
|
|
it('skipped always wins; autodraft with a pending suggestion is not "paid"', () => {
|
|
expect(rowEffectiveStatus(row({ is_skipped: true, total_paid: 500 }))).toBe('skipped');
|
|
expect(rowIsPaid(row({ status: 'autodraft' }))).toBe(true);
|
|
expect(rowIsPaid(row({ status: 'autodraft', autopay_suggestion: { id: 9 } }))).toBe(false);
|
|
});
|
|
|
|
it('detects debt rows by balance, minimum payment, or category', () => {
|
|
expect(rowIsDebt(row({ current_balance: 1200 }))).toBe(true);
|
|
expect(rowIsDebt(row({ minimum_payment: 35 }))).toBe(true);
|
|
expect(rowIsDebt(row({ category_name: 'Credit Cards' }))).toBe(true);
|
|
expect(rowIsDebt(row({ category_name: 'Utilities' }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sortTrackerRows', () => {
|
|
const rows = [
|
|
row({ id: 1, name: 'Zeta', due_date: '2026-06-20', expected_amount: 50, status: 'paid' }),
|
|
row({ id: 2, name: 'Alpha', due_date: '2026-06-05', expected_amount: 200, status: 'missed' }),
|
|
row({ id: 3, name: 'Mid', due_date: '2026-06-12', expected_amount: 100, status: 'upcoming' }),
|
|
];
|
|
|
|
it('manual (default) sort leaves order untouched', () => {
|
|
expect(sortTrackerRows(rows, TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC)).toEqual(rows);
|
|
});
|
|
|
|
it('sorts by name, both directions', () => {
|
|
expect(sortTrackerRows(rows, 'name', TRACKER_SORT_ASC).map((r) => r.name)).toEqual([
|
|
'Alpha',
|
|
'Mid',
|
|
'Zeta',
|
|
]);
|
|
expect(sortTrackerRows(rows, 'name', TRACKER_SORT_DESC).map((r) => r.name)).toEqual([
|
|
'Zeta',
|
|
'Mid',
|
|
'Alpha',
|
|
]);
|
|
});
|
|
|
|
it('does not mutate the input array', () => {
|
|
const copy = [...rows];
|
|
sortTrackerRows(rows, 'name', TRACKER_SORT_ASC);
|
|
expect(rows).toEqual(copy);
|
|
});
|
|
|
|
it('normalizes unknown keys/dirs to safe defaults', () => {
|
|
expect(normalizeTrackerSortKey('nope')).toBe(TRACKER_SORT_DEFAULT);
|
|
expect(normalizeTrackerSortDir('sideways')).toBe(TRACKER_SORT_ASC);
|
|
});
|
|
});
|
|
|
|
describe('paymentSummary (payment form math)', () => {
|
|
it('computes partial payment state', () => {
|
|
const s = paymentSummary(row({ total_paid: 40 }), 100);
|
|
expect(s).toMatchObject({
|
|
target: 100,
|
|
paid: 40,
|
|
paidTowardDue: 40,
|
|
overpaid: 0,
|
|
remaining: 60,
|
|
percent: 40,
|
|
partial: true,
|
|
});
|
|
});
|
|
|
|
it('caps percent at 100 and tracks overpayment', () => {
|
|
const s = paymentSummary(row({ total_paid: 130 }), 100);
|
|
expect(s.percent).toBe(100);
|
|
expect(s.overpaid).toBe(30);
|
|
expect(s.remaining).toBe(0);
|
|
expect(s.partial).toBe(false);
|
|
});
|
|
|
|
it('handles a zero target without dividing by zero', () => {
|
|
const s = paymentSummary(row({ total_paid: 10 }), 0);
|
|
expect(s.percent).toBe(0);
|
|
expect(s.remaining).toBe(0);
|
|
});
|
|
|
|
it('prefers server-computed paid_toward_due/overpaid_amount when present', () => {
|
|
const s = paymentSummary(
|
|
row({ total_paid: 130, paid_toward_due: 100, overpaid_amount: 30 }),
|
|
100,
|
|
);
|
|
expect(s.paidTowardDue).toBe(100);
|
|
expect(s.overpaid).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('amountSearchText / moveInArray', () => {
|
|
it('indexes amounts in plain, fixed, and $ forms', () => {
|
|
expect(amountSearchText(12.5)).toBe('12.5 12.50 $12.50');
|
|
expect(amountSearchText(null, undefined, 'abc')).toBe('');
|
|
});
|
|
|
|
it('moves items preserving the rest of the order', () => {
|
|
expect(moveInArray(['a', 'b', 'c', 'd'], 0, 2)).toEqual(['b', 'c', 'a', 'd']);
|
|
expect(moveInArray(['a', 'b'], 1, 0)).toEqual(['b', 'a']);
|
|
});
|
|
});
|