import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import ExperimentCalendar from '../src/components/ExperimentCalendar'; import { experimentsApi } from '../src/api/client'; jest.mock('../src/api/client', () => ({ experimentsApi: { getCalendar: jest.fn(), }, })); const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const TEMPLATE = [ { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, { fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false }, { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true }, ]; const ANIMALS = [ { id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' }, { id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' }, ]; function todayYYYYMM() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } function makeStatus(animalId, date, customFields = {}) { return { id: `s-${animalId}-${date}`, animal_id: animalId, date: `${date}T00:00:00.000Z`, experiment_description: null, vitals: null, treatment: null, notes: null, custom_fields: customFields, }; } beforeEach(() => { jest.clearAllMocks(); experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: {} }); }); // ── Rendering ────────────────────────────────────────────────────────────────── describe('ExperimentCalendar rendering', () => { it('renders the current month and year', async () => { render( , ); const now = new Date(); const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); await waitFor(() => { expect(screen.getByText(monthLabel)).toBeInTheDocument(); }); }); it('renders day-of-week headers', async () => { render( , ); for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) { expect(screen.getByText(d)).toBeInTheDocument(); } }); it('renders the field selector with active template fields only', async () => { render( , ); await waitFor(() => expect(screen.getByTestId('field-select')).toBeInTheDocument()); const options = screen.getAllByRole('option'); const labels = options.map((o) => o.textContent); expect(labels).toContain('Vitals'); expect(labels).toContain('Weights'); expect(labels).not.toContain('Notes'); // inactive }); }); // ── Default field selection ──────────────────────────────────────────────────── describe('default field selection', () => { it('defaults to the field whose label contains "weight"', async () => { render( , ); await waitFor(() => { expect(experimentsApi.getCalendar).toHaveBeenCalledWith( EXP_ID, 'ww000000-0000-0000-0000-000000000099', ); }); }); it('defaults to first field if none contains "weight"', async () => { const noWeightTemplate = [ { fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true }, { fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, ]; render( , ); await waitFor(() => { expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, 'treatment'); }); }); }); // ── Count badges ────────────────────────────────────────────────────────────── describe('count badges', () => { it('shows a count badge on days returned by the API', async () => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: { [dateStr]: 3 }, }); render( , ); await waitFor(() => { expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('3'); }); }); it('does not show count badge on days with no data', async () => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} }); render( , ); await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled()); expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); }); }); // ── Month navigation ────────────────────────────────────────────────────────── describe('month navigation', () => { it('moves to the previous month on ‹ click', async () => { render( , ); const now = new Date(); const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' }); fireEvent.click(screen.getByLabelText('Previous month')); await waitFor(() => expect(screen.getByText(prevLabel)).toBeInTheDocument()); }); it('moves to the next month on › click', async () => { render( , ); const now = new Date(); const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' }); fireEvent.click(screen.getByLabelText('Next month')); await waitFor(() => expect(screen.getByText(nextLabel)).toBeInTheDocument()); }); it('re-fetches calendar data after field change', async () => { render( , ); await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(2)); expect(experimentsApi.getCalendar).toHaveBeenLastCalledWith(EXP_ID, 'vitals'); }); }); // ── Day click → modal ───────────────────────────────────────────────────────── describe('day click modal', () => { it('opens a modal showing subject data when a day with data is clicked', async () => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: { [dateStr]: 1 }, }); const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325', }), ]; render( , ); await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled()); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { expect(screen.getByText('Rat A')).toBeInTheDocument(); expect(screen.getByText('325')).toBeInTheDocument(); }); }); it('does not open a modal when clicking a day without data', async () => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} }); render( , ); await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled()); // Day button is disabled — click should not open modal const dayBtn = screen.getByTestId(`day-${dateStr}`); expect(dayBtn).toBeDisabled(); fireEvent.click(dayBtn); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('shows all subjects with data on the selected day', async () => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: { [dateStr]: 2 }, }); const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }), makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }), ]; render( , ); await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled()); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { expect(screen.getByText('Rat A')).toBeInTheDocument(); expect(screen.getByText('Rat B')).toBeInTheDocument(); }); }); });