@@ -1166,3 +1166,101 @@ def _get_raw(_player_id: str, key: str, default: object = None) -> object:
11661166 mass .config .get_raw_player_config_value = MagicMock (side_effect = _get_raw )
11671167 sgp = _make_sync_group (mass )
11681168 assert PlayerFeature .POWER in sgp .supported_features
1169+
1170+
1171+ def _recording_wait (order : list [str ]) -> MagicMock :
1172+ """
1173+ Build a wait_for_player_update replacement that records call/enter/exit order.
1174+
1175+ The returned mock records ``wait_for:<player_id>:<value>`` when invoked,
1176+ ``subscribe`` on context enter and ``await`` on context exit, so a test can
1177+ assert that the playback start was wrapped (subscribe before the command is
1178+ issued) rather than awaited after the fact.
1179+ """
1180+
1181+ class _RecordingWait :
1182+ async def __aenter__ (self ) -> None :
1183+ order .append ("subscribe" )
1184+
1185+ async def __aexit__ (self , * _exc : object ) -> bool :
1186+ order .append ("await" )
1187+ return False
1188+
1189+ def _wait (player_id : str , ** kwargs : Any ) -> _RecordingWait :
1190+ order .append (f"wait_for:{ player_id } :{ kwargs .get ('attribute_value' )} " )
1191+ return _RecordingWait ()
1192+
1193+ return MagicMock (side_effect = _wait )
1194+
1195+
1196+ class TestLeaderPlaybackAwaited :
1197+ """A (re)form must not return until the leader confirms it has started playing."""
1198+
1199+ @pytest .mark .asyncio
1200+ async def test_play_waits_for_leader_before_returning (self ) -> None :
1201+ """play() wraps the resume in a wait so the group lock isn't released early."""
1202+ mass = _make_mock_mass ()
1203+ sgp = _make_sync_group (mass )
1204+ leader = _make_mock_player ("leader" , playback_state = PlaybackState .IDLE )
1205+ mass .players .get_player = _player_lookup ({"leader" : leader })
1206+ sgp .sync_leader = leader
1207+ sgp ._attr_group_members = ["leader" ]
1208+
1209+ order : list [str ] = []
1210+ mass .players .wait_for_player_update = _recording_wait (order )
1211+ mass .players .cmd_resume = AsyncMock (side_effect = lambda * _a , ** _k : order .append ("resume" ))
1212+
1213+ with patch .object (sgp , "_form_syncgroup" , new = AsyncMock ()):
1214+ await sgp .play ()
1215+
1216+ # subscribe happens before the resume command, and the wait completes
1217+ # after — i.e. the start is wrapped, not fire-and-forget.
1218+ assert order == [
1219+ f"wait_for:leader:{ PlaybackState .PLAYING } " ,
1220+ "subscribe" ,
1221+ "resume" ,
1222+ "await" ,
1223+ ]
1224+
1225+ @pytest .mark .asyncio
1226+ async def test_play_media_waits_for_leader_before_returning (self ) -> None :
1227+ """play_media() wraps the leader start so a concurrent (un)group can't race it."""
1228+ mass = _make_mock_mass ()
1229+ sgp = _make_sync_group (mass )
1230+ leader = _make_mock_player ("leader" , playback_state = PlaybackState .IDLE )
1231+ mass .players .get_player = _player_lookup ({"leader" : leader })
1232+ sgp .sync_leader = leader
1233+ sgp ._attr_group_members = ["leader" ]
1234+
1235+ order : list [str ] = []
1236+ mass .players .wait_for_player_update = _recording_wait (order )
1237+ mass .players ._handle_play_media = AsyncMock (
1238+ side_effect = lambda * _a , ** _k : order .append ("play_media" )
1239+ )
1240+
1241+ media = MagicMock ()
1242+ media .source_id = "syncgroup_test"
1243+ with patch .object (sgp , "_form_syncgroup" , new = AsyncMock ()):
1244+ await sgp .play_media (media )
1245+
1246+ assert order == [
1247+ f"wait_for:leader:{ PlaybackState .PLAYING } " ,
1248+ "subscribe" ,
1249+ "play_media" ,
1250+ "await" ,
1251+ ]
1252+
1253+ @pytest .mark .asyncio
1254+ async def test_no_wait_when_group_has_no_leader (self ) -> None :
1255+ """With no leader to wait on, play() resumes without arming a playback wait."""
1256+ mass = _make_mock_mass ()
1257+ sgp = _make_sync_group (mass )
1258+ sgp .sync_leader = None
1259+
1260+ mass .players .cmd_resume = AsyncMock ()
1261+
1262+ with patch .object (sgp , "_form_syncgroup" , new = AsyncMock ()):
1263+ await sgp .play ()
1264+
1265+ mass .players .wait_for_player_update .assert_not_called ()
1266+ mass .players .cmd_resume .assert_awaited_once ()
0 commit comments