Skip to content

Commit ba06b45

Browse files
committed
feat: add roborock.testing module for stateful integration testing
Introduces a new roborock.testing package that provides stateful firmware simulators, fake transport channels, and cloud environment fakes. This allows downstream consumers (like the Home Assistant integration) to write high-fidelity integration tests using the real client library classes instead of fragile top-level mocks. New modules: - channel.py: FakeChannel in-memory transport implementing Channel protocol - simulator.py: RoborockDeviceSimulator base class - v1_simulator.py: V1VacuumSimulator with stateful command handlers - cloud.py: FakeRoborockCloud with HTTP endpoint mocking Tests split by module: - test_channel.py: FakeChannel subscribe/publish/notify - test_cloud.py: Discovery, login errors, dynamic device addition - test_v1_simulator.py: Trait refresh/reset, state transitions, push updates
1 parent 5712f92 commit ba06b45

10 files changed

Lines changed: 1185 additions & 60 deletions

File tree

roborock/testing/__init__.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Testing fakes and simulators for python-roborock.
2+
3+
This package provides stateful firmware simulators (e.g. `V1VacuumSimulator`),
4+
fake transport channels (`FakeChannel`), and cloud orchestration simulators (`FakeRoborockCloud`)
5+
to allow downstream consumers (such as Home Assistant integrations) to write high-fidelity
6+
integration tests using the real client library classes instead of fragile top-level mocks.
7+
8+
Testing Architecture & Boundaries
9+
---------------------------------
10+
We fake communication at two boundaries:
11+
1. **Network HTTP API Interception**: `FakeRoborockCloud.patch_device_manager()` routes
12+
HTTP requests (such as discovery, login, home details) to custom mock endpoints using
13+
`aioresponses` under the hood. No Python client methods are mocked; the real EAPI client
14+
executes fully.
15+
2. **Plaintext RPC Message Interception**: Device communication is intercepted at the
16+
plaintext JSON RPC level (Layer 2). The real client classes (`V1Channel`, `MqttChannel`)
17+
run under test, but their transport calls are intercepted by our stateful simulators.
18+
19+
┌────────────────────────────────────────────────────────┐
20+
│ TESTED CLIENT (REAL CODE) │
21+
│ │
22+
│ RoborockDevice / Traits / V1RpcChannel / V1Channel │
23+
└──────────────────────────┬─────────────────────────────┘
24+
25+
ROBOROCKMESSAGE PAYLOADS
26+
(Plaintext JSON commands)
27+
28+
┌──────────────────────────▼─────────────────────────────┐
29+
│ SIMULATOR (TEST FAKE) │
30+
│ │
31+
│ FakeChannel (Intercepts publish/subscribe) │
32+
│ RoborockDeviceSimulator (Stateful firmware simulator) │
33+
└────────────────────────────────────────────────────────┘
34+
35+
Integration Usage Example
36+
-------------------------
37+
```python
38+
from roborock.testing import FakeRoborockCloud, V1VacuumSimulator
39+
40+
async def test_start_vacuum_service():
41+
# Setup cloud state and add a simulated vacuum device
42+
cloud = FakeRoborockCloud()
43+
fake_device = V1VacuumSimulator(duid="living_room_s7", battery=100, state=RoborockStateCode.charging)
44+
cloud.add_device(fake_device)
45+
46+
# Patch channels and API calls using our cloud context manager
47+
with cloud.patch_device_manager():
48+
# Create the real client manager (logins and discovers natively via mock HTTP)
49+
manager = await create_device_manager(
50+
user_params=UserParams(username="test_user", user_data=USER_DATA),
51+
cache=InMemoryCache(),
52+
)
53+
54+
# Fetch the discovered device client
55+
devices = await manager.get_devices()
56+
device = devices[0]
57+
58+
# Trigger client start command
59+
await device.v1_properties.command.send("app_start")
60+
61+
# Assert against the simulated vacuum state
62+
assert fake_device.state == RoborockStateCode.cleaning
63+
```
64+
"""
65+
66+
from roborock.testing.channel import FakeChannel
67+
from roborock.testing.cloud import FakeRoborockCloud, FakeUserState, FakeWebApiClient
68+
from roborock.testing.simulator import RoborockDeviceSimulator
69+
from roborock.testing.v1_simulator import V1VacuumSimulator
70+
71+
__all__ = [
72+
"FakeChannel",
73+
"FakeRoborockCloud",
74+
"FakeUserState",
75+
"FakeWebApiClient",
76+
"RoborockDeviceSimulator",
77+
"V1VacuumSimulator",
78+
]

roborock/testing/channel.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Fake channel transport implementation for python-roborock.
2+
3+
This module defines `FakeChannel`, which simulates low-level connection,
4+
subscription, and publishing logic at the message boundary. It acts as an
5+
in-memory replacement for `MqttChannel` and `LocalChannel` during testing.
6+
"""
7+
8+
from collections.abc import Callable
9+
from unittest.mock import AsyncMock, MagicMock
10+
11+
from roborock.mqtt.health_manager import HealthManager
12+
from roborock.protocols.v1_protocol import LocalProtocolVersion
13+
from roborock.roborock_message import RoborockMessage
14+
15+
16+
class FakeChannel:
17+
"""A stateful, in-memory transport simulator.
18+
19+
It captures all published messages in `published_messages`, maintains a registry
20+
of active callbacks in `subscribers`, and enables tests or stateful simulators to
21+
unconditionally push unsolicited messages using `notify_subscribers`.
22+
23+
Caller API
24+
----------
25+
The public interface consists of `AsyncMock` / `MagicMock` attributes that
26+
wrap internal implementations. Because they are mocks, callers can:
27+
28+
- **Inspect calls**: ``channel.publish.assert_called_once()``
29+
- **Inject failures**: ``channel.publish.side_effect = RoborockException(...)``
30+
to simulate transport errors on the next publish.
31+
- **Replace behavior**: ``channel.connect.side_effect = my_custom_connect``
32+
to substitute entirely custom logic.
33+
- **Queue canned responses**: Append to ``channel.response_queue`` to have
34+
the channel automatically deliver a response to subscribers on the next
35+
publish (useful for low-level RPC request/response testing).
36+
- **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)``
37+
to simulate the device broadcasting a state change.
38+
"""
39+
40+
def __init__(self, is_local: bool = False):
41+
"""Initialize the fake channel."""
42+
self.subscribers: list[Callable[[RoborockMessage], None]] = []
43+
self.published_messages: list[RoborockMessage] = []
44+
self.response_queue: list[RoborockMessage] = []
45+
self._is_connected = False
46+
self._is_local = is_local
47+
48+
# Set this to an exception instance to make the next publish raise it.
49+
# This is a convenience shortcut; callers can also replace
50+
# ``publish.side_effect`` directly for more control.
51+
self.publish_side_effect: Exception | None = None
52+
53+
# AsyncMock wrapping _publish. Callers can replace side_effect to
54+
# inject transport errors, e.g.:
55+
# channel.publish.side_effect = RoborockException("timeout")
56+
self.publish = AsyncMock(side_effect=self._publish)
57+
58+
# AsyncMock wrapping _subscribe. Callers can replace side_effect to
59+
# simulate subscription failures, e.g.:
60+
# channel.subscribe.side_effect = RoborockException("sub failed")
61+
self.subscribe = AsyncMock(side_effect=self._subscribe) # type: ignore[assignment]
62+
63+
# AsyncMock wrapping _connect. Callers can replace side_effect to
64+
# simulate connection failures, e.g.:
65+
# channel.connect.side_effect = RoborockException("refused")
66+
self.connect = AsyncMock(side_effect=self._connect)
67+
68+
# MagicMock wrapping _close. Callers can assert close was called
69+
# or inject errors on teardown.
70+
self.close = MagicMock(side_effect=self._close)
71+
72+
self.protocol_version = LocalProtocolVersion.V1
73+
self.restart = AsyncMock()
74+
self.health_manager = HealthManager(self.restart)
75+
76+
async def _connect(self) -> None:
77+
self._is_connected = True
78+
79+
def _close(self) -> None:
80+
self._is_connected = False
81+
82+
@property
83+
def is_connected(self) -> bool:
84+
"""Return true if connected."""
85+
return self._is_connected
86+
87+
@property
88+
def is_local_connected(self) -> bool:
89+
"""Return true if locally connected."""
90+
return self._is_connected and self._is_local
91+
92+
async def _publish(self, message: RoborockMessage) -> None:
93+
"""Default publish implementation.
94+
95+
Records the message in ``published_messages`` and, if
96+
``response_queue`` is non-empty, pops the first response and
97+
delivers it to all current subscribers (simulating a
98+
request/response round-trip).
99+
"""
100+
self.published_messages.append(message)
101+
if self.publish_side_effect:
102+
raise self.publish_side_effect
103+
if self.response_queue:
104+
response = self.response_queue.pop(0)
105+
self.notify_subscribers(response)
106+
107+
async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
108+
"""Default subscribe implementation.
109+
110+
Registers the callback and returns an unsubscribe function.
111+
"""
112+
self.subscribers.append(callback)
113+
return lambda: self.subscribers.remove(callback)
114+
115+
def notify_subscribers(self, message: RoborockMessage) -> None:
116+
"""Deliver a message to all current subscribers.
117+
118+
Use this to simulate the channel receiving an unsolicited message
119+
from the device (e.g. a state change broadcast).
120+
"""
121+
for subscriber in list(self.subscribers):
122+
subscriber(message)

0 commit comments

Comments
 (0)