release: bump version to 0.50.0
This commit is contained in:
242
backend/tests/test_motion_agent.py
Normal file
242
backend/tests/test_motion_agent.py
Normal file
@@ -0,0 +1,242 @@
|
||||
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
|
||||
Reference in New Issue
Block a user