diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 3b264d2..256389a 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -19,7 +19,9 @@ class RobotGestureAgent(BaseAgent): :ivar pubsocket: ZMQ PUB socket for sending commands to the Robot Interface. :ivar address: Address to bind/connect the PUB socket. :ivar bind: Whether to bind or connect the PUB socket. - :ivar gesture_data: A list of strings for available gestures + :ivar gesture_tags: A list of strings for available gesture tags + :ivar gesture_basic: A list of strings for 10 basisc gestures + :ivar gesture_single: A list of strings for all available gestures """ subsocket: azmq.Socket @@ -27,19 +29,22 @@ class RobotGestureAgent(BaseAgent): pubsocket: azmq.Socket address = "" bind = False - gesture_data = [] - single_gesture_data = [] + gesture_tags = [] + gesture_basic = [] + gesture_single = [] def __init__( self, name: str, address: str, bind=False, - gesture_data=None, - single_gesture_data=None, + gesture_tags=None, + gesture_basic=None, + gesture_single=None, ): - self.gesture_data = gesture_data or [] - self.single_gesture_data = single_gesture_data or [] + self.gesture_tags = gesture_tags or [] + self.gesture_basic = gesture_basic or [] + self.gesture_single = gesture_single or [] super().__init__(name) self.address = address self.bind = bind @@ -96,14 +101,14 @@ class RobotGestureAgent(BaseAgent): try: gesture_command = GestureCommand.model_validate_json(msg.body) if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: - if gesture_command.data not in self.gesture_data: + if gesture_command.data not in self.gesture_tags: self.logger.warning( "Received gesture tag '%s' which is not in available tags. Early returning", gesture_command.data, ) return elif gesture_command.endpoint == RIEndpoint.GESTURE_SINGLE: - if gesture_command.data not in self.single_gesture_data: + if gesture_command.data not in self.gesture_single: self.logger.warning( "Received gesture '%s' which is not in available gestures. Early returning", gesture_command.data, @@ -130,7 +135,7 @@ class RobotGestureAgent(BaseAgent): body = json.loads(body) gesture_command = GestureCommand.model_validate(body) if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: - if gesture_command.data not in self.gesture_data: + if gesture_command.data not in (self.gesture_tags or self.gesture_single): self.logger.warning( "Received gesture tag '%s' which is not in available tags.\ Early returning", @@ -143,29 +148,39 @@ class RobotGestureAgent(BaseAgent): async def _fetch_gestures_loop(self): """ - Loop to handle fetching gestures received via ZMQ (e.g., from the UI). - - Listens on the 'send_gestures' topic, and returns a list on the get_gestures topic. + REP socket handler for gesture queries. + Supports: + - tags + - basic_gestures + - single_gestures """ while self._running: try: - # Get a request - body = await self.repsocket.recv() + req = await self.repsocket.recv_json() - # Figure out amount, if specified - try: - body = json.loads(body) - except json.JSONDecodeError: - body = None + req_type = req.get("type") + amount = req.get("count") - amount = None - if isinstance(body, int): - amount = body + if req_type == "tags": + data = self.gesture_tags + key = "tags" - # Fetch tags from gesture data and respond - tags = self.gesture_data[:amount] if amount else self.gesture_data - response = json.dumps({"tags": tags}).encode() - await self.repsocket.send(response) + elif req_type == "basic": + data = self.gesture_basic + key = "basic_gestures" + + elif req_type == "single": + data = self.gesture_single + key = "single_gestures" + + else: + await self.repsocket.send_json({}) + continue + + if amount: + data = data[:amount] + + await self.repsocket.send_json({key: data}) except Exception: - self.logger.exception("Error fetching gesture tags.") + self.logger.exception("Error fetching gestures.") diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 5c6ca77..74ca6a5 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -181,8 +181,9 @@ class RICommunicationAgent(BaseAgent): else: self._req_socket.bind(addr) case "actuation": - gesture_data = port_data.get("gestures", []) - single_gesture_data = port_data.get("single_gestures", []) + gesture_tags = port_data.get("gestures", []) + gesture_single = port_data.get("single_gestures", []) + gesture_basic = port_data.get("basic_gestures", []) robot_speech_agent = RobotSpeechAgent( settings.agent_settings.robot_speech_name, address=addr, @@ -192,8 +193,9 @@ class RICommunicationAgent(BaseAgent): settings.agent_settings.robot_gesture_name, address=addr, bind=bind, - gesture_data=gesture_data, - single_gesture_data=single_gesture_data, + gesture_tags=gesture_tags, + gesture_basic=gesture_basic, + gesture_single=gesture_single, ) await robot_speech_agent.start() await asyncio.sleep(0.1) # Small delay diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index afbf1ac..6ad7f74 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -78,7 +78,8 @@ async def get_available_gesture_tags(request: Request, count=0): amount = count or None timeout = 5 # seconds - await req_socket.send(f"{amount}".encode() if amount else b"None") + await req_socket.send_json({"type": "tags", "count": amount}) + try: body = await asyncio.wait_for(req_socket.recv(), timeout=timeout) except TimeoutError: @@ -94,8 +95,75 @@ async def get_available_gesture_tags(request: Request, count=0): logger.error(f"Failed to parse gesture tags JSON: {e}, body: {body}") # Return empty list on JSON error available_tags = [] + return {"available_gestures": available_tags} - return {"available_gesture_tags": available_tags} + +@router.get("/commands/gesture/single") +async def get_available_gestures(request: Request, count=0): + """ + Endpoint to retrieve the available gestures for the robot. + + :param request: The FastAPI request object. + :return: A list of available gestures. + """ + req_socket = Context.instance().socket(zmq.REQ) + req_socket.connect(settings.zmq_settings.internal_gesture_rep_adress) + + # Check to see if we've got any count given in the query parameter + amount = count or None + timeout = 5 # seconds + + await req_socket.send_json({"type": "single", "count": amount}) + try: + body = await asyncio.wait_for(req_socket.recv(), timeout=timeout) + except TimeoutError: + body = '{"tags": []}' + logger.debug("Got timeout error fetching gestures.") + + # Handle empty response and JSON decode errors + available_tags = [] + if body: + try: + available_tags = json.loads(body).get("single_gestures", []) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse gesture tags JSON: {e}, body: {body}") + # Return empty list on JSON error + available_tags = [] + return {"available_gestures": available_tags} + + +@router.get("/commands/gesture/basic") +async def get_available_basic_gestures(request: Request, count=0): + """ + Endpoint to retrieve the available gesture tags for the robot. + + :param request: The FastAPI request object. + :return: A list of 10 available gestures. + """ + req_socket = Context.instance().socket(zmq.REQ) + req_socket.connect(settings.zmq_settings.internal_gesture_rep_adress) + + # Check to see if we've got any count given in the query parameter + amount = count or None + timeout = 5 # seconds + + await req_socket.send_json({"type": "basic", "count": amount}) + try: + body = await asyncio.wait_for(req_socket.recv(), timeout=timeout) + except TimeoutError: + body = '{"tags": []}' + logger.debug("Got timeout error fetching gestures.") + + # Handle empty response and JSON decode errors + available_tags = [] + if body: + try: + available_tags = json.loads(body).get("basic_gestures", []) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse gesture tags JSON: {e}, body: {body}") + # Return empty list on JSON error + available_tags = [] + return {"available_gestures": available_tags} @router.get("/ping_stream") diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index a48dec6..8bd408b 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -12,6 +12,7 @@ class RIEndpoint(str, Enum): SPEECH = "actuate/speech" GESTURE_SINGLE = "actuate/gesture/single" GESTURE_TAG = "actuate/gesture/tag" + GESTURE_BASIC = "actuate/gesture/single" PING = "ping" NEGOTIATE_PORTS = "negotiate/ports" @@ -57,10 +58,7 @@ class GestureCommand(RIMessage): @model_validator(mode="after") def check_endpoint(self): - allowed = { - RIEndpoint.GESTURE_SINGLE, - RIEndpoint.GESTURE_TAG, - } + allowed = {RIEndpoint.GESTURE_SINGLE, RIEndpoint.GESTURE_TAG} if self.endpoint not in allowed: - raise ValueError("endpoint must be GESTURE_SINGLE or GESTURE_TAG") + raise ValueError("endpoint must be GESTURE_SINGLE, GESTURE_TAG or GESTURE_BASIC") return self diff --git a/test/unit/agents/actuation/test_robot_gesture_agent.py b/test/unit/agents/actuation/test_robot_gesture_agent.py index fe051a6..1211d07 100644 --- a/test/unit/agents/actuation/test_robot_gesture_agent.py +++ b/test/unit/agents/actuation/test_robot_gesture_agent.py @@ -11,7 +11,6 @@ from control_backend.schemas.ri_message import RIEndpoint @pytest.fixture def zmq_context(mocker): - """Mock the ZMQ context.""" mock_context = mocker.patch( "control_backend.agents.actuation.robot_gesture_agent.azmq.Context.instance" ) @@ -21,65 +20,54 @@ def zmq_context(mocker): @pytest.mark.asyncio async def test_setup_bind(zmq_context, mocker): - """Setup binds and subscribes to internal commands.""" fake_socket = zmq_context.return_value.socket.return_value agent = RobotGestureAgent("robot_gesture", address="tcp://localhost:5556", bind=True) settings = mocker.patch("control_backend.agents.actuation.robot_gesture_agent.settings") settings.zmq_settings.internal_sub_address = "tcp://internal:1234" + settings.zmq_settings.internal_gesture_rep_adress = "tcp://internal:5557" agent.add_behavior = MagicMock() await agent.setup() - # Check PUB socket binding fake_socket.bind.assert_any_call("tcp://localhost:5556") - # Check REP socket binding - fake_socket.bind.assert_called() - - # Check SUB socket connection and subscriptions fake_socket.connect.assert_any_call("tcp://internal:1234") fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"command") fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"send_gestures") - - # Check behavior was added (twice: once for command loop, once for fetch gestures loop) assert agent.add_behavior.call_count == 2 @pytest.mark.asyncio async def test_setup_connect(zmq_context, mocker): - """Setup connects when bind=False.""" fake_socket = zmq_context.return_value.socket.return_value agent = RobotGestureAgent("robot_gesture", address="tcp://localhost:5556", bind=False) settings = mocker.patch("control_backend.agents.actuation.robot_gesture_agent.settings") settings.zmq_settings.internal_sub_address = "tcp://internal:1234" + settings.zmq_settings.internal_gesture_rep_adress = "tcp://internal:5557" agent.add_behavior = MagicMock() await agent.setup() - # Check PUB socket connection (not binding) fake_socket.connect.assert_any_call("tcp://localhost:5556") fake_socket.connect.assert_any_call("tcp://internal:1234") - # Check REP socket binding (always binds) fake_socket.bind.assert_called() - - # Check behavior was added (twice) assert agent.add_behavior.call_count == 2 @pytest.mark.asyncio -async def test_handle_message_sends_valid_gesture_command(): - """Internal message with valid gesture tag is forwarded to robot pub socket.""" +async def test_handle_message_valid_gesture_tag(): pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent( + "robot_gesture", + address="", + gesture_tags=["hello"], + ) agent.pubsocket = pubsocket - payload = { - "endpoint": RIEndpoint.GESTURE_TAG, - "data": "hello", # "hello" is in gesture_data - } + payload = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "hello"} msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) await agent.handle_message(msg) @@ -88,30 +76,16 @@ async def test_handle_message_sends_valid_gesture_command(): @pytest.mark.asyncio -async def test_handle_message_sends_non_gesture_command(): - """Internal message with non-gesture endpoint is not forwarded by this agent.""" +async def test_handle_message_invalid_gesture_tag(): pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent( + "robot_gesture", + address="", + gesture_tags=["hello"], + ) agent.pubsocket = pubsocket - payload = {"endpoint": "some_other_endpoint", "data": "invalid_tag_not_in_list"} - msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) - - await agent.handle_message(msg) - - # Non-gesture endpoints should not be forwarded by this agent - pubsocket.send_json.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_handle_message_rejects_invalid_gesture_tag(): - """Internal message with invalid gesture tag is not forwarded.""" - pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.pubsocket = pubsocket - - # Use a tag that's not in gesture_data - payload = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid_tag_not_in_list"} + payload = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "nope"} msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) await agent.handle_message(msg) @@ -120,34 +94,38 @@ async def test_handle_message_rejects_invalid_gesture_tag(): @pytest.mark.asyncio -async def test_handle_message_invalid_payload(): - """Invalid payload is caught and does not send.""" +async def test_handle_message_invalid_payload_logged(): pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent("robot_gesture", address="") agent.pubsocket = pubsocket + agent.logger = MagicMock() - msg = InternalMessage(to="robot", sender="tester", body=json.dumps({"bad": "data"})) + msg = InternalMessage(to="robot", sender="tester", body="not json") await agent.handle_message(msg) pubsocket.send_json.assert_not_awaited() + agent.logger.exception.assert_called_once() @pytest.mark.asyncio -async def test_zmq_command_loop_valid_gesture_payload(): - """UI command with valid gesture tag is read from SUB and published.""" - command = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "hello"} +async def test_zmq_command_loop_valid_gesture(): fake_socket = AsyncMock() async def recv_once(): - # stop after first iteration agent._running = False - return b"command", json.dumps(command).encode("utf-8") + return b"command", json.dumps( + {"endpoint": RIEndpoint.GESTURE_TAG, "data": "hello"} + ).encode() fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent( + "robot_gesture", + address="", + gesture_tags=["hello"], + ) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -158,64 +136,23 @@ async def test_zmq_command_loop_valid_gesture_payload(): @pytest.mark.asyncio -async def test_zmq_command_loop_valid_non_gesture_payload(): - """UI command with non-gesture endpoint is not forwarded by this agent.""" - command = {"endpoint": "some_other_endpoint", "data": "anything"} +async def test_zmq_command_loop_invalid_tag(): fake_socket = AsyncMock() async def recv_once(): agent._running = False - return b"command", json.dumps(command).encode("utf-8") + return b"command", json.dumps( + {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid"} + ).encode() fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket - agent._running = True - - await agent._zmq_command_loop() - - fake_socket.send_json.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_zmq_command_loop_invalid_gesture_tag(): - """UI command with invalid gesture tag is not forwarded.""" - command = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid_tag_not_in_list"} - fake_socket = AsyncMock() - - async def recv_once(): - agent._running = False - return b"command", json.dumps(command).encode("utf-8") - - fake_socket.recv_multipart = recv_once - fake_socket.send_json = AsyncMock() - - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket - agent._running = True - - await agent._zmq_command_loop() - - fake_socket.send_json.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_zmq_command_loop_invalid_json(): - """Invalid JSON is ignored without sending.""" - fake_socket = AsyncMock() - - async def recv_once(): - agent._running = False - return b"command", b"{not_json}" - - fake_socket.recv_multipart = recv_once - fake_socket.send_json = AsyncMock() - - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent( + "robot_gesture", + address="", + gesture_tags=["hello"], + ) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -227,7 +164,6 @@ async def test_zmq_command_loop_invalid_json(): @pytest.mark.asyncio async def test_zmq_command_loop_ignores_send_gestures_topic(): - """send_gestures topic is ignored in command loop.""" fake_socket = AsyncMock() async def recv_once(): @@ -237,7 +173,7 @@ async def test_zmq_command_loop_ignores_send_gestures_topic(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") + agent = RobotGestureAgent("robot_gesture", address="") agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -248,201 +184,68 @@ async def test_zmq_command_loop_ignores_send_gestures_topic(): @pytest.mark.asyncio -async def test_fetch_gestures_loop_without_amount(): - """Fetch gestures request without amount returns all tags.""" +async def test_fetch_gestures_tags(): fake_repsocket = AsyncMock() async def recv_once(): agent._running = False - return b"{}" # Empty JSON request + return {"type": "tags"} - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() + fake_repsocket.recv_json = recv_once + fake_repsocket.send_json = AsyncMock() agent = RobotGestureAgent( - "robot_gesture", gesture_data=["hello", "yes", "no", "wave", "point"], address="" + "robot_gesture", + address="", + gesture_tags=["hello", "yes", "no"], ) agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - fake_repsocket.send.assert_awaited_once() - - # Check the response contains all tags - args, kwargs = fake_repsocket.send.call_args - response = json.loads(args[0]) - assert "tags" in response - assert response["tags"] == ["hello", "yes", "no", "wave", "point"] + fake_repsocket.send_json.assert_awaited_once_with({"tags": ["hello", "yes", "no"]}) @pytest.mark.asyncio -async def test_fetch_gestures_loop_with_amount(): - """Fetch gestures request with amount returns limited tags.""" +async def test_fetch_gestures_basic(): fake_repsocket = AsyncMock() - amount = 3 async def recv_once(): agent._running = False - return json.dumps(amount).encode() + return {"type": "basic"} - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() + fake_repsocket.recv_json = recv_once + fake_repsocket.send_json = AsyncMock() agent = RobotGestureAgent( - "robot_gesture", gesture_data=["hello", "yes", "no", "wave", "point"], address="" + "robot_gesture", + address="", + gesture_basic=["wave", "point"], ) agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - fake_repsocket.send.assert_awaited_once() - - args, kwargs = fake_repsocket.send.call_args - response = json.loads(args[0]) - assert "tags" in response - assert len(response["tags"]) == amount - assert response["tags"] == ["hello", "yes", "no"] + fake_repsocket.send_json.assert_awaited_once_with({"basic_gestures": ["wave", "point"]}) @pytest.mark.asyncio -async def test_fetch_gestures_loop_with_integer_request(): - """Fetch gestures request with integer amount.""" - fake_repsocket = AsyncMock() - amount = 2 - - async def recv_once(): - agent._running = False - return json.dumps(amount).encode() - - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() - - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.repsocket = fake_repsocket - agent._running = True - - await agent._fetch_gestures_loop() - - fake_repsocket.send.assert_awaited_once() - - args, kwargs = fake_repsocket.send.call_args - response = json.loads(args[0]) - assert response["tags"] == ["hello", "yes"] - - -@pytest.mark.asyncio -async def test_fetch_gestures_loop_with_invalid_json(): - """Invalid JSON request returns all tags.""" +async def test_fetch_gestures_unknown_type(): fake_repsocket = AsyncMock() async def recv_once(): agent._running = False - return b"not_json" + return {"type": "unknown"} - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() + fake_repsocket.recv_json = recv_once + fake_repsocket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.repsocket = fake_repsocket - agent._running = True - - await agent._fetch_gestures_loop() - - fake_repsocket.send.assert_awaited_once() - - args, kwargs = fake_repsocket.send.call_args - response = json.loads(args[0]) - assert response["tags"] == ["hello", "yes", "no"] - - -@pytest.mark.asyncio -async def test_fetch_gestures_loop_with_non_integer_json(): - """Non-integer JSON request returns all tags.""" - fake_repsocket = AsyncMock() - - async def recv_once(): - agent._running = False - return json.dumps({"not": "an_integer"}).encode() - - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() - - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") - agent.repsocket = fake_repsocket - agent._running = True - - await agent._fetch_gestures_loop() - - fake_repsocket.send.assert_awaited_once() - - args, kwargs = fake_repsocket.send.call_args - response = json.loads(args[0]) - assert response["tags"] == ["hello", "yes", "no"] - - -def test_gesture_data_attribute(): - """Test that gesture_data returns the expected list.""" - gesture_data = ["hello", "yes", "no", "wave"] - agent = RobotGestureAgent("robot_gesture", gesture_data=gesture_data, address="") - - assert agent.gesture_data == gesture_data - assert isinstance(agent.gesture_data, list) - assert len(agent.gesture_data) == 4 - assert "hello" in agent.gesture_data - assert "yes" in agent.gesture_data - assert "no" in agent.gesture_data - assert "invalid_tag_not_in_list" not in agent.gesture_data - - -@pytest.mark.asyncio -async def test_stop_closes_sockets(): - """Stop method closes all sockets.""" - pubsocket = MagicMock() - subsocket = MagicMock() - repsocket = MagicMock() agent = RobotGestureAgent("robot_gesture", address="") - agent.pubsocket = pubsocket - agent.subsocket = subsocket - agent.repsocket = repsocket - - await agent.stop() - - pubsocket.close.assert_called_once() - subsocket.close.assert_called_once() - # Note: repsocket is not closed in stop() method, but you might want to add it - # repsocket.close.assert_called_once() - - -@pytest.mark.asyncio -async def test_initialization_with_custom_gesture_data(): - """Agent can be initialized with custom gesture data.""" - custom_gestures = ["custom1", "custom2", "custom3"] - agent = RobotGestureAgent("robot_gesture", gesture_data=custom_gestures, address="") - - assert agent.gesture_data == custom_gestures - - -@pytest.mark.asyncio -async def test_fetch_gestures_loop_handles_exception(): - """Exception in fetch gestures loop is caught and logged.""" - fake_repsocket = AsyncMock() - - async def recv_once(): - agent._running = False - raise Exception("Test exception") - - fake_repsocket.recv = recv_once - fake_repsocket.send = AsyncMock() - - agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"], address="") agent.repsocket = fake_repsocket - agent.logger = MagicMock() agent._running = True - # Should not raise exception await agent._fetch_gestures_loop() - # Exception should be logged - agent.logger.exception.assert_called_once() + fake_repsocket.send_json.assert_awaited_once_with({}) diff --git a/test/unit/agents/communication/test_ri_communication_agent.py b/test/unit/agents/communication/test_ri_communication_agent.py index 06d8766..a3cc991 100644 --- a/test/unit/agents/communication/test_ri_communication_agent.py +++ b/test/unit/agents/communication/test_ri_communication_agent.py @@ -61,16 +61,18 @@ async def test_setup_success_connects_and_starts_robot(zmq_context): fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": {}}) MockSpeech.return_value.start.assert_awaited_once() MockGesture.return_value.start.assert_awaited_once() + MockSpeech.assert_called_once_with(ANY, address="tcp://localhost:5556", bind=False) MockGesture.assert_called_once_with( ANY, address="tcp://localhost:5556", bind=False, - gesture_data=[], - single_gesture_data=[], + gesture_tags=[], + gesture_basic=[], + gesture_single=[], ) - agent.add_behavior.assert_called_once() + agent.add_behavior.assert_called_once() assert agent.connected is True diff --git a/test/unit/api/v1/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py index e9e637d..05aab79 100644 --- a/test/unit/api/v1/endpoints/test_robot_endpoint.py +++ b/test/unit/api/v1/endpoints/test_robot_endpoint.py @@ -229,120 +229,60 @@ async def test_ping_stream_yields_json_values(monkeypatch): mock_sub_socket.recv_multipart.assert_awaited() -# ---------------------------- -# Updated get_available_gesture_tags tests (REQ socket on tcp://localhost:7788) -# ---------------------------- @pytest.mark.asyncio -async def test_get_available_gesture_tags_success(client, monkeypatch): +async def test_get_available_single_gestures_success(client, monkeypatch): """ - Test successful retrieval of available gesture tags using a REQ socket. + Test successful retrieval of single gestures. """ - # Arrange mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() - response_data = {"tags": ["wave", "nod", "point", "dance"]} + mock_req_socket.send_json = AsyncMock() + response_data = {"single_gestures": ["wave", "point"]} mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) mock_context = MagicMock() mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - # Replace logger methods to avoid noisy logs in tests monkeypatch.setattr(robot.logger, "debug", MagicMock()) monkeypatch.setattr(robot.logger, "error", MagicMock()) - # Act - response = client.get("/commands/gesture/tags") + response = client.get("/commands/gesture/single") - # Assert assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": ["wave", "nod", "point", "dance"]} + assert response.json() == {"available_gestures": ["wave", "point"]} - # Verify ZeroMQ REQ interactions mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") - mock_req_socket.send.assert_awaited_once_with(b"None") + mock_req_socket.send_json.assert_awaited_once_with({"type": "single", "count": None}) mock_req_socket.recv.assert_awaited_once() @pytest.mark.asyncio -async def test_get_available_gesture_tags_with_amount(client, monkeypatch): - """ - The endpoint currently ignores the 'amount' TODO, so behavior is the same as 'success'. - This test asserts that the endpoint still sends b"None" and returns the tags. - """ - # Arrange +async def test_get_available_single_gestures_timeout(client, monkeypatch): mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() - response_data = {"tags": ["wave", "nod"]} - mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) - - mock_context = MagicMock() - mock_context.socket.return_value = mock_req_socket - monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - - monkeypatch.setattr(robot.logger, "debug", MagicMock()) - monkeypatch.setattr(robot.logger, "error", MagicMock()) - - # Act - response = client.get("/commands/gesture/tags") - - # Assert - assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": ["wave", "nod"]} - - mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") - mock_req_socket.send.assert_awaited_once_with(b"None") - - -@pytest.mark.asyncio -async def test_get_available_gesture_tags_timeout(client, monkeypatch): - """ - Test timeout scenario when fetching gesture tags. Endpoint should handle TimeoutError - and return an empty list while logging the timeout. - """ - # Arrange - mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) - mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() + mock_req_socket.send_json = AsyncMock() mock_req_socket.recv = AsyncMock(side_effect=TimeoutError) mock_context = MagicMock() mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - # Patch logger.debug so we can assert it was called with the expected message - mock_debug = MagicMock() - monkeypatch.setattr(robot.logger, "debug", mock_debug) - monkeypatch.setattr(robot.logger, "error", MagicMock()) + response = client.get("/commands/gesture/single") - # Act - response = client.get("/commands/gesture/tags") - - # Assert assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": []} - - # Verify the timeout was logged using the exact string from the endpoint code - mock_debug.assert_called_once_with("Got timeout error fetching gestures.") - - mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") - mock_req_socket.send.assert_awaited_once_with(b"None") - mock_req_socket.recv.assert_awaited_once() + assert response.json() == {"available_gestures": []} @pytest.mark.asyncio -async def test_get_available_gesture_tags_empty_response(client, monkeypatch): +async def test_get_available_single_gestures_missing_key(client, monkeypatch): """ - Test scenario when response contains an empty 'tags' list. + Test response missing 'single_gestures' key. """ - # Arrange mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() - response_data = {"tags": []} - mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) + mock_req_socket.send_json = AsyncMock() + mock_req_socket.recv = AsyncMock(return_value=json.dumps({"unexpected": "value"}).encode()) mock_context = MagicMock() mock_context.socket.return_value = mock_req_socket @@ -351,52 +291,21 @@ async def test_get_available_gesture_tags_empty_response(client, monkeypatch): monkeypatch.setattr(robot.logger, "debug", MagicMock()) monkeypatch.setattr(robot.logger, "error", MagicMock()) - # Act - response = client.get("/commands/gesture/tags") + response = client.get("/commands/gesture/single") - # Assert assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": []} + assert response.json() == {"available_gestures": []} @pytest.mark.asyncio -async def test_get_available_gesture_tags_missing_tags_key(client, monkeypatch): +async def test_get_available_single_gestures_invalid_json(client, monkeypatch): """ - Test scenario when response JSON doesn't contain 'tags' key. + Test invalid JSON response for single gestures. """ - # Arrange mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() - response_data = {"some_other_key": "value"} - mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) - - mock_context = MagicMock() - mock_context.socket.return_value = mock_req_socket - monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - - monkeypatch.setattr(robot.logger, "debug", MagicMock()) - monkeypatch.setattr(robot.logger, "error", MagicMock()) - - # Act - response = client.get("/commands/gesture/tags") - - # Assert - assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": []} - - -@pytest.mark.asyncio -async def test_get_available_gesture_tags_invalid_json(client, monkeypatch): - """ - Test scenario when response contains invalid JSON. Endpoint should log the error - and return an empty list. - """ - # Arrange - mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) - mock_req_socket.connect = MagicMock() - mock_req_socket.send = AsyncMock() - mock_req_socket.recv = AsyncMock(return_value=b"invalid json") + mock_req_socket.send_json = AsyncMock() + mock_req_socket.recv = AsyncMock(return_value=b"not-json") mock_context = MagicMock() mock_context.socket.return_value = mock_req_socket @@ -406,10 +315,78 @@ async def test_get_available_gesture_tags_invalid_json(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", mock_error) monkeypatch.setattr(robot.logger, "debug", MagicMock()) - # Act - response = client.get("/commands/gesture/tags") + response = client.get("/commands/gesture/single") - # Assert - invalid JSON should lead to empty list and error log invocation assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": []} + assert response.json() == {"available_gestures": []} + assert mock_error.call_count == 1 + + +@pytest.mark.asyncio +async def test_get_available_basic_gestures_success(client, monkeypatch): + """ + Test successful retrieval of basic gestures. + """ + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send_json = AsyncMock() + response_data = {"basic_gestures": ["nod", "shake"]} + mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_req_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + monkeypatch.setattr(robot.logger, "error", MagicMock()) + + response = client.get("/commands/gesture/basic") + + assert response.status_code == 200 + assert response.json() == {"available_gestures": ["nod", "shake"]} + + mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") + mock_req_socket.send_json.assert_awaited_once_with({"type": "basic", "count": None}) + mock_req_socket.recv.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_available_basic_gestures_timeout(client, monkeypatch): + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send_json = AsyncMock() + mock_req_socket.recv = AsyncMock(side_effect=TimeoutError) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_req_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + response = client.get("/commands/gesture/basic") + + assert response.status_code == 200 + assert response.json() == {"available_gestures": []} + + +@pytest.mark.asyncio +async def test_get_available_basic_gestures_invalid_json(client, monkeypatch): + """ + Test invalid JSON response for basic gestures. + """ + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send_json = AsyncMock() + mock_req_socket.recv = AsyncMock(return_value=b"{invalid json") + + mock_context = MagicMock() + mock_context.socket.return_value = mock_req_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_error = MagicMock() + monkeypatch.setattr(robot.logger, "error", mock_error) + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + + response = client.get("/commands/gesture/basic") + + assert response.status_code == 200 + assert response.json() == {"available_gestures": []} assert mock_error.call_count == 1