diff --git a/circuitforge_core/input/gestures/camera.py b/circuitforge_core/input/gestures/camera.py index 5f9a58f..a6cc185 100644 --- a/circuitforge_core/input/gestures/camera.py +++ b/circuitforge_core/input/gestures/camera.py @@ -10,7 +10,6 @@ from __future__ import annotations from typing import Iterator import cv2 -import numpy as np class CameraCapture: @@ -39,7 +38,7 @@ class CameraCapture: def is_open(self) -> bool: return self._cap.isOpened() - def frames(self) -> Iterator[np.ndarray]: + def frames(self) -> Iterator: """Yield BGR uint8 frames until camera fails or caller breaks.""" while self._cap.isOpened(): ok, frame = self._cap.read() diff --git a/circuitforge_core/input/gestures/hands.py b/circuitforge_core/input/gestures/hands.py index d9ac3ce..ad54b0b 100644 --- a/circuitforge_core/input/gestures/hands.py +++ b/circuitforge_core/input/gestures/hands.py @@ -71,6 +71,7 @@ class HandsDetector: out: list[HandLandmarks] = [] 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.flags.writeable = False # enforce immutability of stored array out.append( HandLandmarks( points=points, diff --git a/tests/test_input/test_gestures/test_camera.py b/tests/test_input/test_gestures/test_camera.py new file mode 100644 index 0000000..c1c8c2a --- /dev/null +++ b/tests/test_input/test_gestures/test_camera.py @@ -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() diff --git a/tests/test_input/test_gestures/test_hands.py b/tests/test_input/test_gestures/test_hands.py index 434fa87..15e420b 100644 --- a/tests/test_input/test_gestures/test_hands.py +++ b/tests/test_input/test_gestures/test_hands.py @@ -79,7 +79,13 @@ def test_handlandmarks_is_immutable(mock_mp): frame = np.zeros((480, 640, 3), dtype=np.uint8) result = detector.detect(frame)[0] 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")