diff --git a/frontend/src/__tests__/CanSettings.test.tsx b/frontend/src/__tests__/CanSettings.test.tsx new file mode 100644 index 0000000..02a0fa1 --- /dev/null +++ b/frontend/src/__tests__/CanSettings.test.tsx @@ -0,0 +1,251 @@ +/** + * Unit tests for CanSettings component. + * + * Renders the component in jsdom with a mocked socket and seeded CAN store, + * then asserts on rendered content and toggle interactions. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; +import { theme } from '@/theme/Theme'; +import { CAN } from '@/store/Store'; + +// vi.mock is hoisted above variable declarations, so mockEmit must be defined +// with vi.hoisted() to be accessible inside the factory. +const { mockEmit } = vi.hoisted(() => ({ mockEmit: vi.fn() })); + +vi.mock('@/socket/Namespaces', () => ({ + useNamespaces: () => ({ + can: { emit: mockEmit }, + }), +})); + +// Import component after mock is set up +import CanSettings from '@/app/pages/settings/CanSettings'; + +const MOCK_CAN_CONFIG = { + type: 'data', + name: 'can', + interfaces: [ + { + enabled: true, + channel: 'can2', + bitrate: 500000, + bustype: 'socketcan', + is_extended: true, + wait_for_ecu: false, + }, + ], + sensors: { + boost: { + label: 'Boost', + unit: 'Bar', + req_id: '0x000FFFFE', + rep_id: '0x00400021', + priority: 1, + enabled: true, + interface: 'can2', + parameter: ['0x12', '0x9D'], + }, + rpm: { + label: 'RPM', + unit: 'rpm', + req_id: '0x000FFFFE', + rep_id: '0x00400021', + priority: 1, + enabled: false, + interface: 'can2', + parameter: ['0x10', '0x1D'], + }, + coolant: { + label: 'Coolant', + unit: '°C', + req_id: '0x000FFFFE', + rep_id: '0x00400022', + priority: 2, + enabled: true, + interface: 'can2', + parameter: ['0x10', '0xD8'], + }, + }, + signal_sensors: [ + { + key: 'reverse', + label: 'Reverse Gear', + interface: 'can2', + enabled: false, + can_id: '0x00000000', + byte_index: 0, + bit_index: 0, + invert: false, + }, + ], +}; + +const canStore = CAN as any; + +const renderComponent = () => + render( + + + + ); + +describe('CanSettings', () => { + beforeEach(() => { + mockEmit.mockClear(); + canStore.setState((s: any) => ({ ...s, settings: structuredClone(MOCK_CAN_CONFIG) })); + }); + + it('renders interface channel and bustype', () => { + renderComponent(); + expect(screen.getByText(/can2.*socketcan/)).toBeInTheDocument(); + }); + + it('renders all section headers', () => { + renderComponent(); + expect(screen.getByText('MODULE')).toBeInTheDocument(); + expect(screen.getByText('INTERFACES')).toBeInTheDocument(); + expect(screen.getByText('SENSORS')).toBeInTheDocument(); + expect(screen.getByText('SENSORS BY TARGET')).toBeInTheDocument(); + expect(screen.getByText('SENSORS BY PRIORITY')).toBeInTheDocument(); + expect(screen.getByText('SIGNAL SENSORS')).toBeInTheDocument(); + }); + + it('renders all sensor labels in the flat list (at least once each)', () => { + renderComponent(); + expect(screen.getAllByText(/Boost/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/RPM/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Coolant/).length).toBeGreaterThan(0); + }); + + it('renders signal sensor label', () => { + renderComponent(); + expect(screen.getAllByText('Reverse Gear').length).toBeGreaterThan(0); + }); + + it('shows enabled interface as "Enabled"', () => { + renderComponent(); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('shows disabled interface as "Disabled"', () => { + canStore.setState((s: any) => ({ + ...s, + settings: { + ...MOCK_CAN_CONFIG, + interfaces: [{ ...MOCK_CAN_CONFIG.interfaces[0], enabled: false }], + }, + })); + renderComponent(); + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('renders sensors grouped by rep_id as group headers when BY TARGET is expanded', () => { + renderComponent(); + // BY TARGET is collapsed by default — expand it + fireEvent.click(screen.getByText('SENSORS BY TARGET')); + expect(screen.getAllByText('0x00400021').length).toBeGreaterThan(0); + expect(screen.getAllByText('0x00400022').length).toBeGreaterThan(0); + }); + + it('renders priority group headers when BY PRIORITY is expanded', () => { + renderComponent(); + fireEvent.click(screen.getByText('SENSORS BY PRIORITY')); + expect(screen.getByText('Priority 1')).toBeInTheDocument(); + expect(screen.getByText('Priority 2')).toBeInTheDocument(); + }); + + it('SENSORS section is open by default', () => { + renderComponent(); + // sensor labels should be visible without clicking + expect(screen.getAllByText(/Boost/).length).toBeGreaterThan(0); + }); + + it('BY TARGET section is collapsed by default (no extra checkboxes)', () => { + renderComponent(); + // When collapsed: module(1) + flat sensors(3) + signal(1) = 5 checkboxes + expect(screen.getAllByRole('checkbox')).toHaveLength(5); + }); + + it('BY TARGET section expands on click adding sensor checkboxes', () => { + renderComponent(); + const before = screen.getAllByRole('checkbox').length; + fireEvent.click(screen.getByText('SENSORS BY TARGET')); + // Expanding BY TARGET adds 3 more sensor checkboxes (boost, rpm, coolant) + expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(before); + }); + + it('renders the CAN module toggle', () => { + renderComponent(); + expect(screen.getByText(/CAN Bus/)).toBeInTheDocument(); + }); + + it('CAN module toggle emits socket toggle event', () => { + renderComponent(); + // module toggle is the first checkbox (index 0) + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + expect(mockEmit).toHaveBeenCalledWith('toggle'); + }); + + it('toggling an enabled sensor emits save with enabled flipped to false', () => { + renderComponent(); + const checkboxes = screen.getAllByRole('checkbox'); + // index 0 = module toggle, index 1 = boost (first flat-list sensor, enabled=true) + fireEvent.click(checkboxes[1]); + expect(mockEmit).toHaveBeenCalledWith( + 'save', + expect.objectContaining({ + sensors: expect.objectContaining({ + boost: expect.objectContaining({ enabled: false }), + }), + }) + ); + }); + + it('toggling a disabled sensor emits save with enabled flipped to true', () => { + renderComponent(); + const checkboxes = screen.getAllByRole('checkbox'); + // index 0 = module toggle, index 2 = rpm (second flat-list sensor, enabled=false) + fireEvent.click(checkboxes[2]); + expect(mockEmit).toHaveBeenCalledWith( + 'save', + expect.objectContaining({ + sensors: expect.objectContaining({ + rpm: expect.objectContaining({ enabled: true }), + }), + }) + ); + }); + + it('toggling a signal sensor emits save with enabled flipped', () => { + renderComponent(); + const checkboxes = screen.getAllByRole('checkbox'); + // signal sensor toggle is the last checkbox (after module + 3 flat sensors) + fireEvent.click(checkboxes[checkboxes.length - 1]); + expect(mockEmit).toHaveBeenCalledWith( + 'save', + expect.objectContaining({ + signal_sensors: expect.arrayContaining([ + expect.objectContaining({ key: 'reverse', enabled: true }), + ]), + }) + ); + }); + + it('toggling a sensor updates the CAN store', () => { + renderComponent(); + const checkboxes = screen.getAllByRole('checkbox'); + // index 1 = boost in flat list + fireEvent.click(checkboxes[1]); + const updatedSettings = canStore.getState().settings as typeof MOCK_CAN_CONFIG; + expect(updatedSettings.sensors.boost.enabled).toBe(false); + }); + + it('shows fallback message when CAN settings are not available', () => { + canStore.setState((s: any) => ({ ...s, settings: {} })); + renderComponent(); + expect(screen.getByText('CAN settings not available.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/pages/settings/CanSettings.tsx b/frontend/src/app/pages/settings/CanSettings.tsx new file mode 100644 index 0000000..f8cc5e3 --- /dev/null +++ b/frontend/src/app/pages/settings/CanSettings.tsx @@ -0,0 +1,439 @@ +import { useState, Fragment } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import { ToggleSwitch } from '@/theme/styles/Inputs'; +import { Typography } from '@/theme/styles/Typography'; + +import { CAN, APP, useThemeColor } from '@/store/Store'; +import { useNamespaces } from '@/socket/Namespaces'; + +const socket = useNamespaces(); + +// Types +interface CanInterface { + enabled: boolean; + channel: string; + bitrate: number; + bustype: string; + is_extended: boolean; + wait_for_ecu: boolean; +} + +interface CanSensor { + label: string; + unit?: string; + req_id: string; + rep_id: string; + priority: number; + enabled: boolean; + interface: string; + parameter: string[]; +} + +interface CanSignalSensor { + key: string; + label: string; + interface: string; + enabled: boolean; + can_id: string; + byte_index: number; + bit_index: number; + invert?: boolean; + scale?: string; +} + +export interface CanConfig { + interfaces: CanInterface[]; + sensors: Record; + signal_sensors: CanSignalSensor[]; +} + +// Styled components +const Divider = styled.div` + flex: 1 1 0px; + border-bottom: 1px solid ${({ theme }) => theme.colors.dark}; + margin-left: 5px; + margin-right: 5px; + margin-top: 5px; +`; + +const Element = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + height: 35px; + width: 100%; + margin-bottom: 12px; +`; + +const Spacer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; + width: ${({ theme }) => theme.interaction.buttonWidth}px; + padding-right: 5px; + box-sizing: border-box; +`; + +// Collapsible section header — same height/style as Element but clickable +const CollapsibleHeader = styled.div` + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + height: 35px; + width: 100%; + margin-bottom: 8px; + &:active { opacity: 0.7; } +`; + +const Chevron = styled.span` + color: ${({ theme }) => theme.colors.medium}; + font-size: ${({ theme }) => theme.typography.caption2.fontSize}; + padding-left: 8px; + flex-shrink: 0; +`; + +// Table components for sensor details +const SensorTable = styled.table` + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +`; + +const Th = styled.th` + color: ${({ theme }) => theme.colors.medium}; + font-family: ${({ theme }) => theme.typography.caption2.fontFamily}; + font-size: ${({ theme }) => theme.typography.caption2.fontSize}; + font-weight: 600; + text-align: left; + padding: 3px 6px 5px 6px; + border-bottom: 1px solid ${({ theme }) => theme.colors.dark}; + white-space: nowrap; +`; + +const Td = styled.td` + color: ${({ theme }) => theme.colors.light}; + font-family: ${({ theme }) => theme.typography.caption1.fontFamily}; + font-size: ${({ theme }) => theme.typography.caption1.fontSize}; + padding: 10px 6px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +`; + +const TdControl = styled.td` + padding: 10px 6px; + vertical-align: middle; + white-space: nowrap; +`; + +const Tr = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.colors.dark}; + &:last-child { border-bottom: none; } +`; + +const GroupTr = styled.tr` + background-color: ${({ theme }) => theme.colors.dark}; +`; + +const GroupTd = styled.td` + color: ${({ theme }) => theme.colors.medium}; + font-family: ${({ theme }) => theme.typography.caption2.fontFamily}; + font-size: ${({ theme }) => theme.typography.caption2.fontSize}; + font-weight: 600; + padding: 3px 6px; +`; + +const CanSettings = () => { + const Title = Typography.Title; + const Caption2 = Typography.Caption2; + + const canSettings = CAN((state) => state.settings) as unknown as CanConfig; + const canUpdate = CAN((state) => state.update); + const canState = APP((state) => state.system.canState); + const themeColor = useThemeColor(); + const theme = useTheme(); + + const [sensorsOpen, setSensorsOpen] = useState(true); + const [byTargetOpen, setByTargetOpen] = useState(false); + const [byPriorityOpen, setByPriorityOpen] = useState(false); + const [signalOpen, setSignalOpen] = useState(true); + + if (!canSettings || !canSettings.sensors) { + return ( + + CAN settings not available. + + ); + } + + const interfaces = canSettings.interfaces ?? []; + const sensors = canSettings.sensors ?? {}; + const signalSensors = canSettings.signal_sensors ?? []; + + const toggleSensor = (sensorKey: string) => { + const updated = structuredClone(canSettings); + updated.sensors[sensorKey].enabled = !updated.sensors[sensorKey].enabled; + canUpdate((state) => { state.settings = updated as unknown as Record; }); + socket.can.emit('save', updated); + }; + + const toggleSignalSensor = (index: number) => { + const updated = structuredClone(canSettings); + updated.signal_sensors[index].enabled = !updated.signal_sensors[index].enabled; + canUpdate((state) => { state.settings = updated as unknown as Record; }); + socket.can.emit('save', updated); + }; + + // Group sensors by rep_id + const byTarget = Object.entries(sensors).reduce>( + (acc, entry) => { + const repId = entry[1].rep_id; + if (!acc[repId]) acc[repId] = []; + acc[repId].push(entry); + return acc; + }, + {} + ); + + // Group sensors by priority + const byPriority = Object.entries(sensors).reduce>( + (acc, entry) => { + const prio = String(entry[1].priority); + if (!acc[prio]) acc[prio] = []; + acc[prio].push(entry); + return acc; + }, + {} + ); + + const renderToggle = (sensorKey: string, sensor: CanSensor) => ( + + toggleSensor(sensorKey)} /> + + + ); + + return ( + <> + {/* Module state toggle */} + + MODULE + + + {`CAN Bus ${canState ? '(Active)' : '(Inactive)'}`} + + + + socket.can.emit('toggle')} + /> + + + + + + {/* Interfaces — read-only */} + + INTERFACES + + {interfaces.map((iface, i) => ( + + {`${iface.channel} — ${iface.bitrate ? iface.bitrate / 1000 : '?'} kbps — ${iface.bustype}`} + + + + {iface.enabled ? 'Enabled' : 'Disabled'} + + + + ))} + + {/* Sensors — flat list (collapsible) */} + setSensorsOpen((o) => !o)}> + SENSORS + + {sensorsOpen ? '▼' : '▶'} + + {sensorsOpen && ( + + + + NAME + UNIT + PARAMS + REQ ID + REP ID + PRIO + + + + + {Object.entries(sensors).map(([key, sensor]) => ( + + {sensor.label} + {sensor.unit ?? '—'} + {sensor.parameter?.join(', ') ?? '—'} + {sensor.req_id} + {sensor.rep_id} + {sensor.priority} + {renderToggle(key, sensor)} + + ))} + + + )} + + {/* Sensors — grouped by target / rep_id (collapsible) */} + setByTargetOpen((o) => !o)}> + SENSORS BY TARGET + + {byTargetOpen ? '▼' : '▶'} + + {byTargetOpen && ( + + + + NAME + UNIT + PARAMS + REQ ID + PRIO + + + + + {Object.entries(byTarget).map(([repId, entries]) => ( + + + {repId} + + {entries.map(([key, sensor]) => ( + + {sensor.label} + {sensor.unit ?? '—'} + {sensor.parameter?.join(', ') ?? '—'} + {sensor.req_id} + {sensor.priority} + {renderToggle(key, sensor)} + + ))} + + ))} + + + )} + + {/* Sensors — grouped by priority (collapsible) */} + setByPriorityOpen((o) => !o)}> + SENSORS BY PRIORITY + + {byPriorityOpen ? '▼' : '▶'} + + {byPriorityOpen && ( + + + + NAME + UNIT + PARAMS + REQ ID + REP ID + + + + + {Object.entries(byPriority) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([prio, entries]) => ( + + + {`Priority ${prio}`} + + {entries.map(([key, sensor]) => ( + + {sensor.label} + {sensor.unit ?? '—'} + {sensor.parameter?.join(', ') ?? '—'} + {sensor.req_id} + {sensor.rep_id} + {renderToggle(key, sensor)} + + ))} + + ))} + + + )} + + {/* Signal Sensors (collapsible) */} + setSignalOpen((o) => !o)}> + SIGNAL SENSORS + + {signalOpen ? '▼' : '▶'} + + {signalOpen && ( + + + + NAME + INTERFACE + CAN ID + BYTE + BIT + + + + + {signalSensors.map((signal, i) => ( + + {signal.label || signal.key} + {signal.interface} + {signal.can_id} + {signal.byte_index} + {signal.bit_index} + + + toggleSignalSensor(i)} + /> + + + + + ))} + + + )} + > + ); +}; + +export default CanSettings; diff --git a/frontend/src/app/pages/settings/Settings.tsx b/frontend/src/app/pages/settings/Settings.tsx index 125f095..cdef36b 100755 --- a/frontend/src/app/pages/settings/Settings.tsx +++ b/frontend/src/app/pages/settings/Settings.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, ReactNode } from 'react'; +import CanSettings from './CanSettings'; import styled, { useTheme } from 'styled-components'; import ScrollContainer from 'react-indiana-drag-scroll' @@ -787,6 +788,13 @@ const Settings = () => { > } + {settingPage === 'can' && + <> + + + > + } + { saveSettings() }}> {save ? 'All Settings saved.' : 'Save Settings'} diff --git a/frontend/src/app/sidebars/SideBar.tsx b/frontend/src/app/sidebars/SideBar.tsx index d255409..97c343a 100755 --- a/frontend/src/app/sidebars/SideBar.tsx +++ b/frontend/src/app/sidebars/SideBar.tsx @@ -65,6 +65,7 @@ const SideBar = ({ collapseLength }: SideBarProps) => { const appUpdate = APP((state) => state.update) const versionNumber = APP((state) => state.system.version) const sideBarWidth = APP((state) => (state.settings.side_bars as SideBarsSettings | undefined)?.sideBarWidth?.value ?? 0); + const canEnabled = APP((state) => (state.settings as any)?.constants?.modules?.can); const themeColor = useThemeColor(); const theme = useTheme(); @@ -155,6 +156,26 @@ const SideBar = ({ collapseLength }: SideBarProps) => { + {canEnabled && + handleTabChange('can')} + isActive={currentTab === 'can'} + activeColor={theme.colors.light} + inactiveColor={theme.colors.medium}> + + + + + + CAN Bus + + + + } handleTabChange('interface')} isActive={currentTab === 'interface'} diff --git a/tests/functional/test_socketio_server.py b/tests/functional/test_socketio_server.py index 74eb3a5..57b615b 100644 --- a/tests/functional/test_socketio_server.py +++ b/tests/functional/test_socketio_server.py @@ -188,6 +188,14 @@ def test_data_request_returns_car_data(seeded_config_dir): client.disconnect(namespace='/data') +_CAN_PROFILE = _APP_ROOT / 'backend' / 'config' / 'profiles' / 'P1' / 'T5' / 'can.json' + + +def _seed_can(config_dir): + """Copy the P1/T5 can.json into config_dir so load_settings('can') succeeds.""" + shutil.copy(_CAN_PROFILE, config_dir / 'can.json') + + # /can namespace def test_can_toggle_sets_shared_state_event(seeded_config_dir): """'toggle' on /can must set shared_state.toggle_can.""" @@ -204,3 +212,62 @@ def test_can_toggle_sets_shared_state_event(seeded_config_dir): finally: shared_state.toggle_can.clear() client.disconnect(namespace='/can') + + +def test_can_load_returns_settings(seeded_config_dir): + """'load' on /can must respond with a 'settings' event containing sensors and interfaces.""" + _seed_can(seeded_config_dir) + from backend.server import server, socketio + server.config['TESTING'] = True + + client = socketio.test_client(server, namespace='/can') + try: + client.get_received('/can') + client.emit('load', namespace='/can') + received = client.get_received('/can') + settings_events = [r for r in received if r['name'] == 'settings'] + assert len(settings_events) == 1 + payload = settings_events[0]['args'][0] + assert isinstance(payload, dict) + assert 'sensors' in payload + assert 'interfaces' in payload + assert 'signal_sensors' in payload + finally: + client.disconnect(namespace='/can') + + +def test_can_save_persists_sensor_enabled(seeded_config_dir): + """'save' on /can must write the modified sensor enabled state to can.json.""" + _seed_can(seeded_config_dir) + from backend.server import server, socketio + server.config['TESTING'] = True + + # Build a minimal payload toggling boost sensor to disabled + can_data = json.loads(_CAN_PROFILE.read_text()) + can_data['sensors']['boost']['enabled'] = False + + client = socketio.test_client(server, namespace='/can') + try: + client.emit('save', can_data, namespace='/can') + written = json.loads((seeded_config_dir / 'can.json').read_text()) + assert written['sensors']['boost']['enabled'] is False + finally: + client.disconnect(namespace='/can') + + +def test_can_save_persists_signal_sensor_enabled(seeded_config_dir): + """'save' on /can must persist signal_sensor enabled toggles to can.json.""" + _seed_can(seeded_config_dir) + from backend.server import server, socketio + server.config['TESTING'] = True + + can_data = json.loads(_CAN_PROFILE.read_text()) + can_data['signal_sensors'][0]['enabled'] = True + + client = socketio.test_client(server, namespace='/can') + try: + client.emit('save', can_data, namespace='/can') + written = json.loads((seeded_config_dir / 'can.json').read_text()) + assert written['signal_sensors'][0]['enabled'] is True + finally: + client.disconnect(namespace='/can')