229 lines
7.2 KiB
Python
229 lines
7.2 KiB
Python
"""Tests for reliable serial-port identification.
|
|
|
|
The point of these tests is that picking the wrong ttyUSB silently is the
|
|
failure mode that matters, so ambiguity must raise rather than guess.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
SRC = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
|
)
|
|
if SRC not in sys.path:
|
|
sys.path.insert(0, SRC)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from rdm_card_reader import ports # noqa: E402
|
|
from rdm_card_reader.ports import ( # noqa: E402
|
|
AmbiguousPort,
|
|
PortInfo,
|
|
PortNotFound,
|
|
best_usb_candidates,
|
|
resolve,
|
|
)
|
|
|
|
|
|
def make_port(device, vid=None, pid=None, serial=None, description="",
|
|
aliases=None, manufacturer=None, product=None):
|
|
return PortInfo(
|
|
device=device,
|
|
vid=vid,
|
|
pid=pid,
|
|
serial_number=serial,
|
|
description=description,
|
|
aliases=list(aliases or []),
|
|
manufacturer=manufacturer,
|
|
product=product,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def two_adapters(monkeypatch):
|
|
"""Two identical CH340 adapters plus an on-board UART."""
|
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A",
|
|
"USB Serial", ["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
|
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "CH340B",
|
|
"USB Serial", ["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
|
onboard = make_port("/dev/ttyS0", description="ttyS0")
|
|
monkeypatch.setattr(ports, "discover", lambda: [a, b, onboard])
|
|
return a, b, onboard
|
|
|
|
|
|
# -- identity ---------------------------------------------------------------
|
|
|
|
def test_usb_id_and_chip_name():
|
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
|
assert port.usb_id == "1a86:7523"
|
|
assert port.chip == "CH340"
|
|
assert port.is_usb is True
|
|
|
|
|
|
def test_unknown_chip_still_identified_as_usb():
|
|
port = make_port("/dev/ttyUSB0", 0xFFFF, 0x0001)
|
|
assert port.chip is None
|
|
assert port.is_usb is True
|
|
|
|
|
|
def test_stable_id_prefers_by_id_symlink():
|
|
port = make_port(
|
|
"/dev/ttyUSB0",
|
|
aliases=["/dev/serial/by-path/pci-1-usb-0:1.2:1.0-port0",
|
|
"/dev/serial/by-id/usb-1a86_USB-if00-port0"],
|
|
)
|
|
assert port.stable_id == "/dev/serial/by-id/usb-1a86_USB-if00-port0"
|
|
assert port.by_path.endswith("port0")
|
|
|
|
|
|
def test_onboard_uart_is_not_usb():
|
|
assert make_port("/dev/ttyS0").is_usb is False
|
|
|
|
|
|
# -- scoring ----------------------------------------------------------------
|
|
|
|
def test_usb_adapter_scores_above_onboard_uart():
|
|
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
|
onboard = make_port("/dev/ttyS0")
|
|
assert adapter.score() > onboard.score()
|
|
|
|
|
|
def test_serial_number_raises_score():
|
|
with_sn = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
|
without = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, None)
|
|
assert with_sn.score() > without.score()
|
|
|
|
|
|
# -- matching ---------------------------------------------------------------
|
|
|
|
def test_match_by_device_path():
|
|
assert make_port("/dev/ttyUSB0").matches("/dev/ttyUSB0")
|
|
|
|
|
|
def test_match_by_serial_number():
|
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A")
|
|
assert port.matches("CH340A")
|
|
|
|
|
|
def test_match_by_vid_pid():
|
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
|
assert port.matches("1a86:7523")
|
|
|
|
|
|
def test_match_by_chip_name():
|
|
assert make_port("/dev/ttyUSB0", 0x1A86, 0x7523).matches("ch340")
|
|
|
|
|
|
def test_match_by_id_symlink():
|
|
port = make_port("/dev/ttyUSB0",
|
|
aliases=["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
|
assert port.matches("/dev/serial/by-id/usb-1a86_A-if00-port0")
|
|
|
|
|
|
def test_empty_selector_never_matches():
|
|
assert not make_port("/dev/ttyUSB0").matches("")
|
|
|
|
|
|
# -- resolution -------------------------------------------------------------
|
|
|
|
def test_resolve_by_serial_number_picks_right_adapter(two_adapters):
|
|
device, reason = resolve(selectors=["CH340B"])
|
|
assert device == "/dev/ttyUSB1"
|
|
assert "matched" in reason
|
|
|
|
|
|
def test_resolve_by_by_id_path(two_adapters):
|
|
device, _ = resolve(selectors=["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
|
assert device == "/dev/ttyUSB1"
|
|
|
|
|
|
def test_resolve_refuses_when_selector_matches_both(two_adapters):
|
|
"""A vid:pid shared by two adapters must not silently pick one."""
|
|
with pytest.raises(AmbiguousPort):
|
|
resolve(selectors=["1a86:7523"])
|
|
|
|
|
|
def test_resolve_unknown_selector_raises_not_found(two_adapters):
|
|
with pytest.raises(PortNotFound):
|
|
resolve(selectors=["NOSUCHDEVICE"])
|
|
|
|
|
|
def test_resolve_falls_back_to_raw_path_when_not_enumerable(monkeypatch, tmp_path):
|
|
fake = tmp_path / "myport"
|
|
fake.write_text("")
|
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
|
device, reason = resolve(selectors=[str(fake)])
|
|
assert device == str(fake)
|
|
assert "explicit path" in reason
|
|
|
|
|
|
def test_autodetect_requires_exactly_one(monkeypatch):
|
|
single = make_port("/dev/ttyUSB3", 0x1A86, 0x7523, "ONLY")
|
|
monkeypatch.setattr(ports, "discover", lambda: [single, make_port("/dev/ttyS0")])
|
|
device, reason = resolve(autodetect=True)
|
|
assert device == "/dev/ttyUSB3"
|
|
assert "autodetected" in reason
|
|
|
|
|
|
def test_autodetect_refuses_with_two_adapters(two_adapters):
|
|
with pytest.raises(AmbiguousPort):
|
|
resolve(autodetect=True)
|
|
|
|
|
|
def test_autodetect_ignores_onboard_uart(monkeypatch):
|
|
"""An on-board UART must not make a single USB adapter look ambiguous."""
|
|
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ONLY")
|
|
monkeypatch.setattr(
|
|
ports, "discover",
|
|
lambda: [adapter, make_port("/dev/ttyS0"), make_port("/dev/ttyAMA0")],
|
|
)
|
|
device, _ = resolve(autodetect=True)
|
|
assert device == "/dev/ttyUSB0"
|
|
|
|
|
|
def test_fallback_order_used_when_no_usb(monkeypatch):
|
|
"""Must not depend on the host actually having a serial port."""
|
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
|
monkeypatch.setattr(ports.os.path, "exists", lambda path: path == "/dev/ttyS0")
|
|
device, reason = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
|
assert device == "/dev/ttyS0"
|
|
assert "default order" in reason
|
|
|
|
|
|
def test_fallback_order_skips_missing_ports(monkeypatch):
|
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
|
monkeypatch.setattr(
|
|
ports.os.path, "exists", lambda path: path == "/dev/ttyUSB0"
|
|
)
|
|
device, _ = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
|
assert device == "/dev/ttyUSB0"
|
|
|
|
|
|
def test_no_port_at_all_raises(monkeypatch):
|
|
monkeypatch.setattr(ports, "discover", lambda: [])
|
|
with pytest.raises(PortNotFound):
|
|
resolve(fallback_ports=[])
|
|
|
|
|
|
# -- helper ----------------------------------------------------------------
|
|
|
|
def test_best_usb_candidates_dedupes_same_identity():
|
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SAME")
|
|
dup = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "SAME")
|
|
result = best_usb_candidates([a, dup])
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_best_usb_candidates_keeps_distinct_serial_numbers():
|
|
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "A")
|
|
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "B")
|
|
assert len(best_usb_candidates([a, b])) == 2
|
|
|
|
|
|
def test_describe_is_human_readable():
|
|
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ABC", "USB Serial")
|
|
text = port.describe()
|
|
assert "/dev/ttyUSB0" in text
|
|
assert "CH340" in text
|
|
assert "ABC" in text
|