import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, fireEvent, screen, act } from '@testing-library/react' import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable' const ROWS: VacationDaysTableRow[] = [ { year: 2026, totalDays: 10, savedDaysIn: 2, expiring: 0, expiringAge: 0, expiringOverCap: 0 }, { year: 2027, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 }, { year: 2028, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 }, ] function renderTable( rows: VacationDaysTableRow[], onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise, ) { return render() } describe('VacationDaysTable', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('renders one row per year', () => { renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) expect(screen.getByTestId('vacation-year-row-2026')).toBeInTheDocument() expect(screen.getByTestId('vacation-year-row-2027')).toBeInTheDocument() expect(screen.getByTestId('vacation-year-row-2028')).toBeInTheDocument() }) it('shows the total days and saved days for each year', () => { renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) const totalInput = screen.getByTestId('vacation-total-days-2026') as HTMLInputElement const savedInput = screen.getByTestId('vacation-saved-days-2026') as HTMLInputElement expect(totalInput.value).toBe('10') expect(savedInput.value).toBe('2') }) it('autosaves a total-days edit after the debounce delay, without a Save button', async () => { const onSave = vi.fn().mockResolvedValue(undefined) renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } }) expect(onSave).not.toHaveBeenCalled() expect(screen.queryByTestId('vacation-save-2027')).not.toBeInTheDocument() await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledWith(2027, 30, 0) }) it('autosaves a saved-days-in edit', async () => { const onSave = vi.fn().mockResolvedValue(undefined) renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-saved-days-2026'), { target: { value: '5' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledWith(2026, 10, 5) }) it('does not mix up edits between rows', async () => { const onSave = vi.fn().mockResolvedValue(undefined) renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-total-days-2026'), { target: { value: '15' } }) fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '20' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledWith(2026, 15, 2) expect(onSave).toHaveBeenCalledWith(2027, 20, 0) expect(onSave).toHaveBeenCalledTimes(2) }) it('collapses rapid successive edits to the same row into a single save', async () => { const onSave = vi.fn().mockResolvedValue(undefined) renderTable(ROWS, onSave) const input = screen.getByTestId('vacation-total-days-2027') fireEvent.change(input, { target: { value: '26' } }) await act(async () => { await vi.advanceTimersByTimeAsync(400) }) fireEvent.change(input, { target: { value: '27' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledTimes(1) expect(onSave).toHaveBeenCalledWith(2027, 27, 0) }) it('shows a saving then saved indicator around the autosave', async () => { let resolveSave: () => void = () => {} const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }), ) renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparar') await act(async () => { resolveSave() await Promise.resolve() }) expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparat') }) it('shows an error indicator when the autosave fails, without losing the typed value', async () => { const onSave = vi.fn().mockRejectedValue(new Error('network blew up')) renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '99' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent(/gick inte|kunde inte|error/i) expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('99') }) it('does not clobber an unsaved edit when the parent re-renders with fresh rows mid-debounce', async () => { const onSave = vi.fn().mockResolvedValue(undefined) const { rerender } = render() fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } }) // Simulate a background refetch (e.g. from another row's save bumping // `data`) landing mid-debounce, still carrying the OLD server value for // this row. rerender() expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40') await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledWith(2027, 40, 0) }) it('accepts a fresh server value once the row is no longer dirty', async () => { const onSave = vi.fn().mockResolvedValue(undefined) const { rerender } = render() fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } }) await act(async () => { await vi.advanceTimersByTimeAsync(800) }) expect(onSave).toHaveBeenCalledWith(2027, 40, 0) // Once the save resolved the row is clean again — a later prop update // (e.g. from a real refetch confirming the save) should be reflected. const updatedRows = ROWS.map((r) => (r.year === 2027 ? { ...r, totalDays: 40 } : r)) rerender() expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40') }) it('shows an empty message when there are no rows', () => { render() expect(screen.queryByTestId('vacation-days-table-pd-1')).not.toBeInTheDocument() expect(screen.getByText(/Inga år att visa/)).toBeInTheDocument() }) it('scopes its root testid to the given personalDataUuid, for multiple tables on one page', () => { renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) expect(screen.getByTestId('vacation-days-table-pd-1')).toBeInTheDocument() }) it('shows an assumed badge for a year with no entered data (§3.2)', () => { const rows = [ ...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 }, ] renderTable(rows, vi.fn().mockResolvedValue(undefined)) expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument() expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument() }) it('shows the at-risk figure in neutral ink when nothing is expiring', () => { renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) const atRisk = screen.getByTestId('vacation-at-risk-2026') expect(atRisk).toHaveTextContent('0') expect(atRisk).not.toHaveClass('vacation-rows__at-risk-value--danger') }) it('shows the at-risk figure in danger ink when days are expiring, with a tooltip splitting the cause', () => { const rows = [ { year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 5, expiringAge: 2, expiringOverCap: 3 }, ...ROWS.slice(1), ] renderTable(rows, vi.fn().mockResolvedValue(undefined)) const atRisk = screen.getByTestId('vacation-at-risk-2026') expect(atRisk).toHaveTextContent('5') expect(atRisk).toHaveClass('vacation-rows__at-risk-value--danger') expect(atRisk.title).toContain('2') expect(atRisk.title).toContain('3') }) it('shows only the relevant cause in the at-risk tooltip when the other is zero', () => { const rows = [ { year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 4, expiringAge: 4, expiringOverCap: 0 }, ...ROWS.slice(1), ] renderTable(rows, vi.fn().mockResolvedValue(undefined)) const atRisk = screen.getByTestId('vacation-at-risk-2026') expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester') }) })