fdac26b9d9
- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior - camera engine: capture path additions plus new test_camera_engine suite - value stream: schema + processing updates (~178 lines) - color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path - modal CSS: large refactor (+883), components.css polish (+110) - templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox - frontend core: state, modal, icons, graph-nodes, app - frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards - locales: en/ru/zh - storage: color_strip, picture_source, value_source loaders touched - preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""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",
|
||
]
|