Step 2 - LDF Loading: - ldfparser integration (Python) / custom regex parser (C++) - QTreeWidget with expandable signal rows, merged Value column - Hex/Dec toggle, FreeFormat schedule entries, auto-reload - Baud rate auto-detection from LDF Step 3 - Signal Editing: - Bit packing/unpacking (signal value ↔ frame bytes) - ReadOnlyColumnDelegate for per-column editability - Value clamping to signal width, recursion guard Step 4 - Rx Panel: - receive_rx_frame() API with timestamp, signal unpacking - Change highlighting (yellow), auto-scroll toggle, clear button - Dashboard view (in-place update per frame_id) Step 5 - Connection Panel: - ConnectionManager with state machine (Disconnected/Connecting/Connected/Error) - Port scanning (pyserial / QSerialPort), connect/disconnect with UI mapping Step 6 - BabyLIN Backend: - BabyLinBackend wrapping Lipowsky BabyLIN_library.py DLL - Mock mode for macOS/CI, device scan, SDF loading, signal access - Frame callbacks, raw command access Step 7 - Master Scheduler: - QTimer-based schedule execution with start/stop/pause - Frame sent callback with visual highlighting - Mock Rx simulation, manual send, global rate override Tests: Python 171 | C++ 124 (Steps 1-5 parity, Steps 6-7 Python-first) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
"""
|
|
test_connection.py — Tests for Step 5: Connection panel & device discovery.
|
|
|
|
Tests the ConnectionManager state machine and the GUI integration.
|
|
Uses mocking since we don't have real BabyLIN hardware in tests.
|
|
|
|
MOCKING IN TESTS:
|
|
=================
|
|
"Mocking" means replacing a real object with a fake one that
|
|
behaves predictably. We mock:
|
|
- serial.tools.list_ports.comports() → return fake port list
|
|
- serial.Serial() → return a fake serial port object
|
|
This lets us test the connect/disconnect logic without hardware.
|
|
|
|
Python's unittest.mock provides:
|
|
- patch(): temporarily replace a function/class during a test
|
|
- MagicMock(): create a fake object that records all calls to it
|
|
|
|
# In C/C++, you'd achieve this with dependency injection or
|
|
# link-time substitution (replacing a .o file with a mock .o).
|
|
"""
|
|
|
|
import sys
|
|
import pytest
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from PyQt6.QtWidgets import QApplication
|
|
from connection_manager import ConnectionManager, ConnectionState, PortInfo
|
|
|
|
SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app():
|
|
application = QApplication.instance() or QApplication(sys.argv)
|
|
yield application
|
|
|
|
|
|
@pytest.fixture
|
|
def mgr():
|
|
"""Fresh ConnectionManager for each test."""
|
|
return ConnectionManager()
|
|
|
|
|
|
@pytest.fixture
|
|
def window(app):
|
|
from main_window import MainWindow
|
|
w = MainWindow()
|
|
w._load_ldf_file(SAMPLE_LDF)
|
|
return w
|
|
|
|
|
|
# ─── ConnectionManager Unit Tests ─────────────────────────────────────
|
|
|
|
class TestConnectionState:
|
|
"""Test the connection state machine."""
|
|
|
|
def test_initial_state_disconnected(self, mgr):
|
|
assert mgr.state == ConnectionState.DISCONNECTED
|
|
|
|
def test_not_connected_initially(self, mgr):
|
|
assert not mgr.is_connected
|
|
|
|
def test_no_error_initially(self, mgr):
|
|
assert mgr.error_message == ""
|
|
|
|
def test_no_port_initially(self, mgr):
|
|
assert mgr.connected_port is None
|
|
|
|
|
|
class TestPortScanning:
|
|
"""Test serial port enumeration."""
|
|
|
|
@patch("connection_manager.serial.tools.list_ports.comports")
|
|
def test_scan_returns_ports(self, mock_comports, mgr):
|
|
"""Mock comports() to return fake ports."""
|
|
# Create fake port objects that have .device, .description, .hwid
|
|
fake_port = MagicMock()
|
|
fake_port.device = "/dev/ttyUSB0"
|
|
fake_port.description = "BabyLIN USB"
|
|
fake_port.hwid = "USB VID:PID=1234:5678"
|
|
mock_comports.return_value = [fake_port]
|
|
|
|
ports = mgr.scan_ports()
|
|
assert len(ports) == 1
|
|
assert ports[0].device == "/dev/ttyUSB0"
|
|
assert ports[0].description == "BabyLIN USB"
|
|
|
|
@patch("connection_manager.serial.tools.list_ports.comports")
|
|
def test_scan_empty_when_no_devices(self, mock_comports, mgr):
|
|
mock_comports.return_value = []
|
|
ports = mgr.scan_ports()
|
|
assert len(ports) == 0
|
|
|
|
|
|
class TestConnect:
|
|
"""Test connect/disconnect flow."""
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_connect_success(self, mock_serial_class, mgr):
|
|
"""Mock Serial() to simulate successful connection."""
|
|
mock_serial_class.return_value = MagicMock()
|
|
|
|
result = mgr.connect("/dev/ttyUSB0", 19200)
|
|
assert result is True
|
|
assert mgr.state == ConnectionState.CONNECTED
|
|
assert mgr.is_connected
|
|
assert mgr.connected_port is not None
|
|
assert mgr.connected_port.device == "/dev/ttyUSB0"
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_connect_failure(self, mock_serial_class, mgr):
|
|
"""Mock Serial() to simulate connection failure."""
|
|
import serial
|
|
mock_serial_class.side_effect = serial.SerialException("Port not found")
|
|
|
|
result = mgr.connect("/dev/nonexistent", 19200)
|
|
assert result is False
|
|
assert mgr.state == ConnectionState.ERROR
|
|
assert "Port not found" in mgr.error_message
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_disconnect_after_connect(self, mock_serial_class, mgr):
|
|
mock_serial_instance = MagicMock()
|
|
mock_serial_instance.is_open = True
|
|
mock_serial_class.return_value = mock_serial_instance
|
|
|
|
mgr.connect("/dev/ttyUSB0")
|
|
mgr.disconnect()
|
|
|
|
assert mgr.state == ConnectionState.DISCONNECTED
|
|
assert not mgr.is_connected
|
|
assert mgr.connected_port is None
|
|
# Verify serial.close() was called
|
|
mock_serial_instance.close.assert_called_once()
|
|
|
|
def test_disconnect_when_already_disconnected(self, mgr):
|
|
"""Disconnecting when already disconnected should be a no-op."""
|
|
mgr.disconnect()
|
|
assert mgr.state == ConnectionState.DISCONNECTED
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_reconnect_disconnects_first(self, mock_serial_class, mgr):
|
|
"""Connecting when already connected should disconnect first."""
|
|
mock_serial_instance = MagicMock()
|
|
mock_serial_instance.is_open = True
|
|
mock_serial_class.return_value = mock_serial_instance
|
|
|
|
mgr.connect("/dev/ttyUSB0")
|
|
mgr.connect("/dev/ttyUSB1")
|
|
|
|
# First connection should have been closed
|
|
mock_serial_instance.close.assert_called()
|
|
|
|
|
|
# ─── GUI Integration Tests ────────────────────────────────────────────
|
|
|
|
class TestConnectionUI:
|
|
"""Test that connection state changes update the GUI correctly."""
|
|
|
|
def test_initial_ui_disconnected(self, window):
|
|
assert "Disconnected" in window.lbl_conn_status.text()
|
|
assert window.btn_connect.isEnabled()
|
|
assert not window.btn_disconnect.isEnabled()
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_ui_after_connect(self, mock_serial_class, window):
|
|
mock_serial_class.return_value = MagicMock()
|
|
|
|
# Add a fake device to the dropdown
|
|
window.combo_device.addItem("Test Port", "/dev/ttyUSB0")
|
|
window.combo_device.setCurrentIndex(0)
|
|
window._on_connect()
|
|
|
|
assert "Connected" in window.lbl_conn_status.text()
|
|
assert not window.btn_connect.isEnabled()
|
|
assert window.btn_disconnect.isEnabled()
|
|
assert "Connected" in window.lbl_status_connection.text()
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_ui_after_disconnect(self, mock_serial_class, window):
|
|
mock_instance = MagicMock()
|
|
mock_instance.is_open = True
|
|
mock_serial_class.return_value = mock_instance
|
|
|
|
window.combo_device.addItem("Test Port", "/dev/ttyUSB0")
|
|
window.combo_device.setCurrentIndex(0)
|
|
window._on_connect()
|
|
window._on_disconnect()
|
|
|
|
assert "Disconnected" in window.lbl_conn_status.text()
|
|
assert window.btn_connect.isEnabled()
|
|
assert not window.btn_disconnect.isEnabled()
|
|
|
|
@patch("connection_manager.serial.Serial")
|
|
def test_ui_after_error(self, mock_serial_class, window):
|
|
import serial
|
|
mock_serial_class.side_effect = serial.SerialException("Access denied")
|
|
|
|
window.combo_device.addItem("Test Port", "/dev/ttyUSB0")
|
|
window.combo_device.setCurrentIndex(0)
|
|
|
|
# Monkeypatch the error dialog to avoid blocking
|
|
with patch.object(type(window), '_on_connect', lambda self: (
|
|
self._conn_mgr.connect(self.combo_device.currentData()),
|
|
self._update_connection_ui()
|
|
)):
|
|
window._conn_mgr.connect("/dev/ttyUSB0")
|
|
window._update_connection_ui()
|
|
|
|
assert "Error" in window.lbl_conn_status.text()
|
|
assert window.btn_connect.isEnabled() # Can retry
|
|
|
|
@patch("connection_manager.serial.tools.list_ports.comports")
|
|
def test_refresh_populates_dropdown(self, mock_comports, window):
|
|
fake_port = MagicMock()
|
|
fake_port.device = "/dev/ttyUSB0"
|
|
fake_port.description = "BabyLIN USB"
|
|
fake_port.hwid = "USB VID:PID=1234:5678"
|
|
mock_comports.return_value = [fake_port]
|
|
|
|
window._on_refresh_devices()
|
|
|
|
assert window.combo_device.count() == 1
|
|
assert "/dev/ttyUSB0" in window.combo_device.itemText(0)
|
|
|
|
@patch("connection_manager.serial.tools.list_ports.comports")
|
|
def test_refresh_empty_when_no_devices(self, mock_comports, window):
|
|
mock_comports.return_value = []
|
|
window._on_refresh_devices()
|
|
assert window.combo_device.count() == 0
|