"""Tests for the serial transport abstraction.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from ledgrab.core.devices.serial_transport import ( DEFAULT_BAUD_RATE, PySerialTransport, SerialPortInfo, list_serial_ports, open_transport, parse_serial_url, port_exists, ) # ── URL parsing ──────────────────────────────────────────────────── @pytest.mark.parametrize( "url,expected", [ ("COM3", ("COM3", DEFAULT_BAUD_RATE)), ("COM3:230400", ("COM3", 230400)), ("/dev/ttyUSB0", ("/dev/ttyUSB0", DEFAULT_BAUD_RATE)), ("/dev/ttyUSB0:230400", ("/dev/ttyUSB0", 230400)), # USB URLs use @ for baud since `:` separates VID:PID:serial ("usb:1a86:7523", ("usb:1a86:7523", DEFAULT_BAUD_RATE)), ("usb:1a86:7523:AB01", ("usb:1a86:7523:AB01", DEFAULT_BAUD_RATE)), ("usb:1a86:7523@230400", ("usb:1a86:7523", 230400)), ("usb:1a86:7523:AB01@500000", ("usb:1a86:7523:AB01", 500000)), ], ) def test_parse_serial_url(url, expected): assert parse_serial_url(url) == expected def test_parse_serial_url_strips_whitespace(): assert parse_serial_url(" COM3 ") == ("COM3", DEFAULT_BAUD_RATE) # ── PySerialTransport ────────────────────────────────────────────── def test_pyserial_transport_open_and_write(monkeypatch): fake_serial_module = MagicMock() fake_handle = MagicMock(is_open=True) fake_serial_module.Serial.return_value = fake_handle monkeypatch.setitem(__import__("sys").modules, "serial", fake_serial_module) t = PySerialTransport("COM3", 115200) assert not t.is_open t.open() fake_serial_module.Serial.assert_called_once_with(port="COM3", baudrate=115200, timeout=1.0) assert t.is_open t.write(b"hello") fake_handle.write.assert_called_once_with(b"hello") t.flush() fake_handle.flush.assert_called_once() t.close() fake_handle.close.assert_called_once() def test_pyserial_transport_write_before_open_raises(): t = PySerialTransport("COM3", 115200) with pytest.raises(RuntimeError, match="not open"): t.write(b"x") # ── Factory ──────────────────────────────────────────────────────── def test_open_transport_picks_pyserial_for_com_url(): t = open_transport("COM3:230400") assert isinstance(t, PySerialTransport) assert t._device == "COM3" assert t._baud_rate == 230400 def test_open_transport_explicit_baud_overrides_url(): t = open_transport("COM3:230400", baud_rate=500000) assert t._baud_rate == 500000 def test_open_transport_picks_android_for_usb_url(monkeypatch): """usb: URLs should route to the Android transport even off-Android.""" # Importing AndroidSerialTransport itself works on any host; only the # bridge call inside .open() fails when not on Android. t = open_transport("usb:1a86:7523@230400") assert type(t).__name__ == "AndroidSerialTransport" assert t._baud_rate == 230400 # ── Discovery ────────────────────────────────────────────────────── def test_list_serial_ports_uses_pyserial_on_desktop(monkeypatch): monkeypatch.setattr("ledgrab.core.devices.serial_transport.is_android", lambda: False) fake_module = MagicMock() fake_module.tools.list_ports.comports.return_value = [ MagicMock(device="COM3", description="USB Serial CH340"), MagicMock(device="COM4", description=None), ] with patch.dict( "sys.modules", { "serial": fake_module, "serial.tools": fake_module.tools, "serial.tools.list_ports": fake_module.tools.list_ports, }, ): ports = list_serial_ports() assert len(ports) == 2 assert ports[0] == SerialPortInfo(device="COM3", description="USB Serial CH340") # Falls back to device id when description is None assert ports[1].description == "COM4" def test_list_serial_ports_routes_to_android_when_on_android(monkeypatch): monkeypatch.setattr("ledgrab.core.devices.serial_transport.is_android", lambda: True) fake_devices = [SerialPortInfo(device="usb:1a86:7523", description="CH340 LED")] fake_mod = MagicMock() fake_mod.list_android_usb_devices.return_value = fake_devices monkeypatch.setitem( __import__("sys").modules, "ledgrab.core.devices.android_serial_transport", fake_mod, ) assert list_serial_ports() == fake_devices def test_port_exists_is_case_insensitive(monkeypatch): fake_ports = [ SerialPortInfo(device="COM3", description="x"), SerialPortInfo(device="/dev/ttyUSB0", description="y"), ] monkeypatch.setattr( "ledgrab.core.devices.serial_transport.list_serial_ports", lambda: fake_ports ) assert port_exists("com3") is True assert port_exists("/dev/ttyusb0") is True assert port_exists("COM99") is False