diff --git a/frontend/package.json b/frontend/package.json index cfac42d..e271c56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,11 @@ }, "dependencies": { "axios": "^1.7.2", - "recharts": "^2.12.7", "date-fns": "^3.6.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.24.0" + "react-router-dom": "^6.24.0", + "recharts": "^2.15.4" }, "devDependencies": { "@babel/core": "^7.24.7", @@ -38,8 +38,12 @@ }, "jest": { "testEnvironment": "jsdom", - "setupFilesAfterEnv": ["/tests/setup.js"], - "testMatch": ["**/tests/**/*.test.{js,jsx}"], + "setupFilesAfterEnv": [ + "/tests/setup.js" + ], + "testMatch": [ + "**/tests/**/*.test.{js,jsx}" + ], "transform": { "^.+\\.[jt]sx?$": "babel-jest" }, diff --git a/frontend/tests/ExperimentCalendar.test.jsx b/frontend/tests/ExperimentCalendar.test.jsx index bb51098..7327b9c 100644 --- a/frontend/tests/ExperimentCalendar.test.jsx +++ b/frontend/tests/ExperimentCalendar.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import ExperimentCalendar from '../src/components/ExperimentCalendar'; import { experimentsApi } from '../src/api/client'; @@ -9,6 +9,17 @@ jest.mock('../src/api/client', () => ({ }, })); +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children }) =>
{children}
, + BarChart: ({ children }) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Legend: () => null, +})); + const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const TEMPLATE = [ @@ -22,18 +33,18 @@ const ANIMALS = [ { id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' }, ]; -function todayYYYYMM() { +function todayStr() { const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } -function makeStatus(animalId, date, customFields = {}) { +function makeStatus(animalId, date, customFields = {}, vitals = null) { return { id: `s-${animalId}-${date}`, animal_id: animalId, date: `${date}T00:00:00.000Z`, experiment_description: null, - vitals: null, + vitals, treatment: null, notes: null, custom_fields: customFields, @@ -42,29 +53,21 @@ function makeStatus(animalId, date, 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 () => { + it('renders the current month and year', () => { render( - , + , ); const now = new Date(); const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); - await waitFor(() => { - expect(screen.getByText(monthLabel)).toBeInTheDocument(); - }); + expect(screen.getByText(monthLabel)).toBeInTheDocument(); }); - it('renders day-of-week headers', async () => { + it('renders day-of-week headers', () => { render( , ); @@ -73,11 +76,11 @@ describe('ExperimentCalendar rendering', () => { } }); - it('renders the field selector with active template fields only', async () => { + it('renders the field selector with active template fields only', () => { render( , ); - await waitFor(() => expect(screen.getByTestId('field-select')).toBeInTheDocument()); + expect(screen.getByTestId('field-select')).toBeInTheDocument(); const options = screen.getAllByRole('option'); const labels = options.map((o) => o.textContent); expect(labels).toContain('Vitals'); @@ -89,19 +92,14 @@ describe('ExperimentCalendar rendering', () => { // ── Default field selection ──────────────────────────────────────────────────── describe('default field selection', () => { - it('defaults to the field whose label contains "weight"', async () => { + it('defaults to the field whose label contains "weight"', () => { render( , ); - await waitFor(() => { - expect(experimentsApi.getCalendar).toHaveBeenCalledWith( - EXP_ID, - 'ww000000-0000-0000-0000-000000000099', - ); - }); + expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099'); }); - it('defaults to first field if none contains "weight"', async () => { + it('defaults to first field if none contains "weight"', () => { 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 }, @@ -109,189 +107,161 @@ describe('default field selection', () => { render( , ); - await waitFor(() => { - expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, 'treatment'); - }); + expect(screen.getByTestId('field-select')).toHaveValue('treatment'); }); }); -// ── Count badges ────────────────────────────────────────────────────────────── +// ── Count badges (computed from allStatuses) ─────────────────────────────────── 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 }, - }); - + it('shows a count badge for a day where the selected field is non-empty', () => { + const dateStr = todayStr(); + const statuses = [ + makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), + makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }), + ]; render( - , + , ); - - await waitFor(() => { - expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('3'); - }); + expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2'); }); - 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: {} }); - + it('does not show count badge on days with no data', () => { + const dateStr = todayStr(); render( , ); - - await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled()); expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); }); + + it('shows a count badge for builtin field (vitals)', () => { + const dateStr = todayStr(); + const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')]; + render( + , + ); + // Switch to vitals field + fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); + expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); + }); }); // ── Month navigation ────────────────────────────────────────────────────────── describe('month navigation', () => { - it('moves to the previous month on ‹ click', async () => { + it('moves to the previous month on ‹ click', () => { 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()); + expect(screen.getByText(prevLabel)).toBeInTheDocument(); }); - it('moves to the next month on › click', async () => { + it('moves to the next month on › click', () => { 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()); + expect(screen.getByText(nextLabel)).toBeInTheDocument(); }); - it('re-fetches calendar data after field change', async () => { + it('recomputes counts after field change', () => { + const dateStr = todayStr(); + // Only has vitals, not weights + const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')]; render( - , + , ); - await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(1)); - + // Default is weights — no badge + expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); + // Switch to vitals — badge appears fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); - - await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(2)); - expect(experimentsApi.getCalendar).toHaveBeenLastCalledWith(EXP_ID, 'vitals'); + expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); }); }); // ── 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 }, - }); - + it('opens a modal when a day with data is clicked', async () => { + const dateStr = todayStr(); const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { - 'ww000000-0000-0000-0000-000000000099': '325', - }), + 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(); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Rat A')).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: {} }); + it('shows subject value in read-only view after clicking day', async () => { + const dateStr = todayStr(); + const statuses = [ + makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), + ]; + render( + , + ); + fireEvent.click(screen.getByTestId(`day-${dateStr}`)); + await waitFor(() => expect(screen.getByText('325')).toBeInTheDocument()); + }); + it('does not open a modal when clicking a day without data', () => { + const dateStr = todayStr(); 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 }, - }); - + it('shows all animals in the modal (with and without data)', async () => { + const dateStr = todayStr(); 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(); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Rat A')).toBeInTheDocument(); + expect(within(dialog).getByText('Rat B')).toBeInTheDocument(); // shown even without data }); }); }); + +// ── Bar chart ───────────────────────────────────────────────────────────────── + +describe('bar chart', () => { + it('renders bar chart when animals have statuses', () => { + const dateStr = todayStr(); + const statuses = [ + makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), + ]; + render( + , + ); + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + }); + + it('does not render bar chart when no animals have statuses', () => { + render( + , + ); + expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); + }); +});