""" 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