109 lines
4.5 KiB
JavaScript
109 lines
4.5 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']);
|
||
|
|
});
|
||
|
|
});
|