@@ -276,6 +276,7 @@ def __init__(self, status_sequence: list[dict]) -> None:
276276 self ._statuses = [StatusV2 .from_dict (s ) for s in status_sequence ]
277277 self ._index = 0
278278 self ._logger = logging .getLogger ("test-wait" )
279+ self .sent_commands : list [tuple [RoborockCommand , list | dict | None ]] = []
279280
280281 async def get_status (self ) -> StatusV2 :
281282 if self ._index < len (self ._statuses ):
@@ -284,6 +285,9 @@ async def get_status(self) -> StatusV2:
284285 return status
285286 return self ._statuses [- 1 ]
286287
288+ async def send_command (self , command : RoborockCommand , params = None ) -> None :
289+ self .sent_commands .append ((command , params ))
290+
287291
288292_ScriptedStatusClient .wait_for_step_complete = (
289293 routine_runner_module ._RoutineMqttClient .wait_for_step_complete
@@ -368,3 +372,91 @@ async def exercise() -> None:
368372 await client .wait_for_step_complete ()
369373
370374 asyncio .run (exercise ())
375+
376+
377+ def test_wait_for_step_complete_resume_after_mid_clean_charge (monkeypatch ) -> None :
378+ """Robot returns to dock mid-clean with low battery, charges, gets resumed, completes."""
379+ monkeypatch .setattr (routine_runner_module , "_STEP_START_POLL_INTERVAL_SECONDS" , 0.0 )
380+ monkeypatch .setattr (routine_runner_module , "_STATUS_POLL_INTERVAL_SECONDS" , 0.0 )
381+ monkeypatch .setattr (routine_runner_module , "_RESUME_BATTERY_THRESHOLD" , 80 )
382+
383+ async def exercise () -> None :
384+ client = _ScriptedStatusClient ([
385+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 55 }, # segment cleaning
386+ {"state" : 26 , "in_cleaning" : 3 , "battery" : 30 }, # going to wash mop
387+ {"state" : 6 , "in_cleaning" : 3 , "battery" : 14 }, # returning home (low battery)
388+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 14 }, # charging mid-clean, too low
389+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 50 }, # still too low
390+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 80 }, # threshold reached → resume sent
391+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 80 }, # cleaning resumes
392+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 60 },
393+ {"state" : 6 , "in_cleaning" : 3 , "battery" : 40 }, # returning home
394+ {"state" : 8 , "in_cleaning" : 0 , "battery" : 40 }, # step complete
395+ ])
396+ await client .wait_for_step_complete ()
397+ assert len (client .sent_commands ) == 1
398+ assert client .sent_commands [0 ] == (RoborockCommand .RESUME_SEGMENT_CLEAN , [])
399+
400+ asyncio .run (exercise ())
401+
402+
403+ def test_wait_for_step_complete_resume_zoned_clean (monkeypatch ) -> None :
404+ """Resume uses correct command for zone cleaning."""
405+ monkeypatch .setattr (routine_runner_module , "_STEP_START_POLL_INTERVAL_SECONDS" , 0.0 )
406+ monkeypatch .setattr (routine_runner_module , "_STATUS_POLL_INTERVAL_SECONDS" , 0.0 )
407+ monkeypatch .setattr (routine_runner_module , "_RESUME_BATTERY_THRESHOLD" , 80 )
408+
409+ async def exercise () -> None :
410+ client = _ScriptedStatusClient ([
411+ {"state" : 18 , "in_cleaning" : 2 , "battery" : 50 }, # zone cleaning
412+ {"state" : 8 , "in_cleaning" : 2 , "battery" : 80 }, # charging mid-clean → resume
413+ {"state" : 18 , "in_cleaning" : 2 , "battery" : 80 }, # resumes
414+ {"state" : 8 , "in_cleaning" : 0 , "battery" : 60 }, # step complete
415+ ])
416+ await client .wait_for_step_complete ()
417+ assert len (client .sent_commands ) == 1
418+ assert client .sent_commands [0 ] == (RoborockCommand .RESUME_ZONED_CLEAN , [])
419+
420+ asyncio .run (exercise ())
421+
422+
423+ def test_wait_for_step_complete_no_resume_when_battery_low (monkeypatch ) -> None :
424+ """No resume sent while battery is below threshold."""
425+ monkeypatch .setattr (routine_runner_module , "_STEP_START_POLL_INTERVAL_SECONDS" , 0.0 )
426+ monkeypatch .setattr (routine_runner_module , "_STATUS_POLL_INTERVAL_SECONDS" , 0.0 )
427+ monkeypatch .setattr (routine_runner_module , "_STEP_COMPLETE_TIMEOUT_SECONDS" , 0.1 )
428+ monkeypatch .setattr (routine_runner_module , "_RESUME_BATTERY_THRESHOLD" , 80 )
429+
430+ async def exercise () -> None :
431+ client = _ScriptedStatusClient ([
432+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 50 }, # cleaning
433+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 14 }, # charging, below threshold
434+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 50 }, # still below
435+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 79 }, # still below
436+ ])
437+ with pytest .raises (routine_runner_module .RoutineExecutionError , match = "Timed out" ):
438+ await client .wait_for_step_complete ()
439+ assert len (client .sent_commands ) == 0
440+
441+ asyncio .run (exercise ())
442+
443+
444+ def test_wait_for_step_complete_resume_only_sent_once (monkeypatch ) -> None :
445+ """Resume command is only sent once even if robot returns to dock again."""
446+ monkeypatch .setattr (routine_runner_module , "_STEP_START_POLL_INTERVAL_SECONDS" , 0.0 )
447+ monkeypatch .setattr (routine_runner_module , "_STATUS_POLL_INTERVAL_SECONDS" , 0.0 )
448+ monkeypatch .setattr (routine_runner_module , "_RESUME_BATTERY_THRESHOLD" , 80 )
449+
450+ async def exercise () -> None :
451+ client = _ScriptedStatusClient ([
452+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 95 }, # cleaning
453+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 80 }, # charging → resume sent
454+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 80 }, # cleaning resumes
455+ {"state" : 8 , "in_cleaning" : 3 , "battery" : 80 }, # back to charging again → second resume sent
456+ {"state" : 18 , "in_cleaning" : 3 , "battery" : 80 }, # cleaning resumes again
457+ {"state" : 8 , "in_cleaning" : 0 , "battery" : 60 }, # step complete
458+ ])
459+ await client .wait_for_step_complete ()
460+ assert len (client .sent_commands ) == 2
461+
462+ asyncio .run (exercise ())
0 commit comments