/
/
/
1"""Tests for Sync Group Player protocol awareness and locking."""
2
3from __future__ import annotations
4
5import asyncio
6import inspect
7import time
8from typing import Any
9from unittest.mock import AsyncMock, MagicMock, patch
10
11import pytest
12from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
13from music_assistant_models.player import OutputProtocol
14
15from music_assistant.constants import CONF_GROUP_MEMBERS, CONF_PLAYERS
16from music_assistant.providers.sync_group.player import SyncGroupPlayer
17
18
19def _player_lookup(players: dict[str, MagicMock]) -> MagicMock:
20 """Create a get_player side_effect from a player dict."""
21 return MagicMock(side_effect=lambda pid: players.get(pid))
22
23
24def _make_mock_mass() -> MagicMock:
25 """Create a minimal mock MusicAssistant instance."""
26 mass = MagicMock()
27 mass.players = MagicMock()
28 mass.players.get_player = MagicMock(return_value=None)
29 mass.players._handle_cmd_stop = AsyncMock()
30 mass.players._handle_cmd_resume = AsyncMock()
31 mass.players._handle_play_media = AsyncMock()
32 mass.players.cmd_set_members = AsyncMock()
33 mass.players._handle_set_members = AsyncMock()
34 # get_player_lock is an async context manager (used by syncgroup to lock the sync leader)
35 lock_ctx = AsyncMock()
36 lock_ctx.__aenter__.return_value = None
37 lock_ctx.__aexit__.return_value = False
38 mass.players.get_player_lock = MagicMock(return_value=lock_ctx)
39 # wait_for_player_update is an async context manager â return one that no-ops.
40 # __aexit__ must explicitly return False so exceptions inside the `async with`
41 # body propagate (an unconfigured AsyncMock returns a truthy MagicMock and
42 # would silently swallow real test failures).
43 wait_ctx = AsyncMock()
44 wait_ctx.__aenter__.return_value = None
45 wait_ctx.__aexit__.return_value = False
46 mass.players.wait_for_player_update = MagicMock(return_value=wait_ctx)
47 mass.players.trigger_player_update = MagicMock()
48 mass.call_later = MagicMock()
49 mass.cancel_timer = MagicMock()
50 mass.config = MagicMock()
51 mass.config.get_base_player_config.return_value = MagicMock(
52 name=None, default_name="Test Group", get_value=MagicMock(return_value=True)
53 )
54 return mass
55
56
57def _make_mock_player(
58 player_id: str,
59 provider_domain: str = "sonos",
60 available: bool = True,
61 protocol_domains: list[str] | None = None,
62 active_output_protocol: str | None = None,
63 playback_state: PlaybackState = PlaybackState.IDLE,
64) -> MagicMock:
65 """Create a mock player with configurable protocol support."""
66 player = MagicMock()
67 player.player_id = player_id
68 player.display_name = player_id
69 player.available = available
70 player.active_output_protocol = active_output_protocol
71 player.playback_state = playback_state
72 player.protocol_parent_id = None
73 player.provider = MagicMock()
74 player.provider.domain = provider_domain
75
76 # Build linked_output_protocols
77 protocols = []
78 for domain in protocol_domains or []:
79 proto = MagicMock(spec=OutputProtocol)
80 proto.protocol_domain = domain
81 proto.available = True
82 # default to a synthetic protocol id; tests that need a specific id can override
83 proto.output_protocol_id = f"{player_id}_{domain}_proto"
84 protocols.append(proto)
85 player.linked_output_protocols = protocols
86
87 # State mock
88 player.state = MagicMock()
89 player.state.available = available
90 player.state.playback_state = playback_state
91 player.state.can_group_with = set()
92 player.state.group_members = []
93 player.state.supported_features = {PlayerFeature.SET_MEMBERS}
94 # default to "not synced" so the syncgroup form path doesn't enter the
95 # stale-state wait loop. Tests that want to assert on the stale-state
96 # behavior can override this explicitly.
97 player.state.synced_to = None
98 player.synced_to = None
99 player.set_members = AsyncMock()
100
101 return player
102
103
104def _make_sync_group(mass: MagicMock, player_id: str = "syncgroup_test") -> SyncGroupPlayer:
105 """Create a SyncGroupPlayer with mock provider."""
106 provider = MagicMock()
107 provider.domain = "sync_group"
108 provider.instance_id = "sync_group_test"
109 provider.name = "Sync Group"
110 provider.mass = mass
111
112 def _config_get_value(key: str, default: object = None) -> object:
113 # CONF_DYNAMIC_GROUP_MEMBERS resolves to "dynamic_members"
114 if key == "dynamic_members":
115 return True
116 if key == "members_filter":
117 return []
118 return default
119
120 mass.config.get_base_player_config.return_value = MagicMock(
121 name=None, default_name="Test Group", get_value=_config_get_value
122 )
123
124 sgp = SyncGroupPlayer(provider, player_id)
125 sgp._cache.clear()
126 return sgp
127
128
129class TestProtocolAwareLeaderSelection:
130 """Test that leader selection prefers protocol continuity."""
131
132 def test_select_leader_prefers_active_protocol(self) -> None:
133 """When preferred protocol is given, prefer members supporting it."""
134 mass = _make_mock_mass()
135 sgp = _make_sync_group(mass)
136
137 # Player A: sonos native only
138 player_a = _make_mock_player("player_a", provider_domain="sonos")
139 # Player B: has airplay protocol
140 player_b = _make_mock_player(
141 "player_b", provider_domain="sonos", protocol_domains=["airplay"]
142 )
143
144 mass.players.get_player = _player_lookup({"player_a": player_a, "player_b": player_b})
145
146 sgp._attr_group_members = ["player_a", "player_b"]
147
148 leader = sgp._select_sync_leader(preferred_protocol_domain="airplay")
149 assert leader == player_b
150
151 def test_select_leader_fallback_when_no_protocol_match(self) -> None:
152 """When no member supports the preferred protocol, fall back to first available."""
153 mass = _make_mock_mass()
154 sgp = _make_sync_group(mass)
155
156 player_a = _make_mock_player("player_a", provider_domain="sonos")
157 mass.players.get_player = _player_lookup({"player_a": player_a})
158
159 sgp._attr_group_members = ["player_a"]
160
161 leader = sgp._select_sync_leader(preferred_protocol_domain="airplay")
162 assert leader == player_a
163
164 def test_select_leader_no_protocol_uses_first_available(self) -> None:
165 """When no preferred protocol, pick first available."""
166 mass = _make_mock_mass()
167 sgp = _make_sync_group(mass)
168
169 player_a = _make_mock_player("player_a")
170 player_b = _make_mock_player("player_b")
171 mass.players.get_player = _player_lookup({"player_a": player_a, "player_b": player_b})
172
173 sgp._attr_group_members = ["player_a", "player_b"]
174
175 leader = sgp._select_sync_leader()
176 assert leader == player_a
177
178
179class TestMemberSupportsProtocol:
180 """Test protocol domain checking for members."""
181
182 def test_native_provider_match(self) -> None:
183 """Player's own provider domain matches."""
184 mass = _make_mock_mass()
185 sgp = _make_sync_group(mass)
186
187 player = _make_mock_player("p1", provider_domain="airplay")
188 assert sgp._member_supports_protocol_domain(player, "airplay") is True
189
190 def test_linked_protocol_match(self) -> None:
191 """Player has a linked output protocol matching the domain."""
192 mass = _make_mock_mass()
193 sgp = _make_sync_group(mass)
194
195 player = _make_mock_player("p1", provider_domain="sonos", protocol_domains=["airplay"])
196 assert sgp._member_supports_protocol_domain(player, "airplay") is True
197
198 def test_no_match(self) -> None:
199 """Player doesn't support the requested protocol."""
200 mass = _make_mock_mass()
201 sgp = _make_sync_group(mass)
202
203 player = _make_mock_player("p1", provider_domain="sonos")
204 assert sgp._member_supports_protocol_domain(player, "airplay") is False
205
206
207class TestActiveProtocolDomain:
208 """Test that active_protocol_domain is derived correctly from live state."""
209
210 def test_no_leader_returns_none(self) -> None:
211 """With no sync leader, active_protocol_domain is None."""
212 mass = _make_mock_mass()
213 sgp = _make_sync_group(mass)
214 sgp.sync_leader = None
215 assert sgp.active_protocol_domain is None
216
217 def test_native_leader_returns_native_domain(self) -> None:
218 """With a native leader (no active output protocol) return the leader's domain."""
219 mass = _make_mock_mass()
220 sgp = _make_sync_group(mass)
221 leader = _make_mock_player("leader", provider_domain="sonos")
222 mass.players.get_player = _player_lookup({"leader": leader})
223 sgp.sync_leader = leader
224 sgp._attr_group_members = ["leader"]
225 assert sgp.active_protocol_domain == "sonos"
226
227 def test_active_protocol_with_requiring_member(self) -> None:
228 """Non-native protocol stays active while a member still requires it."""
229 mass = _make_mock_mass()
230 sgp = _make_sync_group(mass)
231 leader = _make_mock_player(
232 "leader", provider_domain="sonos", active_output_protocol="ap_leader"
233 )
234 ap_protocol = _make_mock_player("ap_leader", provider_domain="airplay")
235 # AirPlay-only member (only linked protocol is airplay)
236 ap_only = _make_mock_player(
237 "ap_only", provider_domain="universal_player", protocol_domains=["airplay"]
238 )
239 ap_only.is_native_player = False
240 mass.players.get_player = _player_lookup(
241 {"leader": leader, "ap_leader": ap_protocol, "ap_only": ap_only}
242 )
243 sgp.sync_leader = leader
244 sgp._attr_group_members = ["leader", "ap_only"]
245 assert sgp.active_protocol_domain == "airplay"
246
247 def test_active_protocol_downshifts_when_no_member_requires_it(self) -> None:
248 """Non-native protocol downshifts to native when no member still requires it."""
249 mass = _make_mock_mass()
250 sgp = _make_sync_group(mass)
251 leader = _make_mock_player(
252 "leader", provider_domain="sonos", active_output_protocol="ap_leader"
253 )
254 ap_protocol = _make_mock_player("ap_leader", provider_domain="airplay")
255 mass.players.get_player = _player_lookup({"leader": leader, "ap_leader": ap_protocol})
256 sgp.sync_leader = leader
257 # Only a native-capable Sonos leader remains; no one requires airplay
258 sgp._attr_group_members = ["leader"]
259 assert sgp.active_protocol_domain == "sonos"
260
261 def test_dissolve_clears_sync_leader(self) -> None:
262 """Verify _dissolve_syncgroup clears the sync leader."""
263 mass = _make_mock_mass()
264 sgp = _make_sync_group(mass)
265 source = inspect.getsource(sgp._dissolve_syncgroup)
266 assert "self.sync_leader = None" in source
267
268 @pytest.mark.asyncio
269 async def test_dissolve_schedules_protocol_clear_while_leader_still_playing(self) -> None:
270 """
271 Dissolve must schedule the delayed protocol clear while the leader plays.
272
273 Real devices report new state with a delay after a stop, so a playback
274 state check here would skip the clear and leave a stale active protocol
275 on the leader.
276 """
277 mass = _make_mock_mass()
278 sgp = _make_sync_group(mass)
279 leader = _make_mock_player(
280 "leader",
281 provider_domain="wiim",
282 active_output_protocol="ap_leader",
283 playback_state=PlaybackState.PLAYING,
284 )
285 mass.players.get_player = _player_lookup({"leader": leader})
286 sgp.sync_leader = leader
287
288 with patch.object(sgp, "update_state"):
289 await sgp._dissolve_syncgroup()
290
291 # use getattr to defeat mypy's narrowing after the earlier assignment,
292 # since it can't see that _dissolve_syncgroup mutates sync_leader.
293 assert getattr(sgp, "sync_leader") is None # noqa: B009
294 mass.players.schedule_active_output_protocol_clear.assert_called_once_with(leader)
295
296
297class TestControllerLockCategory:
298 """Test that the controller's lock categories serialize correctly."""
299
300 def test_play_lock_key_format(self) -> None:
301 """Lock key for play category uses 'play_{player_id}' format."""
302 # The decorator uses lock category string as prefix
303 # play_media, set_members, enqueue_next_media all use "play" category
304 lock_key = "play_test_player_123"
305 assert lock_key.startswith("play_")
306
307
308class TestDynamicLeaderSwitch:
309 """Test dynamic leader switching behaviour."""
310
311 @pytest.mark.asyncio
312 async def test_dynamic_leader_switch_hands_off_to_new_leader(self) -> None:
313 """When the new leader IS in the live session, seamless protocol-level handoff is used."""
314 mass = _make_mock_mass()
315 sgp = _make_sync_group(mass)
316
317 # AirPlay-only protocol member ensures active_protocol_domain resolves
318 # to "airplay" and drives new-leader selection toward a member that
319 # supports AirPlay.
320 ap_only = _make_mock_player(
321 "ap_only", provider_domain="universal_player", protocol_domains=["airplay"]
322 )
323 ap_only.is_native_player = False
324
325 # new_leader's AirPlay protocol player (must be in the session for handoff)
326 ap_new = _make_mock_player("ap_new", provider_domain="airplay")
327
328 old_leader = _make_mock_player(
329 "old_leader",
330 provider_domain="sonos",
331 active_output_protocol="ap_old",
332 protocol_domains=["airplay"],
333 )
334 new_leader = _make_mock_player(
335 "new_leader",
336 provider_domain="sonos",
337 protocol_domains=["airplay"],
338 active_output_protocol="ap_new",
339 )
340 # Wire the linked airplay protocols on the parents to point to the
341 # corresponding protocol player ids so _resolve_session_target can find them.
342 new_leader.linked_output_protocols[0].output_protocol_id = "ap_new"
343 ap_only.linked_output_protocols[0].output_protocol_id = "ap_only_proto"
344 # ap_only's airplay protocol player needs to exist for the protocol-id resolution
345 ap_only_proto = _make_mock_player("ap_only_proto", provider_domain="airplay")
346
347 ap_protocol = _make_mock_player("ap_old", provider_domain="airplay")
348 # Set up the live session with new_leader's protocol player in sync_clients
349 mock_session = MagicMock()
350 mock_session.sync_clients = [ap_protocol, ap_new, ap_only]
351 ap_protocol.stream = MagicMock()
352 ap_protocol.stream.session = mock_session
353
354 mass.players.get_player = _player_lookup(
355 {
356 "old_leader": old_leader,
357 "new_leader": new_leader,
358 "ap_old": ap_protocol,
359 "ap_new": ap_new,
360 "ap_only": ap_only,
361 "ap_only_proto": ap_only_proto,
362 }
363 )
364
365 sgp.sync_leader = old_leader
366 sgp._attr_group_members = ["old_leader", "new_leader", "ap_only"]
367
368 with patch.object(sgp, "update_state"):
369 await sgp._dynamic_leader_switch("old_leader")
370
371 assert sgp.sync_leader == new_leader
372 assert "old_leader" not in sgp._attr_group_members
373
374 # 1. Old leader's session protocol player got told to step out (self-remove)
375 ap_protocol.set_members.assert_any_await(player_ids_to_remove=["ap_old"])
376
377 # 2. New leader's protocol player got the remaining members added.
378 # ap_only is already a protocol player on the airplay domain (its own
379 # provider is universal_player but its linked airplay protocol resolves
380 # via _resolve_session_target). The exact id depends on the mock wiring,
381 # but the call must have happened.
382 ap_new.set_members.assert_awaited()
383 add_call = ap_new.set_members.await_args
384 assert add_call.kwargs.get("player_ids_to_add"), (
385 "expected new leader's protocol player to receive the remaining members"
386 )
387
388 @pytest.mark.asyncio
389 async def test_dynamic_leader_switch_dissolves_when_new_leader_not_in_session(
390 self,
391 ) -> None:
392 """When the new leader is NOT in the live session, fall back to dissolve+reform."""
393 mass = _make_mock_mass()
394 mass.players.cmd_resume = AsyncMock()
395 sgp = _make_sync_group(mass)
396
397 old_leader = _make_mock_player(
398 "old_leader",
399 provider_domain="sonos",
400 active_output_protocol="ap_old",
401 protocol_domains=["airplay"],
402 )
403 # Freshly-added player â NOT in the live session
404 fresh_player = _make_mock_player(
405 "fresh_player", provider_domain="sonos", protocol_domains=["airplay"]
406 )
407 ap_protocol = _make_mock_player("ap_old", provider_domain="airplay")
408 # Session does NOT contain fresh_player's protocol player
409 mock_session = MagicMock()
410 mock_session.sync_clients = [ap_protocol]
411 ap_protocol.stream = MagicMock()
412 ap_protocol.stream.session = mock_session
413
414 mass.players.get_player = _player_lookup(
415 {
416 "old_leader": old_leader,
417 "fresh_player": fresh_player,
418 "ap_old": ap_protocol,
419 }
420 )
421
422 sgp.sync_leader = old_leader
423 sgp._attr_group_members = ["old_leader", "fresh_player"]
424
425 with patch.object(sgp, "update_state"):
426 await sgp._dynamic_leader_switch("old_leader")
427
428 # The old protocol player must NOT have been told to self-remove
429 # (that path is only for the seamless handoff). Instead, dissolve+reform
430 # happened: wait_for_player_update was used to wrap the stop.
431 ap_protocol.set_members.assert_not_awaited()
432 mass.players.wait_for_player_update.assert_called()
433 assert "old_leader" not in sgp._attr_group_members
434
435
436class TestPowerLifecycle:
437 """Test that power(True/False) drives the group's form/dissolve lifecycle."""
438
439 @pytest.mark.asyncio
440 async def test_power_on_forms_group_and_picks_leader(self) -> None:
441 """power(True) should select a sync leader and mark the group powered."""
442 mass = _make_mock_mass()
443 sgp = _make_sync_group(mass)
444
445 leader = _make_mock_player("leader", provider_domain="sonos")
446 mass.players.get_player = _player_lookup({"leader": leader})
447 sgp._attr_group_members = ["leader"]
448
449 with patch.object(sgp, "update_state"):
450 await sgp.power(True)
451
452 assert sgp.sync_leader == leader
453 assert sgp._attr_powered is True
454
455 @pytest.mark.asyncio
456 async def test_power_on_with_no_members_stays_unformed(self) -> None:
457 """power(True) on an empty group should leave sync_leader as None but mark powered."""
458 mass = _make_mock_mass()
459 sgp = _make_sync_group(mass)
460
461 with patch.object(sgp, "update_state"):
462 await sgp.power(True)
463
464 assert sgp.sync_leader is None
465 # group is powered (intent to be active) even though no leader could be picked
466 assert sgp._attr_powered is True
467
468 @pytest.mark.asyncio
469 async def test_power_off_dissolves_group(self) -> None:
470 """power(False) should clear the sync leader and mark unpowered."""
471 mass = _make_mock_mass()
472 sgp = _make_sync_group(mass)
473
474 leader = _make_mock_player("leader", provider_domain="sonos")
475 mass.players.get_player = _player_lookup({"leader": leader})
476 sgp.sync_leader = leader
477 sgp._attr_group_members = ["leader"]
478 sgp._attr_powered = True
479
480 with patch.object(sgp, "update_state"):
481 await sgp.power(False)
482
483 # use getattr to defeat mypy's narrowing of these attributes after the
484 # earlier assignments, since it can't see that power(False) mutates them.
485 assert getattr(sgp, "sync_leader") is None # noqa: B009
486 assert getattr(sgp, "_attr_powered") is False # noqa: B009
487
488 @pytest.mark.asyncio
489 async def test_stop_does_not_dissolve_group(self) -> None:
490 """stop() should stop the leader but leave the group formed and powered."""
491 mass = _make_mock_mass()
492 sgp = _make_sync_group(mass)
493
494 leader = _make_mock_player("leader", provider_domain="sonos")
495 mass.players.get_player = _player_lookup({"leader": leader})
496 sgp.sync_leader = leader
497 sgp._attr_group_members = ["leader"]
498 sgp._attr_powered = True
499
500 await sgp.stop()
501
502 # leader was stopped via the internal handler
503 mass.players._handle_cmd_stop.assert_awaited_once_with("leader")
504 # but the group is still formed: sync_leader and powered are unchanged
505 assert sgp.sync_leader == leader
506 assert sgp._attr_powered is True
507
508
509class TestSetMembersDoesNotRegisterIncompatible:
510 """Regression test for: incompatible members must NOT be added to _attr_group_members."""
511
512 @pytest.mark.asyncio
513 async def test_incompatible_member_is_not_registered(self) -> None:
514 """A member that fails the can_group_with check must not be appended."""
515 mass = _make_mock_mass()
516 sgp = _make_sync_group(mass)
517
518 leader = _make_mock_player("leader", provider_domain="sonos")
519 # leader's can_group_with does NOT include the incompatible member
520 leader.state.can_group_with = {"leader"}
521 incompatible = _make_mock_player("incompatible", provider_domain="alien_protocol")
522
523 mass.players.get_player = _player_lookup({"leader": leader, "incompatible": incompatible})
524 sgp.sync_leader = leader
525 sgp._attr_group_members = ["leader"]
526
527 await sgp.set_members(player_ids_to_add=["incompatible"])
528
529 # incompatible must NOT linger in the internal member list
530 assert "incompatible" not in sgp._attr_group_members
531 # and the leader was never asked to add it (the call may still happen
532 # with empty lists since the member-changed path is taken, but the
533 # incompatible id must not appear in either add or remove)
534 for call in mass.players._handle_set_members.await_args_list:
535 assert "incompatible" not in (call.kwargs.get("player_ids_to_add") or [])
536 assert "incompatible" not in (call.kwargs.get("player_ids_to_remove") or [])
537
538 @pytest.mark.asyncio
539 async def test_compatible_member_is_registered_and_forwarded(self) -> None:
540 """A compatible member should be appended and forwarded to the leader."""
541 mass = _make_mock_mass()
542 sgp = _make_sync_group(mass)
543
544 leader = _make_mock_player("leader", provider_domain="sonos")
545 leader.state.can_group_with = {"compatible"}
546 compatible = _make_mock_player("compatible", provider_domain="sonos")
547
548 mass.players.get_player = _player_lookup({"leader": leader, "compatible": compatible})
549 sgp.sync_leader = leader
550 sgp._attr_group_members = ["leader"]
551
552 await sgp.set_members(player_ids_to_add=["compatible"])
553
554 assert "compatible" in sgp._attr_group_members
555 mass.players._handle_set_members.assert_awaited_once()
556 kwargs = mass.players._handle_set_members.await_args.kwargs
557 assert kwargs.get("player_ids_to_add") == ["compatible"]
558
559 @pytest.mark.asyncio
560 async def test_member_added_when_no_leader_yet(self) -> None:
561 """Adding to an empty/unformed group must register the member regardless."""
562 mass = _make_mock_mass()
563 sgp = _make_sync_group(mass)
564
565 member = _make_mock_player("member", provider_domain="sonos")
566 mass.players.get_player = _player_lookup({"member": member})
567
568 # no sync leader, empty group
569 sgp.sync_leader = None
570 sgp._attr_group_members = []
571
572 await sgp.set_members(player_ids_to_add=["member"])
573
574 # member is registered so a future _form_syncgroup can pick it as leader
575 assert "member" in sgp._attr_group_members
576 # but _handle_set_members on the leader is not called (no leader yet)
577 mass.players._handle_set_members.assert_not_awaited()
578
579
580class TestSetMembersRemovesExternalJoiner:
581 """Regression: a member joined to the leader outside MA must be removable."""
582
583 @pytest.mark.asyncio
584 async def test_external_joiner_removal_forwards_to_leader(self) -> None:
585 """
586 Removing an external joiner must forward straight to the sync leader.
587
588 A player grouped directly to the sync leader (e.g. via the Sonos app)
589 appears in group_members via the leader's live state but is not in the
590 group's tracked member list, so it must not be silently skipped.
591 """
592 mass = _make_mock_mass()
593 sgp = _make_sync_group(mass)
594
595 leader = _make_mock_player("leader", provider_domain="sonos")
596 # the leader's live native group includes an external joiner the group
597 # itself never registered
598 leader.state.group_members = ["leader", "external_x"]
599 external = _make_mock_player("external_x", provider_domain="sonos")
600
601 mass.players.get_player = _player_lookup({"leader": leader, "external_x": external})
602 sgp.sync_leader = leader
603 sgp._attr_group_members = ["leader"]
604
605 await sgp.set_members(player_ids_to_remove=["external_x"])
606
607 # the removal must be forwarded to the sync leader
608 mass.players._handle_set_members.assert_awaited_once()
609 call = mass.players._handle_set_members.await_args
610 assert call.args[0] is leader
611 assert call.kwargs.get("player_ids_to_remove") == ["external_x"]
612 # the group's tracked member list is untouched (external was never in it)
613 assert sgp._attr_group_members == ["leader"]
614
615 @pytest.mark.asyncio
616 async def test_tracked_member_removal_uses_managed_path(self) -> None:
617 """A member the group actually manages is removed via the managed path."""
618 mass = _make_mock_mass()
619 sgp = _make_sync_group(mass)
620
621 leader = _make_mock_player("leader", provider_domain="sonos")
622 leader.state.group_members = ["leader", "member_b"]
623 member_b = _make_mock_player("member_b", provider_domain="sonos")
624
625 mass.players.get_player = _player_lookup({"leader": leader, "member_b": member_b})
626 sgp.sync_leader = leader
627 sgp._attr_group_members = ["leader", "member_b"]
628
629 await sgp.set_members(player_ids_to_remove=["member_b"])
630
631 # tracked member is dropped from the internal list and forwarded
632 assert "member_b" not in sgp._attr_group_members
633 mass.players._handle_set_members.assert_awaited_once()
634 call = mass.players._handle_set_members.await_args
635 assert call.kwargs.get("player_ids_to_remove") == ["member_b"]
636
637
638def _make_sync_group_with_filters(
639 mass: MagicMock,
640 allowed_members: list[str] | None = None,
641) -> SyncGroupPlayer:
642 """Create a SyncGroupPlayer where the allowed_members filter can be configured."""
643 provider = MagicMock()
644 provider.domain = "sync_group"
645 provider.instance_id = "sync_group_test"
646 provider.name = "Sync Group"
647 provider.mass = mass
648
649 def _config_get_value(key: str, default: object = None) -> object:
650 if key == "dynamic_members":
651 return True
652 if key == "allowed_members":
653 return allowed_members or []
654 return default
655
656 mass.config.get_base_player_config.return_value = MagicMock(
657 name=None, default_name="Test Group", get_value=_config_get_value
658 )
659
660 sgp = SyncGroupPlayer(provider, "syncgroup_test")
661 sgp._cache.clear()
662 return sgp
663
664
665class TestPlayerFilters:
666 """Test the allowed_members filter semantics."""
667
668 def test_no_filter_allows_anything(self) -> None:
669 """Empty filter: any player is allowed."""
670 mass = _make_mock_mass()
671 sgp = _make_sync_group_with_filters(mass)
672 assert sgp._is_member_allowed("p1") is True
673
674 def test_allowed_members_restricts_to_listed(self) -> None:
675 """allowed_members populated: only listed IDs pass."""
676 mass = _make_mock_mass()
677 sgp = _make_sync_group_with_filters(mass, allowed_members=["p1"])
678 assert sgp._is_member_allowed("p1") is True
679 assert sgp._is_member_allowed("p2") is False
680
681 @pytest.mark.asyncio
682 async def test_set_members_rejects_filtered_player(self) -> None:
683 """set_members must skip a candidate that fails the filter."""
684 mass = _make_mock_mass()
685 sgp = _make_sync_group_with_filters(mass, allowed_members=["allowed"])
686
687 leader = _make_mock_player("leader", provider_domain="sonos")
688 leader.state.can_group_with = {"allowed", "blocked"}
689 allowed = _make_mock_player("allowed", provider_domain="sonos")
690 blocked = _make_mock_player("blocked", provider_domain="sonos")
691
692 mass.players.get_player = _player_lookup(
693 {"leader": leader, "allowed": allowed, "blocked": blocked}
694 )
695 sgp.sync_leader = leader
696 sgp._attr_group_members = ["leader"]
697
698 await sgp.set_members(player_ids_to_add=["allowed", "blocked"])
699
700 assert "allowed" in sgp._attr_group_members
701 assert "blocked" not in sgp._attr_group_members
702
703 def test_can_group_with_current_members_exempt_from_filter(self) -> None:
704 """
705 A current member not listed in allowed_members must still seed candidates.
706
707 Regression: filter must constrain JOINERS only, not gate the seed of
708 current members. Otherwise enabling a filter that doesn't list a current
709 member makes the candidate set empty (no joinable players show up).
710 """
711 mass = _make_mock_mass()
712 # current member is "leader", but the allow-list only lists "candidate".
713 sgp = _make_sync_group_with_filters(mass, allowed_members=["candidate"])
714
715 leader = _make_mock_player("leader", provider_domain="sonos")
716 leader.state.can_group_with = {"candidate", "outsider"}
717
718 candidate = _make_mock_player("candidate", provider_domain="sonos")
719 outsider = _make_mock_player("outsider", provider_domain="sonos")
720
721 mass.players.get_player = _player_lookup(
722 {"leader": leader, "candidate": candidate, "outsider": outsider}
723 )
724 sgp._attr_group_members = ["leader"]
725
726 result = sgp.can_group_with
727 # The current member (leader) is exempt and stays in the set.
728 assert "leader" in result
729 # An allowed candidate passes the filter.
730 assert "candidate" in result
731 # A player not in the allow-list is rejected.
732 assert "outsider" not in result
733
734
735class TestPresetMembersInDynamicGroup:
736 """In a dynamic group, configured members act as a preset, not as a lock."""
737
738 def _make_dynamic_group_with_preset(
739 self, mass: MagicMock, preset_members: list[str]
740 ) -> SyncGroupPlayer:
741 """Create a dynamic sync group whose CONF_GROUP_MEMBERS is the given preset."""
742 provider = MagicMock()
743 provider.domain = "sync_group"
744 provider.instance_id = "sync_group_test"
745 provider.name = "Sync Group"
746 provider.mass = mass
747
748 def _config_get_value(key: str, default: object = None) -> object:
749 if key == "dynamic_members":
750 return True
751 if key == "group_members":
752 return list(preset_members)
753 return default
754
755 mass.config.get_base_player_config.return_value = MagicMock(
756 name=None, default_name="Test Group", get_value=_config_get_value
757 )
758 sgp = SyncGroupPlayer(provider, "syncgroup_test")
759 sgp._cache.clear()
760 return sgp
761
762 @pytest.mark.asyncio
763 async def test_preset_member_can_be_unjoined(self) -> None:
764 """A preset member in a dynamic group can be unjoined for the session."""
765 mass = _make_mock_mass()
766 sgp = self._make_dynamic_group_with_preset(mass, ["leader", "preset_b"])
767 # Re-trigger on_config_updated so the new config is applied.
768 await sgp.on_config_updated()
769
770 leader = _make_mock_player("leader", provider_domain="sonos")
771 leader.state.can_group_with = {"preset_b"}
772 preset_b = _make_mock_player("preset_b", provider_domain="sonos")
773 mass.players.get_player = _player_lookup({"leader": leader, "preset_b": preset_b})
774 sgp.sync_leader = leader
775
776 await sgp.set_members(player_ids_to_remove=["preset_b"])
777
778 assert "preset_b" not in sgp._attr_group_members
779
780 @pytest.mark.asyncio
781 async def test_static_group_members_empty_in_dynamic_mode(self) -> None:
782 """In dynamic mode static_group_members is empty (frontend gating)."""
783 mass = _make_mock_mass()
784 sgp = self._make_dynamic_group_with_preset(mass, ["a", "b", "c"])
785 await sgp.on_config_updated()
786 assert sgp._attr_static_group_members == []
787 # but the configured members are seeded into group_members
788 assert set(sgp._attr_group_members) == {"a", "b", "c"}
789
790 @pytest.mark.asyncio
791 async def test_power_on_re_applies_preset_members(self) -> None:
792 """Powering on re-applies the configured preset, restoring any previously unjoined member."""
793 mass = _make_mock_mass()
794 sgp = self._make_dynamic_group_with_preset(mass, ["leader", "preset_b"])
795 await sgp.on_config_updated()
796
797 leader = _make_mock_player("leader", provider_domain="sonos")
798 preset_b = _make_mock_player("preset_b", provider_domain="sonos")
799 mass.players.get_player = _player_lookup({"leader": leader, "preset_b": preset_b})
800 sgp._attr_group_members = ["leader"] # preset_b was unjoined in a previous session
801
802 with patch.object(sgp, "update_state"):
803 await sgp.power(True)
804
805 assert "preset_b" in sgp._attr_group_members
806
807 @pytest.mark.asyncio
808 async def test_form_syncgroup_does_not_re_add_unjoined_preset(self) -> None:
809 """Mid-session unjoins must stick: _form_syncgroup must NOT re-add preset members."""
810 mass = _make_mock_mass()
811 sgp = self._make_dynamic_group_with_preset(mass, ["leader", "preset_b"])
812 await sgp.on_config_updated()
813
814 leader = _make_mock_player("leader", provider_domain="sonos")
815 leader.state.group_members = ["leader"]
816 preset_b = _make_mock_player("preset_b", provider_domain="sonos")
817 mass.players.get_player = _player_lookup({"leader": leader, "preset_b": preset_b})
818 sgp.sync_leader = leader
819 sgp._attr_group_members = ["leader"] # preset_b was unjoined during this session
820
821 with (
822 patch.object(sgp, "update_state"),
823 patch.object(sgp, "_translate_to_parent_ids", side_effect=lambda x: list(x)),
824 ):
825 await sgp._form_syncgroup()
826
827 assert "preset_b" not in sgp._attr_group_members
828
829 def test_preset_member_bypasses_allow_list_filter(self) -> None:
830 """
831 A preset member that was unjoined must still pass the allow-list filter.
832
833 Regression: after unjoining a preset member, the user must be able to
834 re-add it via the OSD even when allowed_members doesn't list it.
835 """
836 mass = _make_mock_mass()
837 provider = MagicMock()
838 provider.domain = "sync_group"
839 provider.instance_id = "sync_group_test"
840 provider.name = "Sync Group"
841 provider.mass = mass
842
843 def _config_get_value(key: str, default: object = None) -> object:
844 if key == "dynamic_members":
845 return True
846 if key == "group_members":
847 return ["preset_a", "preset_b"]
848 if key == "allowed_members":
849 return ["other"]
850 return default
851
852 mass.config.get_base_player_config.return_value = MagicMock(
853 name=None, default_name="Test Group", get_value=_config_get_value
854 )
855 sgp = SyncGroupPlayer(provider, "syncgroup_test")
856 sgp._cache.clear()
857
858 # preset members bypass the allow-list filter
859 assert sgp._is_member_allowed("preset_a") is True
860 assert sgp._is_member_allowed("preset_b") is True
861 # the explicit allow-list entry still passes
862 assert sgp._is_member_allowed("other") is True
863 # an unrelated player is rejected
864 assert sgp._is_member_allowed("outsider") is False
865
866 @pytest.mark.asyncio
867 async def test_can_group_with_falls_back_when_all_members_offline(self) -> None:
868 """
869 An offline preset member must not block adding other players.
870
871 Regression: with only offline members in the list, can_group_with
872 returned an empty set instead of offering compatible players, so
873 nothing could be added to the group until the member came back.
874 """
875 mass = _make_mock_mass()
876 sgp = self._make_dynamic_group_with_preset(mass, ["offline_member"])
877 await sgp.on_config_updated()
878
879 offline_member = _make_mock_player("offline_member", available=False)
880 candidate = _make_mock_player("candidate")
881 candidate.type = PlayerType.PLAYER
882 candidate.state.can_group_with = {"other"}
883 candidate.state.active_group = None
884 mass.players.get_player = _player_lookup(
885 {"offline_member": offline_member, "candidate": candidate}
886 )
887 mass.players.iter_players = MagicMock(return_value=[candidate])
888
889 assert "candidate" in sgp.can_group_with
890
891 @pytest.mark.asyncio
892 async def test_can_group_with_aggregates_from_available_members(self) -> None:
893 """With at least one available member, candidates come from the members only."""
894 mass = _make_mock_mass()
895 sgp = self._make_dynamic_group_with_preset(mass, ["offline_member", "online_member"])
896 await sgp.on_config_updated()
897
898 offline_member = _make_mock_player("offline_member", available=False)
899 online_member = _make_mock_player("online_member")
900 online_member.state.can_group_with = {"online_member", "friend"}
901 mass.players.get_player = _player_lookup(
902 {"offline_member": offline_member, "online_member": online_member}
903 )
904
905 result = sgp.can_group_with
906
907 assert {"online_member", "friend"} <= result
908 mass.players.iter_players.assert_not_called()
909
910
911class TestGetConfigEntriesMemberPicker:
912 """Test the member options offered in the group settings dropdown."""
913
914 @pytest.mark.asyncio
915 async def test_slaved_follower_is_still_offered(self) -> None:
916 """
917 A synced follower must stay selectable in the member dropdown.
918
919 Regression: a member removed from the config during active playback
920 remains slaved at the protocol level. Its can_group_with is then empty,
921 and since it is no longer in the saved ids it vanished from the
922 dropdown, making it impossible to re-add without stopping playback.
923 """
924 mass = _make_mock_mass()
925 leader = _make_mock_player("leader")
926 leader.type = PlayerType.PLAYER
927 leader.state.can_group_with = {"follower"}
928 # slaved follower: empty can_group_with while synced_to is set
929 follower = _make_mock_player("follower")
930 follower.type = PlayerType.PLAYER
931 follower.state.synced_to = "leader"
932 # idle player that cannot group with anything must NOT be offered
933 solo = _make_mock_player("solo")
934 solo.type = PlayerType.PLAYER
935 mass.players.all_players = MagicMock(return_value=[leader, follower, solo])
936 sgp = _make_sync_group(mass)
937
938 entries = await sgp.get_config_entries()
939 members_entry = next(x for x in entries if x.key == CONF_GROUP_MEMBERS)
940 option_ids = {option.value for option in members_entry.options}
941
942 assert option_ids == {"leader", "follower"}
943
944
945class TestMembersFilterMigration:
946 """Test the members_filter (exclusion) -> allowed_members (inclusion) migration."""
947
948 def _run_migrate(self, data: dict[str, Any]) -> dict[str, Any]:
949 """Run the migration block against a copy of the provided data dict."""
950 all_player_configs = data.get(CONF_PLAYERS, {})
951 if isinstance(all_player_configs, dict):
952 group_provider_domains = {"sync_group", "universal_group"}
953 universe = {
954 pid
955 for pid, cfg in all_player_configs.items()
956 if isinstance(cfg, dict) and cfg.get("provider") not in group_provider_domains
957 }
958 for player_cfg in all_player_configs.values():
959 if not isinstance(player_cfg, dict):
960 continue
961 if player_cfg.get("provider") != "sync_group":
962 continue
963 values = player_cfg.setdefault("values", {})
964 old_exclude = values.get("members_filter") or []
965 if not old_exclude or values.get("allowed_members") is not None:
966 continue
967 values["allowed_members"] = sorted(universe - set(old_exclude))
968 values["members_filter"] = []
969 return data
970
971 def test_inverts_exclusion_to_inclusion(self) -> None:
972 """An exclusion list of [X] becomes an inclusion list of universe - {X}."""
973 data = {
974 CONF_PLAYERS: {
975 "a": {"provider": "sonos", "player_id": "a"},
976 "b": {"provider": "airplay", "player_id": "b"},
977 "c": {"provider": "chromecast", "player_id": "c"},
978 "syncgroup_1": {
979 "provider": "sync_group",
980 "player_id": "syncgroup_1",
981 "values": {"members_filter": ["b"]},
982 },
983 }
984 }
985 out = self._run_migrate(data)
986 sg = out[CONF_PLAYERS]["syncgroup_1"]["values"]
987 assert sg["allowed_members"] == ["a", "c"]
988 assert sg["members_filter"] == []
989
990 def test_idempotent_when_already_migrated(self) -> None:
991 """Re-running migration on already-migrated config is a no-op."""
992 data = {
993 CONF_PLAYERS: {
994 "a": {"provider": "sonos", "player_id": "a"},
995 "syncgroup_1": {
996 "provider": "sync_group",
997 "player_id": "syncgroup_1",
998 "values": {"allowed_members": ["a"], "members_filter": []},
999 },
1000 }
1001 }
1002 out = self._run_migrate(data)
1003 sg = out[CONF_PLAYERS]["syncgroup_1"]["values"]
1004 assert sg["allowed_members"] == ["a"]
1005 assert sg["members_filter"] == []
1006
1007 def test_skips_when_no_exclusion_list(self) -> None:
1008 """A sync_group without a members_filter is left untouched."""
1009 data = {
1010 CONF_PLAYERS: {
1011 "syncgroup_1": {
1012 "provider": "sync_group",
1013 "player_id": "syncgroup_1",
1014 "values": {},
1015 },
1016 }
1017 }
1018 out = self._run_migrate(data)
1019 sg = out[CONF_PLAYERS]["syncgroup_1"]["values"]
1020 assert "allowed_members" not in sg
1021
1022 def test_excludes_group_providers_from_universe(self) -> None:
1023 """Group-providing players (sync_group/universal_group) are not in the universe."""
1024 data = {
1025 CONF_PLAYERS: {
1026 "a": {"provider": "sonos", "player_id": "a"},
1027 "ug": {"provider": "universal_group", "player_id": "ug"},
1028 "sg": {"provider": "sync_group", "player_id": "sg"},
1029 "syncgroup_1": {
1030 "provider": "sync_group",
1031 "player_id": "syncgroup_1",
1032 "values": {"members_filter": ["a"]},
1033 },
1034 }
1035 }
1036 out = self._run_migrate(data)
1037 sg = out[CONF_PLAYERS]["syncgroup_1"]["values"]
1038 # Universe = {a} (sg/ug/syncgroup_1 are group providers excluded).
1039 # Inversion removes a, so allowed_members is empty.
1040 assert sg["allowed_members"] == []
1041
1042
1043class TestIsActiveSession:
1044 """The is_active_session property gates whether children report active_group."""
1045
1046 def test_dormant_group_is_not_active(self) -> None:
1047 """A freshly-initialized group has no leader and no grace timer."""
1048 mass = _make_mock_mass()
1049 sgp = _make_sync_group(mass)
1050 assert sgp.is_active_session is False
1051
1052 def test_group_with_sync_leader_is_active(self) -> None:
1053 """A group with a sync leader is considered active even without playback."""
1054 mass = _make_mock_mass()
1055 sgp = _make_sync_group(mass)
1056 sgp.sync_leader = _make_mock_player("leader")
1057 assert sgp.is_active_session is True
1058
1059 def test_group_in_grace_window_is_active(self) -> None:
1060 """While the idle-grace task is pending, the group still claims captured members."""
1061 mass = _make_mock_mass()
1062 sgp = _make_sync_group(mass)
1063 # simulate a pending grace task without actually running asyncio
1064 sgp._idle_grace_task = MagicMock()
1065 assert sgp.is_active_session is True
1066
1067
1068class TestPowerlessLifecycle:
1069 """Form-on-play / deform-on-stop semantics when no power control is assigned."""
1070
1071 @pytest.mark.asyncio
1072 async def test_play_media_forms_group_when_dormant(self) -> None:
1073 """play_media on a dormant group should select a leader and forward to it."""
1074 mass = _make_mock_mass()
1075 sgp = _make_sync_group(mass)
1076 leader = _make_mock_player("leader", provider_domain="sonos")
1077 mass.players.get_player = _player_lookup({"leader": leader})
1078 sgp._attr_group_members = ["leader"]
1079 assert sgp.sync_leader is None
1080
1081 with patch.object(sgp, "update_state"):
1082 await sgp.play_media(MagicMock(source_id="src", uri="x"))
1083
1084 assert sgp.sync_leader == leader
1085 # mypy narrows sync_leader to None from the earlier assert; the
1086 # play_media call is what re-populates it, but mypy can't track that
1087 # mutation through the patch context â hence the ignore.
1088 mass.players._handle_play_media.assert_awaited_once() # type: ignore[unreachable]
1089
1090 @pytest.mark.asyncio
1091 async def test_stop_dissolves_group_when_powerless(self) -> None:
1092 """stop() on a group without power control should dissolve the session."""
1093 mass = _make_mock_mass()
1094 sgp = _make_sync_group(mass)
1095 leader = _make_mock_player("leader", provider_domain="sonos")
1096 mass.players.get_player = _player_lookup({"leader": leader})
1097 sgp.sync_leader = leader
1098 sgp._attr_group_members = ["leader"]
1099 # _attr_powered is None by default (no power control) so stop dissolves
1100 assert sgp._attr_powered is None
1101
1102 with patch.object(sgp, "update_state"):
1103 await sgp.stop()
1104
1105 # leader was stopped via the internal handler ...
1106 mass.players._handle_cmd_stop.assert_any_await("leader")
1107 # ... and the session was dissolved (sync_leader cleared)
1108 assert sgp.sync_leader is None
1109
1110 @pytest.mark.asyncio
1111 async def test_stop_preserves_group_when_pinned_with_fake_power(self) -> None:
1112 """stop() should NOT dissolve when the user has pinned the group on."""
1113 mass = _make_mock_mass()
1114 sgp = _make_sync_group(mass)
1115 leader = _make_mock_player("leader", provider_domain="sonos")
1116 mass.players.get_player = _player_lookup({"leader": leader})
1117 sgp.sync_leader = leader
1118 sgp._attr_group_members = ["leader"]
1119 sgp._attr_powered = True # user explicitly pinned via Fake control
1120
1121 await sgp.stop()
1122
1123 mass.players._handle_cmd_stop.assert_any_await("leader")
1124 # session stays intact
1125 assert sgp.sync_leader == leader
1126
1127
1128class TestIdleGraceTimer:
1129 """The idle-grace timer absorbs natural PLAYINGâIDLE transitions."""
1130
1131 def test_grace_scheduled_on_playing_to_idle(self) -> None:
1132 """_update_attributes schedules a grace task when the leader transitions to IDLE."""
1133 mass = _make_mock_mass()
1134 # create_task should return a sentinel we can inspect
1135 sentinel = MagicMock()
1136 mass.create_task = MagicMock(return_value=sentinel)
1137 sgp = _make_sync_group(mass)
1138 leader = _make_mock_player("leader", playback_state=PlaybackState.IDLE)
1139 sgp.sync_leader = leader
1140 # previous state was PLAYING
1141 sgp._attr_playback_state = PlaybackState.PLAYING
1142 sgp._attr_powered = None # no pin â debounce applies
1143
1144 sgp._update_attributes()
1145
1146 assert sgp._idle_grace_task is sentinel
1147 mass.create_task.assert_called_once()
1148
1149 def test_grace_not_scheduled_when_pinned(self) -> None:
1150 """No grace task when the user has pinned the group with Fake power."""
1151 mass = _make_mock_mass()
1152 mass.create_task = MagicMock()
1153 sgp = _make_sync_group(mass)
1154 leader = _make_mock_player("leader", playback_state=PlaybackState.IDLE)
1155 sgp.sync_leader = leader
1156 sgp._attr_playback_state = PlaybackState.PLAYING
1157 sgp._attr_powered = True # explicit pin
1158
1159 sgp._update_attributes()
1160
1161 assert sgp._idle_grace_task is None
1162 mass.create_task.assert_not_called()
1163
1164 def test_grace_cancelled_on_resume(self) -> None:
1165 """A pending grace task is cancelled when the leader resumes playback."""
1166 mass = _make_mock_mass()
1167 mass.create_task = MagicMock()
1168 sgp = _make_sync_group(mass)
1169 leader = _make_mock_player("leader", playback_state=PlaybackState.PLAYING)
1170 sgp.sync_leader = leader
1171 # pretend a grace task is pending
1172 prior_task = MagicMock()
1173 prior_task.done.return_value = False
1174 sgp._idle_grace_task = prior_task
1175 sgp._attr_playback_state = PlaybackState.IDLE
1176
1177 sgp._update_attributes()
1178
1179 prior_task.cancel.assert_called_once()
1180 assert sgp._idle_grace_task is None
1181
1182 def test_grace_cancelled_on_explicit_stop(self) -> None:
1183 """An explicit stop cancels the pending grace task immediately."""
1184 mass = _make_mock_mass()
1185 sgp = _make_sync_group(mass)
1186 leader = _make_mock_player("leader")
1187 mass.players.get_player = _player_lookup({"leader": leader})
1188 sgp.sync_leader = leader
1189 prior_task = MagicMock()
1190 prior_task.done.return_value = False
1191 sgp._idle_grace_task = prior_task
1192
1193 async def _run() -> None:
1194 with patch.object(sgp, "update_state"):
1195 await sgp.stop()
1196
1197 import asyncio # noqa: PLC0415
1198
1199 asyncio.run(_run())
1200
1201 prior_task.cancel.assert_called_once()
1202 assert sgp._idle_grace_task is None
1203
1204
1205class TestFormWaitsForLeaderUnsynced:
1206 """The form path must wait for a stale leader to report synced_to=None."""
1207
1208 @pytest.mark.asyncio
1209 async def test_form_waits_when_leader_still_synced(self) -> None:
1210 """If the new leader still reports synced_to, we wait before proceeding."""
1211 mass = _make_mock_mass()
1212 sgp = _make_sync_group(mass)
1213 leader = _make_mock_player("leader", provider_domain="sonos")
1214 # leader reports stale synced_to
1215 leader.state.synced_to = "old_leader"
1216 # but make _wait_member_unsynced succeed (clears state) so the form proceeds
1217 mass.players.get_player = _player_lookup({"leader": leader})
1218 sgp._attr_group_members = ["leader"]
1219
1220 async def fake_wait(
1221 _member_id: str,
1222 _timeout: float = 5.0,
1223 ) -> bool:
1224 leader.state.synced_to = None # simulate provider catching up
1225 return True
1226
1227 with (
1228 patch.object(sgp, "update_state"),
1229 patch.object(sgp, "_wait_member_unsynced", side_effect=fake_wait) as wait_mock,
1230 ):
1231 await sgp._form_syncgroup()
1232
1233 wait_mock.assert_awaited_once_with("leader")
1234 assert sgp.sync_leader == leader
1235
1236 @pytest.mark.asyncio
1237 async def test_form_aborts_when_leader_stuck(self) -> None:
1238 """If the wait returns False (leader genuinely stuck), abort the form."""
1239 mass = _make_mock_mass()
1240 sgp = _make_sync_group(mass)
1241 leader = _make_mock_player("leader", provider_domain="sonos")
1242 leader.state.synced_to = "old_leader"
1243 mass.players.get_player = _player_lookup({"leader": leader})
1244 sgp._attr_group_members = ["leader"]
1245
1246 with (
1247 patch.object(sgp, "update_state"),
1248 patch.object(sgp, "_wait_member_unsynced", return_value=False),
1249 ):
1250 await sgp._form_syncgroup()
1251
1252 # form must NOT have left a sync_leader set, so the caller's play_media
1253 # won't be issued against a stuck player (the original Poolhouse race).
1254 assert sgp.sync_leader is None
1255
1256 @pytest.mark.asyncio
1257 async def test_form_aborts_when_leader_cleared_during_wait(self) -> None:
1258 """If a concurrent dissolve clears sync_leader during the wait, abort the stale form."""
1259 mass = _make_mock_mass()
1260 sgp = _make_sync_group(mass)
1261 leader = _make_mock_player("leader", provider_domain="sonos")
1262 leader.state.synced_to = "old_leader"
1263 member = _make_mock_player("m2", provider_domain="sonos")
1264 mass.players.get_player = _player_lookup({"leader": leader, "m2": member})
1265 sgp._attr_group_members = ["leader", "m2"]
1266
1267 async def fake_wait(_member_id: str, _timeout: float = 5.0) -> bool:
1268 leader.state.synced_to = None
1269 sgp.sync_leader = None # simulate a concurrent dissolve while waiting
1270 return True
1271
1272 with (
1273 patch.object(sgp, "update_state"),
1274 patch.object(sgp, "_wait_member_unsynced", side_effect=fake_wait),
1275 ):
1276 await sgp._form_syncgroup()
1277
1278 # the stale form attempt must not (re)sync any members
1279 mass.players._handle_set_members.assert_not_awaited()
1280 assert sgp.sync_leader is None
1281
1282
1283class TestWaitMemberUnsynced:
1284 """The helper that waits for a member's synced_to to clear (with recovery)."""
1285
1286 @pytest.mark.asyncio
1287 async def test_returns_true_when_already_unsynced(self) -> None:
1288 """Member already reports synced_to=None â return True without recovery."""
1289 mass = _make_mock_mass()
1290 sgp = _make_sync_group(mass)
1291 member = _make_mock_player("m1")
1292 member.synced_to = None
1293 mass.players.get_player = _player_lookup({"m1": member})
1294 mass.players.cmd_ungroup = AsyncMock()
1295
1296 ok = await sgp._wait_member_unsynced("m1")
1297
1298 assert ok is True
1299 mass.players.cmd_ungroup.assert_not_awaited()
1300
1301 @pytest.mark.asyncio
1302 async def test_attempts_recovery_when_stuck(self) -> None:
1303 """If the first wait doesn't clear it, kick the member from its stale parent directly."""
1304 mass = _make_mock_mass()
1305 sgp = _make_sync_group(mass)
1306 member = _make_mock_player("m1")
1307 member.synced_to = "old_leader" # still stale after first wait
1308 old_leader = _make_mock_player("old_leader")
1309 mass.players.get_player = _player_lookup({"m1": member, "old_leader": old_leader})
1310
1311 # _handle_set_members on the stale parent is the expected recovery action.
1312 # We make it succeed by side-effect-clearing synced_to.
1313 async def _kick(_parent: Any, player_ids_to_remove: list[str]) -> None:
1314 assert player_ids_to_remove == ["m1"]
1315 member.synced_to = None
1316
1317 mass.players._handle_set_members = AsyncMock(side_effect=_kick)
1318 mass.players.cmd_ungroup = AsyncMock()
1319
1320 ok = await sgp._wait_member_unsynced("m1")
1321
1322 assert ok is True
1323 mass.players._handle_set_members.assert_awaited_once_with(
1324 old_leader, player_ids_to_remove=["m1"]
1325 )
1326 mass.players.cmd_ungroup.assert_not_awaited()
1327
1328 @pytest.mark.asyncio
1329 async def test_returns_false_when_genuinely_stuck(self) -> None:
1330 """If recovery doesn't help either, return False so callers can abort."""
1331 mass = _make_mock_mass()
1332 sgp = _make_sync_group(mass)
1333 member = _make_mock_player("m1")
1334 member.synced_to = "old_leader" # stays stuck even after recovery
1335 old_leader = _make_mock_player("old_leader")
1336 mass.players.get_player = _player_lookup({"m1": member, "old_leader": old_leader})
1337 mass.players._handle_set_members = AsyncMock() # no-op: state stays stale
1338
1339 ok = await sgp._wait_member_unsynced("m1")
1340
1341 assert ok is False
1342 mass.players._handle_set_members.assert_awaited_once_with(
1343 old_leader, player_ids_to_remove=["m1"]
1344 )
1345
1346 @pytest.mark.asyncio
1347 async def test_returns_false_when_stale_parent_gone(self) -> None:
1348 """If the stale parent no longer exists, skip the kick and report stuck."""
1349 mass = _make_mock_mass()
1350 sgp = _make_sync_group(mass)
1351 member = _make_mock_player("m1")
1352 member.synced_to = "old_leader" # parent is no longer registered
1353 mass.players.get_player = _player_lookup({"m1": member})
1354
1355 ok = await sgp._wait_member_unsynced("m1")
1356
1357 assert ok is False
1358 mass.players._handle_set_members.assert_not_awaited()
1359
1360
1361class TestSupportedFeaturesPower:
1362 """POWER feature is only advertised when the user opts in via Fake control."""
1363
1364 def test_power_not_in_base_features_by_default(self) -> None:
1365 """No power_control config â POWER feature not advertised."""
1366 mass = _make_mock_mass()
1367 # default config: no CONF_POWER_CONTROL
1368 mass.config.get_raw_player_config_value = MagicMock(return_value=None)
1369 sgp = _make_sync_group(mass)
1370 assert PlayerFeature.POWER not in sgp.supported_features
1371
1372 def test_power_advertised_when_fake_control_assigned(self) -> None:
1373 """User assigns Fake power control â POWER feature shows up."""
1374 mass = _make_mock_mass()
1375
1376 # CONF_POWER_CONTROL is the only key we care about; for everything
1377 # else return whatever the default config helper returns.
1378 def _get_raw(_player_id: str, key: str, default: object = None) -> object:
1379 if key == "power_control":
1380 return "fake"
1381 return default
1382
1383 mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
1384 sgp = _make_sync_group(mass)
1385 assert PlayerFeature.POWER in sgp.supported_features
1386
1387
1388def _recording_wait(order: list[str]) -> MagicMock:
1389 """
1390 Build a wait_for_player_update replacement that records call/enter/exit order.
1391
1392 The returned mock records ``wait_for:<player_id>:<value>`` when invoked,
1393 ``subscribe`` on context enter and ``await`` on context exit, so a test can
1394 assert that the playback start was wrapped (subscribe before the command is
1395 issued) rather than awaited after the fact.
1396 """
1397
1398 class _RecordingWait:
1399 async def __aenter__(self) -> None:
1400 order.append("subscribe")
1401
1402 async def __aexit__(self, *_exc: object) -> bool:
1403 order.append("await")
1404 return False
1405
1406 def _wait(player_id: str, **kwargs: Any) -> _RecordingWait:
1407 order.append(f"wait_for:{player_id}:{kwargs.get('attribute_value')}")
1408 return _RecordingWait()
1409
1410 return MagicMock(side_effect=_wait)
1411
1412
1413class TestLeaderPlaybackAwaited:
1414 """A (re)form must not return until the leader confirms it has started playing."""
1415
1416 @pytest.mark.asyncio
1417 async def test_play_waits_for_leader_before_returning(self) -> None:
1418 """play() wraps the resume in a wait so the group lock isn't released early."""
1419 mass = _make_mock_mass()
1420 sgp = _make_sync_group(mass)
1421 leader = _make_mock_player("leader", playback_state=PlaybackState.IDLE)
1422 mass.players.get_player = _player_lookup({"leader": leader})
1423 sgp.sync_leader = leader
1424 sgp._attr_group_members = ["leader"]
1425
1426 order: list[str] = []
1427 mass.players.wait_for_player_update = _recording_wait(order)
1428 mass.players.cmd_resume = AsyncMock(side_effect=lambda *_a, **_k: order.append("resume"))
1429
1430 with patch.object(sgp, "_form_syncgroup", new=AsyncMock()):
1431 await sgp.play()
1432
1433 # subscribe happens before the resume command, and the wait completes
1434 # after â i.e. the start is wrapped, not fire-and-forget.
1435 assert order == [
1436 f"wait_for:leader:{PlaybackState.PLAYING}",
1437 "subscribe",
1438 "resume",
1439 "await",
1440 ]
1441
1442 @pytest.mark.asyncio
1443 async def test_play_media_waits_for_leader_before_returning(self) -> None:
1444 """play_media() wraps the leader start so a concurrent (un)group can't race it."""
1445 mass = _make_mock_mass()
1446 sgp = _make_sync_group(mass)
1447 leader = _make_mock_player("leader", playback_state=PlaybackState.IDLE)
1448 mass.players.get_player = _player_lookup({"leader": leader})
1449 sgp.sync_leader = leader
1450 sgp._attr_group_members = ["leader"]
1451
1452 order: list[str] = []
1453 mass.players.wait_for_player_update = _recording_wait(order)
1454 mass.players._handle_play_media = AsyncMock(
1455 side_effect=lambda *_a, **_k: order.append("play_media")
1456 )
1457
1458 media = MagicMock()
1459 media.source_id = "syncgroup_test"
1460 with patch.object(sgp, "_form_syncgroup", new=AsyncMock()):
1461 await sgp.play_media(media)
1462
1463 assert order == [
1464 f"wait_for:leader:{PlaybackState.PLAYING}",
1465 "subscribe",
1466 "play_media",
1467 "await",
1468 ]
1469
1470 @pytest.mark.asyncio
1471 async def test_no_wait_when_group_has_no_leader(self) -> None:
1472 """With no leader to wait on, play() resumes without arming a playback wait."""
1473 mass = _make_mock_mass()
1474 sgp = _make_sync_group(mass)
1475 sgp.sync_leader = None
1476
1477 mass.players.cmd_resume = AsyncMock()
1478
1479 with patch.object(sgp, "_form_syncgroup", new=AsyncMock()):
1480 await sgp.play()
1481
1482 mass.players.wait_for_player_update.assert_not_called()
1483 mass.players.cmd_resume.assert_awaited_once()
1484
1485
1486class TestPlaybackStartMarker:
1487 """A just-issued playback start counts as playing for group-command decisions."""
1488
1489 def _setup_group(self, mass: MagicMock, group_state: PlaybackState) -> SyncGroupPlayer:
1490 sgp = _make_sync_group(mass)
1491 # a provider without dynamic leader switching, so leader removal takes the
1492 # dissolve+reform path
1493 leader = _make_mock_player("leader", provider_domain="wiim")
1494 other = _make_mock_player("other", provider_domain="wiim")
1495 mass.players.get_player = _player_lookup({"leader": leader, "other": other})
1496 sgp.sync_leader = leader
1497 sgp._attr_group_members = ["leader", "other"]
1498 sgp._attr_playback_state = group_state
1499 # close scheduled background coroutines (idle-grace) instead of leaking them
1500 mass.create_task = MagicMock(side_effect=lambda coro, *_a, **_k: coro.close())
1501 return sgp
1502
1503 @pytest.mark.asyncio
1504 async def test_leader_removal_during_startup_window_resumes(self) -> None:
1505 """
1506 Leader removal right after a start command must still resume.
1507
1508 Devices report transient states (a false PLAYING, bounces through IDLE)
1509 while a group session starts, so the group state alone misreads the
1510 startup window as idle and would skip the post-reform resume.
1511 """
1512 mass = _make_mock_mass()
1513 sgp = self._setup_group(mass, group_state=PlaybackState.IDLE)
1514 sgp._playback_start_at = time.monotonic()
1515
1516 with (
1517 patch.object(sgp, "_dissolve_and_reform", new=AsyncMock()) as reform,
1518 patch.object(sgp, "_dynamic_leader_switch", new=AsyncMock()) as dyn_switch,
1519 ):
1520 await sgp.set_members(player_ids_to_remove=["leader"])
1521
1522 dyn_switch.assert_not_awaited()
1523 reform.assert_awaited_once()
1524 assert reform.await_args is not None
1525 assert reform.await_args.kwargs.get("resume_playback") is True
1526
1527 @pytest.mark.asyncio
1528 async def test_paused_group_never_resumes_despite_recent_start(self) -> None:
1529 """PAUSED is deliberate user intent and always wins over the startup marker."""
1530 mass = _make_mock_mass()
1531 sgp = self._setup_group(mass, group_state=PlaybackState.PAUSED)
1532 sgp._playback_start_at = time.monotonic()
1533
1534 with (
1535 patch.object(sgp, "_dissolve_and_reform", new=AsyncMock()) as reform,
1536 patch.object(sgp, "_dynamic_leader_switch", new=AsyncMock()),
1537 ):
1538 await sgp.set_members(player_ids_to_remove=["leader"])
1539
1540 reform.assert_awaited_once()
1541 assert reform.await_args is not None
1542 assert reform.await_args.kwargs.get("resume_playback") is False
1543
1544 @pytest.mark.asyncio
1545 async def test_await_leader_playback_stamps_the_marker(self) -> None:
1546 """The playback wait stamps the start so decision logic can see the window."""
1547 mass = _make_mock_mass()
1548 sgp = _make_sync_group(mass)
1549 sgp.sync_leader = _make_mock_player("leader")
1550 assert sgp._playback_recently_started is False
1551
1552 async with sgp._await_leader_playback():
1553 pass
1554
1555 assert sgp._playback_recently_started is True
1556
1557 @pytest.mark.asyncio
1558 async def test_stop_voids_the_marker(self) -> None:
1559 """An explicit stop clears the marker so a later unjoin won't resume."""
1560 mass = _make_mock_mass()
1561 sgp = _make_sync_group(mass)
1562 leader = _make_mock_player("leader")
1563 mass.players.get_player = _player_lookup({"leader": leader})
1564 sgp.sync_leader = leader
1565 sgp._playback_start_at = time.monotonic()
1566
1567 with patch.object(sgp, "update_state"):
1568 await sgp.stop()
1569
1570 assert sgp._playback_recently_started is False
1571
1572
1573class TestDebouncedReform:
1574 """Leader removal re-forms debounced so cascaded unjoins coalesce."""
1575
1576 def _setup_group(self, mass: MagicMock, members: list[str]) -> SyncGroupPlayer:
1577 sgp = _make_sync_group(mass)
1578 players = {
1579 member_id: _make_mock_player(member_id, provider_domain="wiim") for member_id in members
1580 }
1581 mass.players.get_player = _player_lookup(players)
1582 sgp.sync_leader = players[members[0]]
1583 sgp._attr_group_members = list(members)
1584 sgp._attr_playback_state = PlaybackState.PLAYING
1585 # run scheduled coroutines for real so the debounced re-form actually fires;
1586 # eager_start mirrors mass.create_task so the runner enters its sleep at
1587 # schedule time (a later cancel then always lands inside the body)
1588 mass.create_task = MagicMock(
1589 side_effect=lambda coro, *_a, **_k: asyncio.Task(
1590 coro, loop=asyncio.get_running_loop(), eager_start=True
1591 )
1592 )
1593 return sgp
1594
1595 @pytest.mark.asyncio
1596 async def test_leader_removal_schedules_debounced_reform(self) -> None:
1597 """The stop/dissolve happens immediately; the re-form (with resume) is debounced."""
1598 mass = _make_mock_mass()
1599 sgp = self._setup_group(mass, members=["leader", "m2"])
1600
1601 with (
1602 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1603 patch.object(sgp, "update_state"),
1604 patch.object(sgp, "play", new=AsyncMock()) as play,
1605 ):
1606 await sgp.set_members(player_ids_to_remove=["leader"])
1607
1608 # immediate half: leader stopped, group dissolved, re-form pending
1609 mass.players._handle_cmd_stop.assert_any_await("leader")
1610 assert sgp.sync_leader is None
1611 task = sgp._reform_task
1612 assert task is not None
1613 play.assert_not_awaited()
1614 # the group keeps claiming its members while the re-form is pending
1615 assert sgp.is_active_session is True
1616
1617 await task
1618
1619 play.assert_awaited_once()
1620 assert sgp._reform_task is None
1621
1622 @pytest.mark.asyncio
1623 async def test_cascaded_unjoins_coalesce_into_single_reform(self) -> None:
1624 """A second unjoin within the window re-arms it: one re-form, final member list."""
1625 mass = _make_mock_mass()
1626 sgp = self._setup_group(mass, members=["leader", "m2", "m3"])
1627
1628 with (
1629 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1630 patch.object(sgp, "update_state"),
1631 patch.object(sgp, "play", new=AsyncMock()) as play,
1632 ):
1633 await sgp.set_members(player_ids_to_remove=["leader"])
1634 first_task = sgp._reform_task
1635 assert first_task is not None
1636
1637 # second unjoin lands inside the debounce window
1638 await sgp.set_members(player_ids_to_remove=["m2"])
1639 second_task = sgp._reform_task
1640 assert second_task is not None
1641 assert second_task is not first_task
1642
1643 await asyncio.gather(first_task, second_task)
1644
1645 play.assert_awaited_once()
1646 assert sgp._attr_group_members == ["m3"]
1647
1648 @pytest.mark.asyncio
1649 async def test_explicit_stop_cancels_pending_reform(self) -> None:
1650 """An explicit stop during the window means silence â no resume may follow."""
1651 mass = _make_mock_mass()
1652 sgp = self._setup_group(mass, members=["leader", "m2"])
1653
1654 with (
1655 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1656 patch.object(sgp, "update_state"),
1657 patch.object(sgp, "play", new=AsyncMock()) as play,
1658 ):
1659 await sgp.set_members(player_ids_to_remove=["leader"])
1660 task = sgp._reform_task
1661 assert task is not None
1662
1663 await sgp.stop()
1664 assert sgp._reform_task is None
1665 await task
1666
1667 play.assert_not_awaited()
1668 assert sgp.is_active_session is False
1669
1670 @pytest.mark.asyncio
1671 async def test_reform_aborts_when_all_members_left(self) -> None:
1672 """When the window drains the whole group, the re-form fires as a no-op."""
1673 mass = _make_mock_mass()
1674 sgp = self._setup_group(mass, members=["leader", "m2"])
1675
1676 with (
1677 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1678 patch.object(sgp, "update_state"),
1679 patch.object(sgp, "play", new=AsyncMock()) as play,
1680 ):
1681 await sgp.set_members(player_ids_to_remove=["leader"])
1682 await sgp.set_members(player_ids_to_remove=["m2"])
1683 task = sgp._reform_task
1684 assert task is not None
1685
1686 await task
1687
1688 play.assert_not_awaited()
1689 assert sgp._reform_task is None
1690 assert sgp.is_active_session is False
1691
1692 @pytest.mark.asyncio
1693 async def test_explicit_play_supersedes_pending_reform(self) -> None:
1694 """A user play during the window forms right away; the pending re-form is dropped."""
1695 mass = _make_mock_mass()
1696 sgp = self._setup_group(mass, members=["leader", "m2"])
1697 mass.players.cmd_resume = AsyncMock()
1698
1699 with (
1700 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1701 patch.object(sgp, "update_state"),
1702 ):
1703 await sgp.set_members(player_ids_to_remove=["leader"])
1704 task = sgp._reform_task
1705 assert task is not None
1706
1707 await sgp.play()
1708 assert sgp._reform_task is None
1709 await task
1710
1711 mass.players.cmd_resume.assert_awaited_once()
1712 assert sgp.sync_leader is not None
1713 assert sgp.sync_leader.player_id == "m2"
1714
1715 @pytest.mark.asyncio
1716 async def test_reform_survives_cancel_from_its_own_form(self) -> None:
1717 """
1718 The firing re-form must not cancel itself.
1719
1720 The runner resumes via play() -> _form_syncgroup, which cancels any pending
1721 re-form timer â including, without the identity guard, the very task that
1722 is executing it.
1723 """
1724 mass = _make_mock_mass()
1725 sgp = self._setup_group(mass, members=["leader", "m2"])
1726 mass.players.cmd_resume = AsyncMock()
1727
1728 with (
1729 patch("music_assistant.providers.sync_group.player.REFORM_DEBOUNCE_SECONDS", 0.05),
1730 patch.object(sgp, "update_state"),
1731 ):
1732 await sgp.set_members(player_ids_to_remove=["leader"])
1733 task = sgp._reform_task
1734 assert task is not None
1735
1736 await task
1737
1738 mass.players.cmd_resume.assert_awaited_once()
1739 assert sgp.sync_leader is not None
1740 assert sgp.sync_leader.player_id == "m2"
1741 assert sgp._reform_task is None
1742
1743
1744class TestReformResumeGate:
1745 """Removing the sync leader resumes playback if (and only if) the group was playing."""
1746
1747 def _setup_group(self, mass: MagicMock, group_state: PlaybackState) -> SyncGroupPlayer:
1748 sgp = _make_sync_group(mass)
1749 # a provider without dynamic leader switching, so leader removal takes the
1750 # dissolve+reform path
1751 leader = _make_mock_player("leader", provider_domain="wiim")
1752 other = _make_mock_player("other", provider_domain="wiim")
1753 mass.players.get_player = _player_lookup({"leader": leader, "other": other})
1754 sgp.sync_leader = leader
1755 sgp._attr_group_members = ["leader", "other"]
1756 sgp._attr_playback_state = group_state
1757 # close scheduled background coroutines (idle-grace) instead of leaking them
1758 mass.create_task = MagicMock(side_effect=lambda coro, *_a, **_k: coro.close())
1759 return sgp
1760
1761 @pytest.mark.asyncio
1762 async def test_leader_removal_resumes_when_playing(self) -> None:
1763 """Leader removal on a playing group must reform with a resume."""
1764 mass = _make_mock_mass()
1765 sgp = self._setup_group(mass, group_state=PlaybackState.PLAYING)
1766
1767 with (
1768 patch.object(sgp, "_dissolve_and_reform", new=AsyncMock()) as reform,
1769 patch.object(sgp, "_dynamic_leader_switch", new=AsyncMock()) as dyn_switch,
1770 patch.object(sgp, "_active_session_player", return_value=None),
1771 ):
1772 await sgp.set_members(player_ids_to_remove=["leader"])
1773
1774 dyn_switch.assert_not_awaited()
1775 reform.assert_awaited_once()
1776 assert reform.await_args is not None
1777 assert reform.await_args.kwargs.get("resume_playback") is True
1778
1779 @pytest.mark.asyncio
1780 async def test_leader_removal_does_not_resume_when_idle(self) -> None:
1781 """A genuinely idle group must not spuriously start playing on leader removal."""
1782 mass = _make_mock_mass()
1783 sgp = self._setup_group(mass, group_state=PlaybackState.IDLE)
1784
1785 with (
1786 patch.object(sgp, "_dissolve_and_reform", new=AsyncMock()) as reform,
1787 patch.object(sgp, "_dynamic_leader_switch", new=AsyncMock()),
1788 patch.object(sgp, "_active_session_player", return_value=None),
1789 ):
1790 await sgp.set_members(player_ids_to_remove=["leader"])
1791
1792 reform.assert_awaited_once()
1793 assert reform.await_args is not None
1794 assert reform.await_args.kwargs.get("resume_playback") is False
1795