diff --git a/tests/map/__snapshots__/test_map_parser.ambr b/tests/map/__snapshots__/test_map_parser.ambr new file mode 100644 index 000000000..d2b18aac0 --- /dev/null +++ b/tests/map/__snapshots__/test_map_parser.ambr @@ -0,0 +1,137 @@ +# serializer version: 1 +# name: test_parse_v1_fixtures[s5_fw1886_with_forbidden_zones_and_virtual_walls.bin] + dict({ + 'additional_parameters': dict({ + 'is_valid': True, + 'major_version': 1, + 'map_index': 875, + 'map_sequence': 64, + 'minor_version': 0, + }), + 'charger': Point(x=25612, y=25254, a=None), + 'goto': None, + 'goto_path_points': 0, + 'image_format': 'PNG', + 'image_size': tuple( + 1048, + 952, + ), + 'no_go_areas': list([ + Area(x0=27000, y0=28750, x1=28850, y1=28750, x2=28850, y2=28100, x3=27000, y3=28100), + ]), + 'no_mopping_areas': None, + 'path_points': 55, + 'predicted_path_points': 0, + 'rooms': dict({ + }), + 'vacuum_position': Point(x=28697, y=26764, a=0), + 'walls': list([ + Wall(x0=25300, y0=27850, x1=25300, y1=26850), + ]), + 'zones': list([ + Zone(x0=27289, y0=25480, x1=28919, y1=27358), + ]), + }) +# --- +# name: test_parse_v1_fixtures[s5_fw1886_with_goto_target.bin] + dict({ + 'additional_parameters': dict({ + 'is_valid': True, + 'major_version': 1, + 'map_index': 876, + 'map_sequence': 84, + 'minor_version': 0, + }), + 'charger': Point(x=25612, y=25254, a=None), + 'goto': Point(x=25874, y=26218, a=None), + 'goto_path_points': 0, + 'image_format': 'PNG', + 'image_size': tuple( + 1052, + 1048, + ), + 'no_go_areas': list([ + ]), + 'no_mopping_areas': None, + 'path_points': 32, + 'predicted_path_points': 3, + 'rooms': dict({ + }), + 'vacuum_position': Point(x=26139, y=26459, a=0), + 'walls': list([ + ]), + 'zones': None, + }) +# --- +# name: test_parse_v1_fixtures[s5_fw2008_with_segments.bin] + dict({ + 'additional_parameters': dict({ + 'is_valid': True, + 'major_version': 1, + 'map_index': 919, + 'map_sequence': 814, + 'minor_version': 1, + }), + 'charger': Point(x=25652, y=25222, a=98), + 'goto': None, + 'goto_path_points': 0, + 'image_format': 'PNG', + 'image_size': tuple( + 1024, + 964, + ), + 'no_go_areas': list([ + ]), + 'no_mopping_areas': None, + 'path_points': 933, + 'predicted_path_points': 0, + 'rooms': dict({ + 1: Room(x0=23250, y0=24950, x1=26550, y1=26400, number=1, name=None, pos_x=None, pos_y=None), + 2: Room(x0=25150, y0=26400, x1=26600, y1=29950, number=2, name=None, pos_x=None, pos_y=None), + }), + 'vacuum_position': Point(x=25642, y=25401, a=-88), + 'walls': list([ + ]), + 'zones': list([ + ]), + }) +# --- +# name: test_parse_v1_fixtures[s6_fw2652_with_active_segment_and_no_mop_zone.bin] + dict({ + 'additional_parameters': dict({ + 'is_valid': True, + 'major_version': 1, + 'map_index': 804, + 'map_sequence': 43, + 'minor_version': 0, + }), + 'charger': Point(x=25273, y=25603, a=-1), + 'goto': None, + 'goto_path_points': 0, + 'image_format': 'PNG', + 'image_size': tuple( + 1644, + 1344, + ), + 'no_go_areas': list([ + Area(x0=25000, y0=28000, x1=27200, y1=28000, x2=27200, y2=27100, x3=25000, y3=27100), + ]), + 'no_mopping_areas': list([ + Area(x0=29750, y0=27400, x1=32400, y1=27400, x2=32400, y2=25950, x3=29750, y3=25950), + ]), + 'path_points': 78, + 'predicted_path_points': 0, + 'rooms': dict({ + 16: Room(x0=29550, y0=29550, x1=30450, y1=30900, number=16, name=None, pos_x=None, pos_y=None), + 17: Room(x0=29000, y0=24550, x1=33100, y1=29400, number=17, name=None, pos_x=None, pos_y=None), + 18: Room(x0=30550, y0=29400, x1=34750, y1=31250, number=18, name=None, pos_x=None, pos_y=None), + 19: Room(x0=25050, y0=24550, x1=28950, y1=29650, number=19, name=None, pos_x=None, pos_y=None), + 20: Room(x0=25150, y0=29350, x1=28150, y1=30950, number=20, name=None, pos_x=None, pos_y=None), + 21: Room(x0=33350, y0=24500, x1=35300, y1=29400, number=21, name=None, pos_x=None, pos_y=None), + }), + 'vacuum_position': Point(x=30550, y=26455, a=-92), + 'walls': list([ + ]), + 'zones': None, + }) +# --- diff --git a/tests/map/test_map_parser.py b/tests/map/test_map_parser.py index 4c6489727..52499480a 100644 --- a/tests/map/test_map_parser.py +++ b/tests/map/test_map_parser.py @@ -1,22 +1,125 @@ """Tests for the map parser.""" +import io from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch import pytest +from PIL import Image +from syrupy.assertion import SnapshotAssertion +from vacuum_map_parser_base.config.drawable import Drawable from roborock.exceptions import RoborockException -from roborock.map.map_parser import MapParser, MapParserConfig +from roborock.map.map_parser import MapParser, MapParserConfig, ParsedMapData, _create_image_generator -MAP_DATA_FILE = Path(__file__).parent / "raw_map_data" +V1_FIXTURES_DIR = Path(__file__).resolve().parent / "testdata" / "v1" DEFAULT_MAP_CONFIG = MapParserConfig() +def _map_data_to_snapshot(result: ParsedMapData) -> dict[str, Any]: + md = result.map_data + assert md is not None + assert result.image_content is not None + image = Image.open(io.BytesIO(result.image_content)) + return { + "image_size": image.size, + "image_format": image.format, + "charger": md.charger, + "vacuum_position": md.vacuum_position, + "goto": md.goto, + "rooms": md.rooms, + "walls": md.walls, + "no_go_areas": md.no_go_areas, + "no_mopping_areas": md.no_mopping_areas, + "zones": md.zones, + "path_points": len(md.path.path[0]) if md.path and md.path.path else 0, + "goto_path_points": len(md.goto_path.path[0]) if md.goto_path and md.goto_path.path else 0, + "predicted_path_points": len(md.predicted_path.path[0]) if md.predicted_path and md.predicted_path.path else 0, + "additional_parameters": md.additional_parameters, + } + + @pytest.mark.parametrize("map_content", [b"", b"12345"]) -def test_invalid_map_content(map_content: bytes): +def test_invalid_map_content(map_content: bytes) -> None: """Test that parsing map data returns the expected image and data.""" parser = MapParser(DEFAULT_MAP_CONFIG) with pytest.raises(RoborockException, match="Failed to parse map data"): parser.parse(map_content) -# We can add additional tests here in the future that actually parse valid map data +@pytest.mark.parametrize( + "filename", + [ + "s5_fw2008_with_segments.bin", + "s5_fw1886_with_forbidden_zones_and_virtual_walls.bin", + "s5_fw1886_with_goto_target.bin", + "s6_fw2652_with_active_segment_and_no_mop_zone.bin", + ], +) +def test_parse_v1_fixtures( + filename: str, + snapshot: SnapshotAssertion, +) -> None: + """Test that parsing valid V1 fixtures matches expected snapshot.""" + raw_data = (V1_FIXTURES_DIR / filename).read_bytes() + parser = MapParser(DEFAULT_MAP_CONFIG) + result = parser.parse(raw_data) + + assert result is not None + assert result.image_content is not None + assert result.image_content.startswith(b"\x89PNG\r\n\x1a\n") + assert _map_data_to_snapshot(result) == snapshot + + +def test_map_parser_config_variants() -> None: + """Test map parser with different display flags and scales.""" + raw_data = (V1_FIXTURES_DIR / "s6_fw2652_with_active_segment_and_no_mop_zone.bin").read_bytes() + + config_custom = MapParserConfig( + show_background=False, + show_walls=False, + show_rooms=False, + map_scale=2, + ) + parser_custom = MapParser(config_custom) + result_custom = parser_custom.parse(raw_data) + assert result_custom is not None + assert result_custom.image_content is not None + + parser_scaled = MapParser(MapParserConfig(map_scale=1)) + result_scaled = parser_scaled.parse(raw_data) + assert result_scaled is not None + assert result_scaled.image_content is not None + + img_scale2 = Image.open(io.BytesIO(result_custom.image_content)) + img_scale1 = Image.open(io.BytesIO(result_scaled.image_content)) + assert img_scale2.width > img_scale1.width + assert img_scale2.height > img_scale1.height + + +def test_map_parser_rendering_failure() -> None: + """Test that failure to generate an image raises RoborockException.""" + raw_data = (V1_FIXTURES_DIR / "s5_fw2008_with_segments.bin").read_bytes() + parser = MapParser(DEFAULT_MAP_CONFIG) + + mock_map_data = MagicMock() + mock_map_data.image = None + + with ( + patch.object(parser._map_parser, "parse", return_value=mock_map_data), + pytest.raises(RoborockException, match="Failed to render map image"), + ): + parser.parse(raw_data) + + +def test_create_image_generator() -> None: + """Test image generator creation with default and custom drawables.""" + generator = _create_image_generator(DEFAULT_MAP_CONFIG) + assert generator is not None + + custom_generator = _create_image_generator( + DEFAULT_MAP_CONFIG, + drawables=[Drawable.PATH, Drawable.CHARGER], + ) + assert custom_generator is not None diff --git a/tests/map/testdata/v1/README.md b/tests/map/testdata/v1/README.md new file mode 100644 index 000000000..7359239e5 --- /dev/null +++ b/tests/map/testdata/v1/README.md @@ -0,0 +1,20 @@ +# V1 Map Test Data Fixtures + +These binary test files contain real raw Roborock V1 map payloads (`RRMap` format) used for unit testing and performance benchmarking of the Roborock V1 map parser (`roborock.map.map_parser`). + +## Source & Attribution + +The fixtures in this directory are adapted from the [Valetudo](https://github.com/Hypfer/Valetudo) project: +- **Repository**: https://github.com/Hypfer/Valetudo +- **Path in Source**: `backend/test/lib/robots/roborock/res/map/` +- **License**: Apache License 2.0 (compatible with `python-roborock`) +- **Author/Project**: Hypfer and Valetudo contributors + +## Fixture Descriptions + +| File Name | Original Source File | Model & Firmware | Features Exercised | +| :--- | :--- | :--- | :--- | +| `s5_fw2008_with_segments.bin` | `S5_FW2008_with_segments.bin` | Roborock S5 (fw 2008) | Multi-room segmentation (rooms 1 & 2), charger dock location, vacuum position, and path coordinates. | +| `s5_fw1886_with_forbidden_zones_and_virtual_walls.bin` | `S5_FW1886_with_forbidden_zones_and_virtual_walls_and_currently_cleaned_zones.bin` | Roborock S5 (fw 1886) | Virtual walls (`walls`), forbidden/no-go zones (`no_go_areas`), and currently cleaned zones (`zones`). | +| `s5_fw1886_with_goto_target.bin` | `S5_FW1886_with_goto_target.bin` | Roborock S5 (fw 1886) | Pinpoint target location (`goto`) and predicted navigation path to target (`predicted_path`). | +| `s6_fw2652_with_active_segment_and_no_mop_zone.bin` | `S6_FW2652_with_active_segment_and_no_mop_zone.bin` | Roborock S6 (fw 2652) | Complex layout with 6 segmented rooms (16, 17, 18, 19, 20, 21), active cleaning segment, and no-mop zone. | diff --git a/tests/map/testdata/v1/s5_fw1886_with_forbidden_zones_and_virtual_walls.bin b/tests/map/testdata/v1/s5_fw1886_with_forbidden_zones_and_virtual_walls.bin new file mode 100644 index 000000000..663a94f98 Binary files /dev/null and b/tests/map/testdata/v1/s5_fw1886_with_forbidden_zones_and_virtual_walls.bin differ diff --git a/tests/map/testdata/v1/s5_fw1886_with_goto_target.bin b/tests/map/testdata/v1/s5_fw1886_with_goto_target.bin new file mode 100644 index 000000000..cc804a9b3 Binary files /dev/null and b/tests/map/testdata/v1/s5_fw1886_with_goto_target.bin differ diff --git a/tests/map/testdata/v1/s5_fw2008_with_segments.bin b/tests/map/testdata/v1/s5_fw2008_with_segments.bin new file mode 100644 index 000000000..ae9715a2e Binary files /dev/null and b/tests/map/testdata/v1/s5_fw2008_with_segments.bin differ diff --git a/tests/map/testdata/v1/s6_fw2652_with_active_segment_and_no_mop_zone.bin b/tests/map/testdata/v1/s6_fw2652_with_active_segment_and_no_mop_zone.bin new file mode 100644 index 000000000..d6338c957 Binary files /dev/null and b/tests/map/testdata/v1/s6_fw2652_with_active_segment_and_no_mop_zone.bin differ