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