reachy_mini_conversation_rmscript / tests /test_rmscript_tool.py
dlouapre's picture
dlouapre HF Staff
Rename conversation app to reachy_mini_conversation_rmscript standalone app
b461b14
Raw
History Blame Contribute Delete
4.14 kB
"""Tests for loading .rmscript files as conversation tools."""
import base64
import asyncio
from typing import Any, List
from unittest.mock import MagicMock
import numpy as np
import pytest
# Requires the standalone rmscript DSL compiler package.
pytest.importorskip("rmscript")
from reachy_mini_conversation_rmscript.tools import rmscript_tool # noqa: E402
from reachy_mini_conversation_rmscript.dance_emotion_moves import GotoQueueMove # noqa: E402
from reachy_mini_conversation_rmscript.tools.rmscript_tool import make_rmscript_tool_class # noqa: E402
def _make_deps(queued: List[Any]) -> MagicMock:
"""Mock ToolDependencies: identity head pose, antennas [right=0.1, left=0.2]."""
deps = MagicMock()
deps.reachy_mini.get_current_head_pose.return_value = np.eye(4)
deps.reachy_mini.get_current_joint_positions.return_value = ([0.0] * 7, [0.1, 0.2])
deps.movement_manager.queue_move.side_effect = queued.append
deps.camera_worker = None
return deps
@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Skip the timeline sleeps so tests run instantly."""
async def _instant(_seconds: float) -> None:
return None
monkeypatch.setattr(rmscript_tool.asyncio, "sleep", _instant)
def _run(tool: Any, deps: Any) -> dict:
"""Run an async tool to completion."""
return asyncio.run(tool(deps))
def test_make_tool_class_metadata() -> None:
"""A valid script compiles to a zero-argument tool with the first line as description."""
cls = make_rmscript_tool_class('"Wave hello"\nlook left', "wave", None)
assert cls is not None
assert cls.name == "wave"
assert cls.description == "Wave hello"
assert cls().parameters_schema == {"type": "object", "properties": {}, "required": []}
def test_make_tool_class_compile_error_returns_none() -> None:
"""An invalid script logs the error and yields no tool class."""
assert make_rmscript_tool_class('"bad"\nfloop the gleeb', "broken", None) is None
def test_execution_queues_moves_and_threads_start_state() -> None:
"""Each move (and the wait hold) starts from the previous move's target."""
queued: List[Any] = []
deps = _make_deps(queued)
cls = make_rmscript_tool_class('"t"\nlook left\nwait 0.5s\nlook right', "t", None)
assert cls is not None
result = _run(cls(), deps)
assert result["status"] == "ran t"
# 2 look actions + 1 wait (hold) = 3 queued moves
assert len(queued) == 3
assert all(isinstance(m, GotoQueueMove) for m in queued)
# The wait holds the previous target (start == target == look-left target).
assert np.allclose(queued[1].start_head_pose, queued[0].target_head_pose)
assert np.allclose(queued[1].target_head_pose, queued[1].start_head_pose)
# The second look starts where the hold ended (threaded start state).
assert np.allclose(queued[2].start_head_pose, queued[1].target_head_pose)
def test_single_antenna_keeps_other_in_place() -> None:
"""A single-antenna command holds the other antenna at its current position."""
queued: List[Any] = []
deps = _make_deps(queued) # current antennas [right=0.1, left=0.2]
cls = make_rmscript_tool_class('"t"\nantenna left down', "t", None)
assert cls is not None
_run(cls(), deps)
move = queued[0]
# left (index 1) -> 180deg; right (index 0) held at its current 0.1
assert move.target_antennas[0] == pytest.approx(0.1)
assert move.target_antennas[1] == pytest.approx(np.pi)
def test_picture_returns_b64(monkeypatch: pytest.MonkeyPatch) -> None:
"""A `picture` action returns the latest camera frame as base64 JPEG."""
queued: List[Any] = []
deps = _make_deps(queued)
deps.camera_worker = MagicMock()
deps.camera_worker.get_latest_frame.return_value = np.zeros((4, 4, 3), dtype=np.uint8)
monkeypatch.setattr(rmscript_tool, "encode_bgr_frame_as_jpeg", lambda _frame: b"jpegbytes")
cls = make_rmscript_tool_class('"t"\npicture', "t", None)
assert cls is not None
result = _run(cls(), deps)
assert result["b64_im"] == base64.b64encode(b"jpegbytes").decode("utf-8")