diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 1550393b..7ffccc2d 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -5,7 +5,7 @@ in-memory replacement for `MqttChannel` and `LocalChannel` during testing. """ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -37,6 +37,9 @@ class FakeChannel(Channel): publish (useful for low-level RPC request/response testing). - **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)`` to simulate the device broadcasting a state change. + - **Intercept published messages**: Register a handler/callback via + ``channel.publish_handler = my_handler`` (e.g. stateful simulator) + to reactively process commands. """ subscribe: Any @@ -49,6 +52,11 @@ def __init__(self, is_local: bool = False): self._is_connected = False self._is_local = is_local + # A callback to intercept published messages (e.g., bound simulator handler). + # Can be either synchronous or asynchronous: Callable[[RoborockMessage], Awaitable[Any]]. + # By default, routes to self._default_publish_handler to handle the response_queue. + self.publish_handler: Callable[[RoborockMessage], Awaitable[Any]] | None = self._default_publish_handler + # Set this to an exception instance to make the next publish raise it. # This is a convenience shortcut; callers can also replace # ``publish.side_effect`` directly for more control. @@ -96,14 +104,16 @@ def is_local_connected(self) -> bool: async def _publish(self, message: RoborockMessage) -> None: """Default publish implementation. - Records the message in ``published_messages`` and, if - ``response_queue`` is non-empty, pops the first response and - delivers it to all current subscribers (simulating a - request/response round-trip). + Records the message in ``published_messages`` and executes ``publish_handler``. """ self.published_messages.append(message) if self.publish_side_effect: raise self.publish_side_effect + if self.publish_handler: + await self.publish_handler(message) + + async def _default_publish_handler(self, message: RoborockMessage) -> None: + """Default handler that pops canned responses from response_queue.""" if self.response_queue: response = self.response_queue.pop(0) self.notify_subscribers(response) @@ -124,3 +134,15 @@ def notify_subscribers(self, message: RoborockMessage) -> None: """ for subscriber in list(self.subscribers): subscriber(message) + + def inject_error(self, exception: Exception) -> None: + """Inject a transient failure into all channel operations (publish, subscribe, connect).""" + self.publish.side_effect = exception + self.subscribe.side_effect = exception + self.connect.side_effect = exception + + def clear_error(self) -> None: + """Restore default success behaviors on all channel operations.""" + self.publish.side_effect = self._publish + self.subscribe.side_effect = self._subscribe + self.connect.side_effect = self._connect diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 704e0f54..9fee2bb2 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -6,6 +6,7 @@ """ import contextlib +import datetime import re from typing import Any from unittest.mock import AsyncMock, patch @@ -173,6 +174,44 @@ def get_homes_callback(url, **kwargs): callback=get_homes_callback, ) + def get_default_home_data(self) -> HomeData: + """Construct the default HomeData using simulated devices registered in the cloud.""" + devices = [d.device_info for d in self.cloud.simulated_devices.values()] + products = [d.product for d in self.cloud.simulated_devices.values()] + return HomeData( + id=self.cloud.home_id, + name=self.cloud.home_name, + devices=devices, + products=products, + received_devices=[], + rooms=[], + ) + + def set_homes_response( + self, + home_data: HomeData | None = None, + status: int = 200, + ) -> None: + """Easily set the faked response payload for the homes endpoint using a HomeData dataclass. + + If home_data is None, it defaults to the active get_default_home_data() state. + """ + self.homes_status = status + if status != 200: + self.homes_payload_override = None + return + + if home_data is None: + home_data = self.get_default_home_data() + + self.homes_payload_override = { + "api": None, + "code": 200, + "result": home_data.as_dict(), + "status": "ok", + "success": True, + } + class FakeRoborockCloud: """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): with aioresponses() as mocked: self.web_api.mock_requests(mocked) - # Patch Channel factories and rate limiters + # Mock sleep logic to speed up tests. + sleep_time = datetime.timedelta(seconds=0.001) + # Patch Channel factories, rate limiters, and backoff sleep intervals with ( patch( "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): patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True), patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel), patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel), + patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), + patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), ): yield diff --git a/roborock/testing/simulator.py b/roborock/testing/simulator.py index caebc000..6dd05240 100644 --- a/roborock/testing/simulator.py +++ b/roborock/testing/simulator.py @@ -73,26 +73,20 @@ def __init__( # MQTT channel is always present — all protocols use it. self.mqtt_channel = FakeChannel(is_local=False) - self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish + self.mqtt_channel.publish_handler = self._handle_mqtt_publish # Local channel is only used by V1 devices. A01/B01 (MQTT-only) # simulators should pass has_local_channel=False. self.local_channel: FakeChannel | None = None if has_local_channel: self.local_channel = FakeChannel(is_local=True) - self.local_channel.publish.side_effect = self._handle_local_publish + self.local_channel.publish_handler = self._handle_local_publish async def _handle_local_publish(self, message: RoborockMessage) -> None: assert self.local_channel is not None - self.local_channel.published_messages.append(message) - if self.local_channel.publish_side_effect: - raise self.local_channel.publish_side_effect await self._handle_publish(message, self.local_channel) async def _handle_mqtt_publish(self, message: RoborockMessage) -> None: - self.mqtt_channel.published_messages.append(message) - if self.mqtt_channel.publish_side_effect: - raise self.mqtt_channel.publish_side_effect await self._handle_publish(message, self.mqtt_channel) async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None: diff --git a/tests/devices/__snapshots__/test_device_manager.ambr b/tests/devices/__snapshots__/test_device_manager.ambr index 98017c92..558e04e8 100644 --- a/tests/devices/__snapshots__/test_device_manager.ambr +++ b/tests/devices/__snapshots__/test_device_manager.ambr @@ -382,7 +382,7 @@ 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, - 'dockType': 3, + 'dockType': 7, 'dss': 169, 'dustCollectionStatus': 0, 'errorCode': 0, @@ -409,7 +409,7 @@ 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, - 'waterBoxMode': 203, + 'waterBoxMode': 200, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), @@ -595,18 +595,6 @@ 'receivedDevices': list([ ]), 'rooms': list([ - dict({ - 'id': 2362048, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362044, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362041, - 'name': '**REDACTED**', - }), ]), }), }) diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index bd2ed125..d5851935 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -1,120 +1,64 @@ """Tests for the DeviceManager class.""" import asyncio -import datetime -from collections.abc import Generator, Iterator +from collections.abc import Generator +from dataclasses import replace from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import pytest import syrupy from roborock.data import HomeData, UserData +from roborock.data.containers import HomeDataDevice, HomeDataProduct, RoborockCategory from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException, RoborockInvalidCredentials +from roborock.testing import FakeRoborockCloud, V1VacuumSimulator from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) USER_PARAMS = UserParams(username="test_user", user_data=USER_DATA) +HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) NETWORK_INFO = mock_data.NETWORK_INFO -@pytest.fixture(autouse=True, name="mqtt_session") -def setup_mqtt_session() -> Generator[Mock, None, None]: - """Fixture to set up the MQTT session for the tests.""" - with patch("roborock.devices.device_manager.create_lazy_mqtt_session") as mock_create_session: - yield mock_create_session +@pytest.fixture(name="cloud") +def cloud_fixture() -> FakeRoborockCloud: + """Fixture to set up FakeRoborockCloud.""" + return FakeRoborockCloud(user_data=USER_DATA) -@pytest.fixture(autouse=True, name="mock_rpc_channel") -def rpc_channel_fixture() -> AsyncMock: - """Fixture to set up the channel for tests.""" - return AsyncMock() +@pytest.fixture(name="fake_device") +def fake_device_fixture(cloud: FakeRoborockCloud) -> V1VacuumSimulator: + """Fixture to set up a stateful V1VacuumSimulator registered to the cloud.""" + device = V1VacuumSimulator( + duid="abc123", + device_info=HOME_DATA.devices[0], + product=HOME_DATA.products[0], + ) + cloud.add_device(device) + return device -@pytest.fixture(autouse=True) -async def discover_features_fixture( - mock_rpc_channel: AsyncMock, -) -> None: - """Fixture to handle device feature discovery.""" - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - - -@pytest.fixture(autouse=True) -def channel_fixture(mock_rpc_channel: AsyncMock) -> Generator[Mock, None, None]: - """Fixture to set up the local session for the tests.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_unsub = Mock() - mock_channel.return_value.subscribe = AsyncMock() - mock_channel.return_value.subscribe.return_value = mock_unsub - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(autouse=True) -def mock_sleep() -> Generator[None, None, None]: - """Mock sleep logic to speed up tests.""" - sleep_time = datetime.timedelta(seconds=0.001) - with ( - patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), - patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), - ): +@pytest.fixture(name="patch_device_manager") +def patch_device_manager_fixture(cloud: FakeRoborockCloud) -> Generator[None, None, None]: + """Fixture to patch the device manager and HTTP client.""" + with cloud.patch_device_manager(): yield -@pytest.fixture(name="channel_exception") -def channel_failure_exception_fixture(mock_rpc_channel: AsyncMock) -> Exception: - """Fixture that provides the exception to be raised by the failing channel.""" - return RoborockException("Connection failed") - - -@pytest.fixture(name="channel_failure") -def channel_failure_fixture(mock_rpc_channel: AsyncMock, channel_exception: Exception) -> Generator[Mock, None, None]: - """Fixture that makes channel subscribe fail.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_channel.return_value.subscribe = AsyncMock(side_effect=channel_exception) - mock_channel.return_value.is_connected = False - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(name="home_data_no_devices") -def home_data_no_devices_fixture() -> Iterator[HomeData]: - """Mock home data API that returns no devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData( - id=1, - name="Test Home", - devices=[], - products=[], - ) - mock_home_data.return_value = home_data - yield home_data - - -@pytest.fixture(name="home_data") -def home_data_fixture() -> Iterator[HomeData]: - """Mock home data API that returns devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) - mock_home_data.return_value = home_data - yield home_data - - -async def test_no_devices(home_data_no_devices: HomeData) -> None: +async def test_no_devices(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager created with no devices returned from the API.""" - device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert devices == [] -async def test_with_device(home_data: HomeData) -> None: +async def test_with_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test the DeviceManager created with devices returned from the API.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -130,7 +74,9 @@ async def test_with_device(home_data: HomeData) -> None: await device_manager.close() -async def test_get_non_existent_device(home_data: HomeData) -> None: +async def test_get_non_existent_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test getting a non-existent device.""" device_manager = await create_device_manager(USER_PARAMS) device = await device_manager.get_device("non_existent_duid") @@ -140,7 +86,6 @@ async def test_get_non_existent_device(home_data: HomeData) -> None: async def test_create_home_data_api_exception() -> None: """Test that exceptions from the home data API are propagated through the wrapper.""" - with patch("roborock.devices.device_manager.RoborockApiClient.get_home_data_v3") as mock_get_home_data: mock_get_home_data.side_effect = RoborockException("Test exception") user_params = UserParams(username="test_user", user_data=USER_DATA) @@ -164,7 +109,13 @@ async def test_device_manager_unauthorized_hook() -> None: @pytest.mark.parametrize(("prefer_cache", "expected_call_count"), [(True, 1), (False, 2)]) -async def test_cache_logic(prefer_cache: bool, expected_call_count: int) -> None: +async def test_cache_logic( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + prefer_cache: bool, + expected_call_count: int, +) -> None: """Test that the cache logic works correctly.""" call_count = 0 @@ -173,15 +124,15 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: call_count += 1 return HomeData.from_dict(mock_data.HOME_DATA_RAW) - # First call happens during create_device_manager initialization with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, ): device_manager = await create_device_manager(USER_PARAMS, cache=InMemoryCache()) + # First call happens during create_device_manager initialization assert call_count == 1 - # Second call should use cache, not increment call_count + # Second call should use cache, not increment call_count if prefer_cache=True devices2 = await device_manager.discover_devices(prefer_cache=prefer_cache) assert call_count == expected_call_count assert len(devices2) == 1 @@ -193,9 +144,10 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: await device_manager.close() -async def test_home_data_api_fails_with_cache_fallback() -> None: +async def test_home_data_api_fails_with_cache_fallback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that home data exceptions may still fall back to use the cache when available.""" - cache = InMemoryCache() cache_data = await cache.get() cache_data.home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) @@ -216,10 +168,13 @@ async def test_home_data_api_fails_with_cache_fallback() -> None: await device_manager.close() -async def test_ready_callback(home_data: HomeData) -> None: +async def test_ready_callback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that the ready callback is invoked when a device connects.""" ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) + await device_manager.get_devices() # Callback should be called for the discovered device assert len(ready_devices) == 1 @@ -236,41 +191,32 @@ async def test_ready_callback(home_data: HomeData) -> None: await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - RoborockException("Connection failed"), - ], -) -async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_failure( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that start_connect retries when connection fails.""" + # Make connection fail initially + channel_exception = RoborockException("Connection failed") + fake_device.mqtt_channel.inject_error(channel_exception) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(channel_exception) + ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) devices = await device_manager.get_devices() - # The device should attempt to connect in the background at least once - # by the time this function returns. - subscribe_mock = channel_failure.return_value.subscribe - assert subscribe_mock.call_count > 0 - # Device should exist but not be connected assert len(devices) == 1 assert not devices[0].is_connected assert not ready_devices - # Verify retry attempts - assert channel_failure.return_value.subscribe.call_count >= 1 - - # Reset the mock channel so that it succeeds on the next attempt - mock_unsub = Mock() - subscribe_mock = AsyncMock() - subscribe_mock.return_value = mock_unsub - channel_failure.return_value.subscribe = subscribe_mock - channel_failure.return_value.is_connected = True + # Clear error so connection succeeds on retry + fake_device.mqtt_channel.clear_error() + fake_device.local_channel.clear_error() - # Wait for the device to attempt to connect again + # Wait for the device to attempt to connect again and succeed attempts = 0 - while subscribe_mock.call_count < 1: + while not devices[0].is_connected: await asyncio.sleep(0.01) attempts += 1 assert attempts < 10, "Device did not connect after multiple attempts" @@ -280,10 +226,11 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, assert len(ready_devices) == 1 await device_manager.close() - assert mock_unsub.call_count == 1 -async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: +async def test_rediscover_devices( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that we can discover devices multiple times and discover new devices.""" raw_devices: list[dict[str, Any]] = mock_data.HOME_DATA_RAW["devices"] assert len(raw_devices) > 0 @@ -291,8 +238,7 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: home_data_responses = [ HomeData.from_dict(mock_data.HOME_DATA_RAW), - # New device added on second call. We make a copy and updated fields to simulate - # a new device. + # New device added on second call. HomeData.from_dict( { **mock_data.HOME_DATA_RAW, @@ -310,19 +256,31 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: ), ] - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - # Device #2 - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: nonlocal home_data_responses return home_data_responses.pop(0) - # First call happens during create_device_manager initialization + # The new device is also a V1 vacuum simulator + new_device_product = HomeDataProduct( + id="product-id-123", + name="New Device", + model="roborock.newmodel.v1", + category=RoborockCategory.VACUUM, + ) + fake_device2 = V1VacuumSimulator( + duid="new_device_duid", + device_info=HomeDataDevice( + duid="new_device_duid", + name="New Device", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-123", + sn="fake_sn_new", + pv="1.0", + ), + product=new_device_product, + ) + cloud.add_device(fake_device2) + with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, @@ -344,26 +302,31 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: assert device_1.name == "Roborock S7 MaxV" new_device = await device_manager.get_device("new_device_duid") - assert new_device + assert new_device is not None assert new_device.name == "New Device" - # Ensure closing again works without error await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - Exception("Unexpected error"), - ], -) -async def test_start_connect_unexpected_error(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_unexpected_error( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that some unexpected errors from start_connect are propagated.""" + # Raise generic Exception + fake_device.mqtt_channel.inject_error(Exception("Unexpected error")) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(Exception("Unexpected error")) + with pytest.raises(Exception, match="Unexpected error"): await create_device_manager(USER_PARAMS) -async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.SnapshotAssertion) -> None: +async def test_diagnostics_collection( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + snapshot: syrupy.SnapshotAssertion, +) -> None: """Test that diagnostics are collected correctly in the DeviceManager.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -381,107 +344,119 @@ async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.Snap await device_manager.close() -async def test_unsupported_protocol_version() -> None: +async def test_unsupported_protocol_version(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager with some supported and unsupported product IDs.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "unknown-pv", # Fake new protocol version we've never seen - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "New Roborock Model", - "model": "roborock.vacuum.newmodel", - "category": "robot.vacuum.cleaner", - }, - ], - } - ) - mock_home_data.return_value = home_data + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="unknown-pv", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="New Roborock Model", + model="roborock.vacuum.newmodel", + category=RoborockCategory.VACUUM, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] - device_manager = await create_device_manager(USER_PARAMS) - # Only the supported device should be created. The other device is ignored - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} - # Verify diagnostics - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} + await device_manager.close() -async def test_unsupported_v1_category() -> None: +async def test_unsupported_v1_category(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test that non-vacuum V1 devices are skipped as unsupported.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "1.0", - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "Roborock RockNeo", - "model": "roborock.mower.q105", - "category": "roborock.mower", - }, - ], - } - ) - mock_home_data.return_value = home_data - - device_manager = await create_device_manager(USER_PARAMS) - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] - - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="1.0", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="Roborock RockNeo", + model="roborock.mower.q105", + category=RoborockCategory.MOWER, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] + + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + + await device_manager.close()