+ );
+}
diff --git a/frontend/src/lib/crossSubjectChart.js b/frontend/src/lib/crossSubjectChart.js
new file mode 100644
index 0000000..8443231
--- /dev/null
+++ b/frontend/src/lib/crossSubjectChart.js
@@ -0,0 +1,52 @@
+// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.
+
+// Sort a Recharts tooltip payload by numeric value descending, dropping entries
+// whose value is null/undefined. Returns a NEW array (does not mutate input).
+export function sortPayloadByValueDesc(payload) {
+ if (!Array.isArray(payload)) return [];
+ return payload
+ .filter((p) => p && p.value != null)
+ .sort((a, b) => b.value - a.value);
+}
+
+// Toggle an id's membership in a Set, returning a NEW Set.
+export function toggleInSet(set, id) {
+ const next = new Set(set);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+}
+
+// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
+// Returns a NEW Set.
+export function setIdsHidden(set, ids, hidden) {
+ const next = new Set(set);
+ for (const id of ids) {
+ if (hidden) next.add(id);
+ else next.delete(id);
+ }
+ return next;
+}
+
+// Tri-state for a group checkbox given which subject ids are hidden.
+// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
+export function groupVisibilityState(subjectIds, hiddenSet) {
+ if (subjectIds.length === 0) return 'none';
+ const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
+ if (hiddenCount === 0) return 'all';
+ if (hiddenCount === subjectIds.length) return 'none';
+ return 'some';
+}
+
+// Group chart "lines" by their .group field, preserving first-seen group order
+// and line order within each group. Returns [{ group, subjects: [line...] }].
+export function groupLines(lines) {
+ const order = [];
+ const byGroup = new Map();
+ for (const line of lines) {
+ const g = line.group ?? '—';
+ if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
+ byGroup.get(g).push(line);
+ }
+ return order.map((group) => ({ group, subjects: byGroup.get(group) }));
+}
diff --git a/frontend/tests/ExperimentAnalysisCharts.test.jsx b/frontend/tests/ExperimentAnalysisCharts.test.jsx
new file mode 100644
index 0000000..17c2021
--- /dev/null
+++ b/frontend/tests/ExperimentAnalysisCharts.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
+// Coverage boundary: with recharts mocked out, these tests cover only the sidebar
+// integration path (toggle, checkbox state, persistence). The legend onClick/hover
+// wiring, line hide/strokeWidth, and tooltip ordering are exercised by the pure-helper
+// unit tests (crossSubjectChart.test.js) and SubjectSidebar.test.jsx.
+jest.mock('recharts', () => {
+ const Pass = ({ children }) =>
{children}
;
+ return {
+ ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
+ XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
+ Tooltip: () => null, Legend: () => null,
+ };
+});
+
+import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';
+
+const ANIMALS = [
+ { id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
+ { id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
+];
+const STATUSES = [
+ { animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
+ { animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
+];
+const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
+const DAILY_TEMPLATE = [];
+
+function renderChart() {
+ render(
+ ,
+ );
+}
+
+it('toggles the subject sidebar open and closed', () => {
+ renderChart();
+ expect(screen.queryByText('Subjects')).toBeNull();
+ fireEvent.click(screen.getByText('▸ Subjects'));
+ expect(screen.getByText('Subjects')).toBeInTheDocument();
+ expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
+ expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('◂ Subjects'));
+ expect(screen.queryByText('Subjects')).toBeNull();
+});
+
+it('unchecking a subject in the sidebar persists across re-render', () => {
+ renderChart();
+ fireEvent.click(screen.getByText('▸ Subjects'));
+ const cb = screen.getByLabelText('Mouse-A1');
+ expect(cb.checked).toBe(true);
+ fireEvent.click(cb);
+ expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
+ expect(screen.getByText('Show all')).toBeInTheDocument();
+});
diff --git a/frontend/tests/SubjectSidebar.test.jsx b/frontend/tests/SubjectSidebar.test.jsx
new file mode 100644
index 0000000..069e123
--- /dev/null
+++ b/frontend/tests/SubjectSidebar.test.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import { SubjectSidebar } from '../src/components/SubjectSidebar';
+
+const LINES = [
+ { id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
+ { id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
+ { id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
+];
+
+function setup(overrides = {}) {
+ const props = {
+ lines: LINES,
+ hiddenSubjects: new Set(),
+ onToggleSubject: jest.fn(),
+ onToggleGroup: jest.fn(),
+ onShowAll: jest.fn(),
+ onHighlight: jest.fn(),
+ ...overrides,
+ };
+ render();
+ return props;
+}
+
+it('renders each group header with its subject count', () => {
+ setup();
+ expect(screen.getByText('Group A')).toBeInTheDocument();
+ expect(screen.getByText('Group B')).toBeInTheDocument();
+ expect(screen.getByText('(2)')).toBeInTheDocument();
+ expect(screen.getByText('(1)')).toBeInTheDocument();
+});
+
+it('renders a subject checkbox per subject, checked when visible', () => {
+ setup({ hiddenSubjects: new Set(['a2']) });
+ expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
+ expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
+});
+
+it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
+ const { onToggleSubject } = setup();
+ fireEvent.click(screen.getByLabelText('Mouse-A1'));
+ expect(onToggleSubject).toHaveBeenCalledWith('a1');
+});
+
+it('shows the group checkbox as indeterminate when the group is mixed', () => {
+ setup({ hiddenSubjects: new Set(['a1']) });
+ const groupCheckbox = screen.getByLabelText(/Group A/);
+ expect(groupCheckbox.indeterminate).toBe(true);
+ expect(groupCheckbox.checked).toBe(false);
+});
+
+it('checks the group checkbox when all members are visible', () => {
+ setup();
+ expect(screen.getByLabelText(/Group A/).checked).toBe(true);
+});
+
+it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
+ const { onToggleGroup } = setup();
+ fireEvent.click(screen.getByLabelText(/Group A/));
+ expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
+});
+
+it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => {
+ setup({ hiddenSubjects: new Set(['a1', 'a2']) });
+ const groupCheckbox = screen.getByLabelText(/Group A/);
+ expect(groupCheckbox.checked).toBe(false);
+ expect(groupCheckbox.indeterminate).toBe(false);
+});
+
+it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => {
+ const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) });
+ fireEvent.click(screen.getByLabelText(/Group A/));
+ expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false);
+});
+
+it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
+ const { rerender } = render(
+ {}} onToggleGroup={() => {}}
+ onShowAll={() => {}} onHighlight={() => {}} />,
+ );
+ expect(screen.queryByText('Show all')).toBeNull();
+ rerender(
+ {}} onToggleGroup={() => {}}
+ onShowAll={() => {}} onHighlight={() => {}} />,
+ );
+ expect(screen.getByText('Show all')).toBeInTheDocument();
+});
+
+it('calls onShowAll when the Show all link is clicked', () => {
+ const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
+ fireEvent.click(screen.getByText('Show all'));
+ expect(onShowAll).toHaveBeenCalled();
+});
+
+it('calls onHighlight on subject row hover enter/leave', () => {
+ const { onHighlight } = setup();
+ const row = screen.getByLabelText('Mouse-A1').closest('label');
+ fireEvent.mouseEnter(row);
+ expect(onHighlight).toHaveBeenCalledWith('a1');
+ fireEvent.mouseLeave(row);
+ expect(onHighlight).toHaveBeenCalledWith(null);
+});
diff --git a/frontend/tests/crossSubjectChart.test.js b/frontend/tests/crossSubjectChart.test.js
new file mode 100644
index 0000000..39f1e60
--- /dev/null
+++ b/frontend/tests/crossSubjectChart.test.js
@@ -0,0 +1,82 @@
+import {
+ sortPayloadByValueDesc,
+ toggleInSet,
+ setIdsHidden,
+ groupVisibilityState,
+ groupLines,
+} from '../src/lib/crossSubjectChart';
+
+describe('sortPayloadByValueDesc', () => {
+ it('sorts by value descending and drops null/undefined values', () => {
+ const payload = [
+ { dataKey: 'a', value: 40 },
+ { dataKey: 'b', value: null },
+ { dataKey: 'c', value: 92 },
+ { dataKey: 'd', value: 78 },
+ ];
+ expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
+ });
+ it('returns [] for non-array input', () => {
+ expect(sortPayloadByValueDesc(undefined)).toEqual([]);
+ });
+ it('does not mutate the input array', () => {
+ const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
+ sortPayloadByValueDesc(payload);
+ expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
+ });
+});
+
+describe('toggleInSet', () => {
+ it('adds a missing id and removes a present id, returning a new Set', () => {
+ const a = new Set(['x']);
+ const added = toggleInSet(a, 'y');
+ expect([...added].sort()).toEqual(['x', 'y']);
+ expect(added).not.toBe(a);
+ const removed = toggleInSet(added, 'x');
+ expect([...removed]).toEqual(['y']);
+ });
+});
+
+describe('setIdsHidden', () => {
+ it('adds all ids when hidden=true', () => {
+ expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
+ });
+ it('removes all ids when hidden=false', () => {
+ expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
+ });
+ it('returns a new Set', () => {
+ const s = new Set();
+ expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
+ });
+});
+
+describe('groupVisibilityState', () => {
+ it("returns 'all' when none of the ids are hidden", () => {
+ expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
+ });
+ it("returns 'none' when all ids are hidden", () => {
+ expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
+ });
+ it("returns 'some' when a subset is hidden", () => {
+ expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
+ });
+ it("returns 'none' for an empty group", () => {
+ expect(groupVisibilityState([], new Set())).toBe('none');
+ });
+});
+
+describe('groupLines', () => {
+ it('groups by .group preserving first-seen group order and within-group order', () => {
+ const lines = [
+ { id: '1', group: 'B' },
+ { id: '2', group: 'A' },
+ { id: '3', group: 'B' },
+ ];
+ const out = groupLines(lines);
+ expect(out.map((g) => g.group)).toEqual(['B', 'A']);
+ expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
+ });
+ it("uses '—' for lines with no group", () => {
+ expect(groupLines([{ id: '1' }])[0].group).toBe('—');
+ });
+});