reachy_mini_hermes / tests /test_robot_tools.py
Timbo89's picture
Publish public companion experience from c47252b6
b35c494 verified
Raw
History Blame Contribute Delete
13.4 kB
from __future__ import annotations
import threading
import time
import pytest
from reachy_mini_hermes.robot_tools import (
ReachyRobotActions,
completed_robot_tool_call,
manual_precision_action,
manual_robot_action,
robot_control_options,
)
class FakeMove:
duration = 0.04
def evaluate(self, elapsed: float) -> tuple[dict[str, float], list[float], float]:
return {"elapsed": elapsed}, [elapsed, -elapsed], elapsed
class FakeLibrary:
def __init__(self) -> None:
self.requested: list[str] = []
def get(self, name: str) -> FakeMove:
self.requested.append(name)
return FakeMove()
class FakeRobot:
def __init__(self) -> None:
self.targets: list[dict[str, object]] = []
self.head_samples: list[object] = []
self.body_samples: list[float] = []
self.antenna_samples: list[list[float]] = []
self.cancellations = 0
def get_current_head_pose(self) -> list[list[float]]:
return [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
def goto_target(self, **kwargs: object) -> None:
self.targets.append(kwargs)
def set_target_head_pose(self, head: object) -> None:
self.head_samples.append(head)
def set_target_body_yaw(self, body_yaw: float) -> None:
self.body_samples.append(body_yaw)
def set_target_antenna_joint_positions(self, positions: list[float]) -> None:
self.antenna_samples.append(positions)
def cancel_move(self) -> None:
self.cancellations += 1
def test_completed_robot_tool_call_requires_completed_output_item() -> None:
payload = {
"item": {
"type": "function_call",
"name": "move_reachy_head",
"status": "completed",
"call_id": "call-look",
"arguments": '{"direction":"left"}',
}
}
call = completed_robot_tool_call("response.output_item.done", payload)
assert call is not None
assert call.call_id == "call-look"
assert call.name == "move_reachy_head"
assert call.arguments == {"direction": "left"}
assert completed_robot_tool_call("response.function_call_arguments.done", payload) is None
def test_manual_robot_controls_translate_only_curated_values() -> None:
assert manual_robot_action("look", "left") == ("move_reachy_head", {"direction": "left"})
assert manual_robot_action("emotion", "happy") == (
"express_reachy_emotion",
{"emotion": "happy"},
)
assert manual_robot_action("dance", "groovy") == ("dance_reachy", {"style": "groovy"})
assert robot_control_options()["look"] == [
"left",
"right",
"up",
"down",
"up_left",
"up_right",
"down_left",
"down_right",
"center",
]
with pytest.raises(ValueError):
manual_robot_action("joint", "neck=180")
def test_precision_controls_validate_only_cartesian_axes() -> None:
assert manual_precision_action("pitch", -1.0, body_yaw_degrees=3.0) == (
"nudge_reachy",
{"axis": "pitch", "delta": -1.0, "body_yaw_degrees": 3.0},
)
assert manual_precision_action("center_all", 0.0) == (
"nudge_reachy",
{"axis": "center_all", "delta": 0.0, "body_yaw_degrees": 0.0},
)
with pytest.raises(ValueError):
manual_precision_action("joint_4", 1.0)
with pytest.raises(ValueError):
manual_precision_action("yaw", 25.0)
with pytest.raises(ValueError):
manual_precision_action("body_yaw", float("nan"))
with pytest.raises(ValueError):
manual_precision_action("body_yaw", 5.0, body_yaw_degrees=float("nan"))
def test_precision_head_and_base_moves_are_clamped_and_interpolated(monkeypatch) -> None:
monkeypatch.setattr("reachy_mini_hermes.robot_tools.create_head_pose", lambda **kwargs: kwargs)
monkeypatch.setattr(
"reachy_mini_hermes.robot_tools.interpolate_head_pose",
lambda start, target, ratio: target,
)
robot = FakeRobot()
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
pitch = actions.execute(
"nudge_reachy",
{"axis": "pitch", "delta": -2.5, "body_yaw_degrees": 0.0},
)
base = actions.execute(
"nudge_reachy",
{"axis": "body_yaw", "delta": 10.0, "body_yaw_degrees": 27.0},
)
assert pitch["ok"] is True
assert pitch["target"]["pitch"] == -2.5
assert robot.head_samples[-1]["pitch"] == -2.5
assert robot.head_samples[-1]["mm"] is True
assert base["target"] == {"body_yaw": 30.0}
assert robot.body_samples[-1] == pytest.approx(0.5235987756)
def test_precision_center_all_resets_head_and_base(monkeypatch) -> None:
monkeypatch.setattr("reachy_mini_hermes.robot_tools.create_head_pose", lambda **kwargs: kwargs)
monkeypatch.setattr(
"reachy_mini_hermes.robot_tools.interpolate_head_pose",
lambda start, target, ratio: target,
)
robot = FakeRobot()
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
result = actions.execute(
"nudge_reachy",
{"axis": "center_all", "delta": 0.0, "body_yaw_degrees": 18.0},
)
assert result["ok"] is True
assert result["target"] == {
"body_yaw": 0.0,
"x": 0.0,
"y": 0.0,
"z": 0.0,
"roll": 0.0,
"pitch": 0.0,
"yaw": 0.0,
}
assert robot.head_samples[-1]["pitch"] == 0.0
assert robot.body_samples[-1] == 0.0
def test_robot_actions_use_safe_curated_moves_without_move_audio(monkeypatch) -> None:
monkeypatch.setattr(
"reachy_mini_hermes.robot_tools.create_head_pose",
lambda **kwargs: kwargs,
)
robot = FakeRobot()
library = FakeLibrary()
actions = ReachyRobotActions(
robot,
threading.Event(),
library_factory=lambda: library,
)
assert actions.execute("move_reachy_head", {"direction": "left"})["ok"] is True
assert actions.execute("express_reachy_emotion", {"emotion": "happy"})["move"] == "laughing2"
assert actions.execute("dance_reachy", {"style": "short"})["move"] == "dance1"
assert robot.targets[0]["body_yaw"] is None
assert library.requested == ["laughing2", "dance1"]
assert robot.head_samples
assert robot.body_samples
assert robot.antenna_samples
assert robot.cancellations == 0
def test_diagonal_look_uses_one_bounded_semantic_head_pose(monkeypatch) -> None:
monkeypatch.setattr(
"reachy_mini_hermes.robot_tools.create_head_pose",
lambda **kwargs: kwargs,
)
robot = FakeRobot()
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
result = actions.execute("move_reachy_head", {"direction": "up_left"})
assert result == {"ok": True, "action": "move_reachy_head", "direction": "up_left"}
assert robot.targets == [
{
"head": {"yaw": 25.0, "pitch": -18.0, "degrees": True},
"antennas": None,
"body_yaw": None,
"duration": 0.6,
}
]
def test_stop_cancels_precision_body_interpolation_without_late_targets(monkeypatch) -> None:
robot = FakeRobot()
completed = threading.Event()
results: list[dict[str, object]] = []
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
actions.start()
def on_complete(result: dict[str, object]) -> None:
results.append(result)
completed.set()
actions.enqueue(
"nudge_reachy",
{"axis": "body_yaw", "delta": 10.0, "body_yaw_degrees": 0.0},
on_complete=on_complete,
)
deadline = time.monotonic() + 1
while not robot.body_samples and time.monotonic() < deadline:
time.sleep(0.01)
assert actions.cancel(stop_media=False) is True
assert completed.wait(1)
time.sleep(0.05)
samples_after_stop = len(robot.body_samples)
time.sleep(0.15)
assert len(robot.body_samples) == samples_after_stop
assert results == [{"ok": False, "error": "Robot action was cancelled", "action": "nudge_reachy"}]
assert actions.wait_idle(1)
actions.close()
def test_unknown_or_unapproved_robot_action_is_rejected() -> None:
actions = ReachyRobotActions(FakeRobot(), threading.Event(), library_factory=FakeLibrary)
assert actions.execute("dance_reachy", {"style": "unknown"})["ok"] is False
assert actions.execute("shell", {"command": "rm -rf /"})["ok"] is False
def test_worker_completes_tool_with_actual_execution_result(monkeypatch) -> None:
monkeypatch.setattr("reachy_mini_hermes.robot_tools.create_head_pose", lambda **kwargs: kwargs)
robot = FakeRobot()
completed = threading.Event()
results: list[dict[str, object]] = []
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
actions.start()
def on_complete(result: dict[str, object]) -> None:
results.append(result)
completed.set()
accepted = actions.enqueue(
"move_reachy_head",
{"direction": "left"},
on_complete=on_complete,
)
assert accepted == {"accepted": True, "queued": "move_reachy_head"}
assert completed.wait(2)
assert results == [{"ok": True, "action": "move_reachy_head", "direction": "left"}]
actions.close()
def test_manual_actions_reject_surprising_queueing_when_robot_is_busy() -> None:
actions = ReachyRobotActions(FakeRobot(), threading.Event(), library_factory=FakeLibrary)
first = actions.enqueue("dance_reachy", {"style": "short"}, reject_if_busy=True)
second = actions.enqueue("move_reachy_head", {"direction": "left"}, reject_if_busy=True)
assert first["accepted"] is True
assert second == {"ok": False, "error": "Robot is busy", "code": "robot_busy"}
assert actions.pending_count == 1
assert actions.cancel() is False
assert actions.pending_count == 0
def test_manual_stop_uses_cooperative_move_flag_without_stopping_media() -> None:
robot = FakeRobot()
actions = ReachyRobotActions(robot, threading.Event(), library_factory=FakeLibrary)
actions._busy.set()
assert actions.cancel(stop_media=False) is True
assert actions._cancel_requested.is_set()
assert robot.head_samples[-1] == robot.get_current_head_pose()
assert robot.cancellations == 0
def test_manual_stop_interrupts_recorded_move_without_sdk_media_cancel() -> None:
class LongMove(FakeMove):
duration = 2.0
class LongLibrary(FakeLibrary):
def get(self, name: str) -> LongMove:
self.requested.append(name)
return LongMove()
robot = FakeRobot()
completed = threading.Event()
results: list[dict[str, object]] = []
actions = ReachyRobotActions(robot, threading.Event(), library_factory=LongLibrary)
actions.start()
def on_complete(result: dict[str, object]) -> None:
results.append(result)
completed.set()
actions.enqueue(
"dance_reachy",
{"style": "energetic"},
on_complete=on_complete,
)
for _ in range(100):
if robot.head_samples:
break
threading.Event().wait(0.01)
assert actions.cancel(stop_media=False) is True
assert completed.wait(1)
assert results == [{"ok": False, "error": "Robot action was cancelled", "action": "dance_reachy"}]
assert robot.cancellations == 0
assert actions.wait_idle(1)
actions.close()
def test_manual_look_can_hold_pose_without_restoring_idle_motion(monkeypatch) -> None:
monkeypatch.setattr("reachy_mini_hermes.robot_tools.create_head_pose", lambda **kwargs: kwargs)
completed = threading.Event()
lifecycle: list[str] = []
actions = ReachyRobotActions(
FakeRobot(),
threading.Event(),
before_action=lambda: lifecycle.append("before"),
after_action=lambda: lifecycle.append("after"),
)
actions.start()
actions.enqueue(
"move_reachy_head",
{"direction": "left"},
hold_pose=True,
on_complete=lambda result: completed.set(),
)
assert completed.wait(2)
assert lifecycle == ["before"]
actions.close()
def test_cancel_generation_blocks_dequeued_action_before_execution(monkeypatch) -> None:
monkeypatch.setattr("reachy_mini_hermes.robot_tools.create_head_pose", lambda **kwargs: kwargs)
robot = FakeRobot()
entered = threading.Event()
release = threading.Event()
completed = threading.Event()
results: list[dict[str, object]] = []
def before_action() -> None:
entered.set()
release.wait(2)
def on_complete(result: dict[str, object]) -> None:
results.append(result)
completed.set()
lifecycle: list[str] = []
actions = ReachyRobotActions(
robot,
threading.Event(),
before_action=before_action,
after_action=lambda: lifecycle.append("after"),
)
actions.start()
actions.enqueue(
"move_reachy_head",
{"direction": "left"},
on_complete=on_complete,
)
assert entered.wait(2)
assert actions.cancel() is True
release.set()
assert completed.wait(2)
assert results[0]["ok"] is False
assert robot.targets == []
assert robot.cancellations == 1
assert lifecycle == []
actions.close()