Files
ledgrab/server/tests/test_camera_engine.py
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

303 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the camera capture engine — focus on resolution selection logic.
The probe-for-max behavior in ``_enumerate_cameras`` and the resolution
priority logic in ``CameraCaptureStream.initialize`` are exercised here
without requiring a real webcam, by stubbing ``cv2.VideoCapture``.
"""
from __future__ import annotations
import sys
import types
from typing import Any, Dict, List, Tuple
import pytest
from ledgrab.core.capture_engines import camera_engine as ce
# ---------------------------------------------------------------------------
# _parse_resolution — pure function, no stubs needed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("auto", None),
("AUTO", None),
("", None),
(None, None),
(0, None),
("1920x1080", (1920, 1080)),
("1920X1080", (1920, 1080)),
("2560×1440", (2560, 1440)), # unicode multiplication sign
(" 1280x720 ", (1280, 720)),
("garbage", None),
("1920x", None),
("0x0", None),
("-1x100", None),
],
)
def test_parse_resolution(value: Any, expected: Tuple[int, int] | None) -> None:
assert ce._parse_resolution(value) == expected
# ---------------------------------------------------------------------------
# Stubs that emulate cv2.VideoCapture without needing a real camera
# ---------------------------------------------------------------------------
CAP_PROP_FRAME_WIDTH = 3
CAP_PROP_FRAME_HEIGHT = 4
CAP_PROP_FPS = 5
class _StubCap:
"""Minimal cv2.VideoCapture stand-in.
- ``default_dims``: what get() returns immediately after open.
- ``max_dims``: what set(9999) clamps to (the camera's hardware ceiling).
- ``set()`` honors any width/height up to ``max_dims`` and clamps higher.
"""
def __init__(
self,
default_dims: Tuple[int, int],
max_dims: Tuple[int, int],
opened: bool = True,
):
self._default = default_dims
self._max = max_dims
self._w, self._h = default_dims
self._opened = opened
self.read_calls = 0
self.set_calls: List[Tuple[int, float]] = []
def isOpened(self) -> bool:
return self._opened
def get(self, prop: int) -> float:
if prop == CAP_PROP_FRAME_WIDTH:
return float(self._w)
if prop == CAP_PROP_FRAME_HEIGHT:
return float(self._h)
if prop == CAP_PROP_FPS:
return 30.0
return 0.0
def set(self, prop: int, value: float) -> bool:
self.set_calls.append((prop, value))
if prop == CAP_PROP_FRAME_WIDTH:
self._w = int(min(value, self._max[0]))
elif prop == CAP_PROP_FRAME_HEIGHT:
self._h = int(min(value, self._max[1]))
return True
def read(self):
self.read_calls += 1
# Return a 3-channel frame of the current size as a list of zeros —
# the engine only inspects shape[:2].
import numpy as np
return True, np.zeros((self._h, self._w, 3), dtype=np.uint8)
def release(self) -> None:
self._opened = False
def _install_cv2_stub(monkeypatch: pytest.MonkeyPatch, factory) -> List[_StubCap]:
"""Install a fake `cv2` module so the engine code can `import cv2`.
`factory(index, backend) -> _StubCap` produces a stub for each open call.
Returns the list that all created stubs are appended to (for assertions).
"""
created: List[_StubCap] = []
def _video_capture(index, backend=None):
cap = factory(index, backend)
created.append(cap)
return cap
fake_cv2 = types.SimpleNamespace(
VideoCapture=_video_capture,
CAP_PROP_FRAME_WIDTH=CAP_PROP_FRAME_WIDTH,
CAP_PROP_FRAME_HEIGHT=CAP_PROP_FRAME_HEIGHT,
CAP_PROP_FPS=CAP_PROP_FPS,
COLOR_BGR2RGB=4,
cvtColor=lambda frame, _flag: frame, # passthrough
)
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
# Also bypass the SetupAPI friendly-name probe (Windows-only ctypes call)
monkeypatch.setattr(ce, "_get_camera_friendly_names", lambda: {0: "Stub Cam"})
# Reset the enumeration cache so each test sees a fresh probe
ce._camera_cache = None
ce._camera_cache_time = 0
ce._active_cv2_indices.clear()
return created
# ---------------------------------------------------------------------------
# _enumerate_cameras — probe-for-max
# ---------------------------------------------------------------------------
def test_enumerate_reports_camera_max_not_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""A camera whose default mode is 640x480 but max is 2560x1440 should be
reported at its max — that's what shows up in the display selector."""
def factory(index, _backend):
if index == 0:
return _StubCap(default_dims=(640, 480), max_dims=(2560, 1440))
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
_install_cv2_stub(monkeypatch, factory)
cams = ce._enumerate_cameras("auto")
assert len(cams) == 1
assert cams[0]["width"] == 2560
assert cams[0]["height"] == 1440
assert cams[0]["name"] == "Stub Cam"
def test_enumerate_falls_back_when_probe_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""If a driver rejects the probe set() with an exception, fall back to the
default mode rather than reporting 0x0."""
class _RejectingCap(_StubCap):
def set(self, prop: int, value: float) -> bool:
raise RuntimeError("driver hates large values")
def factory(index, _backend):
if index == 0:
return _RejectingCap(default_dims=(800, 600), max_dims=(800, 600))
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
_install_cv2_stub(monkeypatch, factory)
cams = ce._enumerate_cameras("auto")
assert len(cams) == 1
assert cams[0]["width"] == 800
assert cams[0]["height"] == 600
# ---------------------------------------------------------------------------
# CameraCaptureStream.initialize — resolution selection priority
# ---------------------------------------------------------------------------
def _open_stream_with_config(
monkeypatch: pytest.MonkeyPatch,
config: Dict[str, Any],
cam_max: Tuple[int, int] = (2560, 1440),
) -> Tuple[ce.CameraCaptureStream, List[_StubCap]]:
def factory(index, _backend):
if index == 0:
return _StubCap(default_dims=(640, 480), max_dims=cam_max)
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
created = _install_cv2_stub(monkeypatch, factory)
stream = ce.CameraCaptureStream(display_index=0, config=config)
stream.initialize()
return stream, created
def _set_calls_for_size(cap: _StubCap) -> List[Tuple[int, float]]:
return [c for c in cap.set_calls if c[0] in (CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT)]
def test_init_default_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
"""No resolution config = open at camera max via _PROBE_MAX_DIM."""
stream, created = _open_stream_with_config(monkeypatch, config={})
# Two caps: one for enumeration probe, one for the stream's own open.
assert len(created) >= 2
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
# Stream should request the max-probe sentinel for both width and height.
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
def test_init_resolution_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "auto"})
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
def test_init_explicit_resolution_string(monkeypatch: pytest.MonkeyPatch) -> None:
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "1280x720"})
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# Should not request the max sentinel when an explicit size is given.
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) not in size_sets
def test_init_legacy_numeric_keys_take_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stored configs from before the rename still work and override the
new `resolution` field."""
stream, created = _open_stream_with_config(
monkeypatch,
config={
"resolution": "1920x1080",
"resolution_width": 1280,
"resolution_height": 720,
},
)
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# `resolution` should be ignored when legacy numeric keys are set.
assert (CAP_PROP_FRAME_WIDTH, 1920.0) not in size_sets
def test_init_legacy_zeroes_fall_through_to_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Legacy keys with value 0 are the historical 'no override' state — they
should fall through to the new `resolution` field."""
stream, created = _open_stream_with_config(
monkeypatch,
config={
"resolution": "1280x720",
"resolution_width": 0,
"resolution_height": 0,
},
)
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# ---------------------------------------------------------------------------
# Engine class surface — config defaults and choices
# ---------------------------------------------------------------------------
def test_default_config_exposes_resolution() -> None:
cfg = ce.CameraEngine.get_default_config()
assert cfg["resolution"] == "auto"
# Legacy numeric keys are no longer surfaced — UI shouldn't render them.
assert "resolution_width" not in cfg
assert "resolution_height" not in cfg
def test_config_choices_include_resolution_presets() -> None:
choices = ce.CameraEngine.get_config_choices()
assert "resolution" in choices
# Exact set: auto + the 5 standard sizes the UI lists.
assert choices["resolution"] == [
"auto",
"640x480",
"1280x720",
"1920x1080",
"2560x1440",
"3840x2160",
]