88
99import asyncio
1010import base64
11- from collections .abc import AsyncGenerator
11+ from collections .abc import AsyncGenerator , Generator
1212from pathlib import Path
1313from typing import cast
1414from unittest .mock import Mock , patch
1515
1616import pytest
1717
1818from roborock .cli import _await_q10_map_push , cli
19- from roborock .data .b01_q10 .b01_q10_code_mappings import B01_Q10_DP
19+ from roborock .data .b01_q10 .b01_q10_code_mappings import B01_Q10_DP , YXDeviceState
2020from roborock .devices .traits .b01 .q10 import Q10PropertiesApi , create
2121from roborock .devices .traits .b01 .q10 .command import CommandTrait
2222from roborock .devices .traits .b01 .q10 .map import MapContentTrait , MapDpsTrait
@@ -62,6 +62,12 @@ def _zone_blob() -> str:
6262 return base64 .b64encode (bytes ([1 , 1 ]) + record ).decode ()
6363
6464
65+ @pytest .fixture (name = "render_map" )
66+ def render_map_fixture () -> Generator [Mock , None , None ]:
67+ with patch ("roborock.devices.traits.b01.q10.map.render_q10_map" ) as render :
68+ yield render
69+
70+
6571def test_update_from_map_packet_populates_image_and_rooms () -> None :
6672 """A pushed 01 01 map packet populates the image and rooms."""
6773 packet = parse_map_packet (FIXTURE .read_bytes ())
@@ -441,18 +447,16 @@ def test_trace_without_map_is_retained_without_rendering() -> None:
441447 assert trait .image_content is None
442448
443449
444- def test_render_failure_clears_stale_image () -> None :
450+ def test_render_failure_clears_stale_image (render_map : Mock ) -> None :
445451 """A failed composition cannot leave an image from older source data."""
446452 packet = parse_map_packet (FIXTURE .read_bytes ())
447453 trace = Q10TracePacket (points = [Q10Point (1 , 2 )])
448454 trait = _map_trait ()
449455
450- with patch (
451- "roborock.devices.traits.b01.q10.map.render_q10_map" ,
452- side_effect = [b"initial image" , RoborockException ("invalid map" )],
453- ):
454- trait .update_from_map_packet (packet )
455- trait .update_from_trace_packet (trace )
456+ render_map .side_effect = [b"initial image" , RoborockException ("invalid map" )]
457+
458+ trait .update_from_map_packet (packet )
459+ trait .update_from_trace_packet (trace )
456460
457461 assert trait .path == trace .points
458462 assert trait .image_content is None
@@ -461,29 +465,27 @@ def test_render_failure_clears_stale_image() -> None:
461465# --- Overlays ----------------------------------------------------------------
462466
463467
464- def test_map_dps_update_renders_decoded_overlays () -> None :
468+ def test_map_dps_update_renders_decoded_overlays (render_map : Mock ) -> None :
465469 """A DPS update recomposes an existing map with decoded overlays."""
466470 map_dps = MapDpsTrait ()
467471 trait = _map_trait (map_dps )
468472 packet = parse_map_packet (FIXTURE .read_bytes ())
469473 notified : list [None ] = []
470474 trait .add_update_listener (lambda : notified .append (None ))
471475
472- with patch (
473- "roborock.devices.traits.b01.q10.map.render_q10_map" ,
474- side_effect = [b"base image" , b"image with overlays" ],
475- ) as render :
476- trait .update_from_map_packet (packet )
477- notified .clear ()
478- map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
476+ render_map .side_effect = [b"base image" , b"image with overlays" ]
477+
478+ trait .update_from_map_packet (packet )
479+ notified .clear ()
480+ map_dps .update_from_dps ({B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob ()})
479481
480482 assert len (map_dps .overlays .zones ) == 1
481483 assert trait .image_content == b"image with overlays"
482484 assert notified == [None ]
483- assert render .call_count == 2
484- assert render .call_args .args [0 ] is packet
485- assert render .call_args .args [1 ] is None
486- assert render .call_args .args [2 ] is map_dps .overlays
485+ assert render_map .call_count == 2
486+ assert render_map .call_args .args [0 ] is packet
487+ assert render_map .call_args .args [1 ] is None
488+ assert render_map .call_args .args [2 ] is map_dps .overlays
487489
488490
489491def test_map_dps_blobs_are_decoded_only_when_dps_arrives () -> None :
@@ -538,3 +540,99 @@ def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
538540
539541 assert map_dps .overlays == Q10MapOverlays ()
540542 assert not notified
543+
544+
545+ async def test_charging_status_renders_robot_at_dock (render_map : Mock ) -> None :
546+ """Charging status adds the idle robot marker without inventing a path."""
547+ map_dps = MapDpsTrait ()
548+ trait = _map_trait (map_dps )
549+ packet = parse_map_packet (FIXTURE .read_bytes ())
550+ updated = asyncio .Event ()
551+ trait .add_update_listener (updated .set )
552+ render_map .side_effect = [b"map with dock" , b"map with docked robot" ]
553+
554+ trait .update_from_map_packet (packet )
555+ updated .clear ()
556+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
557+ map_dps .update_from_dps ({B01_Q10_DP .BATTERY : 50 })
558+
559+ await asyncio .wait_for (updated .wait (), timeout = 1 )
560+
561+ assert trait .image_content == b"map with docked robot"
562+ assert trait .path == []
563+ assert render_map .call_count == 2
564+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
565+
566+
567+ def test_docked_state_hides_trace_only_from_rendering (render_map : Mock ) -> None :
568+ """A docked render omits the valid trace without deleting source data."""
569+ map_dps = MapDpsTrait ()
570+ trait = _map_trait (map_dps )
571+ packet = parse_map_packet (FIXTURE .read_bytes ())
572+ trace = Q10TracePacket (points = [Q10Point (1 , 2 ), Q10Point (3 , 4 )])
573+ render_map .return_value = b"map"
574+
575+ trait .update_from_map_packet (packet )
576+ trait .update_from_trace_packet (trace )
577+ assert render_map .call_args .args [1 ] is trace
578+
579+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
580+
581+ assert trait .path == trace .points
582+ assert render_map .call_args .args [1 ] is None
583+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
584+
585+
586+ def test_late_trace_is_retained_but_hidden_while_docked (render_map : Mock ) -> None :
587+ """A late trace stays available but is not part of a docked render."""
588+ map_dps = MapDpsTrait ()
589+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code })
590+ trait = _map_trait (map_dps )
591+ trace = Q10TracePacket (points = [Q10Point (1 , 2 )])
592+ render_map .return_value = b"map"
593+
594+ trait .update_from_map_packet (parse_map_packet (FIXTURE .read_bytes ()))
595+ trait .update_from_trace_packet (trace )
596+
597+ assert trait .path == trace .points
598+ assert render_map .call_args .args [1 ] is None
599+
600+
601+ def test_emptying_state_keeps_robot_at_dock (render_map : Mock ) -> None :
602+ """Dock emptying must not briefly remove the docked robot marker."""
603+ map_dps = MapDpsTrait ()
604+ trait = _map_trait (map_dps )
605+ packet = parse_map_packet (FIXTURE .read_bytes ())
606+
607+ render_map .side_effect = [b"map with dock" , b"map while emptying" ]
608+
609+ trait .update_from_map_packet (packet )
610+ map_dps .update_from_dps ({B01_Q10_DP .STATUS : YXDeviceState .EMPTYING_THE_BIN .code })
611+
612+ assert trait .image_content == b"map while emptying"
613+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
614+
615+
616+ async def test_combined_status_and_overlay_update_renders_once (render_map : Mock ) -> None :
617+ """One map DPS update publishes the complete new rendering state."""
618+ map_dps = MapDpsTrait ()
619+ trait = _map_trait (map_dps )
620+ updated = asyncio .Event ()
621+ trait .add_update_listener (updated .set )
622+ render_map .side_effect = [b"base map" , b"combined map" ]
623+
624+ trait .update_from_map_packet (parse_map_packet (FIXTURE .read_bytes ()))
625+ updated .clear ()
626+ map_dps .update_from_dps (
627+ {
628+ B01_Q10_DP .STATUS : YXDeviceState .CHARGING .code ,
629+ B01_Q10_DP .RESTRICTED_ZONE_UP : _zone_blob (),
630+ }
631+ )
632+
633+ await asyncio .wait_for (updated .wait (), timeout = 1 )
634+
635+ assert render_map .call_count == 2
636+ assert len (render_map .call_args .args [2 ].zones ) == 1
637+ assert render_map .call_args .kwargs ["robot_at_dock" ] is True
638+ assert trait .image_content == b"combined map"
0 commit comments