1111import asyncio
1212import base64
1313from collections .abc import AsyncGenerator
14- from dataclasses import replace
1514from pathlib import Path
1615from typing import cast
17- from unittest .mock import Mock
16+ from unittest .mock import Mock , patch
1817
1918import pytest
2019
2120from roborock .cli import _await_q10_map_push , cli
2221from roborock .data .b01_q10 .b01_q10_code_mappings import B01_Q10_DP
2322from roborock .devices .traits .b01 .q10 import Q10PropertiesApi , create
2423from roborock .devices .traits .b01 .q10 .map import MapContentTrait , MapDpsTrait
25- from roborock .map . b01_grid_layers import GridCalibration
24+ from roborock .exceptions import RoborockException
2625from roborock .map .b01_q10_map_parser import (
27- Q10HeaderCalibration ,
28- Q10MapPacket ,
2926 Q10Point ,
3027 Q10TracePacket ,
3128 parse_map_packet ,
3835FIXTURE = Path ("tests/map/testdata/b01_q10_map.bin" )
3936TRACE_SESSION_FIXTURE = Path ("tests/map/testdata/b01_q10_trace_session.bin" )
4037
41- # A header calibration whose pixel origin (0, 5) is usable (not a keepalive
42- # frame), so a short path can calibrate the fixture map.
43- _USABLE_HEADER = Q10HeaderCalibration (origin_x = 0 , origin_y = 50 , resolution = 5 , charger_x = 0 , charger_y = 0 , charger_phi = 0 )
4438
39+ def _map_trait () -> MapContentTrait :
40+ """Create a high-level trait with its required low-level dependency."""
41+ return MapContentTrait (MapDpsTrait ())
4542
46- def _trait_with_map () -> MapContentTrait :
47- """A trait with the fixture map already pushed into it."""
48- trait = MapContentTrait ()
49- trait .update_from_map_packet (parse_map_packet (FIXTURE .read_bytes ()))
50- return trait
5143
52-
53- def _floor_world_points (packet : Q10MapPacket , cal : GridCalibration , count : int ) -> list [Q10Point ]:
54- """``count`` world points lying on the map's floor under ``cal``."""
55- layers = packet .layers
56- floor = [
57- (px , py )
58- for py in range (layers .height )
59- for px in range (layers .width )
60- if layers .cell_class (layers .grid [py * layers .width + px ]) == "floor"
61- ]
62- return [Q10Point (* (int (v ) for v in cal .pixel_to_world (px , py ))) for px , py in floor [:count ]]
44+ def _zone_blob () -> str :
45+ """Return one base64-encoded restricted-zone DPS value."""
46+ vertices = [(0 , 0 ), (40 , 0 ), (40 , 40 ), (0 , 40 )]
47+ record = bytes ([0 , len (vertices )]) + b"" .join (
48+ int .to_bytes (value & 0xFFFF , 2 , "big" ) for point in vertices for value in point
49+ )
50+ return base64 .b64encode (bytes ([1 , 1 ]) + record ).decode ()
6351
6452
6553def test_update_from_map_packet_populates_image_and_rooms () -> None :
66- """A pushed 01 01 map packet populates the image, rooms and map data ."""
54+ """A pushed 01 01 map packet populates the image and rooms ."""
6755 packet = parse_map_packet (FIXTURE .read_bytes ())
68- trait = MapContentTrait ()
56+ trait = _map_trait ()
6957 updates : list [None ] = []
7058 trait .add_update_listener (lambda : updates .append (None ))
7159
@@ -80,7 +68,7 @@ def test_update_from_map_packet_populates_image_and_rooms() -> None:
8068def test_update_from_trace_packet_populates_path_and_position () -> None :
8169 """A pushed 02 01 trace packet populates the path, position and heading."""
8270 trace = parse_trace_packet (TRACE_SESSION_FIXTURE .read_bytes ())
83- trait = MapContentTrait ()
71+ trait = _map_trait ()
8472 updates : list [None ] = []
8573 trait .add_update_listener (lambda : updates .append (None ))
8674
@@ -103,7 +91,7 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None:
10391
10492class _FakeQ10Properties :
10593 def __init__ (self ) -> None :
106- self .map = MapContentTrait ()
94+ self .map = _map_trait ()
10795 self .refresh_count = 0
10896
10997 async def refresh (self ) -> None :
@@ -220,97 +208,79 @@ async def test_subscribe_loop_routes_trace_push(
220208
221209def test_trace_without_map_is_retained_without_rendering () -> None :
222210 """A trace is retained even when no map is available to render yet."""
223- trait = MapContentTrait ()
211+ trait = _map_trait ()
224212 trait .update_from_trace_packet (Q10TracePacket (points = [Q10Point (i , 0 ) for i in range (30 )]))
225213 assert len (trait .path ) == 30
226214 assert trait .image_content is None
227215
228216
229- def test_trace_update_projects_short_path_using_header () -> None :
230- """A map header and short trace are sufficient to render a path."""
231- trait = MapContentTrait ()
232- packet = replace (parse_map_packet (FIXTURE .read_bytes ()), header_calibration = _USABLE_HEADER )
233- trait .update_from_map_packet (packet )
234- base = trait .image_content
235- assert base is not None
236- true = GridCalibration (resolution = 20.0 , origin_x = 0.0 , origin_y = 5.0 , y_sign = 1 )
237- trait .update_from_trace_packet (Q10TracePacket (points = _floor_world_points (packet , true , 6 )))
238- assert len (trait .path ) < 20 # far too short for the full origin+resolution fit
239-
240- assert trait .image_content is not None
241- assert trait .image_content != base
217+ def test_render_failure_clears_stale_image () -> None :
218+ """A failed composition cannot leave an image from older source data."""
219+ packet = parse_map_packet (FIXTURE .read_bytes ())
220+ trace = Q10TracePacket (points = [Q10Point (1 , 2 )])
221+ trait = _map_trait ()
242222
223+ with patch (
224+ "roborock.devices.traits.b01.q10.map.render_q10_map" ,
225+ side_effect = [b"initial image" , RoborockException ("invalid map" )],
226+ ):
227+ trait .update_from_map_packet (packet )
228+ trait .update_from_trace_packet (trace )
243229
244- def test_short_trace_without_header_cannot_be_projected () -> None :
245- """Without a header origin a short trace cannot be placed on the map."""
246- packet = parse_map_packet (FIXTURE .read_bytes ())
247- trait = MapContentTrait ()
248- trait .update_from_map_packet (packet ) # the fixture header is a keepalive frame
249- base = trait .image_content
250- true = GridCalibration (resolution = 10.0 , origin_x = 0.0 , origin_y = 5.0 , y_sign = 1 )
251- trait .update_from_trace_packet (Q10TracePacket (points = _floor_world_points (packet , true , 6 )))
252- assert trait .image_content == base
230+ assert trait .path == trace .points
231+ assert trait .image_content is None
253232
254233
255234# --- Overlays ----------------------------------------------------------------
256235
257236
258- def test_load_overlays_places_zones_after_calibration () -> None :
259- """Decoded no-go / no-mop zones are drawn once the sources calibrate ."""
237+ def test_map_dps_update_renders_decoded_overlays () -> None :
238+ """A DPS update recomposes an existing map with decoded overlays ."""
260239 map_dps = MapDpsTrait ()
261240 trait = MapContentTrait (map_dps )
262- packet = replace (parse_map_packet (FIXTURE .read_bytes ()), header_calibration = _USABLE_HEADER )
263- trait .update_from_map_packet (packet )
264- true = GridCalibration (resolution = 20.0 , origin_x = 0.0 , origin_y = 5.0 , y_sign = 1 )
265- trait .update_from_trace_packet (Q10TracePacket (points = _floor_world_points (packet , true , 6 )))
266- before = trait .image_content
267- assert before is not None
268-
269- def rect (zone_type : int , corners : list [tuple [int , int ]]) -> bytes :
270- out = bytes ([zone_type , len (corners )])
271- for x , y in corners :
272- out += int .to_bytes (x & 0xFFFF , 2 , "big" ) + int .to_bytes (y & 0xFFFF , 2 , "big" )
273- return out .ljust (18 , b"\x00 " )
241+ packet = parse_map_packet (FIXTURE .read_bytes ())
242+ notified : list [None ] = []
243+ trait .add_update_listener (lambda : notified .append (None ))
274244
275- blob = bytes ([1 , 1 ]) + rect (0 , [(0 , 0 ), (40 , 0 ), (40 , 40 ), (0 , 40 )])
276- map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : base64 .b64encode (blob ).decode ()})
245+ with patch (
246+ "roborock.devices.traits.b01.q10.map.render_q10_map" ,
247+ side_effect = [b"base image" , b"image with overlays" ],
248+ ) as render :
249+ trait .update_from_map_packet (packet )
250+ notified .clear ()
251+ map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
277252
278253 assert len (map_dps .zones ) == 1
279- assert trait .image_content != before
254+ assert trait .image_content == b"image with overlays"
255+ assert notified == [None ]
256+ assert render .call_count == 2
257+ assert render .call_args .args [0 ] is packet
258+ assert render .call_args .args [1 ] is None
259+ assert tuple (render .call_args .args [2 ].zones ) == tuple (map_dps .zones )
280260
281261
282262def test_load_overlays_partial_update_keeps_existing_zones () -> None :
283263 """A status push without the zone DP (None) must not wipe loaded zones."""
284264 map_dps = MapDpsTrait ()
285- blob = (
286- bytes ([1 , 1 ])
287- + bytes ([0 , 4 ])
288- + b"" .join (int .to_bytes (v & 0xFFFF , 2 , "big" ) for xy in [(0 , 0 ), (4 , 0 ), (4 , 4 ), (0 , 4 )] for v in xy )
289- )
290- map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : base64 .b64encode (blob ).decode ()})
265+ map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
291266 assert len (map_dps .zones ) == 1
292267 # A later partial update carrying only the (empty) virtual-wall DP.
293268 map_dps .update_from_dps ({B01_Q10_DP .VIRTUAL_WALL_UP : "AA==" })
294269 assert len (map_dps .zones ) == 1 # zones preserved
295270 assert map_dps .virtual_walls == []
296271
297272
298- def test_map_dps_trait_updates_high_level_map_content () -> None :
299- """The low-level DPS trait notifies the dependent high-level map trait ."""
273+ def test_map_dps_update_without_map_does_not_notify_map_content () -> None :
274+ """A DPS update cannot change high-level content before a map arrives ."""
300275 map_dps = MapDpsTrait ()
301276 trait = MapContentTrait (map_dps )
302- blob = (
303- bytes ([1 , 1 ])
304- + bytes ([0 , 4 ])
305- + b"" .join (int .to_bytes (v & 0xFFFF , 2 , "big" ) for xy in [(0 , 0 ), (4 , 0 ), (4 , 4 ), (0 , 4 )] for v in xy )
306- )
307277 notified = []
308278 trait .add_update_listener (lambda : notified .append (True ))
309279
310- map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : base64 . b64encode ( blob ). decode ()})
280+ map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
311281
312282 assert len (map_dps .zones ) == 1
313- assert notified # listeners learn the overlays changed
283+ assert not notified
314284
315285
316286def test_map_dps_push_without_overlay_data_points_is_noop () -> None :
0 commit comments