import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import PaycheckView, { ordinal, formatCurrency, formatPayDate } from '../pages/PaycheckView.jsx'; // ─── Pure function tests ─────────────────────────────────────────────────────── describe('ordinal', () => { it('formats 1st, 2nd, 3rd', () => { expect(ordinal(1)).toBe('1st'); expect(ordinal(2)).toBe('2nd'); expect(ordinal(3)).toBe('3rd'); expect(ordinal(4)).toBe('4th'); expect(ordinal(11)).toBe('11th'); expect(ordinal(12)).toBe('12th'); expect(ordinal(13)).toBe('13th'); expect(ordinal(21)).toBe('21st'); expect(ordinal(22)).toBe('22nd'); expect(ordinal(31)).toBe('31st'); }); }); describe('formatCurrency', () => { it('formats numbers as USD', () => { expect(formatCurrency(1500)).toBe('$1,500.00'); expect(formatCurrency(0)).toBe('$0.00'); expect(formatCurrency(99.9)).toBe('$99.90'); expect(formatCurrency('150.5')).toBe('$150.50'); expect(formatCurrency(null)).toBe('$0.00'); expect(formatCurrency(undefined)).toBe('$0.00'); }); }); describe('formatPayDate', () => { it('formats YYYY-MM-DD to readable date', () => { expect(formatPayDate('2026-03-01')).toBe('March 1, 2026'); expect(formatPayDate('2026-01-15')).toBe('January 15, 2026'); expect(formatPayDate('2025-12-31')).toBe('December 31, 2025'); }); }); // ─── Component helpers ───────────────────────────────────────────────────────── const virtualPaycheck = (num) => ({ id: null, period_year: 2026, period_month: 3, paycheck_number: num, pay_date: `2026-03-${num === 1 ? '01' : '15'}`, gross: 3000, net: 2400, bills: [], one_time_expenses: [], financing: [], }); const savedPaycheck = (num) => ({ ...virtualPaycheck(num), id: num, }); function mockFetch(paychecks, categories = []) { return vi.fn((url) => { if (url.includes('/api/paychecks')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(paychecks) }); } if (url.includes('/api/expense-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(categories) }); } if (url.includes('/api/actuals')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); }); } function renderView() { return render( ); } // ─── PaycheckView rendering tests ───────────────────────────────────────────── describe('PaycheckView', () => { beforeEach(() => { // Fake only Date (not setTimeout/setInterval) so RTL's waitFor keeps working vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-03-19') }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('shows current month and year in nav', async () => { global.fetch = mockFetch([virtualPaycheck(1), virtualPaycheck(2)]); renderView(); expect(screen.getByText('March 2026')).toBeInTheDocument(); }); it('shows loading state then renders paychecks', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); expect(screen.getByText('Loading…')).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('Paycheck 1')).toBeInTheDocument(); expect(screen.getByText('Paycheck 2')).toBeInTheDocument(); }); }); it('shows virtual preview banner for virtual paychecks', async () => { global.fetch = mockFetch([virtualPaycheck(1), virtualPaycheck(2)]); renderView(); await waitFor(() => { expect(screen.getByText(/Previewing from current settings/)).toBeInTheDocument(); }); }); it('does not show preview banner for saved paychecks', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => { expect(screen.queryByText(/Previewing from current settings/)).not.toBeInTheDocument(); }); }); it('shows error state on fetch failure', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); renderView(); await waitFor(() => { expect(screen.getByText(/Error: Network error/)).toBeInTheDocument(); }); }); it('navigates to previous month', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => screen.getByText('March 2026')); await act(async () => { await userEvent.click(screen.getByRole('button', { name: '←' })); }); expect(screen.getByText('February 2026')).toBeInTheDocument(); }); it('navigates to next month', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => screen.getByText('March 2026')); await act(async () => { await userEvent.click(screen.getByRole('button', { name: '→' })); }); expect(screen.getByText('April 2026')).toBeInTheDocument(); }); it('wraps from January to December on prev', async () => { vi.useRealTimers(); vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-01-15') }); global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => screen.getByText('January 2026')); await act(async () => { await userEvent.click(screen.getByRole('button', { name: '←' })); }); expect(screen.getByText('December 2025')).toBeInTheDocument(); }); it('renders bills in paycheck columns', async () => { const pc1 = { ...savedPaycheck(1), bills: [ { paycheck_bill_id: 10, bill_id: 1, name: 'Electric', amount: 150, effective_amount: 150, due_day: 5, category: 'Utilities', variable_amount: false, paid: false, paid_at: null, amount_override: null }, ], }; global.fetch = mockFetch([pc1, savedPaycheck(2)]); renderView(); await waitFor(() => { expect(screen.getByText('Electric')).toBeInTheDocument(); expect(screen.getByText('$150.00')).toBeInTheDocument(); expect(screen.getByText('due 5th')).toBeInTheDocument(); }); }); it('shows remaining balance', async () => { const pc1 = { ...savedPaycheck(1), net: 2400, bills: [ { paycheck_bill_id: 10, bill_id: 1, name: 'Electric', amount: 150, effective_amount: 150, due_day: 5, category: 'Utilities', variable_amount: false, paid: false, paid_at: null, amount_override: null }, ], one_time_expenses: [], financing: [], }; global.fetch = mockFetch([pc1, savedPaycheck(2)]); renderView(); await waitFor(() => { // Remaining = 2400 - 150 = 2250 expect(screen.getAllByText('$2,250.00')[0]).toBeInTheDocument(); }); }); it('shows empty state when no bills', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => { // Two paycheck columns, each with "(none)" for bills expect(screen.getAllByText('(none)').length).toBeGreaterThanOrEqual(2); }); }); it('shows gross and net amounts', async () => { global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); renderView(); await waitFor(() => { expect(screen.getAllByText('$3,000.00').length).toBeGreaterThan(0); // gross expect(screen.getAllByText('$2,400.00').length).toBeGreaterThan(0); // net }); }); });