1010
1111import asyncio
1212import base64
13- from collections .abc import AsyncGenerator
13+ from collections .abc import AsyncGenerator , Generator
1414from pathlib import Path
1515from typing import cast
1616from unittest .mock import Mock , patch
1717
1818import pytest
1919
2020from roborock .cli import _await_q10_map_push , cli
21- from roborock .data .b01_q10 .b01_q10_code_mappings import B01_Q10_DP
21+ from roborock .data .b01_q10 .b01_q10_code_mappings import B01_Q10_DP , YXDeviceState
2222from roborock .devices .traits .b01 .q10 import Q10PropertiesApi , create
2323from roborock .devices .traits .b01 .q10 .map import MapContentTrait , MapDpsTrait
2424from roborock .exceptions import RoborockException
3737TRACE_SESSION_FIXTURE = Path ("tests/map/testdata/b01_q10_trace_session.bin" )
3838
3939
40- def _map_trait () -> MapContentTrait :
40+ def _map_trait (map_dps : MapDpsTrait | None = None ) -> MapContentTrait :
4141 """Create a high-level trait with its required low-level dependency."""
42- return MapContentTrait (MapDpsTrait ())
42+ return MapContentTrait (map_dps or MapDpsTrait ())
4343
4444
4545def _zone_blob () -> str :
@@ -51,6 +51,12 @@ def _zone_blob() -> str:
5151 return base64 .b64encode (bytes ([1 , 1 ]) + record ).decode ()
5252
5353
54+ @pytest .fixture (name = "render_map" )
55+ def render_map_fixture () -> Generator [Mock , None , None ]:
56+ with patch ("roborock.devices.traits.b01.q10.map.render_q10_map" ) as render :
57+ yield render
58+
59+
5460def test_update_from_map_packet_populates_image_and_rooms () -> None :
5561 """A pushed 01 01 map packet populates the image and rooms."""
5662 packet = parse_map_packet (FIXTURE .read_bytes ())
@@ -215,18 +221,16 @@ def test_trace_without_map_is_retained_without_rendering() -> None:
215221 assert trait .image_content is None
216222
217223
218- def test_render_failure_clears_stale_image () -> None :
224+ def test_render_failure_clears_stale_image (render_map : Mock ) -> None :
219225 """A failed composition cannot leave an image from older source data."""
220226 packet = parse_map_packet (FIXTURE .read_bytes ())
221227 trace = Q10TracePacket (points = [Q10Point (1 , 2 )])
222228 trait = _map_trait ()
223229
224- with patch (
225- "roborock.devices.traits.b01.q10.map.render_q10_map" ,
226- side_effect = [b"initial image" , RoborockException ("invalid map" )],
227- ):
228- trait .update_from_map_packet (packet )
229- trait .update_from_trace_packet (trace )
230+ render_map .side_effect = [b"initial image" , RoborockException ("invalid map" )]
231+
232+ trait .update_from_map_packet (packet )
233+ trait .update_from_trace_packet (trace )
230234
231235 assert trait .path == trace .points
232236 assert trait .image_content is None
@@ -235,35 +239,33 @@ def test_render_failure_clears_stale_image() -> None:
235239# --- Overlays ----------------------------------------------------------------
236240
237241
238- def test_map_dps_update_renders_decoded_overlays () -> None :
242+ def test_map_dps_update_renders_decoded_overlays (render_map : Mock ) -> None :
239243 """A DPS update recomposes an existing map with decoded overlays."""
240244 map_dps = MapDpsTrait ()
241- trait = MapContentTrait (map_dps )
245+ trait = _map_trait (map_dps )
242246 packet = parse_map_packet (FIXTURE .read_bytes ())
243247 notified : list [None ] = []
244248 trait .add_update_listener (lambda : notified .append (None ))
245249
246- with patch (
247- "roborock.devices.traits.b01.q10.map.render_q10_map" ,
248- side_effect = [b"base image" , b"image with overlays" ],
249- ) as render :
250- trait .update_from_map_packet (packet )
251- notified .clear ()
252- map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
250+ render_map .side_effect = [b"base image" , b"image with overlays" ]
251+
252+ trait .update_from_map_packet (packet )
253+ notified .clear ()
254+ map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
253255
254256 assert len (map_dps .overlays .zones ) == 1
255257 assert trait .image_content == b"image with overlays"
256258 assert notified == [None ]
257- assert render .call_count == 2
258- assert render .call_args .args [0 ] is packet
259- assert render .call_args .args [1 ] is None
260- assert render .call_args .args [2 ] is map_dps .overlays
259+ assert render_map .call_count == 2
260+ assert render_map .call_args .args [0 ] is packet
261+ assert render_map .call_args .args [1 ] is None
262+ assert render_map .call_args .args [2 ] is map_dps .overlays
261263
262264
263265def test_map_dps_blobs_are_decoded_only_when_dps_arrives () -> None :
264266 """Map and trace renders reuse the overlays decoded by the DPS trait."""
265267 map_dps = MapDpsTrait ()
266- trait = MapContentTrait (map_dps )
268+ trait = _map_trait (map_dps )
267269
268270 with (
269271 patch ("roborock.devices.traits.b01.q10.map.parse_zone_blob" , return_value = []) as parse_zones ,
@@ -291,7 +293,7 @@ def test_load_overlays_partial_update_keeps_existing_zones() -> None:
291293def test_map_dps_update_without_map_does_not_notify_map_content () -> None :
292294 """A DPS update cannot change high-level content before a map arrives."""
293295 map_dps = MapDpsTrait ()
294- trait = MapContentTrait (map_dps )
296+ trait = _map_trait (map_dps )
295297 notified = []
296298 trait .add_update_listener (lambda : notified .append (True ))
297299
@@ -304,11 +306,107 @@ def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
304306def test_map_dps_push_without_overlay_data_points_is_noop () -> None :
305307 """A DPS push carrying neither overlay DP leaves both traits untouched."""
306308 map_dps = MapDpsTrait ()
307- trait = MapContentTrait (map_dps )
309+ trait = _map_trait (map_dps )
308310 notified = []
309311 trait .add_update_listener (lambda : notified .append (True ))
310312
311313 map_dps .update_from_dps ({B01_Q10_DP .BATTERY : 50 })
312314
313315 assert map_dps .overlays == Q10MapOverlays ()
314316 assert not notified
317+
318+
319+ async def test_charging_status_renders_robot_at_dock (render_map : Mock ) -> None :
320+ """Charging status adds the idle robot marker without inventing a path."""
321+ map_dps = MapDpsTrait ()
322+ trait = _map_trait (map_dps )
323+ packet = parse_map_packet (FIXTURE .read_bytes ())
324+ updated = asyncio .Event ()
325+ trait .add_update_listener (updated .set )
326+ render_map .side_effect = [b"map with dock" , b"map with docked robot" ]
327+
328+ trait .update_from_map_packet (packet )
329+ updated .clear ()
330+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
331+ map_dps .update_from_dps ({B01_Q10_DP .BATTERY : 50 })
332+
333+ await asyncio .wait_for (updated .wait (), timeout = 1 )
334+
335+ assert trait .image_content == b"map with docked robot"
336+ assert trait .path == []
337+ assert render_map .call_count == 2
338+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
339+
340+
341+ def test_docked_state_hides_trace_only_from_rendering (render_map : Mock ) -> None :
342+ """A docked render omits the valid trace without deleting source data."""
343+ map_dps = MapDpsTrait ()
344+ trait = _map_trait (map_dps )
345+ packet = parse_map_packet (FIXTURE .read_bytes ())
346+ trace = Q10TracePacket (points = [Q10Point (1 , 2 ), Q10Point (3 , 4 )])
347+ render_map .return_value = b"map"
348+
349+ trait .update_from_map_packet (packet )
350+ trait .update_from_trace_packet (trace )
351+ assert render_map .call_args .args [1 ] is trace
352+
353+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
354+
355+ assert trait .path == trace .points
356+ assert render_map .call_args .args [1 ] is None
357+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
358+
359+
360+ def test_late_trace_is_retained_but_hidden_while_docked (render_map : Mock ) -> None :
361+ """A late trace stays available but is not part of a docked render."""
362+ map_dps = MapDpsTrait ()
363+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
364+ trait = _map_trait (map_dps )
365+ trace = Q10TracePacket (points = [Q10Point (1 , 2 )])
366+ render_map .return_value = b"map"
367+
368+ trait .update_from_map_packet (parse_map_packet (FIXTURE .read_bytes ()))
369+ trait .update_from_trace_packet (trace )
370+
371+ assert trait .path == trace .points
372+ assert render_map .call_args .args [1 ] is None
373+
374+
375+ def test_emptying_state_keeps_robot_at_dock (render_map : Mock ) -> None :
376+ """Dock emptying must not briefly remove the docked robot marker."""
377+ map_dps = MapDpsTrait ()
378+ trait = _map_trait (map_dps )
379+ packet = parse_map_packet (FIXTURE .read_bytes ())
380+
381+ render_map .side_effect = [b"map with dock" , b"map while emptying" ]
382+
383+ trait .update_from_map_packet (packet )
384+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .EMPTYING_THE_BIN .code })
385+
386+ assert trait .image_content == b"map while emptying"
387+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
388+
389+
390+ async def test_combined_status_and_overlay_update_renders_once (render_map : Mock ) -> None :
391+ """One map DPS update publishes the complete new rendering state."""
392+ map_dps = MapDpsTrait ()
393+ trait = _map_trait (map_dps )
394+ updated = asyncio .Event ()
395+ trait .add_update_listener (updated .set )
396+ render_map .side_effect = [b"base map" , b"combined map" ]
397+
398+ trait .update_from_map_packet (parse_map_packet (FIXTURE .read_bytes ()))
399+ updated .clear ()
400+ map_dps .update_from_dps (
401+ {
402+ B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code ,
403+ B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob (),
404+ }
405+ )
406+
407+ await asyncio .wait_for (updated .wait (), timeout = 1 )
408+
409+ assert render_map .call_count == 2
410+ assert len (render_map .call_args .args [2 ].zones ) == 1
411+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
412+ assert trait .image_content == b"combined map"
0 commit comments