diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 95895ea..cac27bf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,6 +22,6 @@ test: tags: - test script: - - uv run --only-group test pytest test/integration + - uv run --only-group integration-test pytest test/integration - uv run --only-group test pytest test/unit diff --git a/pyproject.toml b/pyproject.toml index faa584e..ee88a50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,12 @@ dev = [ "ruff>=0.14.2", "ruff-format>=0.3.0", ] +integration-test = [ + {include-group = "test"}, + "asyncio>=4.0.0", + "soundfile>=0.13.1", + "zmq>=0.0.0", +] test = [ "pytest>=8.4.2", "pytest-asyncio>=1.2.0", diff --git a/src/control_backend/agents/ri_communication_agent.py b/src/control_backend/agents/ri_communication_agent.py index 504c707..71c2e52 100644 --- a/src/control_backend/agents/ri_communication_agent.py +++ b/src/control_backend/agents/ri_communication_agent.py @@ -7,7 +7,6 @@ import zmq from control_backend.core.config import settings from control_backend.core.zmq_context import context -from control_backend.schemas.message import Message from control_backend.agents.ri_command_agent import RICommandAgent logger = logging.getLogger(__name__) diff --git a/test/integration/agents/test_ri_commands_agent.py b/test/integration/agents/test_ri_command_agent.py similarity index 97% rename from test/integration/agents/test_ri_commands_agent.py rename to test/integration/agents/test_ri_command_agent.py index 219d682..fa310a8 100644 --- a/test/integration/agents/test_ri_commands_agent.py +++ b/test/integration/agents/test_ri_command_agent.py @@ -1,10 +1,8 @@ -import asyncio import zmq import json import pytest from unittest.mock import AsyncMock, MagicMock, patch from control_backend.agents.ri_command_agent import RICommandAgent -from control_backend.schemas.ri_message import SpeechCommand @pytest.mark.asyncio diff --git a/test/integration/agents/test_ri_communication_agent.py b/test/integration/agents/test_ri_communication_agent.py index 3e4a056..ba2cdc2 100644 --- a/test/integration/agents/test_ri_communication_agent.py +++ b/test/integration/agents/test_ri_communication_agent.py @@ -81,47 +81,45 @@ def fake_json_invalid_id_negototiate(): } ) +def mock_command_agent(): + """Fixture to create a mock BDIAgent.""" + agent = MagicMock() + agent.bdi = MagicMock() + agent.jid = "ri_command_agent@test" + return agent + + + @pytest.mark.asyncio async def test_setup_creates_socket_and_negotiate_1(monkeypatch): - """ - Test the setup of the communication agent - """ - # --- Arrange --- fake_socket = MagicMock() fake_socket.send_json = AsyncMock() - fake_socket.recv_json = fake_json_correct_negototiate_1() + fake_socket.recv_json = AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 5555, "bind": False}, + {"id": "actuation", "port": 5556, "bind": True}, + ], + }) - # Mock context.socket to return our fake socket monkeypatch.setattr( "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket ) - # Mock RICommandAgent agent startup - with patch( - "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True - ) as MockCommandAgent: + with patch("control_backend.agents.ri_communication_agent.RICommandAgent") as MockCommandAgent: fake_agent_instance = MockCommandAgent.return_value fake_agent_instance.start = AsyncMock() - # --- Act --- - agent = RICommunicationAgent( - "test@server", "password", address="tcp://localhost:5555", bind=False - ) + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) await agent.setup() - # --- Assert --- fake_socket.connect.assert_any_call("tcp://localhost:5555") fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) - fake_socket.recv_json.assert_awaited() fake_agent_instance.start.assert_awaited() MockCommandAgent.assert_called_once_with( - ANY, # Server Name - ANY, # Server Password - address="tcp://*:5556", # derived from the 'port' value in negotiation - bind=True, + ANY, ANY, address="tcp://*:5556", bind=True ) - # Ensure the agent attached a ListenBehaviour assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) @@ -141,6 +139,9 @@ async def test_setup_creates_socket_and_negotiate_2(monkeypatch): ) # Mock RICommandAgent agent startup + + patch("control_backend.agents.ri_communication_agent.RICommandAgent", mock_command_agent) + with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True ) as MockCommandAgent: @@ -588,4 +589,4 @@ async def test_setup_unpacking_exception(monkeypatch, caplog): fake_agent_instance.start.assert_not_awaited() # Ensure no behaviour was attached - assert not any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + assert not any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py new file mode 100644 index 0000000..8e05e25 --- /dev/null +++ b/test/integration/conftest.py @@ -0,0 +1,57 @@ +import sys +from unittest.mock import MagicMock, AsyncMock + +class DummyCyclicBehaviour: + async def run(self): + pass + + def kill(self): + self.is_killed = True + return None + +class DummyAgent: + def __init__(self, jid=None, password=None, *_, **__): + self.jid = jid + self.password = password + self.behaviours = [] + + async def start(self): + return AsyncMock() + + def add_behaviour(self, behaviour): + behaviour.agent = self + self.behaviours.append(behaviour) + + async def stop(self): + pass + + +def pytest_configure(config): + """ + This hook runs at the start of the pytest session, before any tests are + collected. It mocks heavy or unavailable modules to prevent ImportErrors. + """ + # --- Mock spade and spade-bdi --- + mock_spade = MagicMock() + mock_spade.agent = MagicMock(Agent=DummyAgent) + mock_spade.behaviour = MagicMock(CyclicBehaviour=DummyCyclicBehaviour) + mock_spade_bdi = MagicMock() + mock_spade_bdi.bdi = MagicMock() + + mock_spade.agent.Message = MagicMock() + + sys.modules["spade"] = mock_spade + sys.modules["spade.agent"] = mock_spade.agent + sys.modules["spade.behaviour"] = mock_spade.behaviour + sys.modules["spade_bdi"] = mock_spade_bdi + sys.modules["spade_bdi.bdi"] = mock_spade_bdi.bdi + + # --- Mock the config module to prevent Pydantic ImportError --- + mock_config_module = MagicMock() + + # The code under test does `from ... import settings`, so our mock module + # must have a `settings` attribute. We'll make it a MagicMock so we can + # configure it later in our tests using mocker.patch. + mock_config_module.settings = MagicMock() + + sys.modules["control_backend.core.config"] = mock_config_module \ No newline at end of file diff --git a/uv.lock b/uv.lock index 3f1a137..1c53b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -127,6 +127,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419, upload-time = "2023-09-30T22:11:16.072Z" }, ] +[[package]] +name = "asyncio" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/ea/26c489a11f7ca862d5705db67683a7361ce11c23a7b98fc6c2deaeccede2/asyncio-4.0.0.tar.gz", hash = "sha256:570cd9e50db83bc1629152d4d0b7558d6451bb1bfd5dfc2e935d96fc2f40329b", size = 5371, upload-time = "2025-08-05T02:51:46.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/64/eff2564783bd650ca25e15938d1c5b459cda997574a510f7de69688cb0b4/asyncio-4.0.0-py3-none-any.whl", hash = "sha256:c1eddb0659231837046809e68103969b2bef8b0400d59cfa6363f6b5ed8cc88b", size = 5555, upload-time = "2025-08-05T02:51:45.767Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -1354,6 +1363,15 @@ dev = [ { name = "ruff" }, { name = "ruff-format" }, ] +integration-test = [ + { name = "asyncio" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "soundfile" }, + { name = "zmq" }, +] test = [ { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1387,6 +1405,15 @@ dev = [ { name = "ruff", specifier = ">=0.14.2" }, { name = "ruff-format", specifier = ">=0.3.0" }, ] +integration-test = [ + { name = "asyncio", specifier = ">=4.0.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "soundfile", specifier = ">=0.13.1" }, + { name = "zmq", specifier = ">=0.0.0" }, +] test = [ { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, @@ -1412,7 +1439,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] - [[package]] name = "pre-commit" version = "4.3.0" @@ -2217,6 +2243,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, +] + [[package]] name = "spade" version = "4.1.0" @@ -2744,3 +2789,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] + +[[package]] +name = "zmq" +version = "0.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyzmq" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/78/833b2808793c1619835edb1a4e17a023d5d625f4f97ff25ffff986d1f472/zmq-0.0.0.tar.gz", hash = "sha256:6b1a1de53338646e8c8405803cffb659e8eb7bb02fff4c9be62a7acfac8370c9", size = 966, upload-time = "2015-05-21T17:34:26.603Z" }