|
| 1 | +/** |
| 2 | + * ephys-view.test.js — Unit tests for ephys-related pure helpers in details.js. |
| 3 | + * |
| 4 | + * Tests hasEphysAssemblies and buildEphysProbeCard, which are Node-safe (no DOM/Three.js). |
| 5 | + */ |
| 6 | + |
| 7 | +import { describe, it, expect, vi } from 'vitest'; |
| 8 | + |
| 9 | +// Mock Three.js and OBJLoader so the ephys-viz-3d import doesn't fail in Node |
| 10 | +vi.mock('three', () => ({ default: {} })); |
| 11 | +vi.mock('three/addons/loaders/OBJLoader.js', () => ({ OBJLoader: class {} })); |
| 12 | + |
| 13 | +// Mock brain-viz-3d exports needed by ephys-viz-3d |
| 14 | +vi.mock('../subject/brain-viz-3d.js', () => ({ |
| 15 | + STRUCTURE_COLORS: {}, |
| 16 | + TARGET_X: 0, TARGET_Y: -3.668, TARGET_Z: -1.2, |
| 17 | + makeCCFMatrix: () => ({}), |
| 18 | + cssHexToThree: (h) => parseInt(h.replace('#', ''), 16), |
| 19 | + surfaceY: () => null, |
| 20 | + loadBrainMesh: () => {}, |
| 21 | + createBrainViz3D: () => document.createElement('div'), |
| 22 | +})); |
| 23 | + |
| 24 | +// Mock brain-viz for ITEM_COLORS |
| 25 | +vi.mock('../subject/brain-viz.js', () => ({ |
| 26 | + ITEM_COLORS: ['#FF6B6B', '#4ECDC4', '#45B7D1'], |
| 27 | + createBrainVizCanvas: () => ({ canvas: document.createElement('canvas') }), |
| 28 | +})); |
| 29 | + |
| 30 | +// Mock assets/view.js |
| 31 | +vi.mock('../assets/view.js', () => ({ |
| 32 | + buildQcLink: () => null, |
| 33 | + buildMetadataLink: () => null, |
| 34 | + buildCoLink: () => null, |
| 35 | + buildS3ConsoleUrl: () => null, |
| 36 | +})); |
| 37 | + |
| 38 | +import { hasEphysAssemblies, buildEphysProbeCard } from '../subject/details.js'; |
| 39 | + |
| 40 | +// --------------------------------------------------------------------------- |
| 41 | +// Fixtures |
| 42 | +// --------------------------------------------------------------------------- |
| 43 | + |
| 44 | +const EPHYS_CONFIG = { |
| 45 | + object_type: 'Ephys assembly config', |
| 46 | + device_name: 'Ephys Assembly 46121', |
| 47 | + modules: [ |
| 48 | + { object_type: 'MIS module config', arc_angle: -10, module_angle: -15, angle_unit: 'degrees' }, |
| 49 | + ], |
| 50 | + probes: [ |
| 51 | + { |
| 52 | + device_name: '46121', |
| 53 | + dye: 'DiD', |
| 54 | + notes: 'Some notes here.', |
| 55 | + primary_targeted_structure: { acronym: 'PAL', id: '803', name: 'Pallidum' }, |
| 56 | + other_targeted_structure: null, |
| 57 | + coordinate_system: { |
| 58 | + name: 'PROBE_RUFD', |
| 59 | + origin: 'Tip', |
| 60 | + axis_unit: 'micrometer', |
| 61 | + axes: [ |
| 62 | + { direction: 'Left_to_right', name: 'X' }, |
| 63 | + { direction: 'Down_to_up', name: 'Y' }, |
| 64 | + { direction: 'Back_to_front', name: 'Z' }, |
| 65 | + { direction: 'Up_to_down', name: 'Depth' }, |
| 66 | + ], |
| 67 | + }, |
| 68 | + transform: [ |
| 69 | + { object_type: 'Rotation', angles: [90, 0, -90], angles_unit: 'degrees' }, |
| 70 | + { object_type: 'Translation', translation: [3.96, -12.10, -4.04, 3.611] }, |
| 71 | + ], |
| 72 | + }, |
| 73 | + ], |
| 74 | +}; |
| 75 | + |
| 76 | +const ACQUISITION_WITH_EPHYS = { |
| 77 | + data_streams: [ |
| 78 | + { |
| 79 | + object_type: 'Data stream', |
| 80 | + configurations: [EPHYS_CONFIG], |
| 81 | + }, |
| 82 | + ], |
| 83 | +}; |
| 84 | + |
| 85 | +const ACQUISITION_WITHOUT_EPHYS = { |
| 86 | + data_streams: [ |
| 87 | + { |
| 88 | + object_type: 'Data stream', |
| 89 | + configurations: [ |
| 90 | + { object_type: 'Camera config', device_name: 'Camera 1' }, |
| 91 | + ], |
| 92 | + }, |
| 93 | + ], |
| 94 | +}; |
| 95 | + |
| 96 | +// --------------------------------------------------------------------------- |
| 97 | +// hasEphysAssemblies |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | + |
| 100 | +describe('hasEphysAssemblies', () => { |
| 101 | + it('returns true when acquisition has at least one Ephys assembly config', () => { |
| 102 | + expect(hasEphysAssemblies(ACQUISITION_WITH_EPHYS)).toBe(true); |
| 103 | + }); |
| 104 | + |
| 105 | + it('returns false when no Ephys assembly config exists', () => { |
| 106 | + expect(hasEphysAssemblies(ACQUISITION_WITHOUT_EPHYS)).toBe(false); |
| 107 | + }); |
| 108 | + |
| 109 | + it('returns false for empty data_streams', () => { |
| 110 | + expect(hasEphysAssemblies({ data_streams: [] })).toBe(false); |
| 111 | + }); |
| 112 | + |
| 113 | + it('returns false for null input', () => { |
| 114 | + expect(hasEphysAssemblies(null)).toBe(false); |
| 115 | + }); |
| 116 | + |
| 117 | + it('returns false for undefined input', () => { |
| 118 | + expect(hasEphysAssemblies(undefined)).toBe(false); |
| 119 | + }); |
| 120 | + |
| 121 | + it('finds ephys config nested inside a stream with mixed configs', () => { |
| 122 | + const mixed = { |
| 123 | + data_streams: [ |
| 124 | + { |
| 125 | + configurations: [ |
| 126 | + { object_type: 'Camera config' }, |
| 127 | + { object_type: 'Ephys assembly config', probes: [] }, |
| 128 | + ], |
| 129 | + }, |
| 130 | + ], |
| 131 | + }; |
| 132 | + expect(hasEphysAssemblies(mixed)).toBe(true); |
| 133 | + }); |
| 134 | +}); |
| 135 | + |
| 136 | +// --------------------------------------------------------------------------- |
| 137 | +// buildEphysProbeCard |
| 138 | +// --------------------------------------------------------------------------- |
| 139 | + |
| 140 | +describe('buildEphysProbeCard', () => { |
| 141 | + const probe = { |
| 142 | + name: '46121', |
| 143 | + dye: 'DiD', |
| 144 | + notes: 'Probe notes.', |
| 145 | + ap: -4.04, |
| 146 | + ml: 3.96, |
| 147 | + depth: 3.611, |
| 148 | + probeDir: [-0.258, 0.070, 0.964], |
| 149 | + modules: [{ arc_angle: -10, module_angle: -15 }], |
| 150 | + primaryStructure: { acronym: 'PAL', id: '803', name: 'Pallidum' }, |
| 151 | + otherStructures: [], |
| 152 | + structureIds: ['803'], |
| 153 | + }; |
| 154 | + |
| 155 | + it('renders the probe name in a heading', () => { |
| 156 | + const html = buildEphysProbeCard(probe, 0); |
| 157 | + expect(html).toContain('46121'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('renders the primary targeted structure', () => { |
| 161 | + const html = buildEphysProbeCard(probe, 0); |
| 162 | + expect(html).toContain('Pallidum'); |
| 163 | + expect(html).toContain('PAL'); |
| 164 | + }); |
| 165 | + |
| 166 | + it('renders the dye', () => { |
| 167 | + const html = buildEphysProbeCard(probe, 0); |
| 168 | + expect(html).toContain('DiD'); |
| 169 | + }); |
| 170 | + |
| 171 | + it('renders module angles', () => { |
| 172 | + const html = buildEphysProbeCard(probe, 0); |
| 173 | + expect(html).toContain('arc -10'); |
| 174 | + expect(html).toContain('module -15'); |
| 175 | + }); |
| 176 | + |
| 177 | + it('renders position and depth', () => { |
| 178 | + const html = buildEphysProbeCard(probe, 0); |
| 179 | + expect(html).toContain('-4.04'); |
| 180 | + expect(html).toContain('3.96'); |
| 181 | + expect(html).toContain('3.61'); |
| 182 | + }); |
| 183 | + |
| 184 | + it('renders notes', () => { |
| 185 | + const html = buildEphysProbeCard(probe, 0); |
| 186 | + expect(html).toContain('Probe notes.'); |
| 187 | + }); |
| 188 | + |
| 189 | + it('does not render other targets section when otherStructures is empty', () => { |
| 190 | + const html = buildEphysProbeCard(probe, 0); |
| 191 | + expect(html).not.toContain('Other targets'); |
| 192 | + }); |
| 193 | + |
| 194 | + it('renders other targeted structures when present', () => { |
| 195 | + const p2 = { |
| 196 | + ...probe, |
| 197 | + otherStructures: [{ acronym: 'MD', id: '362', name: 'Mediodorsal nucleus of thalamus' }], |
| 198 | + }; |
| 199 | + const html = buildEphysProbeCard(p2, 0); |
| 200 | + expect(html).toContain('Mediodorsal nucleus of thalamus'); |
| 201 | + expect(html).toContain('Other targets'); |
| 202 | + }); |
| 203 | + |
| 204 | + it('omits dye row when dye is null', () => { |
| 205 | + const p2 = { ...probe, dye: null }; |
| 206 | + const html = buildEphysProbeCard(p2, 0); |
| 207 | + expect(html).not.toContain('DiD'); |
| 208 | + }); |
| 209 | + |
| 210 | + it('omits notes row when notes is null', () => { |
| 211 | + const p2 = { ...probe, notes: null }; |
| 212 | + const html = buildEphysProbeCard(p2, 1); |
| 213 | + expect(html).not.toContain('Probe notes.'); |
| 214 | + }); |
| 215 | + |
| 216 | + it('handles probe with no modules gracefully', () => { |
| 217 | + const p2 = { ...probe, modules: [] }; |
| 218 | + const html = buildEphysProbeCard(p2, 0); |
| 219 | + expect(html).not.toContain('Module angles'); |
| 220 | + }); |
| 221 | + |
| 222 | + it('shows "Probe N:" prefix using 1-based index', () => { |
| 223 | + const html = buildEphysProbeCard(probe, 0); |
| 224 | + expect(html).toContain('Probe 1:'); |
| 225 | + }); |
| 226 | + |
| 227 | + it('index 2 shows "Probe 3:"', () => { |
| 228 | + const html = buildEphysProbeCard(probe, 2); |
| 229 | + expect(html).toContain('Probe 3:'); |
| 230 | + }); |
| 231 | +}); |
0 commit comments