Files
planet/backend/tests/test_motion_agent.py
linkong 899e3bce43
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.71.0
2026-06-11 16:47:24 +08:00

360 lines
11 KiB
Python

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["protocol_version"] == "motion.v2"
assert payload["input_mode"] == "single"
assert payload["camera_id"] == "unknown"
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 status["protocol_version"] == "motion.v2"
assert status["armed"] is False
assert status["paused"] is False
assert status["devices_open"] is False
assert heartbeat == {
"timestamp_ms": 123,
"protocol_version": "motion.v2",
"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["protocol_version"] == "motion.v2"
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_v2_gesture_set_accepts_frontend_motion_gestures():
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=0)
for gesture in [
"rotate_up",
"rotate_down",
"focus_prev",
"focus_next",
"layer_prev",
"layer_next",
]:
event = state.accept(
GestureObservation(gesture, confidence=0.9, intensity=0.8, timestamp_ms=1000)
)
assert event is not None
assert event.gesture == gesture
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
@pytest.mark.asyncio
async def test_motion_agent_command_updates_control_state():
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
armed = await server.handle_command(
json.dumps(
{
"type": "command",
"command": "set_armed",
"request_id": "req-armed",
"payload": {"armed": True},
}
)
)
paused = await server.handle_command(
{
"type": "command",
"command": "set_paused",
"request_id": "req-paused",
"payload": {"paused": True},
}
)
assert armed.ok is True
assert armed.request_id == "req-armed"
assert armed.status["armed"] is True
assert paused.ok is True
assert paused.status["paused"] is True
@pytest.mark.asyncio
async def test_motion_agent_open_devices_command_accepts_dual_mode():
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
try:
result = await server.handle_command(
{
"type": "command",
"command": "open_devices",
"request_id": "req-open",
"payload": {"input_mode": "dual_redundant"},
}
)
assert result.ok is True
assert result.status["input_mode"] == "dual_redundant"
assert result.status["active_camera_ids"] == ("dry-run:null-camera",)
assert server._recognition_subprocess is not None
assert server._recognition_subprocess.returncode is None
finally:
await server.stop_recognition_subprocess()
def test_motion_agent_dual_fusion_merges_matching_observations():
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
server.state.mode = "dual_redundant"
selected, fusion = server._fuse_observations(
[
GestureObservation("zoom_in", confidence=0.82, intensity=0.4, camera_id="usb:0"),
GestureObservation("zoom_in", confidence=0.86, intensity=0.8, camera_id="usb:1"),
]
)
assert selected.gesture == "zoom_in"
assert selected.camera_id == "fusion"
assert selected.confidence > 0.86
assert fusion == {
"source_cameras": ["usb:1", "usb:0"],
"window_ms": 120,
"reason": "matched_observations",
}
def test_motion_agent_dual_fusion_suppresses_close_conflict():
server = MotionAgentServer(MotionAgentConfig(dry_run=True, confidence_threshold=0.7))
selected, fusion = server._fuse_observations(
[
GestureObservation("zoom_in", confidence=0.82, intensity=0.5, camera_id="usb:0"),
GestureObservation("zoom_out", confidence=0.78, intensity=0.5, camera_id="usb:1"),
]
)
assert selected.gesture == "zoom_in"
assert selected.confidence == 0
assert fusion["reason"] == "conflict_ignored"