"""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", ]