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();
});
});
});