Add USB camera and uhubctl support for new test suite. Also included some bug fixes (#10204)
* Add USB camera and uhubctl support for new test suite. Also added some bug fixes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refactor test messages for clarity and consistency in regex tests --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
GitHub
Copilot Autofix powered by AI
parent
6b15571e14
commit
de23e5199d
@@ -0,0 +1,90 @@
|
||||
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
|
||||
|
||||
If this test fails, someone changed the firmware enum (or this Python
|
||||
mirror) and they must stay in sync — the admin RPC sends these as u8
|
||||
wire values directly.
|
||||
|
||||
Also exercises `coerce_event_code` for the happy + error paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.input_events import InputEventCode, coerce_event_code
|
||||
|
||||
|
||||
class TestInputEventCodeValues:
|
||||
"""These values MUST match src/input/InputBroker.h exactly."""
|
||||
|
||||
def test_navigation_keys(self) -> None:
|
||||
assert int(InputEventCode.UP) == 17
|
||||
assert int(InputEventCode.DOWN) == 18
|
||||
assert int(InputEventCode.LEFT) == 19
|
||||
assert int(InputEventCode.RIGHT) == 20
|
||||
|
||||
def test_action_keys(self) -> None:
|
||||
assert int(InputEventCode.SELECT) == 10
|
||||
assert int(InputEventCode.CANCEL) == 24
|
||||
assert int(InputEventCode.BACK) == 27
|
||||
|
||||
def test_long_press_variants(self) -> None:
|
||||
assert int(InputEventCode.SELECT_LONG) == 11
|
||||
assert int(InputEventCode.UP_LONG) == 12
|
||||
assert int(InputEventCode.DOWN_LONG) == 13
|
||||
|
||||
def test_fn_keys(self) -> None:
|
||||
assert int(InputEventCode.FN_F1) == 0xF1
|
||||
assert int(InputEventCode.FN_F2) == 0xF2
|
||||
assert int(InputEventCode.FN_F3) == 0xF3
|
||||
assert int(InputEventCode.FN_F4) == 0xF4
|
||||
assert int(InputEventCode.FN_F5) == 0xF5
|
||||
|
||||
def test_system_events(self) -> None:
|
||||
assert int(InputEventCode.SHUTDOWN) == 0x9B
|
||||
assert int(InputEventCode.GPS_TOGGLE) == 0x9E
|
||||
assert int(InputEventCode.SEND_PING) == 0xAF
|
||||
|
||||
def test_auto_increment_block(self) -> None:
|
||||
# C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30.
|
||||
assert int(InputEventCode.USER_PRESS) == 28
|
||||
assert int(InputEventCode.ALT_PRESS) == 29
|
||||
assert int(InputEventCode.ALT_LONG) == 30
|
||||
|
||||
|
||||
class TestCoerceEventCode:
|
||||
def test_int_passthrough(self) -> None:
|
||||
assert coerce_event_code(20) == 20
|
||||
assert coerce_event_code(0) == 0
|
||||
assert coerce_event_code(255) == 255
|
||||
|
||||
def test_enum_passthrough(self) -> None:
|
||||
assert coerce_event_code(InputEventCode.RIGHT) == 20
|
||||
assert coerce_event_code(InputEventCode.FN_F1) == 0xF1
|
||||
|
||||
def test_name_case_insensitive(self) -> None:
|
||||
assert coerce_event_code("right") == 20
|
||||
assert coerce_event_code("RIGHT") == 20
|
||||
assert coerce_event_code("Right") == 20
|
||||
|
||||
def test_input_broker_prefix_stripped(self) -> None:
|
||||
assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1
|
||||
assert coerce_event_code("input_broker_select") == 10
|
||||
|
||||
def test_hyphen_and_underscore_equivalence(self) -> None:
|
||||
assert coerce_event_code("fn-f1") == 0xF1
|
||||
|
||||
def test_int_out_of_range_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="u8"):
|
||||
coerce_event_code(256)
|
||||
with pytest.raises(ValueError, match="u8"):
|
||||
coerce_event_code(-1)
|
||||
|
||||
def test_unknown_name_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="unknown event code name"):
|
||||
coerce_event_code("NOT_A_KEY")
|
||||
|
||||
def test_wrong_type_raises(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
coerce_event_code(1.5) # type: ignore[arg-type]
|
||||
with pytest.raises(TypeError):
|
||||
coerce_event_code(None) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Pin the `uhubctl` default-output parser against canned real-world samples.
|
||||
|
||||
uhubctl's output format has been stable since v2.x but occasionally adds
|
||||
new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
|
||||
regexes to tolerate additions; this test keeps us honest.
|
||||
|
||||
Samples captured from:
|
||||
- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an
|
||||
nRF52 and a CP2102, plus chained USB3 hubs.
|
||||
- v2.5.0 on Linux (hypothetical — reconstructed from the project README).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.uhubctl import (
|
||||
ROLE_VIDS,
|
||||
UhubctlError,
|
||||
parse_list_output,
|
||||
)
|
||||
|
||||
# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026.
|
||||
_SAMPLE_MACOS_V26 = """\
|
||||
Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 1: 0100 power
|
||||
Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262]
|
||||
Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001]
|
||||
Port 4: 0100 power
|
||||
Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps]
|
||||
Port 1: 02a0 power 5gbps Rx.Detect
|
||||
Port 2: 02a0 power 5gbps Rx.Detect
|
||||
Port 3: 02a0 power 5gbps Rx.Detect
|
||||
Port 4: 02a0 power 5gbps Rx.Detect
|
||||
Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 1: 0100 power
|
||||
Port 2: 0100 power
|
||||
Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
|
||||
Port 4: 0100 power
|
||||
"""
|
||||
|
||||
|
||||
# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub).
|
||||
_SAMPLE_LINUX_NONPPPS = """\
|
||||
Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports]
|
||||
Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader]
|
||||
Port 2: 0100 power
|
||||
Port 3: 0100 power
|
||||
Port 4: 0100 power
|
||||
"""
|
||||
|
||||
|
||||
class TestParseListOutput:
|
||||
def test_parses_macos_sample_hub_count(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
assert len(hubs) == 3
|
||||
|
||||
def test_parses_hub_location_and_vid(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
via_hub = hubs[0]
|
||||
assert via_hub["location"] == "1-1.3"
|
||||
assert via_hub["vid"] == 0x2109
|
||||
assert via_hub["pid"] == 0x2817
|
||||
assert via_hub["ppps"] is True
|
||||
|
||||
def test_parses_port_with_device(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
nrf52_hub = hubs[0]
|
||||
port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2)
|
||||
assert port2["device_vid"] == 0x239A
|
||||
assert port2["device_pid"] == 0x8029
|
||||
assert "RAKwireless" in port2["device_desc"]
|
||||
|
||||
def test_empty_port_has_no_device(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
nrf52_hub = hubs[0]
|
||||
port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1)
|
||||
assert port1["device_vid"] is None
|
||||
assert port1["device_pid"] is None
|
||||
assert port1["device_desc"] is None
|
||||
|
||||
def test_ports_count(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_MACOS_V26)
|
||||
for hub in hubs:
|
||||
assert len(hub["ports"]) == 4 # each sample hub has 4 ports
|
||||
|
||||
def test_non_ppps_hub_flagged(self) -> None:
|
||||
hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS)
|
||||
assert len(hubs) == 1
|
||||
assert hubs[0]["ppps"] is False
|
||||
|
||||
def test_handles_empty_input(self) -> None:
|
||||
assert parse_list_output("") == []
|
||||
|
||||
def test_handles_malformed_lines_gracefully(self) -> None:
|
||||
# Lines that don't match HUB_RE or PORT_RE are ignored silently.
|
||||
garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS
|
||||
hubs = parse_list_output(garbage)
|
||||
assert len(hubs) == 1
|
||||
|
||||
|
||||
class TestRoleVids:
|
||||
def test_nrf52_mapped(self) -> None:
|
||||
assert 0x239A in ROLE_VIDS["nrf52"]
|
||||
|
||||
def test_esp32s3_covers_both_vids(self) -> None:
|
||||
# Espressif native USB + CP2102 USB-UART on heltec-v3 boards.
|
||||
assert 0x303A in ROLE_VIDS["esp32s3"]
|
||||
assert 0x10C4 in ROLE_VIDS["esp32s3"]
|
||||
|
||||
|
||||
class TestResolveTargetErrorPaths:
|
||||
def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# Clear any env-var pinning that might make this pass accidentally.
|
||||
for key in (
|
||||
"MESHTASTIC_UHUBCTL_LOCATION_FLUX",
|
||||
"MESHTASTIC_UHUBCTL_PORT_FLUX",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
with pytest.raises(UhubctlError, match="unknown role"):
|
||||
resolve_target("flux")
|
||||
|
||||
def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int")
|
||||
with pytest.raises(UhubctlError, match="not a valid integer"):
|
||||
resolve_target("nrf52")
|
||||
|
||||
def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# Env-var pinning should NOT require uhubctl to be running / installed.
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7")
|
||||
assert resolve_target("nrf52") == ("9-9.9", 7)
|
||||
|
||||
def test_normalize_role_strips_alt_suffix(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from meshtastic_mcp.uhubctl import resolve_target
|
||||
|
||||
# esp32s3_alt collapses to esp32s3 for env-var lookup.
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2")
|
||||
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3")
|
||||
assert resolve_target("esp32s3_alt") == ("2-2", 3)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
|
||||
|
||||
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
|
||||
if the format string changes, this test — and the parser in
|
||||
`tests/ui/_screen_log.py` — have to be updated together.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events
|
||||
|
||||
|
||||
class TestFrameEventParse:
|
||||
def test_exact_firmware_output(self) -> None:
|
||||
raw = "Screen: frame 2/8 name=home reason=next"
|
||||
evt = FrameEvent.parse(raw)
|
||||
assert evt is not None
|
||||
assert evt.idx == 2
|
||||
assert evt.count == 8
|
||||
assert evt.name == "home"
|
||||
assert evt.reason == "next"
|
||||
assert evt.raw == raw
|
||||
|
||||
def test_with_log_prefix(self) -> None:
|
||||
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
|
||||
(timestamp, severity, etc.) — the regex uses .search() not .match()
|
||||
so prefixes are fine."""
|
||||
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
|
||||
evt = FrameEvent.parse(raw)
|
||||
assert evt is not None
|
||||
assert evt.idx == 4
|
||||
assert evt.count == 12
|
||||
assert evt.name == "nodelist_nodes"
|
||||
assert evt.reason == "fn_f3"
|
||||
|
||||
def test_rebuild_reason(self) -> None:
|
||||
evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild")
|
||||
assert evt is not None
|
||||
assert evt.reason == "rebuild"
|
||||
|
||||
def test_all_fn_reasons(self) -> None:
|
||||
for k in range(1, 6):
|
||||
evt = FrameEvent.parse(
|
||||
f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}"
|
||||
)
|
||||
assert evt is not None and evt.reason == f"fn_f{k}"
|
||||
|
||||
def test_unknown_name_is_preserved(self) -> None:
|
||||
"""If the reverse-map returns 'unknown', that still parses cleanly."""
|
||||
evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev")
|
||||
assert evt is not None and evt.name == "unknown"
|
||||
|
||||
def test_non_matching_line_returns_none(self) -> None:
|
||||
assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None
|
||||
assert FrameEvent.parse("") is None
|
||||
assert FrameEvent.parse("Screen: without the right format") is None
|
||||
|
||||
|
||||
class TestIterFrameEvents:
|
||||
def test_filters_non_matching_lines(self) -> None:
|
||||
lines = [
|
||||
"Booting...",
|
||||
"Screen: frame 1/5 name=home reason=rebuild",
|
||||
"Some other log line",
|
||||
"Screen: frame 2/5 name=textMessage reason=next",
|
||||
]
|
||||
evts = list(iter_frame_events(lines))
|
||||
assert len(evts) == 2
|
||||
assert evts[0].reason == "rebuild"
|
||||
assert evts[1].reason == "next"
|
||||
|
||||
|
||||
class TestRegexAnchoring:
|
||||
def test_regex_is_compiled(self) -> None:
|
||||
assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None
|
||||
|
||||
def test_regex_allows_unusual_names(self) -> None:
|
||||
r"""Name is `\S+`, so compound names with underscores/digits match."""
|
||||
m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2")
|
||||
assert m is not None and m["name"] == "nodelist_hopsignal"
|
||||
Reference in New Issue
Block a user