Skip to content

Commit a8260c5

Browse files
authored
test(map): add v1 map test fixtures and snapshot tests (#969)
* test(map): add v1 map test fixtures and snapshot tests * test(map): assert goto target in v1 map snapshots and fix fixture doc
1 parent b3a98b9 commit a8260c5

7 files changed

Lines changed: 264 additions & 4 deletions
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# serializer version: 1
2+
# name: test_parse_v1_fixtures[s5_fw1886_with_forbidden_zones_and_virtual_walls.bin]
3+
dict({
4+
'additional_parameters': dict({
5+
'is_valid': True,
6+
'major_version': 1,
7+
'map_index': 875,
8+
'map_sequence': 64,
9+
'minor_version': 0,
10+
}),
11+
'charger': Point(x=25612, y=25254, a=None),
12+
'goto': None,
13+
'goto_path_points': 0,
14+
'image_format': 'PNG',
15+
'image_size': tuple(
16+
1048,
17+
952,
18+
),
19+
'no_go_areas': list([
20+
Area(x0=27000, y0=28750, x1=28850, y1=28750, x2=28850, y2=28100, x3=27000, y3=28100),
21+
]),
22+
'no_mopping_areas': None,
23+
'path_points': 55,
24+
'predicted_path_points': 0,
25+
'rooms': dict({
26+
}),
27+
'vacuum_position': Point(x=28697, y=26764, a=0),
28+
'walls': list([
29+
Wall(x0=25300, y0=27850, x1=25300, y1=26850),
30+
]),
31+
'zones': list([
32+
Zone(x0=27289, y0=25480, x1=28919, y1=27358),
33+
]),
34+
})
35+
# ---
36+
# name: test_parse_v1_fixtures[s5_fw1886_with_goto_target.bin]
37+
dict({
38+
'additional_parameters': dict({
39+
'is_valid': True,
40+
'major_version': 1,
41+
'map_index': 876,
42+
'map_sequence': 84,
43+
'minor_version': 0,
44+
}),
45+
'charger': Point(x=25612, y=25254, a=None),
46+
'goto': Point(x=25874, y=26218, a=None),
47+
'goto_path_points': 0,
48+
'image_format': 'PNG',
49+
'image_size': tuple(
50+
1052,
51+
1048,
52+
),
53+
'no_go_areas': list([
54+
]),
55+
'no_mopping_areas': None,
56+
'path_points': 32,
57+
'predicted_path_points': 3,
58+
'rooms': dict({
59+
}),
60+
'vacuum_position': Point(x=26139, y=26459, a=0),
61+
'walls': list([
62+
]),
63+
'zones': None,
64+
})
65+
# ---
66+
# name: test_parse_v1_fixtures[s5_fw2008_with_segments.bin]
67+
dict({
68+
'additional_parameters': dict({
69+
'is_valid': True,
70+
'major_version': 1,
71+
'map_index': 919,
72+
'map_sequence': 814,
73+
'minor_version': 1,
74+
}),
75+
'charger': Point(x=25652, y=25222, a=98),
76+
'goto': None,
77+
'goto_path_points': 0,
78+
'image_format': 'PNG',
79+
'image_size': tuple(
80+
1024,
81+
964,
82+
),
83+
'no_go_areas': list([
84+
]),
85+
'no_mopping_areas': None,
86+
'path_points': 933,
87+
'predicted_path_points': 0,
88+
'rooms': dict({
89+
1: Room(x0=23250, y0=24950, x1=26550, y1=26400, number=1, name=None, pos_x=None, pos_y=None),
90+
2: Room(x0=25150, y0=26400, x1=26600, y1=29950, number=2, name=None, pos_x=None, pos_y=None),
91+
}),
92+
'vacuum_position': Point(x=25642, y=25401, a=-88),
93+
'walls': list([
94+
]),
95+
'zones': list([
96+
]),
97+
})
98+
# ---
99+
# name: test_parse_v1_fixtures[s6_fw2652_with_active_segment_and_no_mop_zone.bin]
100+
dict({
101+
'additional_parameters': dict({
102+
'is_valid': True,
103+
'major_version': 1,
104+
'map_index': 804,
105+
'map_sequence': 43,
106+
'minor_version': 0,
107+
}),
108+
'charger': Point(x=25273, y=25603, a=-1),
109+
'goto': None,
110+
'goto_path_points': 0,
111+
'image_format': 'PNG',
112+
'image_size': tuple(
113+
1644,
114+
1344,
115+
),
116+
'no_go_areas': list([
117+
Area(x0=25000, y0=28000, x1=27200, y1=28000, x2=27200, y2=27100, x3=25000, y3=27100),
118+
]),
119+
'no_mopping_areas': list([
120+
Area(x0=29750, y0=27400, x1=32400, y1=27400, x2=32400, y2=25950, x3=29750, y3=25950),
121+
]),
122+
'path_points': 78,
123+
'predicted_path_points': 0,
124+
'rooms': dict({
125+
16: Room(x0=29550, y0=29550, x1=30450, y1=30900, number=16, name=None, pos_x=None, pos_y=None),
126+
17: Room(x0=29000, y0=24550, x1=33100, y1=29400, number=17, name=None, pos_x=None, pos_y=None),
127+
18: Room(x0=30550, y0=29400, x1=34750, y1=31250, number=18, name=None, pos_x=None, pos_y=None),
128+
19: Room(x0=25050, y0=24550, x1=28950, y1=29650, number=19, name=None, pos_x=None, pos_y=None),
129+
20: Room(x0=25150, y0=29350, x1=28150, y1=30950, number=20, name=None, pos_x=None, pos_y=None),
130+
21: Room(x0=33350, y0=24500, x1=35300, y1=29400, number=21, name=None, pos_x=None, pos_y=None),
131+
}),
132+
'vacuum_position': Point(x=30550, y=26455, a=-92),
133+
'walls': list([
134+
]),
135+
'zones': None,
136+
})
137+
# ---

‎tests/map/test_map_parser.py‎

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,125 @@
11
"""Tests for the map parser."""
22

3+
import io
34
from pathlib import Path
5+
from typing import Any
6+
from unittest.mock import MagicMock, patch
47

58
import pytest
9+
from PIL import Image
10+
from syrupy.assertion import SnapshotAssertion
11+
from vacuum_map_parser_base.config.drawable import Drawable
612

713
from roborock.exceptions import RoborockException
8-
from roborock.map.map_parser import MapParser, MapParserConfig
14+
from roborock.map.map_parser import MapParser, MapParserConfig, ParsedMapData, _create_image_generator
915

10-
MAP_DATA_FILE = Path(__file__).parent / "raw_map_data"
16+
V1_FIXTURES_DIR = Path(__file__).resolve().parent / "testdata" / "v1"
1117
DEFAULT_MAP_CONFIG = MapParserConfig()
1218

1319

20+
def _map_data_to_snapshot(result: ParsedMapData) -> dict[str, Any]:
21+
md = result.map_data
22+
assert md is not None
23+
assert result.image_content is not None
24+
image = Image.open(io.BytesIO(result.image_content))
25+
return {
26+
"image_size": image.size,
27+
"image_format": image.format,
28+
"charger": md.charger,
29+
"vacuum_position": md.vacuum_position,
30+
"goto": md.goto,
31+
"rooms": md.rooms,
32+
"walls": md.walls,
33+
"no_go_areas": md.no_go_areas,
34+
"no_mopping_areas": md.no_mopping_areas,
35+
"zones": md.zones,
36+
"path_points": len(md.path.path[0]) if md.path and md.path.path else 0,
37+
"goto_path_points": len(md.goto_path.path[0]) if md.goto_path and md.goto_path.path else 0,
38+
"predicted_path_points": len(md.predicted_path.path[0]) if md.predicted_path and md.predicted_path.path else 0,
39+
"additional_parameters": md.additional_parameters,
40+
}
41+
42+
1443
@pytest.mark.parametrize("map_content", [b"", b"12345"])
15-
def test_invalid_map_content(map_content: bytes):
44+
def test_invalid_map_content(map_content: bytes) -> None:
1645
"""Test that parsing map data returns the expected image and data."""
1746
parser = MapParser(DEFAULT_MAP_CONFIG)
1847
with pytest.raises(RoborockException, match="Failed to parse map data"):
1948
parser.parse(map_content)
2049

2150

22-
# We can add additional tests here in the future that actually parse valid map data
51+
@pytest.mark.parametrize(
52+
"filename",
53+
[
54+
"s5_fw2008_with_segments.bin",
55+
"s5_fw1886_with_forbidden_zones_and_virtual_walls.bin",
56+
"s5_fw1886_with_goto_target.bin",
57+
"s6_fw2652_with_active_segment_and_no_mop_zone.bin",
58+
],
59+
)
60+
def test_parse_v1_fixtures(
61+
filename: str,
62+
snapshot: SnapshotAssertion,
63+
) -> None:
64+
"""Test that parsing valid V1 fixtures matches expected snapshot."""
65+
raw_data = (V1_FIXTURES_DIR / filename).read_bytes()
66+
parser = MapParser(DEFAULT_MAP_CONFIG)
67+
result = parser.parse(raw_data)
68+
69+
assert result is not None
70+
assert result.image_content is not None
71+
assert result.image_content.startswith(b"\x89PNG\r\n\x1a\n")
72+
assert _map_data_to_snapshot(result) == snapshot
73+
74+
75+
def test_map_parser_config_variants() -> None:
76+
"""Test map parser with different display flags and scales."""
77+
raw_data = (V1_FIXTURES_DIR / "s6_fw2652_with_active_segment_and_no_mop_zone.bin").read_bytes()
78+
79+
config_custom = MapParserConfig(
80+
show_background=False,
81+
show_walls=False,
82+
show_rooms=False,
83+
map_scale=2,
84+
)
85+
parser_custom = MapParser(config_custom)
86+
result_custom = parser_custom.parse(raw_data)
87+
assert result_custom is not None
88+
assert result_custom.image_content is not None
89+
90+
parser_scaled = MapParser(MapParserConfig(map_scale=1))
91+
result_scaled = parser_scaled.parse(raw_data)
92+
assert result_scaled is not None
93+
assert result_scaled.image_content is not None
94+
95+
img_scale2 = Image.open(io.BytesIO(result_custom.image_content))
96+
img_scale1 = Image.open(io.BytesIO(result_scaled.image_content))
97+
assert img_scale2.width > img_scale1.width
98+
assert img_scale2.height > img_scale1.height
99+
100+
101+
def test_map_parser_rendering_failure() -> None:
102+
"""Test that failure to generate an image raises RoborockException."""
103+
raw_data = (V1_FIXTURES_DIR / "s5_fw2008_with_segments.bin").read_bytes()
104+
parser = MapParser(DEFAULT_MAP_CONFIG)
105+
106+
mock_map_data = MagicMock()
107+
mock_map_data.image = None
108+
109+
with (
110+
patch.object(parser._map_parser, "parse", return_value=mock_map_data),
111+
pytest.raises(RoborockException, match="Failed to render map image"),
112+
):
113+
parser.parse(raw_data)
114+
115+
116+
def test_create_image_generator() -> None:
117+
"""Test image generator creation with default and custom drawables."""
118+
generator = _create_image_generator(DEFAULT_MAP_CONFIG)
119+
assert generator is not None
120+
121+
custom_generator = _create_image_generator(
122+
DEFAULT_MAP_CONFIG,
123+
drawables=[Drawable.PATH, Drawable.CHARGER],
124+
)
125+
assert custom_generator is not None

‎tests/map/testdata/v1/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# V1 Map Test Data Fixtures
2+
3+
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`).
4+
5+
## Source & Attribution
6+
7+
The fixtures in this directory are adapted from the [Valetudo](https://github.com/Hypfer/Valetudo) project:
8+
- **Repository**: https://github.com/Hypfer/Valetudo
9+
- **Path in Source**: `backend/test/lib/robots/roborock/res/map/`
10+
- **License**: Apache License 2.0 (compatible with `python-roborock`)
11+
- **Author/Project**: Hypfer and Valetudo contributors
12+
13+
## Fixture Descriptions
14+
15+
| File Name | Original Source File | Model & Firmware | Features Exercised |
16+
| :--- | :--- | :--- | :--- |
17+
| `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. |
18+
| `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`). |
19+
| `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`). |
20+
| `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.
67.6 KB
Binary file not shown.
64.1 KB
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)