feat: daylight tz, camera engine, value stream + modal/UI polish

- 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
This commit is contained in:
2026-05-01 18:42:43 +03:00
parent 816a27db73
commit fdac26b9d9
64 changed files with 2716 additions and 837 deletions
+303
View File
@@ -0,0 +1,303 @@
"""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",
]