888f8fd16e
ruff --select UP007,UP045 --fix converted ~1760 sites across the backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The remaining module-level alias targets that ruff conservatively skips (BindableFloatInput, ColorList, DeviceConfig) were converted by hand earlier in the pass. black -formatted the result so the wider unions fit cleanly under the 100-char line budget. pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007", "UP045"] so future legacy imports fire CI on every push. The pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise UP045 (split off from UP007 in v0.13).
153 lines
5.2 KiB
Python
153 lines
5.2 KiB
Python
"""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
|