mirror of
https://github.com/meshtastic/firmware.git
synced 2026-05-19 14:25:28 -04:00
* 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>
382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture,
|
|
and a `ui_home_state` autouse guard that resets to the home frame before
|
|
every test (prevents state bleed if a prior test exited inside a menu).
|
|
|
|
The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production
|
|
code, so the `capture_screen` MCP tool can share them). These fixtures wire
|
|
them into pytest + write per-test captures to `tests/ui_captures/…`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
import pytest
|
|
from meshtastic_mcp import admin as admin_mod
|
|
from meshtastic_mcp import camera as camera_mod
|
|
from meshtastic_mcp import ocr as ocr_mod
|
|
from meshtastic_mcp.input_events import InputEventCode
|
|
|
|
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
|
|
|
|
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
|
|
# SSD1306) qualifies today — nrf52 (rak4631) has no display.
|
|
UI_CAPABLE_ROLES = ("esp32s3",)
|
|
|
|
# Where per-test captures land. One subdirectory per session seed, then per
|
|
# sanitized test nodeid — identical pattern to other pytest artifacts.
|
|
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
|
|
|
|
|
|
def _sanitize_nodeid(nodeid: str) -> str:
|
|
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid)
|
|
|
|
|
|
# ---------- Role gating ----------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str:
|
|
"""Resolve the single role the UI tier drives.
|
|
|
|
Today that's `esp32s3`. Skips if the hub doesn't have one. A future
|
|
multi-screen hub could pick a role per parametrization.
|
|
"""
|
|
for role in UI_CAPABLE_ROLES:
|
|
if role in hub_devices:
|
|
return role
|
|
pytest.skip(
|
|
f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str:
|
|
port = (
|
|
hub_devices[ui_capable_role].get("port")
|
|
if isinstance(hub_devices[ui_capable_role], dict)
|
|
else hub_devices[ui_capable_role]
|
|
)
|
|
if not port:
|
|
pytest.skip(f"{ui_capable_role!r} has no usable port")
|
|
return port
|
|
|
|
|
|
# ---------- Camera + OCR session fixtures ---------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]:
|
|
"""Session-scoped camera backend. Closed at teardown.
|
|
|
|
Backend + device selected by env vars (see `meshtastic_mcp.camera`).
|
|
Falls through to `NullBackend` when no camera is configured, so the
|
|
tests run end-to-end on machines without hardware; they just won't
|
|
have useful images.
|
|
"""
|
|
role = ui_capable_role_session or "esp32s3"
|
|
cam = camera_mod.get_camera(role)
|
|
try:
|
|
yield cam
|
|
finally:
|
|
cam.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None:
|
|
"""Session-scoped lookup mirroring `ui_capable_role` but non-skipping.
|
|
|
|
Used by the `camera` session fixture so it doesn't depend on a
|
|
test-scoped skip.
|
|
"""
|
|
for role in UI_CAPABLE_ROLES:
|
|
if role in hub_devices:
|
|
return role
|
|
return None
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _ocr_warm() -> None:
|
|
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
|
|
|
|
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
|
|
Swallows errors — if OCR isn't installed, warm is a no-op.
|
|
"""
|
|
try:
|
|
ocr_mod.warm()
|
|
except Exception: # noqa: BLE001 — belt: never block the suite on OCR init
|
|
pass
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def _ui_screen_kept_on(
|
|
ui_capable_role_session: str | None, hub_devices: dict[str, Any]
|
|
) -> Iterator[None]:
|
|
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
|
|
|
|
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
|
|
silently DROPS any event that arrives while the screen is off — it just
|
|
wakes the screen and returns. Every first event in each test would
|
|
disappear. We set `display.screen_on_secs = 86400` at session start
|
|
(effectively "always on" for the test window) and restore the prior
|
|
value at teardown.
|
|
"""
|
|
if ui_capable_role_session is None:
|
|
yield
|
|
return
|
|
|
|
hub_entry = hub_devices[ui_capable_role_session]
|
|
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
|
|
if not port:
|
|
yield
|
|
return
|
|
|
|
original: int | None = None
|
|
try:
|
|
current = admin_mod.get_config(section="display", port=port)
|
|
original = int(
|
|
current.get("config", {}).get("display", {}).get("screen_on_secs") or 0
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
try:
|
|
admin_mod.set_config("display.screen_on_secs", 86400, port=port)
|
|
# Send one wake event so the screen is actually ON going into the
|
|
# first test. The event itself gets dropped (screenWasOff), but the
|
|
# wake side-effect sticks.
|
|
try:
|
|
admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
time.sleep(1.5) # Let the screen finish its wake transition.
|
|
except (
|
|
Exception
|
|
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
|
|
pass
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
if original is not None:
|
|
try:
|
|
admin_mod.set_config("display.screen_on_secs", original, port=port)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
# ---------- Per-test capture + transcript ----------------------------------
|
|
|
|
|
|
class FrameCapture:
|
|
"""Per-test capture recorder. Created once per test via the
|
|
`frame_capture` fixture; call with a label to snapshot the screen.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
cam: camera_mod.CameraBackend,
|
|
dir_path: Path,
|
|
lines: list[str],
|
|
nodeid: str,
|
|
) -> None:
|
|
self._cam = cam
|
|
self._dir = dir_path
|
|
self._lines = lines
|
|
self._nodeid = nodeid
|
|
self._step = 0
|
|
self.captures: list[dict[str, Any]] = []
|
|
self._transcript_path = dir_path / "transcript.md"
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
self._transcript_path.write_text(
|
|
f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def __call__(self, label: str) -> dict[str, Any]:
|
|
self._step += 1
|
|
stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}"
|
|
png_path = self._dir / f"{stem}.png"
|
|
ocr_path = self._dir / f"{stem}.ocr.txt"
|
|
|
|
try:
|
|
png = self._cam.capture()
|
|
except Exception as exc: # noqa: BLE001
|
|
png = b""
|
|
ocr_str = f"[capture error: {exc}]"
|
|
else:
|
|
camera_mod.save_capture(png, png_path)
|
|
try:
|
|
ocr_str = ocr_mod.ocr_text(png)
|
|
except Exception as exc: # noqa: BLE001
|
|
ocr_str = f"[ocr error: {exc}]"
|
|
ocr_path.write_text(ocr_str or "", encoding="utf-8")
|
|
|
|
frame = get_current_frame(self._lines)
|
|
entry: dict[str, Any] = {
|
|
"step": self._step,
|
|
"label": label,
|
|
"png_path": str(png_path) if png else None,
|
|
"ocr_text": ocr_str,
|
|
"frame": (
|
|
{
|
|
"idx": frame.idx,
|
|
"name": frame.name,
|
|
"reason": frame.reason,
|
|
}
|
|
if frame is not None
|
|
else None
|
|
),
|
|
}
|
|
self.captures.append(entry)
|
|
|
|
with self._transcript_path.open("a", encoding="utf-8") as fh:
|
|
frame_str = (
|
|
f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}"
|
|
if frame is not None
|
|
else "frame <none>"
|
|
)
|
|
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
|
|
fh.write(
|
|
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
|
|
)
|
|
return entry
|
|
|
|
|
|
@pytest.fixture
|
|
def frame_capture(
|
|
request: pytest.FixtureRequest,
|
|
camera: camera_mod.CameraBackend,
|
|
session_seed: str,
|
|
) -> Iterator[FrameCapture]:
|
|
nodeid = _sanitize_nodeid(request.node.nodeid)
|
|
dir_path = CAPTURES_ROOT / session_seed / nodeid
|
|
# Fresh directory per test run so reruns don't mix old and new images.
|
|
if dir_path.exists():
|
|
shutil.rmtree(dir_path)
|
|
|
|
lines = getattr(request.node, "_debug_log_buffer", [])
|
|
fc = FrameCapture(camera, dir_path, lines, nodeid)
|
|
# Stash so pytest_runtest_makereport can embed captures in HTML extras.
|
|
request.node._ui_captures = fc.captures # type: ignore[attr-defined]
|
|
yield fc
|
|
|
|
|
|
# ---------- Pre-test home-state reset --------------------------------------
|
|
|
|
|
|
def _send_event(port: str, event: InputEventCode) -> None:
|
|
try:
|
|
admin_mod.send_input_event(event_code=int(event), port=port)
|
|
except Exception: # noqa: BLE001
|
|
# Treat a failed event as soft — the subsequent frame-log assertion
|
|
# surfaces the real problem with better context.
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def ui_home_state(
|
|
request: pytest.FixtureRequest,
|
|
hub_devices: dict[str, Any],
|
|
_ui_screen_kept_on: None,
|
|
) -> Iterator[None]:
|
|
"""Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and
|
|
confirm the device emitted the expected frame log.
|
|
|
|
Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS
|
|
produces a `reason=fn_f1` log line, regardless of whatever frame the
|
|
prior test left us on. BACK is context-sensitive (dismisses overlays
|
|
on some frames, no-op on others) and can silently fail to transition.
|
|
|
|
This fixture doubles as the macro-presence detector: if no `fn_f1`
|
|
log arrives within 5 s, the firmware almost certainly wasn't baked
|
|
with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint
|
|
instead of letting every test body fail with a confusing assertion.
|
|
|
|
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
|
|
living in that directory's conftest.py — no explicit nodeid guard
|
|
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
|
|
against a nodeid that has no leading slash).
|
|
"""
|
|
role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None)
|
|
if role is None:
|
|
yield
|
|
return
|
|
|
|
hub_entry = hub_devices[role]
|
|
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
|
|
lines: list[str] = getattr(request.node, "_debug_log_buffer", [])
|
|
start_len = len(lines)
|
|
|
|
# First: a wake event. The screen should already be kept on by
|
|
# `_ui_screen_kept_on`, but belt + suspenders — if it somehow
|
|
# powered off (sleep after factory_reset, etc.), this first FN_F1
|
|
# gets dropped by InputBroker's screenWasOff guard. That's fine;
|
|
# the second FN_F1 below lands cleanly.
|
|
_send_event(port, InputEventCode.FN_F1)
|
|
time.sleep(0.4)
|
|
_send_event(port, InputEventCode.FN_F1)
|
|
|
|
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
|
|
# after call-start counts — we don't care about the name (it should
|
|
# be `home` or `deviceFocused` depending on board-specific frame order).
|
|
from ._screen_log import wait_for_reason
|
|
|
|
try:
|
|
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
|
except TimeoutError:
|
|
# One more try — FreeRTOS queue may be draining slowly.
|
|
_send_event(port, InputEventCode.FN_F1)
|
|
try:
|
|
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
|
except TimeoutError:
|
|
# Look at what the _debug_log_buffer actually contains to
|
|
# disambiguate "macro off" from "macro on but event lost".
|
|
frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln]
|
|
processing_lines = [
|
|
ln for ln in lines[start_len:] if "Processing input event" in ln
|
|
]
|
|
if frame_lines:
|
|
pytest.skip(
|
|
f"ui_home_state: events fire but none reach Screen "
|
|
f"(saw {len(frame_lines)} frame line(s), "
|
|
f"{len(processing_lines)} admin inject(s)). "
|
|
f"Device may be in an unusual state — try `--force-bake`."
|
|
)
|
|
else:
|
|
pytest.skip(
|
|
"ui_home_state: no `Screen: frame` log after FN_F1. "
|
|
"Firmware not baked with USERPREFS_UI_TEST_LOG — "
|
|
"run with `--force-bake` to reflash, or verify the "
|
|
"macro is active in the bake."
|
|
)
|
|
yield
|
|
|
|
|
|
# ---------- Small helpers reused by tests ---------------------------------
|
|
|
|
|
|
def send_event(
|
|
port: str, event: InputEventCode | int | str, **kwargs: Any
|
|
) -> dict[str, Any]:
|
|
"""Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`."""
|
|
return admin_mod.send_input_event(event_code=event, port=port, **kwargs)
|
|
|
|
|
|
__all__ = [
|
|
"FrameCapture",
|
|
"UI_CAPABLE_ROLES",
|
|
"send_event",
|
|
"wait_for_frame",
|
|
"FrameEvent",
|
|
]
|
|
|
|
|
|
# Make the helpers discoverable to test modules via `from .conftest import …`.
|
|
# pytest auto-loads conftest.py, but the symbols above are also re-exported
|
|
# for readability in the test files.
|