Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions tests/map/__snapshots__/test_map_parser.ambr
Original file line number Diff line number Diff line change
@@ -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,
})
# ---
111 changes: 107 additions & 4 deletions tests/map/test_map_parser.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/map/testdata/v1/README.md
Original file line number Diff line number Diff line change
@@ -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. |
Binary file not shown.
Binary file not shown.
Binary file added tests/map/testdata/v1/s5_fw2008_with_segments.bin
Binary file not shown.
Binary file not shown.
Loading