feat: add experiment calendar view with per-day subject counts
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.
Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
subjects with a non-null/non-empty value for the chosen field on that date.
Supports both builtin fields (vitals, treatment, notes, experiment_description)
and custom fields addressed by fieldId UUID.
Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
(defaults to first field with "weight" in label, else first active field),
blue badges showing subject count per day, click to open modal with all
subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.
Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
count, custom field count, whitespace ignored, multi-month, field echo,
missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
rendering, default field selection, count badges, month navigation,
field-change re-fetch, day click modal, and multi-subject display.
All 47 backend + 13 calendar frontend tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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(
|
||||
<ExperimentCalendar
|
||||
experimentId={EXP_ID}
|
||||
template={TEMPLATE}
|
||||
allStatuses={[]}
|
||||
animals={ANIMALS}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
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(
|
||||
<ExperimentCalendar
|
||||
experimentId={EXP_ID}
|
||||
template={TEMPLATE}
|
||||
allStatuses={statuses}
|
||||
animals={ANIMALS}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ExperimentCalendar
|
||||
experimentId={EXP_ID}
|
||||
template={TEMPLATE}
|
||||
allStatuses={statuses}
|
||||
animals={ANIMALS}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
parseCSVHeaders,
|
||||
parseCSV,
|
||||
getUniqueNoteValues,
|
||||
runAnalysis,
|
||||
} from '../src/lib/csvAnalysis';
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Minimal CSV mimicking the real experiment file structure
|
||||
const FIXTURE_CSV = `timestamp,frame_number,frame_line_status,note
|
||||
0.0,1,10,
|
||||
1.0,2,10,f
|
||||
2.0,3,10,s
|
||||
3.0,4,10,s
|
||||
4.0,5,10,f
|
||||
5.0,6,10,
|
||||
6.0,7,10,s
|
||||
7.0,8,10,f
|
||||
8.0,9,10,f
|
||||
9.0,10,10,s`;
|
||||
|
||||
// Same data but with Windows-style line endings
|
||||
const FIXTURE_CSV_CRLF = FIXTURE_CSV.replace(/\n/g, '\r\n');
|
||||
|
||||
// CSV with quoted field containing a comma
|
||||
const FIXTURE_CSV_QUOTED = `timestamp,note\n1.0,"hello, world"\n2.0,plain`;
|
||||
|
||||
// Out-of-order timestamps (should be sorted)
|
||||
const FIXTURE_UNSORTED = `timestamp,note
|
||||
3.0,s
|
||||
1.0,f
|
||||
2.0,s`;
|
||||
|
||||
const CLASSIFICATIONS = { f: 'Failure', s: 'Success' };
|
||||
|
||||
// ── parseCSVHeaders ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCSVHeaders', () => {
|
||||
test('returns column names from first line', () => {
|
||||
expect(parseCSVHeaders(FIXTURE_CSV)).toEqual([
|
||||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
expect(parseCSVHeaders(FIXTURE_CSV_CRLF)).toEqual([
|
||||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles single-line CSV (no newline)', () => {
|
||||
expect(parseCSVHeaders('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseCSV ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCSV', () => {
|
||||
test('returns headers and rows', () => {
|
||||
const { headers, rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(headers).toEqual(['timestamp', 'frame_number', 'frame_line_status', 'note']);
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
test('row values are keyed by header name', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(rows[0]).toEqual({ timestamp: '0.0', frame_number: '1', frame_line_status: '10', note: '' });
|
||||
expect(rows[1]).toEqual({ timestamp: '1.0', frame_number: '2', frame_line_status: '10', note: 'f' });
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV_CRLF);
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[1].note).toBe('f');
|
||||
});
|
||||
|
||||
test('handles quoted fields containing commas', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV_QUOTED);
|
||||
expect(rows[0].note).toBe('hello, world');
|
||||
expect(rows[1].note).toBe('plain');
|
||||
});
|
||||
|
||||
test('skips blank lines', () => {
|
||||
const csv = 'a,b\n1,2\n\n3,4\n';
|
||||
const { rows } = parseCSV(csv);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUniqueNoteValues ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUniqueNoteValues', () => {
|
||||
test('returns sorted unique non-empty values', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['f', 's']);
|
||||
});
|
||||
|
||||
test('ignores empty strings', () => {
|
||||
const rows = [{ note: '' }, { note: 'a' }, { note: '' }, { note: 'b' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('trims whitespace before deduplication', () => {
|
||||
const rows = [{ note: ' a ' }, { note: 'a' }, { note: 'b' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('returns empty array when no non-empty notes', () => {
|
||||
const rows = [{ note: '' }, { note: '' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── runAnalysis ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runAnalysis', () => {
|
||||
let result;
|
||||
|
||||
beforeEach(() => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||
});
|
||||
|
||||
// Fixture note sequence (sorted by timestamp, empty rows excluded):
|
||||
// t=1 f, t=2 s, t=3 s, t=4 f, t=6 s, t=7 f, t=8 f, t=9 s
|
||||
// Categories: F S S F S F F S
|
||||
|
||||
test('totalNoteRows counts only rows with non-empty note', () => {
|
||||
expect(result.totalNoteRows).toBe(8);
|
||||
});
|
||||
|
||||
test('sequence preserves order and maps to categories', () => {
|
||||
const notes = result.sequence.map(r => r.note);
|
||||
expect(notes).toEqual(['f', 's', 's', 'f', 's', 'f', 'f', 's']);
|
||||
|
||||
const cats = result.sequence.map(r => r.category);
|
||||
expect(cats).toEqual(['Failure', 'Success', 'Success', 'Failure', 'Success', 'Failure', 'Failure', 'Success']);
|
||||
});
|
||||
|
||||
test('categoryCounts totals are correct', () => {
|
||||
expect(result.categoryCounts['Failure'].total).toBe(4);
|
||||
expect(result.categoryCounts['Success'].total).toBe(4);
|
||||
});
|
||||
|
||||
test('categoryCounts byNote breakdown is correct', () => {
|
||||
expect(result.categoryCounts['Failure'].byNote).toEqual({ f: 4 });
|
||||
expect(result.categoryCounts['Success'].byNote).toEqual({ s: 4 });
|
||||
});
|
||||
|
||||
test('runs (run-length encoding) are correct', () => {
|
||||
// F S S F S F F S → F×1, S×2, F×1, S×1, F×2, S×1
|
||||
expect(result.runs).toEqual([
|
||||
{ category: 'Failure', length: 1 },
|
||||
{ category: 'Success', length: 2 },
|
||||
{ category: 'Failure', length: 1 },
|
||||
{ category: 'Success', length: 1 },
|
||||
{ category: 'Failure', length: 2 },
|
||||
{ category: 'Success', length: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('consecutiveStats totalRuns', () => {
|
||||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(3);
|
||||
expect(result.consecutiveStats['Success'].totalRuns).toBe(3);
|
||||
});
|
||||
|
||||
test('consecutiveStats maxRun', () => {
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(2);
|
||||
expect(result.consecutiveStats['Success'].maxRun).toBe(2);
|
||||
});
|
||||
|
||||
test('consecutiveStats avgRun', () => {
|
||||
// Failure runs: [1,1,2] → avg = 4/3
|
||||
expect(result.consecutiveStats['Failure'].avgRun).toBeCloseTo(4 / 3);
|
||||
// Success runs: [2,1,1] → avg = 4/3
|
||||
expect(result.consecutiveStats['Success'].avgRun).toBeCloseTo(4 / 3);
|
||||
});
|
||||
|
||||
test('consecutiveStats runLengths array', () => {
|
||||
expect(result.consecutiveStats['Failure'].runLengths).toEqual([1, 1, 2]);
|
||||
expect(result.consecutiveStats['Success'].runLengths).toEqual([2, 1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — sorting', () => {
|
||||
test('sorts rows by timestamp before building sequence', () => {
|
||||
const { rows } = parseCSV(FIXTURE_UNSORTED);
|
||||
// Input order: s(3), f(1), s(2) — sorted order: f(1), s(2), s(3)
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||
expect(result.sequence.map(r => r.note)).toEqual(['f', 's', 's']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — unclassified notes', () => {
|
||||
test('unclassified notes appear in sequence with null category', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
// Only classify 'f'; leave 's' unclassified
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||
const nullCats = result.sequence.filter(r => r.category === null);
|
||||
expect(nullCats.length).toBe(4); // 4 's' notes
|
||||
});
|
||||
|
||||
test('unclassified notes are excluded from categoryCounts', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||
expect(result.categoryCounts['Success']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unclassified notes do not break or extend runs', () => {
|
||||
// Sequence with 'x' unclassified: f x f → should still be one Failure run of 2
|
||||
const csv = 'ts,note\n1.0,f\n2.0,x\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
// 'x' is unclassified, 'f' → Failure
|
||||
// After skipping 'x': f, f → one run of Failure×2
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — multiple note values per category', () => {
|
||||
test('groups multiple note values under one category', () => {
|
||||
// The user's example: ss and s both belong to Success
|
||||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,s';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success' });
|
||||
expect(result.categoryCounts['Success'].total).toBe(3);
|
||||
expect(result.categoryCounts['Success'].byNote).toEqual({ ss: 1, s: 2 });
|
||||
});
|
||||
|
||||
test('run-length encoding with multiple note values in same category', () => {
|
||||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success', f: 'Failure' });
|
||||
// ss and s are both Success → run of 2, then Failure run of 1
|
||||
expect(result.runs).toEqual([
|
||||
{ category: 'Success', length: 2 },
|
||||
{ category: 'Failure', length: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — edge cases', () => {
|
||||
test('empty rows returns zero totals', () => {
|
||||
const result = runAnalysis([], 'timestamp', 'note', CLASSIFICATIONS);
|
||||
expect(result.totalNoteRows).toBe(0);
|
||||
expect(result.sequence).toEqual([]);
|
||||
expect(result.runs).toEqual([]);
|
||||
expect(result.categoryCounts).toEqual({});
|
||||
expect(result.consecutiveStats).toEqual({});
|
||||
});
|
||||
|
||||
test('all notes unclassified', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', {});
|
||||
expect(result.categoryCounts).toEqual({});
|
||||
expect(result.runs).toEqual([]);
|
||||
});
|
||||
|
||||
test('single note row', () => {
|
||||
const csv = 'ts,note\n1.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.totalNoteRows).toBe(1);
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 1 }]);
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(1);
|
||||
expect(result.consecutiveStats['Failure'].avgRun).toBe(1);
|
||||
});
|
||||
|
||||
test('all same category', () => {
|
||||
const csv = 'ts,note\n1.0,f\n2.0,f\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 3 }]);
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(3);
|
||||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user