Files
ledgrab/server/tests/test_serial_transport.py
T
alexei.dolgolyov 888f8fd16e refactor(types): PEP-604 union sweep + UP007/UP045 enforcement
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).
2026-05-23 01:21:44 +03:00

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