Merge remote-tracking branch 'origin/dev' into feat/belief-collector

This commit is contained in:
Pim Hutting
2025-10-29 15:14:42 +01:00
35 changed files with 2169 additions and 116 deletions

View File

@@ -1,10 +1,10 @@
import json
import logging
from unittest.mock import MagicMock, AsyncMock, call
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter
from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetterBehaviour
# Define a constant for the collector agent name to use in tests
COLLECTOR_AGENT_NAME = "belief_collector"
@@ -22,16 +22,14 @@ def mock_agent(mocker):
@pytest.fixture
def belief_setter(mock_agent, mocker):
"""Fixture to create an instance of BeliefSetter with a mocked agent."""
"""Fixture to create an instance of BeliefSetterBehaviour with a mocked agent."""
# Patch the settings to use a predictable agent name
mocker.patch(
"control_backend.agents.bdi.behaviours.belief_setter.settings.agent_settings.belief_collector_agent_name",
COLLECTOR_AGENT_NAME
COLLECTOR_AGENT_NAME,
)
# Patch asyncio.sleep to prevent tests from actually waiting
mocker.patch("asyncio.sleep", return_value=None)
setter = BeliefSetter()
setter = BeliefSetterBehaviour()
setter.agent = mock_agent
# Mock the receive method, we will control its return value in each test
setter.receive = AsyncMock()
@@ -69,7 +67,7 @@ async def test_run_message_received(belief_setter, mocker):
Test that when a message is received, _process_message is called.
"""
# Arrange
msg = MagicMock();
msg = MagicMock()
belief_setter.receive.return_value = msg
mocker.patch.object(belief_setter, "_process_message")
@@ -115,14 +113,9 @@ def test_process_belief_message_valid_json(belief_setter, mocker):
Test processing a valid belief message with correct thread and JSON body.
"""
# Arrange
beliefs_payload = {
"is_hot": [["kitchen"]],
"is_clean": [["kitchen"], ["bathroom"]]
}
beliefs_payload = {"is_hot": ["kitchen"], "is_clean": ["kitchen", "bathroom"]}
msg = create_mock_message(
sender_node=COLLECTOR_AGENT_JID,
body=json.dumps(beliefs_payload),
thread="beliefs"
sender_node=COLLECTOR_AGENT_JID, body=json.dumps(beliefs_payload), thread="beliefs"
)
mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs")
@@ -139,9 +132,7 @@ def test_process_belief_message_invalid_json(belief_setter, mocker, caplog):
"""
# Arrange
msg = create_mock_message(
sender_node=COLLECTOR_AGENT_JID,
body="this is not a json string",
thread="beliefs"
sender_node=COLLECTOR_AGENT_JID, body="this is not a json string", thread="beliefs"
)
mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs")
@@ -160,9 +151,7 @@ def test_process_belief_message_wrong_thread(belief_setter, mocker):
"""
# Arrange
msg = create_mock_message(
sender_node=COLLECTOR_AGENT_JID,
body='{"some": "data"}',
thread="not_beliefs"
sender_node=COLLECTOR_AGENT_JID, body='{"some": "data"}', thread="not_beliefs"
)
mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs")
@@ -172,16 +161,13 @@ def test_process_belief_message_wrong_thread(belief_setter, mocker):
# Assert
mock_set_beliefs.assert_not_called()
def test_process_belief_message_empty_body(belief_setter, mocker):
"""
Test that a message with an empty body is ignored.
"""
# Arrange
msg = create_mock_message(
sender_node=COLLECTOR_AGENT_JID,
body="",
thread="beliefs"
)
msg = create_mock_message(sender_node=COLLECTOR_AGENT_JID, body="", thread="beliefs")
mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs")
# Act
@@ -197,10 +183,10 @@ def test_set_beliefs_success(belief_setter, mock_agent, caplog):
"""
# Arrange
beliefs_to_set = {
"is_hot": [["kitchen"], ["living_room"]],
"door_is": [["front_door", "closed"]]
"is_hot": ["kitchen"],
"door_opened": ["front_door", "back_door"],
}
# Act
with caplog.at_level(logging.INFO):
belief_setter._set_beliefs(beliefs_to_set)
@@ -208,17 +194,25 @@ def test_set_beliefs_success(belief_setter, mock_agent, caplog):
# Assert
expected_calls = [
call("is_hot", "kitchen"),
call("is_hot", "living_room"),
call("door_is", "front_door", "closed")
call("door_opened", "front_door", "back_door"),
]
mock_agent.bdi.set_belief.assert_has_calls(expected_calls, any_order=True)
assert mock_agent.bdi.set_belief.call_count == 3
assert mock_agent.bdi.set_belief.call_count == 2
# Check logs
assert "Set belief is_hot with arguments ['kitchen']" in caplog.text
assert "Set belief is_hot with arguments ['living_room']" in caplog.text
assert "Set belief door_is with arguments ['front_door', 'closed']" in caplog.text
assert "Set belief door_opened with arguments ['front_door', 'back_door']" in caplog.text
def test_responded_unset(belief_setter, mock_agent):
# Arrange
new_beliefs = {"user_said": ["message"]}
# Act
belief_setter._set_beliefs(new_beliefs)
# Assert
mock_agent.bdi.set_belief.assert_has_calls([call("user_said", "message")])
mock_agent.bdi.remove_belief.assert_has_calls([call("responded")])
def test_set_beliefs_bdi_not_initialized(belief_setter, mock_agent, caplog):
"""
@@ -226,7 +220,7 @@ def test_set_beliefs_bdi_not_initialized(belief_setter, mock_agent, caplog):
"""
# Arrange
mock_agent.bdi = None # Simulate BDI not being ready
beliefs_to_set = {"is_hot": [["kitchen"]]}
beliefs_to_set = {"is_hot": ["kitchen"]}
# Act
with caplog.at_level(logging.WARNING):

View File

@@ -0,0 +1,46 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
import zmq
from control_backend.agents.vad_agent import SocketPoller
@pytest.fixture
def socket():
return AsyncMock()
@pytest.mark.asyncio
async def test_socket_poller_with_data(socket, mocker):
socket_data = b"test"
socket.recv.return_value = socket_data
mock_poller: MagicMock = mocker.patch("control_backend.agents.vad_agent.zmq.Poller")
mock_poller.return_value.poll.return_value = [(socket, zmq.POLLIN)]
poller = SocketPoller(socket)
# Calling `poll` twice to be able to check that the poller is reused
await poller.poll()
data = await poller.poll()
assert data == socket_data
# Ensure that the poller was reused
mock_poller.assert_called_once_with()
mock_poller.return_value.register.assert_called_once_with(socket, zmq.POLLIN)
assert socket.recv.call_count == 2
@pytest.mark.asyncio
async def test_socket_poller_no_data(socket, mocker):
mock_poller: MagicMock = mocker.patch("control_backend.agents.vad_agent.zmq.Poller")
mock_poller.return_value.poll.return_value = []
poller = SocketPoller(socket)
data = await poller.poll()
assert data is None
socket.recv.assert_not_called()

View File

@@ -0,0 +1,95 @@
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pytest
from control_backend.agents.vad_agent import Streaming
@pytest.fixture
def audio_in_socket():
return AsyncMock()
@pytest.fixture
def audio_out_socket():
return AsyncMock()
@pytest.fixture
def streaming(audio_in_socket, audio_out_socket):
import torch
torch.hub.load.return_value = (..., ...) # Mock
return Streaming(audio_in_socket, audio_out_socket)
async def simulate_streaming_with_probabilities(streaming, probabilities: list[float]):
"""
Simulates a streaming scenario with given VAD model probabilities for testing purposes.
:param streaming: The streaming component to be tested.
:param probabilities: A list of probabilities representing the outputs of the VAD model.
"""
model_item = MagicMock()
model_item.item.side_effect = probabilities
streaming.model = MagicMock()
streaming.model.return_value = model_item
audio_in_poller = AsyncMock()
audio_in_poller.poll.return_value = np.empty(shape=512, dtype=np.float32)
streaming.audio_in_poller = audio_in_poller
for _ in probabilities:
await streaming.run()
@pytest.mark.asyncio
async def test_voice_activity_detected(audio_in_socket, audio_out_socket, streaming):
"""
Test a scenario where there is voice activity detected between silences.
:return:
"""
speech_chunk_count = 5
probabilities = [0.0] * 5 + [1.0] * speech_chunk_count + [0.0] * 5
await simulate_streaming_with_probabilities(streaming, probabilities)
audio_out_socket.send.assert_called_once()
data = audio_out_socket.send.call_args[0][0]
assert isinstance(data, bytes)
# each sample has 512 frames of 4 bytes, expecting 7 chunks (5 with speech, 2 as padding)
assert len(data) == 512 * 4 * (speech_chunk_count + 2)
@pytest.mark.asyncio
async def test_voice_activity_short_pause(audio_in_socket, audio_out_socket, streaming):
"""
Test a scenario where there is a short pause between speech, checking whether it ignores the
short pause.
"""
speech_chunk_count = 5
probabilities = (
[0.0] * 5 + [1.0] * speech_chunk_count + [0.0] + [1.0] * speech_chunk_count + [0.0] * 5
)
await simulate_streaming_with_probabilities(streaming, probabilities)
audio_out_socket.send.assert_called_once()
data = audio_out_socket.send.call_args[0][0]
assert isinstance(data, bytes)
# Expecting 13 chunks (2*5 with speech, 1 pause between, 2 as padding)
assert len(data) == 512 * 4 * (speech_chunk_count * 2 + 1 + 2)
@pytest.mark.asyncio
async def test_no_data(audio_in_socket, audio_out_socket, streaming):
"""
Test a scenario where there is no data received. This should not cause errors.
"""
audio_in_poller = AsyncMock()
audio_in_poller.poll.return_value = None
streaming.audio_in_poller = audio_in_poller
await streaming.run()
audio_out_socket.send.assert_not_called()
assert len(streaming.audio_buffer) == 0