From ed5f7f7022cf23371a39e84ee8794d745daf99f2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 16 Sep 2025 05:33:46 -0700 Subject: [PATCH 1/3] chore: Add test coverage of end to end trait parsin from raw responses --- .../devices/__snapshots__/test_v1_device.ambr | 21 ++++++ tests/devices/test_v1_device.py | 71 ++++++++++++++----- 2 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 tests/devices/__snapshots__/test_v1_device.ambr diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr new file mode 100644 index 00000000..333c12b2 --- /dev/null +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_device_trait_command_parsing[payload0-status-StatusTrait-get_status] + S7MaxVStatus(msg_ver=2, msg_seq=515, state=, battery=100, clean_time=5405, clean_area=91287500, square_meter_clean_area=91.3, error_code=, map_present=1, in_cleaning=, in_returning=0, in_fresh_state=1, lab_status=1, water_box_status=0, back_type=None, wash_phase=None, wash_ready=None, fan_power=, dnd_enabled=1, map_status=3, is_locating=0, lock_status=0, water_box_mode=, water_box_carriage_status=0, mop_forbidden_enable=0, camera_status=None, is_exploring=None, home_sec_status=None, home_sec_enable_password=None, adbumper_status=None, water_shortage_status=None, dock_type=None, dust_collection_status=None, auto_dust_collection=None, avoid_count=None, mop_mode=None, debug_mode=None, collision_avoid_status=None, switch_map_mode=None, dock_error_status=None, charge_status=None, unsave_map_reason=4, unsave_map_flag=0, wash_status=None, distance_off=0, in_warmup=None, dry_status=None, rdt=None, clean_percent=None, rss=None, dss=None, common_status=None, corner_clean_mode=None, error_code_name='none', state_name='charging', water_box_mode_name='custom', fan_power_options=['off', 'quiet', 'balanced', 'turbo', 'max', 'custom', 'max_plus'], fan_power_name='custom', mop_mode_name=None) +# --- +# name: test_device_trait_command_parsing[payload1-do_not_disturb-DoNotDisturbTrait-get_dnd_timer] + list([ + dict({ + 'enabled': 1, + 'end_hour': 8, + 'end_minute': 0, + 'start_hour': 22, + 'start_minute': 0, + }), + ]) +# --- +# name: test_device_trait_command_parsing[payload2-clean_summary-CleanSummaryTrait-get_clean_summary] + CleanSummary(clean_time=1442559, clean_area=24258125000, square_meter_clean_area=24258.1, clean_count=296, dust_collection_count=None, records=[1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038], last_clean_t=None) +# --- +# name: test_device_trait_command_parsing[payload3-sound_volume-SoundVolumeTrait-get_volume] + 90 +# --- diff --git a/tests/devices/test_v1_device.py b/tests/devices/test_v1_device.py index de2b7754..a66f3d44 100644 --- a/tests/devices/test_v1_device.py +++ b/tests/devices/test_v1_device.py @@ -1,13 +1,21 @@ """Tests for the Device class.""" +import pathlib from unittest.mock import AsyncMock, Mock +from collections.abc import Awaitable, Callable import pytest +from syrupy import SnapshotAssertion from roborock.containers import HomeData, S7MaxVStatus, UserData from roborock.devices.device import RoborockDevice +from roborock.devices.traits.clean_summary import CleanSummaryTrait +from roborock.devices.traits.dnd import DoNotDisturbTrait +from roborock.devices.traits.sound_volume import SoundVolumeTrait from roborock.devices.traits.status import StatusTrait from roborock.devices.traits.trait import Trait +from roborock.devices.v1_rpc_channel import decode_rpc_response +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from .. import mock_data @@ -15,6 +23,8 @@ HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) STATUS = S7MaxVStatus.from_dict(mock_data.STATUS) +TESTDATA = pathlib.Path("tests/protocols/testdata/v1_protocol/") + @pytest.fixture(autouse=True, name="channel") def device_channel_fixture() -> AsyncMock: @@ -45,7 +55,10 @@ def traits_fixture(rpc_channel: AsyncMock) -> list[Trait]: StatusTrait( product_info=HOME_DATA.products[0], rpc_channel=rpc_channel, - ) + ), + CleanSummaryTrait(rpc_channel=rpc_channel), + DoNotDisturbTrait(rpc_channel=rpc_channel), + SoundVolumeTrait(rpc_channel=rpc_channel), ] @@ -70,19 +83,45 @@ async def test_device_connection(device: RoborockDevice, channel: AsyncMock) -> assert unsub.called -async def test_device_get_status_command(device: RoborockDevice, rpc_channel: AsyncMock) -> None: +@pytest.fixture(name="setup_rpc_channel") +def setup_rpc_channel_fixture(rpc_channel: AsyncMock, payload: pathlib.Path) -> AsyncMock: + """Fixture to set up the RPC channel for the tests.""" + # The values other than the payload are arbitrary + message = RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_RESPONSE, + payload=payload.read_bytes(), + seq=12750, + version=b"1.0", + random=97431, + timestamp=1652547161, + ) + response_message = decode_rpc_response(message) + rpc_channel.send_command.return_value = response_message.data + return rpc_channel + + +@pytest.mark.parametrize( + ("payload", "trait_name", "trait_type", "trait_method"), + [ + (TESTDATA / "get_status.json", "status", StatusTrait, StatusTrait.get_status), + (TESTDATA / "get_dnd.json", "do_not_disturb", DoNotDisturbTrait, DoNotDisturbTrait.get_dnd_timer), + (TESTDATA / "get_clean_summary.json", "clean_summary", CleanSummaryTrait, CleanSummaryTrait.get_clean_summary), + (TESTDATA / "get_volume.json", "sound_volume", SoundVolumeTrait, SoundVolumeTrait.get_volume), + ] +) +async def test_device_trait_command_parsing( + device: RoborockDevice, + setup_rpc_channel: AsyncMock, + snapshot: SnapshotAssertion, + trait_name: str, + trait_type: type[Trait], + trait_method: Callable[..., Awaitable[object]], +) -> None: """Test the device get_status command.""" - # Mock response for get_status command - rpc_channel.send_command.return_value = [STATUS.as_dict()] - - # Test get_status and verify the command was sent - status_api = device.traits["status"] - assert isinstance(status_api, StatusTrait) - assert status_api is not None - status = await status_api.get_status() - assert rpc_channel.send_command.called - - # Verify the result - assert status is not None - assert status.battery == 100 - assert status.state == 8 + trait = device.traits[trait_name] + assert trait + assert isinstance(trait, trait_type) + result = await trait_method(trait) + assert setup_rpc_channel.send_command.called + + assert result == snapshot From 635c123fd554bc14b028e7a319745014d56f0614 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 16 Sep 2025 09:20:53 -0400 Subject: [PATCH 2/3] fix: docstring --- tests/devices/test_v1_device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/test_v1_device.py b/tests/devices/test_v1_device.py index a66f3d44..ea00160c 100644 --- a/tests/devices/test_v1_device.py +++ b/tests/devices/test_v1_device.py @@ -117,7 +117,7 @@ async def test_device_trait_command_parsing( trait_type: type[Trait], trait_method: Callable[..., Awaitable[object]], ) -> None: - """Test the device get_status command.""" + """Test the device trait command.""" trait = device.traits[trait_name] assert trait assert isinstance(trait, trait_type) From 601a1484d9b92ee6226275ba15ad413d46520f02 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 17 Sep 2025 20:58:08 -0700 Subject: [PATCH 3/3] chore: fix lint errors --- tests/devices/test_v1_device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/devices/test_v1_device.py b/tests/devices/test_v1_device.py index ea00160c..83ff3175 100644 --- a/tests/devices/test_v1_device.py +++ b/tests/devices/test_v1_device.py @@ -1,8 +1,8 @@ """Tests for the Device class.""" import pathlib -from unittest.mock import AsyncMock, Mock from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, Mock import pytest from syrupy import SnapshotAssertion @@ -107,7 +107,7 @@ def setup_rpc_channel_fixture(rpc_channel: AsyncMock, payload: pathlib.Path) -> (TESTDATA / "get_dnd.json", "do_not_disturb", DoNotDisturbTrait, DoNotDisturbTrait.get_dnd_timer), (TESTDATA / "get_clean_summary.json", "clean_summary", CleanSummaryTrait, CleanSummaryTrait.get_clean_summary), (TESTDATA / "get_volume.json", "sound_volume", SoundVolumeTrait, SoundVolumeTrait.get_volume), - ] + ], ) async def test_device_trait_command_parsing( device: RoborockDevice,