Add PaycheckView unit tests

Export ordinal/formatCurrency/formatPayDate for direct testing. Tests cover
pure function formatting, virtual/saved rendering, preview banner, error state,
month navigation (including year wrap), bill display, remaining balance, and
gross/net amounts. Uses vi.useFakeTimers({ toFake: ['Date'] }) to control
current date without faking setTimeout (which would break RTL's waitFor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:19:58 -04:00
parent be410d7d88
commit bb2038c58c
2 changed files with 255 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
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(
<MemoryRouter>
<PaycheckView />
</MemoryRouter>
);
}
// ─── 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
});
});
});

View File

@@ -25,6 +25,8 @@ function todayISO() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
export { ordinal, formatCurrency, formatPayDate };
// ─── PaycheckColumn ─────────────────────────────────────────────────────────── // ─── PaycheckColumn ───────────────────────────────────────────────────────────
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) { function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) {