fix(input/gestures): enforce numpy array immutability in HandLandmarks; add CameraCapture tests

- Set points.flags.writeable = False in HandsDetector.detect() so in-place
  mutation of HandLandmarks.points raises ValueError (frozen=True alone does not
  protect numpy array contents)
- Extend test_handlandmarks_is_immutable to assert ValueError on array mutation
- Add test_camera.py with 3 tests covering is_open, frames() yield/break
  behaviour, and context manager release (was at 0% coverage)
- Remove unused `import numpy as np` from camera.py; fix frames() return
  annotation to Iterator (np.ndarray ref removed with the import)
This commit is contained in:
pyr0ball 2026-04-26 20:48:02 -07:00
parent cb3d186a58
commit 0f5ea86ab0
4 changed files with 57 additions and 3 deletions

View file

@ -10,7 +10,6 @@ from __future__ import annotations
from typing import Iterator from typing import Iterator
import cv2 import cv2
import numpy as np
class CameraCapture: class CameraCapture:
@ -39,7 +38,7 @@ class CameraCapture:
def is_open(self) -> bool: def is_open(self) -> bool:
return self._cap.isOpened() return self._cap.isOpened()
def frames(self) -> Iterator[np.ndarray]: def frames(self) -> Iterator:
"""Yield BGR uint8 frames until camera fails or caller breaks.""" """Yield BGR uint8 frames until camera fails or caller breaks."""
while self._cap.isOpened(): while self._cap.isOpened():
ok, frame = self._cap.read() ok, frame = self._cap.read()

View file

@ -71,6 +71,7 @@ class HandsDetector:
out: list[HandLandmarks] = [] out: list[HandLandmarks] = []
for lm, hand in zip(results.multi_hand_landmarks, results.multi_handedness): for lm, hand in zip(results.multi_hand_landmarks, results.multi_handedness):
points = np.array([[p.x, p.y, p.z] for p in lm.landmark], dtype=np.float32) points = np.array([[p.x, p.y, p.z] for p in lm.landmark], dtype=np.float32)
points.flags.writeable = False # enforce immutability of stored array
out.append( out.append(
HandLandmarks( HandLandmarks(
points=points, points=points,

View file

@ -0,0 +1,48 @@
import numpy as np
import pytest
from unittest.mock import MagicMock, patch
@patch("circuitforge_core.input.gestures.camera.cv2")
def test_is_open_reflects_videocapture_state(mock_cv2):
from circuitforge_core.input.gestures.camera import CameraCapture
mock_cv2.VideoCapture.return_value.isOpened.return_value = True
cam = CameraCapture()
assert cam.is_open is True
mock_cv2.VideoCapture.return_value.isOpened.return_value = False
cam2 = CameraCapture()
assert cam2.is_open is False
@patch("circuitforge_core.input.gestures.camera.cv2")
def test_frames_yields_until_read_fails(mock_cv2):
from circuitforge_core.input.gestures.camera import CameraCapture
frame = np.zeros((480, 640, 3), dtype=np.uint8)
mock_cap = MagicMock()
mock_cap.isOpened.return_value = True
mock_cap.read.side_effect = [
(True, frame),
(True, frame),
(False, None), # triggers break
]
mock_cv2.VideoCapture.return_value = mock_cap
cam = CameraCapture()
collected = list(cam.frames())
assert len(collected) == 2
@patch("circuitforge_core.input.gestures.camera.cv2")
def test_context_manager_calls_release(mock_cv2):
from circuitforge_core.input.gestures.camera import CameraCapture
mock_cap = MagicMock()
mock_cv2.VideoCapture.return_value = mock_cap
with CameraCapture() as cam:
pass
mock_cap.release.assert_called_once()

View file

@ -79,7 +79,13 @@ def test_handlandmarks_is_immutable(mock_mp):
frame = np.zeros((480, 640, 3), dtype=np.uint8) frame = np.zeros((480, 640, 3), dtype=np.uint8)
result = detector.detect(frame)[0] result = detector.detect(frame)[0]
with pytest.raises((AttributeError, TypeError)): with pytest.raises((AttributeError, TypeError)):
result.handedness = "Left" # frozen dataclass must reject mutation result.handedness = (
"Left" # frozen dataclass must reject attribute reassignment
)
with pytest.raises(ValueError):
result.points[0] = np.array(
[1.0, 2.0, 3.0]
) # writeable=False must reject in-place mutation
@patch("circuitforge_core.input.gestures.hands.mp") @patch("circuitforge_core.input.gestures.hands.mp")