2020import pytest
2121
2222from ...utilities .assertions import safe_call_tool
23+ from ...utilities .wait_helpers import wait_for_tool_result
2324
2425logger = logging .getLogger (__name__ )
2526
@@ -48,6 +49,42 @@ def _backups_for(
4849 return [e for e in entries if e ["domain" ] == domain and e ["entity_id" ] == entity_id ]
4950
5051
52+ async def _wait_for_backup (
53+ mcp_client , * , domain : str , entity_id : str , timeout : int = 15
54+ ) -> str :
55+ """Poll the backup list until at least one snapshot for ``domain:entity_id``
56+ appears, returning the first backup name.
57+
58+ Captures are written synchronously by the decorator before the wrapped
59+ write returns, but HA storage propagation between create and edit can
60+ delay when the decorator's pre-edit fetch sees the freshly-created
61+ entity. The poll absorbs that delay rather than asserting on the first
62+ list call (which would fail with ``assert 0 >= 1`` for slow rigs).
63+ """
64+ data = await wait_for_tool_result (
65+ mcp_client ,
66+ tool_name = "ha_manage_backup" ,
67+ arguments = {
68+ "scope" : "edits" ,
69+ "action" : "list" ,
70+ "domain" : domain ,
71+ "entity_id" : entity_id ,
72+ },
73+ predicate = lambda d : bool (
74+ _backups_for (
75+ d .get ("backups" , []) or d .get ("data" , {}).get ("backups" , []),
76+ domain = domain ,
77+ entity_id = entity_id ,
78+ )
79+ ),
80+ description = f"auto-backup snapshot for { domain } :{ entity_id } " ,
81+ timeout = timeout ,
82+ )
83+ entries = data .get ("backups" , []) or data .get ("data" , {}).get ("backups" , [])
84+ mine = _backups_for (entries , domain = domain , entity_id = entity_id )
85+ return mine [0 ]["name" ]
86+
87+
5188# ---------------------------------------------------------------- gating
5289
5390
@@ -238,23 +275,17 @@ async def test_input_boolean_full_loop(
238275 {
239276 "helper_type" : "input_boolean" ,
240277 "helper_id" : helper_id ,
278+ "name" : helper_id ,
241279 "icon" : "mdi:test-tube-empty" ,
242280 },
243281 )
244282 assert edit .get ("success" ) is not False
245283
246- # List backups in the helper_input_boolean domain.
247- listing = await safe_call_tool (
248- mcp_client ,
249- "ha_manage_backup" ,
250- {"scope" : "edits" , "action" : "list" , "domain" : "helper_input_boolean" },
284+ # Poll until the snapshot file appears — absorbs HA storage
285+ # propagation delay between create and edit on slower rigs.
286+ backup_name = await _wait_for_backup (
287+ mcp_client , domain = "helper_input_boolean" , entity_id = helper_id
251288 )
252- assert listing .get ("success" ) is True
253- entries = listing .get ("data" , {}).get ("backups" , [])
254- mine = _backups_for (entries , domain = "helper_input_boolean" , entity_id = helper_id )
255- assert len (mine ) >= 1
256-
257- backup_name = mine [0 ]["name" ]
258289
259290 # Restore — exercises ``input_boolean/update`` WS command.
260291 restore = await safe_call_tool (
@@ -325,9 +356,12 @@ async def test_schedule_full_loop(
325356 assert create .get ("success" ) is not False
326357
327358 # Edit — shrink Monday, split Tuesday, drop Wednesday entirely.
359+ # ``name`` is required by HA's schedule schema on every update
360+ # (validator rejects the WS payload otherwise).
328361 edited = {
329362 "helper_type" : "schedule" ,
330363 "helper_id" : helper_id ,
364+ "name" : helper_id ,
331365 "icon" : "mdi:calendar-remove" ,
332366 "monday" : [{"from" : "10:00:00" , "to" : "12:00:00" }],
333367 "tuesday" : [
@@ -340,22 +374,11 @@ async def test_schedule_full_loop(
340374 edit = await safe_call_tool (mcp_client , "ha_config_set_helper" , edited )
341375 assert edit .get ("success" ) is not False
342376
343- # List + verify the snapshot captured the pre-edit nested structure.
344- listing = await safe_call_tool (
345- mcp_client ,
346- "ha_manage_backup" ,
347- {
348- "scope" : "edits" ,
349- "action" : "list" ,
350- "domain" : "helper_schedule" ,
351- "entity_id" : helper_id ,
352- },
377+ # Poll until the schedule snapshot appears so an HA storage
378+ # propagation race doesn't masquerade as a real assert.
379+ backup_name = await _wait_for_backup (
380+ mcp_client , domain = "helper_schedule" , entity_id = helper_id
353381 )
354- assert listing .get ("success" ) is True
355- entries = listing .get ("data" , {}).get ("backups" , [])
356- mine = _backups_for (entries , domain = "helper_schedule" , entity_id = helper_id )
357- assert len (mine ) >= 1 , "snapshot not captured for schedule edit"
358- backup_name = mine [0 ]["name" ]
359382
360383 # View — assert the nested arrays survived YAML round-trip into
361384 # the snapshot file. Schedule shape:
@@ -437,21 +460,9 @@ async def test_dashboard_full_loop(
437460 "config" : {"views" : [{"title" : "Updated" }]},
438461 },
439462 )
440- listing = await safe_call_tool (
441- mcp_client ,
442- "ha_manage_backup" ,
443- {
444- "scope" : "edits" ,
445- "action" : "list" ,
446- "domain" : "dashboard" ,
447- "entity_id" : url_path ,
448- },
463+ backup_name = await _wait_for_backup (
464+ mcp_client , domain = "dashboard" , entity_id = url_path
449465 )
450- assert listing .get ("success" ) is True
451- entries = listing .get ("data" , {}).get ("backups" , [])
452- mine = _backups_for (entries , domain = "dashboard" , entity_id = url_path )
453- assert len (mine ) >= 1
454- backup_name = mine [0 ]["name" ]
455466
456467 # Restore — fires ``lovelace/config/save``.
457468 restore = await safe_call_tool (
@@ -518,21 +529,9 @@ async def test_script_full_loop(
518529 },
519530 },
520531 )
521- listing = await safe_call_tool (
522- mcp_client ,
523- "ha_manage_backup" ,
524- {
525- "scope" : "edits" ,
526- "action" : "list" ,
527- "domain" : "script" ,
528- "entity_id" : script_id ,
529- },
532+ backup_name = await _wait_for_backup (
533+ mcp_client , domain = "script" , entity_id = script_id
530534 )
531- assert listing .get ("success" ) is True
532- entries = listing .get ("data" , {}).get ("backups" , [])
533- mine = _backups_for (entries , domain = "script" , entity_id = script_id )
534- assert len (mine ) >= 1
535- backup_name = mine [0 ]["name" ]
536535
537536 restore = await safe_call_tool (
538537 mcp_client ,
@@ -595,21 +594,9 @@ async def test_scene_full_loop(
595594 },
596595 },
597596 )
598- listing = await safe_call_tool (
599- mcp_client ,
600- "ha_manage_backup" ,
601- {
602- "scope" : "edits" ,
603- "action" : "list" ,
604- "domain" : "scene" ,
605- "entity_id" : scene_id ,
606- },
597+ backup_name = await _wait_for_backup (
598+ mcp_client , domain = "scene" , entity_id = scene_id
607599 )
608- assert listing .get ("success" ) is True
609- entries = listing .get ("data" , {}).get ("backups" , [])
610- mine = _backups_for (entries , domain = "scene" , entity_id = scene_id )
611- assert len (mine ) >= 1
612- backup_name = mine [0 ]["name" ]
613600
614601 restore = await safe_call_tool (
615602 mcp_client ,
@@ -742,21 +729,9 @@ async def test_label_full_loop(
742729 )
743730 assert edit .get ("success" ) is not False
744731
745- listing = await safe_call_tool (
746- mcp_client ,
747- "ha_manage_backup" ,
748- {
749- "scope" : "edits" ,
750- "action" : "list" ,
751- "domain" : "label" ,
752- "entity_id" : label_id ,
753- },
732+ backup_name = await _wait_for_backup (
733+ mcp_client , domain = "label" , entity_id = label_id
754734 )
755- assert listing .get ("success" ) is True
756- entries = listing .get ("data" , {}).get ("backups" , [])
757- mine = _backups_for (entries , domain = "label" , entity_id = label_id )
758- assert len (mine ) >= 1
759- backup_name = mine [0 ]["name" ]
760735
761736 restore = await safe_call_tool (
762737 mcp_client ,
@@ -825,21 +800,9 @@ async def test_category_full_loop(
825800 )
826801 assert edit .get ("success" ) is not False
827802
828- listing = await safe_call_tool (
829- mcp_client ,
830- "ha_manage_backup" ,
831- {
832- "scope" : "edits" ,
833- "action" : "list" ,
834- "domain" : "category" ,
835- "entity_id" : composite ,
836- },
803+ backup_name = await _wait_for_backup (
804+ mcp_client , domain = "category" , entity_id = composite
837805 )
838- assert listing .get ("success" ) is True
839- entries = listing .get ("data" , {}).get ("backups" , [])
840- mine = _backups_for (entries , domain = "category" , entity_id = composite )
841- assert len (mine ) >= 1
842- backup_name = mine [0 ]["name" ]
843806
844807 restore = await safe_call_tool (
845808 mcp_client ,
@@ -908,21 +871,9 @@ async def test_zone_full_loop(
908871 )
909872 assert edit .get ("success" ) is not False
910873
911- listing = await safe_call_tool (
912- mcp_client ,
913- "ha_manage_backup" ,
914- {
915- "scope" : "edits" ,
916- "action" : "list" ,
917- "domain" : "zone" ,
918- "entity_id" : zone_id ,
919- },
874+ backup_name = await _wait_for_backup (
875+ mcp_client , domain = "zone" , entity_id = zone_id
920876 )
921- assert listing .get ("success" ) is True
922- entries = listing .get ("data" , {}).get ("backups" , [])
923- mine = _backups_for (entries , domain = "zone" , entity_id = zone_id )
924- assert len (mine ) >= 1
925- backup_name = mine [0 ]["name" ]
926877
927878 restore = await safe_call_tool (
928879 mcp_client ,
@@ -988,21 +939,9 @@ async def test_area_full_loop(
988939 )
989940 assert edit .get ("success" ) is not False
990941
991- listing = await safe_call_tool (
992- mcp_client ,
993- "ha_manage_backup" ,
994- {
995- "scope" : "edits" ,
996- "action" : "list" ,
997- "domain" : "area_or_floor" ,
998- "entity_id" : composite ,
999- },
942+ backup_name = await _wait_for_backup (
943+ mcp_client , domain = "area_or_floor" , entity_id = composite
1000944 )
1001- assert listing .get ("success" ) is True
1002- entries = listing .get ("data" , {}).get ("backups" , [])
1003- mine = _backups_for (entries , domain = "area_or_floor" , entity_id = composite )
1004- assert len (mine ) >= 1
1005- backup_name = mine [0 ]["name" ]
1006945
1007946 restore = await safe_call_tool (
1008947 mcp_client ,
@@ -1068,22 +1007,11 @@ async def test_group_full_loop(
10681007 )
10691008 assert edit .get ("success" ) is not False
10701009
1071- entity_id = f"group.{ object_id } "
1072- listing = await safe_call_tool (
1073- mcp_client ,
1074- "ha_manage_backup" ,
1075- {
1076- "scope" : "edits" ,
1077- "action" : "list" ,
1078- "domain" : "group" ,
1079- "entity_id" : entity_id ,
1080- },
1010+ # Decorator uses ``id_param="object_id"`` so the snapshot keys on
1011+ # the object_id, not the full ``group.<id>`` entity_id form.
1012+ backup_name = await _wait_for_backup (
1013+ mcp_client , domain = "group" , entity_id = object_id
10811014 )
1082- assert listing .get ("success" ) is True
1083- entries = listing .get ("data" , {}).get ("backups" , [])
1084- mine = _backups_for (entries , domain = "group" , entity_id = entity_id )
1085- assert len (mine ) >= 1
1086- backup_name = mine [0 ]["name" ]
10871015
10881016 restore = await safe_call_tool (
10891017 mcp_client ,
@@ -1141,25 +1069,15 @@ async def test_entity_full_loop(
11411069 if edit .get ("success" ) is False :
11421070 pytest .skip (f"ha_set_entity unsupported on this HA: { edit } " )
11431071
1144- listing = await safe_call_tool (
1145- mcp_client ,
1146- "ha_manage_backup" ,
1147- {
1148- "scope" : "edits" ,
1149- "action" : "list" ,
1150- "domain" : "entity" ,
1151- "entity_id" : entity_id ,
1152- },
1153- )
1154- assert listing .get ("success" ) is True
1155- entries = listing .get ("data" , {}).get ("backups" , [])
1156- mine = _backups_for (entries , domain = "entity" , entity_id = entity_id )
1157- if not mine :
1072+ try :
1073+ backup_name = await _wait_for_backup (
1074+ mcp_client , domain = "entity" , entity_id = entity_id , timeout = 10
1075+ )
1076+ except TimeoutError :
11581077 # ha_set_entity may take the entity-registry path (no /api/states
11591078 # POST) on some HA versions; if so the entity-domain handler
11601079 # won't fire. Surface clearly rather than asserting wrong.
11611080 pytest .skip ("entity-domain handler did not capture for this edit" )
1162- backup_name = mine [0 ]["name" ]
11631081
11641082 restore = await safe_call_tool (
11651083 mcp_client ,
@@ -1226,23 +1144,9 @@ async def test_dashboard_resource_full_loop(
12261144 )
12271145 assert edit .get ("success" ) is not False
12281146
1229- listing = await safe_call_tool (
1230- mcp_client ,
1231- "ha_manage_backup" ,
1232- {
1233- "scope" : "edits" ,
1234- "action" : "list" ,
1235- "domain" : "dashboard_resource" ,
1236- "entity_id" : str (resource_id ),
1237- },
1238- )
1239- assert listing .get ("success" ) is True
1240- entries = listing .get ("data" , {}).get ("backups" , [])
1241- mine = _backups_for (
1242- entries , domain = "dashboard_resource" , entity_id = str (resource_id )
1147+ backup_name = await _wait_for_backup (
1148+ mcp_client , domain = "dashboard_resource" , entity_id = str (resource_id )
12431149 )
1244- assert len (mine ) >= 1
1245- backup_name = mine [0 ]["name" ]
12461150
12471151 restore = await safe_call_tool (
12481152 mcp_client ,
0 commit comments