marionette / tests /test_hardware.py
RemiFabre
Add deep pipeline integration tests for hardware suite
8e1482d
Raw
History Blame
16.4 kB
"""Tier 2 β€” Hardware integration tests for Marionette.
These tests require a physical Reachy Mini robot connected and powered on.
They exercise the full record/playback pipeline through the HTTP API with
real motor control.
All tests are decorated @pytest.mark.hardware and are skipped by default.
Run with:
cd marionette
pytest tests/test_hardware.py -m hardware
Or via the test runner:
python tests/run_tests.py --hardware
"""
import json
import threading
import time
import wave
from pathlib import Path
import pytest
import uvicorn
from marionette.main import create_app, COUNTDOWN_SECONDS, MOTION_SAMPLE_RATE
# Skip all tests in this module unless -m hardware is specified
pytestmark = pytest.mark.hardware
HARDWARE_PORT = 18043
STARTUP_TIMEOUT = 60 # seconds to wait for robot startup animation
POLL_INTERVAL = 0.5
class _ServerThread:
"""Runs a uvicorn server in a background thread."""
def __init__(self, app, port: int):
self.config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
self.server = uvicorn.Server(self.config)
self.thread = threading.Thread(target=self.server.run, daemon=True)
def start(self):
self.thread.start()
for _ in range(50):
if self.server.started:
break
time.sleep(0.1)
def stop(self):
self.server.should_exit = True
self.thread.join(timeout=5)
def _wait_for_mode(base_url: str, target_mode: str, timeout: float) -> dict:
"""Poll GET /api/state until mode matches target_mode or timeout."""
import httpx
deadline = time.time() + timeout
last_state = {}
while time.time() < deadline:
try:
resp = httpx.get(f"{base_url}/api/state", timeout=5)
last_state = resp.json()
if last_state.get("mode") == target_mode:
return last_state
except Exception:
pass
time.sleep(POLL_INTERVAL)
raise TimeoutError(
f"Timed out waiting for mode={target_mode!r} "
f"(last mode={last_state.get('mode')!r})"
)
@pytest.fixture(scope="session")
def hardware_server(tmp_path_factory):
"""Marionette server connected to a real robot.
Starts the run() loop with a real ReachyMini and a uvicorn web server.
Waits for the startup animation to complete (mode -> idle) before
yielding the base URL and Marionette instance.
"""
from reachy_mini import ReachyMini
tmp = tmp_path_factory.mktemp("hardware")
app, marionette = create_app(
registry_path=tmp / "registry.json",
dataset_root=tmp / "datasets",
)
reachy = ReachyMini()
stop_event = threading.Event()
# Start the run() loop β€” processes recordings/playbacks using the real robot
run_thread = threading.Thread(
target=marionette.run, args=(reachy, stop_event), daemon=True
)
run_thread.start()
# Start the web server
srv = _ServerThread(app, HARDWARE_PORT)
srv.start()
base_url = f"http://127.0.0.1:{HARDWARE_PORT}"
# Wait for startup animation to finish
try:
_wait_for_mode(base_url, "idle", STARTUP_TIMEOUT)
except TimeoutError:
stop_event.set()
srv.stop()
raise
yield base_url, marionette
stop_event.set()
srv.stop()
@pytest.fixture(scope="session")
def base_url(hardware_server):
return hardware_server[0]
@pytest.fixture(scope="session")
def hw_marionette(hardware_server):
return hardware_server[1]
# ──────── Tests ────────────────────────────────────────────────────────
class TestHardwareStartup:
def test_startup_reaches_idle(self, base_url: str):
"""After fixture setup, verify mode is 'idle'."""
import httpx
resp = httpx.get(f"{base_url}/api/state", timeout=5)
assert resp.status_code == 200
assert resp.json()["mode"] == "idle"
class TestHardwareRecording:
def test_record_captures_motion(self, base_url: str):
"""Record 2s silent, verify move JSON has ~200 frames at ~100Hz."""
import httpx
# Start recording
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": 2.0, "record_audio": False, "label": "hw-silent"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
# Wait for countdown + recording + saving to complete
_wait_for_mode(base_url, "idle", timeout=15)
# Verify move exists with expected frame count
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if m["id"] == move_id), None)
assert move is not None, f"Move {move_id} not found in moves list"
assert move["duration"] > 1.5, f"Duration too short: {move['duration']}"
assert move["has_audio"] is False
def test_record_with_audio(self, base_url: str):
"""Record 2s with mic, verify .wav file created."""
import httpx
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": 2.0, "record_audio": True, "label": "hw-audio"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
_wait_for_mode(base_url, "idle", timeout=15)
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if m["id"] == move_id), None)
assert move is not None, f"Move {move_id} not found"
assert move["has_audio"] is True
class TestHardwarePlayback:
def test_playback_silent_completes(self, base_url: str):
"""Play back a silent move, verify mode returns to idle."""
import httpx
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if not m["has_audio"]), None)
assert move is not None, "No silent move available for playback test"
resp = httpx.post(
f"{base_url}/api/play",
json={"move_id": move["id"]},
timeout=5,
)
assert resp.status_code == 200
_wait_for_mode(base_url, "idle", timeout=move["duration"] + 15)
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
assert state["mode"] == "idle"
def test_playback_with_audio_completes(self, base_url: str):
"""Play back a move that has audio, verify sound path is exercised."""
import httpx
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if m["has_audio"]), None)
assert move is not None, (
"No audio move available β€” test_record_with_audio must run first"
)
resp = httpx.post(
f"{base_url}/api/play",
json={"move_id": move["id"]},
timeout=5,
)
assert resp.status_code == 200
_wait_for_mode(base_url, "idle", timeout=move["duration"] + 15)
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
assert state["mode"] == "idle"
assert "finished" in state["message"].lower(), (
f"Expected 'Finished playing' message, got: {state['message']}"
)
def test_record_and_delete(self, base_url: str):
"""Record a move, then delete it and verify file is removed."""
import httpx
# Record
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": 1.5, "record_audio": False, "label": "hw-delete-me"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
_wait_for_mode(base_url, "idle", timeout=15)
# Verify it exists
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
assert any(m["id"] == move_id for m in state["moves"])
# Delete
resp = httpx.delete(
f"{base_url}/api/moves/{move_id}",
timeout=5,
)
assert resp.status_code == 200
# Verify it's gone
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
assert not any(m["id"] == move_id for m in state["moves"])
class TestFullPipeline:
"""End-to-end pipeline tests: record β†’ verify files β†’ replay β†’ delete.
These tests exercise the full lifecycle and check that output files
have the expected structure, durations, and frame counts.
"""
def test_recording_produces_correct_json(self, base_url: str, hw_marionette):
"""Record 3s silent, verify JSON has timestamps, frames at ~100Hz."""
import httpx
duration = 3.0
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": duration, "record_audio": False, "label": "pipeline-json"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
_wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10)
# Read the actual JSON file from disk
json_path = hw_marionette._dataset_dir / f"{move_id}.json"
assert json_path.exists(), f"JSON file not found: {json_path}"
data = json.loads(json_path.read_text())
timestamps = data["time"]
frames = data["set_target_data"]
# Frame count should be close to duration * 100Hz
expected_frames = int(duration * MOTION_SAMPLE_RATE)
assert len(frames) > expected_frames * 0.8, (
f"Too few frames: {len(frames)} (expected ~{expected_frames})"
)
assert len(timestamps) == len(frames)
# Timestamps should span close to the requested duration
actual_duration = timestamps[-1] - timestamps[0]
assert actual_duration > duration * 0.8, (
f"Recorded duration too short: {actual_duration:.2f}s (expected ~{duration}s)"
)
# Each frame must have head pose and antennas
for frame in frames[:3]: # check first few
assert "head" in frame
assert "antennas" in frame
# Cleanup
httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
def test_recording_with_audio_produces_wav(self, base_url: str, hw_marionette):
"""Record 3s with mic, verify WAV file duration matches recording."""
import httpx
duration = 3.0
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": duration, "record_audio": True, "label": "pipeline-audio"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
_wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10)
# Verify WAV exists and has reasonable duration
wav_path = hw_marionette._dataset_dir / f"{move_id}.wav"
assert wav_path.exists(), f"WAV file not found: {wav_path}"
with wave.open(str(wav_path), "rb") as wf:
wav_duration = wf.getnframes() / wf.getframerate()
assert wav_duration > duration * 0.7, (
f"WAV too short: {wav_duration:.2f}s (expected ~{duration}s)"
)
assert wav_duration < duration + 2.0, (
f"WAV too long: {wav_duration:.2f}s (expected ~{duration}s)"
)
# Also verify the JSON file reports has_audio
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if m["id"] == move_id), None)
assert move is not None
assert move["has_audio"] is True
# Cleanup
httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
def test_record_replay_full_lifecycle(self, base_url: str, hw_marionette):
"""Record β†’ verify β†’ replay β†’ verify playback completes β†’ delete."""
import httpx
duration = 2.0
# Step 1: Record
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": duration, "record_audio": False, "label": "lifecycle"},
timeout=5,
)
assert resp.status_code == 200
move_id = resp.json()["move_id"]
state = _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10)
assert any(m["id"] == move_id for m in state["moves"]), "Move not in list after recording"
# Step 2: Replay
resp = httpx.post(
f"{base_url}/api/play",
json={"move_id": move_id},
timeout=5,
)
assert resp.status_code == 200
state = _wait_for_mode(base_url, "idle", timeout=duration + 15)
assert state["mode"] == "idle"
# Step 3: Delete and verify cleanup
json_path = hw_marionette._dataset_dir / f"{move_id}.json"
assert json_path.exists()
resp = httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
assert resp.status_code == 200
assert not json_path.exists(), "JSON file should be deleted"
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
assert not any(m["id"] == move_id for m in state["moves"])
def test_stop_cancels_queued_recording(self, base_url: str):
"""Submit a recording, immediately stop it β€” verify cancel works."""
import httpx
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": 10.0, "record_audio": False, "label": "cancel-test"},
timeout=5,
)
assert resp.status_code == 200
# Immediately try to stop (may be queued or in countdown)
time.sleep(0.1)
resp = httpx.post(f"{base_url}/api/record/stop", timeout=5)
assert resp.status_code == 200
data = resp.json()
assert data["stopped"] is True
# Should return to idle
_wait_for_mode(base_url, "idle", timeout=10)
def test_recording_transitions_through_phases(self, base_url: str):
"""Verify the recording goes through queued β†’ countdown β†’ recording β†’ idle."""
import httpx
duration = 3.0
observed_modes = set()
resp = httpx.post(
f"{base_url}/api/record",
json={"duration": duration, "record_audio": False, "label": "phases"},
timeout=5,
)
assert resp.status_code == 200
# Poll rapidly to observe phase transitions
deadline = time.time() + COUNTDOWN_SECONDS + duration + 10
while time.time() < deadline:
try:
state = httpx.get(f"{base_url}/api/state", timeout=2).json()
observed_modes.add(state["mode"])
# When in recording mode, verify timing fields are present
if state["mode"] == "recording":
assert state["phase_start_at"] is not None, "recording phase_start_at is null"
assert state["phase_end_at"] is not None, "recording phase_end_at is null"
# When in countdown, verify timing fields
if state["mode"] == "countdown":
assert state["phase_start_at"] is not None, "countdown phase_start_at is null"
assert state["phase_end_at"] is not None, "countdown phase_end_at is null"
if state["mode"] == "idle" and "phases" in (state.get("message") or ""):
break # Recording completed
except Exception:
pass
time.sleep(0.15)
# We should have seen at least countdown and recording phases
assert "countdown" in observed_modes, (
f"Never saw countdown mode. Observed: {observed_modes}"
)
assert "recording" in observed_modes, (
f"Never saw recording mode. Observed: {observed_modes}"
)
assert "idle" in observed_modes, (
f"Never returned to idle. Observed: {observed_modes}"
)
# Cleanup
state = httpx.get(f"{base_url}/api/state", timeout=5).json()
move = next((m for m in state["moves"] if "phases" in m.get("label", "")), None)
if move:
httpx.delete(f"{base_url}/api/moves/{move['id']}", timeout=5)