import json import pytest from motion_agent.cameras import ( MotionAgentCameraError, MotionAgentDependencyError, UrlCameraInput, UrlCameraSpec, UsbCameraInput, UsbCameraSpec, ) import motion_agent.cameras as motion_cameras from motion_agent.config import MotionAgentConfig from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint from motion_agent.recognizer import GestureObservation from motion_agent.server import MotionAgentServer from motion_agent.state import GestureStateMachine from motion_agent import cli as motion_cli def test_gesture_event_serializes_stable_protocol_fields(): event = GestureEvent( gesture="rotate_left", confidence=0.91, intensity=0.75, timestamp_ms=1000, seq=7, mode="single", ) payload = json.loads(event.to_json()) assert payload["type"] == "gesture" assert payload["gesture"] == "rotate_left" assert payload["phase"] == "discrete" assert payload["confidence"] == 0.91 assert payload["intensity"] == 0.75 assert payload["timestamp_ms"] == 1000 assert payload["seq"] == 7 assert payload["source"] == "motion-agent" assert payload["mode"] == "single" assert payload["payload"] == {} def test_state_machine_ignores_low_confidence_observations(): state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400) event = state.accept( GestureObservation( gesture="confirm", confidence=0.79, intensity=1, timestamp_ms=1000, ) ) assert event is None def test_state_machine_applies_per_gesture_cooldown(): state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400) first = state.accept( GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000) ) repeated = state.accept( GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200) ) later = state.accept( GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500) ) assert first is not None assert first.seq == 1 assert repeated is None assert later is not None assert later.seq == 2 def test_motion_server_status_includes_dry_run_camera_and_heartbeat(): server = MotionAgentServer(MotionAgentConfig(dry_run=True)) status = json.loads(server.status_event().to_json()) heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json()) assert status["type"] == "status" assert status["camera_count"] == 1 assert status["active_camera_ids"] == ["dry-run:null-camera"] assert status["recognizer"] == "dry-run" assert heartbeat == { "timestamp_ms": 123, "source": "motion-agent", "type": "heartbeat", } def test_skeleton_event_serializes_without_raw_image_fields(): event = SkeletonEvent( joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)], bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")], matched_gesture="rotate_left", confidence=0.91, camera_id="usb:0", timestamp_ms=1000, mode="single", ) payload = json.loads(event.to_json()) assert payload["type"] == "skeleton" assert payload["matched_gesture"] == "rotate_left" assert payload["confidence"] == 0.91 assert payload["camera_id"] == "usb:0" assert payload["joints"] == [ {"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98} ] assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]] assert "image" not in payload assert "frame" not in payload def test_dry_run_recognizer_produces_debug_skeleton(): server = MotionAgentServer(MotionAgentConfig(dry_run=True)) skeleton = server.recognizer.debug_skeleton( None, camera_id="dry-run:null-camera", mode="single", ) assert skeleton is not None assert skeleton.type == "skeleton" assert skeleton.camera_id == "dry-run:null-camera" assert skeleton.joints assert skeleton.bones class ServerRecognizerStub: name = "stub" def recognize(self, frame): _ = frame return None def debug_skeleton(self, frame, **kwargs): _ = frame, kwargs return None def test_motion_server_prefers_camera_urls_over_usb_indexes(): server = MotionAgentServer( MotionAgentConfig( dry_run=False, camera_indexes=(0,), camera_urls=("rtsp://camera.example/live", "http://camera.example/video"), ), recognizer=ServerRecognizerStub(), ) assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"] assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras) def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch): import builtins original_import = builtins.__import__ original_exists = motion_cameras.Path.exists def fake_import(name, *args, **kwargs): if name == "cv2": raise ImportError("cv2 missing") return original_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fake_import) monkeypatch.setattr( motion_cameras.Path, "exists", lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self), ) camera = UsbCameraInput(UsbCameraSpec(index=0)) with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"): camera.open() def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch): original_exists = motion_cameras.Path.exists monkeypatch.setattr( motion_cameras.Path, "exists", lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self), ) camera = UsbCameraInput(UsbCameraSpec(index=0)) with pytest.raises(MotionAgentCameraError, match="/dev/video0"): camera.open() def test_url_camera_reports_unreachable_stream(monkeypatch): class BrokenCapture: def __init__(self, _url): pass def isOpened(self): return False class Cv2Stub: VideoCapture = BrokenCapture import builtins original_import = builtins.__import__ def fake_import(name, *args, **kwargs): if name == "cv2": return Cv2Stub() return original_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fake_import) camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live")) with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"): camera.open() @pytest.mark.asyncio async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys): class BrokenServer: def __init__(self, _config): raise MotionAgentDependencyError("missing cv stack") monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer) exit_code = await motion_cli.async_main([]) captured = capsys.readouterr() assert exit_code == 2 assert "Motion agent failed: missing cv stack" in captured.err assert "Traceback" not in captured.err