Lin_Simulator/python/tests/test_connection.py
Mohamed Salem cb60c2ad5d Steps 2-7: LDF loading, signal editing, Rx display, connection, BabyLIN backend, scheduler
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>
2026-04-04 14:21:24 +02:00

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