Skip to content

Commit e5387b2

Browse files
committed
Refactor integration tests to use stateful fakes and fixtures
1 parent 95e898d commit e5387b2

6 files changed

Lines changed: 306 additions & 270 deletions

File tree

roborock/testing/channel.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
in-memory replacement for `MqttChannel` and `LocalChannel` during testing.
66
"""
77

8+
import inspect
89
from collections.abc import Callable
910
from typing import Any
1011
from unittest.mock import AsyncMock, MagicMock
@@ -37,6 +38,9 @@ class FakeChannel(Channel):
3738
publish (useful for low-level RPC request/response testing).
3839
- **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)``
3940
to simulate the device broadcasting a state change.
41+
- **Intercept published messages**: Register a handler/callback via
42+
``channel.publish_handler = my_handler`` (e.g. stateful simulator)
43+
to reactively process commands.
4044
"""
4145

4246
subscribe: Any
@@ -49,6 +53,11 @@ def __init__(self, is_local: bool = False):
4953
self._is_connected = False
5054
self._is_local = is_local
5155

56+
# A callback to intercept published messages (e.g., bound simulator handler).
57+
# Can be either synchronous or asynchronous: Callable[[RoborockMessage], Any].
58+
# By default, routes to self._default_publish_handler to handle the response_queue.
59+
self.publish_handler: Callable[[RoborockMessage], Any] | None = self._default_publish_handler
60+
5261
# Set this to an exception instance to make the next publish raise it.
5362
# This is a convenience shortcut; callers can also replace
5463
# ``publish.side_effect`` directly for more control.
@@ -96,14 +105,18 @@ def is_local_connected(self) -> bool:
96105
async def _publish(self, message: RoborockMessage) -> None:
97106
"""Default publish implementation.
98107
99-
Records the message in ``published_messages`` and, if
100-
``response_queue`` is non-empty, pops the first response and
101-
delivers it to all current subscribers (simulating a
102-
request/response round-trip).
108+
Records the message in ``published_messages`` and executes ``publish_handler``.
103109
"""
104110
self.published_messages.append(message)
105111
if self.publish_side_effect:
106112
raise self.publish_side_effect
113+
if self.publish_handler:
114+
res = self.publish_handler(message)
115+
if inspect.isawaitable(res):
116+
await res
117+
118+
def _default_publish_handler(self, message: RoborockMessage) -> None:
119+
"""Default handler that pops canned responses from response_queue."""
107120
if self.response_queue:
108121
response = self.response_queue.pop(0)
109122
self.notify_subscribers(response)
@@ -124,3 +137,15 @@ def notify_subscribers(self, message: RoborockMessage) -> None:
124137
"""
125138
for subscriber in list(self.subscribers):
126139
subscriber(message)
140+
141+
def inject_error(self, exception: Exception) -> None:
142+
"""Inject a transient failure into all channel operations (publish, subscribe, connect)."""
143+
self.publish.side_effect = exception
144+
self.subscribe.side_effect = exception
145+
self.connect.side_effect = exception
146+
147+
def clear_error(self) -> None:
148+
"""Restore default success behaviors on all channel operations."""
149+
self.publish.side_effect = self._publish
150+
self.subscribe.side_effect = self._subscribe
151+
self.connect.side_effect = self._connect

roborock/testing/cloud.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import contextlib
9+
import datetime
910
import re
1011
from typing import Any
1112
from unittest.mock import AsyncMock, patch
@@ -173,6 +174,44 @@ def get_homes_callback(url, **kwargs):
173174
callback=get_homes_callback,
174175
)
175176

177+
def get_default_home_data(self) -> HomeData:
178+
"""Construct the default HomeData using simulated devices registered in the cloud."""
179+
devices = [d.device_info for d in self.cloud.simulated_devices.values()]
180+
products = [d.product for d in self.cloud.simulated_devices.values()]
181+
return HomeData(
182+
id=self.cloud.home_id,
183+
name=self.cloud.home_name,
184+
devices=devices,
185+
products=products,
186+
received_devices=[],
187+
rooms=[],
188+
)
189+
190+
def set_homes_response(
191+
self,
192+
home_data: HomeData | None = None,
193+
status: int = 200,
194+
) -> None:
195+
"""Easily set the faked response payload for the homes endpoint using a HomeData dataclass.
196+
197+
If home_data is None, it defaults to the active get_default_home_data() state.
198+
"""
199+
self.homes_status = status
200+
if status != 200:
201+
self.homes_payload_override = None
202+
return
203+
204+
if home_data is None:
205+
home_data = self.get_default_home_data()
206+
207+
self.homes_payload_override = {
208+
"api": None,
209+
"code": 200,
210+
"result": home_data.as_dict(),
211+
"status": "ok",
212+
"success": True,
213+
}
214+
176215

177216
class FakeRoborockCloud:
178217
"""A central state object representing the Roborock Cloud environment under test."""
@@ -234,7 +273,9 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
234273
with aioresponses() as mocked:
235274
self.web_api.mock_requests(mocked)
236275

237-
# Patch Channel factories and rate limiters
276+
# Mock sleep logic to speed up tests.
277+
sleep_time = datetime.timedelta(seconds=0.001)
278+
# Patch Channel factories, rate limiters, and backoff sleep intervals
238279
with (
239280
patch(
240281
"roborock.web_api.RoborockApiClient._login_limiter.try_acquire_async",
@@ -243,5 +284,7 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
243284
patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True),
244285
patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel),
245286
patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel),
287+
patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time),
288+
patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time),
246289
):
247290
yield

roborock/testing/simulator.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,20 @@ def __init__(
7373

7474
# MQTT channel is always present — all protocols use it.
7575
self.mqtt_channel = FakeChannel(is_local=False)
76-
self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish
76+
self.mqtt_channel.publish_handler = self._handle_mqtt_publish
7777

7878
# Local channel is only used by V1 devices. A01/B01 (MQTT-only)
7979
# simulators should pass has_local_channel=False.
8080
self.local_channel: FakeChannel | None = None
8181
if has_local_channel:
8282
self.local_channel = FakeChannel(is_local=True)
83-
self.local_channel.publish.side_effect = self._handle_local_publish
83+
self.local_channel.publish_handler = self._handle_local_publish
8484

8585
async def _handle_local_publish(self, message: RoborockMessage) -> None:
8686
assert self.local_channel is not None
87-
self.local_channel.published_messages.append(message)
88-
if self.local_channel.publish_side_effect:
89-
raise self.local_channel.publish_side_effect
9087
await self._handle_publish(message, self.local_channel)
9188

9289
async def _handle_mqtt_publish(self, message: RoborockMessage) -> None:
93-
self.mqtt_channel.published_messages.append(message)
94-
if self.mqtt_channel.publish_side_effect:
95-
raise self.mqtt_channel.publish_side_effect
9690
await self._handle_publish(message, self.mqtt_channel)
9791

9892
async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:

tests/conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Pytest fixtures for python-roborock tests."""
2+
3+
from collections.abc import Generator
4+
5+
import pytest
6+
7+
from roborock.data import HomeData, UserData
8+
from roborock.testing import FakeRoborockCloud, V1VacuumSimulator
9+
from tests import mock_data
10+
11+
USER_DATA = UserData.from_dict(mock_data.USER_DATA)
12+
HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW)
13+
14+
15+
@pytest.fixture(name="cloud")
16+
def cloud_fixture() -> FakeRoborockCloud:
17+
"""Fixture to set up FakeRoborockCloud."""
18+
return FakeRoborockCloud(user_data=USER_DATA)
19+
20+
21+
@pytest.fixture(name="fake_device")
22+
def fake_device_fixture(cloud: FakeRoborockCloud) -> V1VacuumSimulator:
23+
"""Fixture to set up a stateful V1VacuumSimulator registered to the cloud."""
24+
device = V1VacuumSimulator(
25+
duid="abc123",
26+
device_info=HOME_DATA.devices[0],
27+
product=HOME_DATA.products[0],
28+
)
29+
cloud.add_device(device)
30+
return device
31+
32+
33+
@pytest.fixture(name="patch_device_manager")
34+
def patch_device_manager_fixture(cloud: FakeRoborockCloud) -> Generator[None, None, None]:
35+
"""Fixture to patch the device manager and HTTP client."""
36+
with cloud.patch_device_manager():
37+
yield

tests/devices/__snapshots__/test_device_manager.ambr

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@
382382
'debugMode': 0,
383383
'dndEnabled': 0,
384384
'dockErrorStatus': 0,
385-
'dockType': 3,
385+
'dockType': 7,
386386
'dss': 169,
387387
'dustCollectionStatus': 0,
388388
'errorCode': 0,
@@ -409,7 +409,7 @@
409409
'washPhase': 0,
410410
'washReady': 0,
411411
'waterBoxCarriageStatus': 1,
412-
'waterBoxMode': 203,
412+
'waterBoxMode': 200,
413413
'waterBoxStatus': 1,
414414
'waterShortageStatus': 0,
415415
}),
@@ -595,18 +595,6 @@
595595
'receivedDevices': list([
596596
]),
597597
'rooms': list([
598-
dict({
599-
'id': 2362048,
600-
'name': '**REDACTED**',
601-
}),
602-
dict({
603-
'id': 2362044,
604-
'name': '**REDACTED**',
605-
}),
606-
dict({
607-
'id': 2362041,
608-
'name': '**REDACTED**',
609-
}),
610598
]),
611599
}),
612600
})

0 commit comments

Comments
 (0)