/
/
1"""
2Tests for player grouping logic (independent of protocols).
3
4This module tests the core grouping behavior including:
5- can_group_with filtering logic
6- Group member inclusion/exclusion
7- Sync leader behavior
8- Group state transitions
9- Cache invalidation
10"""
11
12from __future__ import annotations
13
14from unittest.mock import AsyncMock, MagicMock
15
16import pytest
17from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
18
19from music_assistant.controllers.players import PlayerController
20from music_assistant.models.player import LinkedOutputProtocol
21from tests.common import MockPlayer, MockProvider
22
23
24@pytest.fixture
25def mock_mass() -> MagicMock:
26 """Create a mock MusicAssistant instance."""
27 mass = MagicMock()
28 mass.closing = False
29 mass.config = MagicMock()
30 mass.config.get = MagicMock(return_value=[])
31
32 def _get_raw_player_config_value(
33 _player_id: str, key: str, default: str | int | None = None
34 ) -> str | int | None:
35 """Return appropriate defaults for player config values."""
36 if key == "min_volume":
37 return 0
38 if key == "max_volume":
39 return 100
40 return default
41
42 mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
43 # Return "GLOBAL" for log level config (standard default)
44 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
45 mass.config.set = MagicMock()
46 mass.signal_event = MagicMock()
47 mass.get_providers = MagicMock(return_value=[])
48 return mass
49
50
51@pytest.fixture
52def controller(mock_mass: MagicMock) -> PlayerController:
53 """Create a PlayerController instance."""
54 return PlayerController(mock_mass)
55
56
57class TestCanGroupWithBasics:
58 """Test basic can_group_with filtering logic."""
59
60 def test_ungrouped_players_can_group(self, mock_mass: MagicMock) -> None:
61 """Test that two ungrouped players can group with each other."""
62 controller = PlayerController(mock_mass)
63 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
64
65 player_a = MockPlayer(provider, "player_a", "Player A")
66 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
67 # Use explicit player IDs instead of provider instance ID for simpler test
68 player_a._attr_can_group_with = {"player_b"}
69
70 player_b = MockPlayer(provider, "player_b", "Player B")
71 player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
72 player_b._attr_can_group_with = {"player_a"}
73
74 controller._players = {"player_a": player_a, "player_b": player_b}
75 mock_mass.players = controller
76
77 # Trigger state calculation
78 player_a.update_state(signal_event=False)
79 player_b.update_state(signal_event=False)
80
81 # Both players should be able to group with each other
82 assert "player_b" in player_a.state.can_group_with
83 assert "player_a" in player_b.state.can_group_with
84
85 def test_unavailable_players_excluded(self, mock_mass: MagicMock) -> None:
86 """Test that unavailable players are excluded from can_group_with."""
87 controller = PlayerController(mock_mass)
88 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
89
90 player_a = MockPlayer(provider, "player_a", "Player A")
91 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
92 player_a._attr_can_group_with = {"player_b"}
93
94 player_b = MockPlayer(provider, "player_b", "Player B")
95 player_b._attr_available = False # Mark as unavailable
96
97 controller._players = {"player_a": player_a, "player_b": player_b}
98 mock_mass.players = controller
99
100 # Trigger state calculation
101 player_a.update_state(signal_event=False)
102 player_b.update_state(signal_event=False)
103
104 # Unavailable player should be excluded
105 assert "player_b" not in player_a.state.can_group_with
106
107 def test_playing_players_with_different_source_excluded(self, mock_mass: MagicMock) -> None:
108 """
109 Test that players playing different sources are NOT excluded (behavior changed).
110
111 Note: Previously, players with different active sources were excluded from grouping,
112 but this was removed as it was difficult to track reliably.
113 """
114 controller = PlayerController(mock_mass)
115 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
116
117 player_a = MockPlayer(provider, "player_a", "Player A")
118 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
119 player_a._attr_can_group_with = {"player_b"}
120 player_a._attr_playback_state = PlaybackState.PLAYING
121 player_a._attr_active_source = "player_a"
122
123 player_b = MockPlayer(provider, "player_b", "Player B")
124 player_b._attr_playback_state = PlaybackState.PLAYING
125 player_b._attr_active_source = "player_b" # Different source
126
127 controller._players = {"player_a": player_a, "player_b": player_b}
128 mock_mass.players = controller
129
130 # Trigger state calculation
131 player_a.update_state(signal_event=False)
132 player_b.update_state(signal_event=False)
133
134 # Player with different active source is now ALLOWED (behavior changed)
135 assert "player_b" in player_a.state.can_group_with
136
137
138class TestSyncedPlayers:
139 """Test behavior with synced/grouped players."""
140
141 def test_sync_leader_excludes_itself_from_members_can_group_with(
142 self, mock_mass: MagicMock
143 ) -> None:
144 """Test that sync leader doesn't appear in its members' can_group_with."""
145 controller = PlayerController(mock_mass)
146 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
147
148 leader = MockPlayer(provider, "leader", "Leader")
149 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
150 leader._attr_can_group_with = {"member"}
151 leader._attr_group_members = ["leader", "member"]
152
153 member = MockPlayer(provider, "member", "Member")
154
155 controller._players = {"leader": leader, "member": member}
156 mock_mass.players = controller
157
158 # Trigger synced_to calculation
159 leader.update_state(signal_event=False)
160 member.update_state(signal_event=False)
161
162 # Member is synced, so can_group_with should be empty
163 assert member.state.can_group_with == set()
164
165 def test_group_members_included_in_leader_can_group_with(self, mock_mass: MagicMock) -> None:
166 """
167 Test that group members appear in sync leader's can_group_with.
168
169 This allows ungrouping members from the leader.
170 """
171 controller = PlayerController(mock_mass)
172 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
173
174 leader = MockPlayer(provider, "leader", "Leader")
175 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
176 leader._attr_can_group_with = {"member_a", "member_b"}
177 leader._attr_group_members = ["leader", "member_a", "member_b"]
178
179 member_a = MockPlayer(provider, "member_a", "Member A")
180 member_b = MockPlayer(provider, "member_b", "Member B")
181
182 controller._players = {
183 "leader": leader,
184 "member_a": member_a,
185 "member_b": member_b,
186 }
187 mock_mass.players = controller
188
189 # Trigger synced_to calculation
190 leader.update_state(signal_event=False)
191 member_a.update_state(signal_event=False)
192 member_b.update_state(signal_event=False)
193
194 # Leader should be able to see its own members (for ungrouping)
195 assert "member_a" in leader.state.can_group_with
196 assert "member_b" in leader.state.can_group_with
197
198
199class TestSyncLeaderBehavior:
200 """Test sync leader specific behavior."""
201
202 def test_sync_leader_excluded_from_can_group_with(self, mock_mass: MagicMock) -> None:
203 """Test that players with group members (sync leaders) are excluded."""
204 controller = PlayerController(mock_mass)
205 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
206
207 leader = MockPlayer(provider, "leader", "Leader")
208 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
209 leader._attr_can_group_with = {"member", "other"}
210 leader._attr_group_members = ["leader", "member"]
211 leader._attr_playback_state = PlaybackState.PLAYING # Make it playing so it gets excluded
212
213 member = MockPlayer(provider, "member", "Member")
214
215 other = MockPlayer(provider, "other", "Other")
216 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
217 other._attr_can_group_with = {"leader", "member"}
218
219 controller._players = {"leader": leader, "member": member, "other": other}
220 mock_mass.players = controller
221
222 # Trigger synced_to calculation
223 leader.update_state(signal_event=False)
224 member.update_state(signal_event=False)
225 other.update_state(signal_event=False)
226
227 # Leader should NOT appear in other's can_group_with (has group members)
228 assert "leader" not in other.state.can_group_with
229
230
231class TestCircularDependency:
232 """Test that circular dependencies are avoided."""
233
234 def test_no_circular_dependency_in_synced_to(self, mock_mass: MagicMock) -> None:
235 """
236 Test that synced_to calculation doesn't cause circular dependency.
237
238 Regression test for: synced_to calling group_members causing infinite recursion.
239 """
240 controller = PlayerController(mock_mass)
241 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
242
243 leader = MockPlayer(provider, "leader", "Leader")
244 leader._attr_group_members = ["leader", "member"]
245
246 member = MockPlayer(provider, "member", "Member")
247
248 controller._players = {"leader": leader, "member": member}
249 mock_mass.players = controller
250
251 # Mark players as initialized so they are returned by all_players()
252 leader.set_initialized()
253 member.set_initialized()
254
255 # Trigger synced_to calculation via update_state
256 leader.update_state(signal_event=False)
257 member.update_state(signal_event=False)
258
259 # This should not cause infinite recursion
260 assert member.state.synced_to == "leader"
261 assert leader.state.synced_to is None
262
263
264class TestCacheInvalidation:
265 """Test that caches are invalidated correctly."""
266
267 def test_can_group_with_cache_cleared_on_update_state(self, mock_mass: MagicMock) -> None:
268 """Test that can_group_with cache is cleared when update_state is called."""
269 controller = PlayerController(mock_mass)
270 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
271
272 player_a = MockPlayer(provider, "player_a", "Player A")
273 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
274 player_a._attr_can_group_with = {"player_b"}
275
276 player_b = MockPlayer(provider, "player_b", "Player B")
277
278 controller._players = {"player_a": player_a, "player_b": player_b}
279 mock_mass.players = controller
280
281 # Update state after setting attributes and registering with controller
282 player_a.update_state(signal_event=False)
283 player_b.update_state(signal_event=False)
284
285 # Get can_group_with to populate cache
286 initial = player_a.state.can_group_with
287 assert "player_b" in initial
288
289 # Modify underlying data
290 player_a._attr_can_group_with = set()
291
292 # Cache should still have old value
293 assert player_a.state.can_group_with == initial
294
295 # Clear cache via update_state
296 player_a.update_state(signal_event=False)
297
298 # Cache should be cleared, new value should be returned
299 assert player_a.state.can_group_with == set()
300
301
302class TestProviderInstanceIdExpansion:
303 """Test expansion of provider instance IDs in can_group_with."""
304
305 def test_provider_instance_id_expands_to_all_players(self, mock_mass: MagicMock) -> None:
306 """Test that provider instance IDs expand to all available players from that provider."""
307 controller = PlayerController(mock_mass)
308 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
309
310 player_a = MockPlayer(provider, "player_a", "Player A")
311 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
312 player_a._attr_can_group_with = {"test"} # Provider instance ID
313
314 player_b = MockPlayer(provider, "player_b", "Player B")
315 player_c = MockPlayer(provider, "player_c", "Player C")
316
317 controller._players = {
318 "player_a": player_a,
319 "player_b": player_b,
320 "player_c": player_c,
321 }
322 mock_mass.players = controller
323 # Set up get_provider to return the provider for instance ID
324 mock_mass.get_provider = MagicMock(return_value=provider)
325
326 # Mark players as initialized so they are returned by all_players()
327 player_a.set_initialized()
328 player_b.set_initialized()
329 player_c.set_initialized()
330
331 # Trigger state calculation
332 player_a.update_state(signal_event=False)
333 player_b.update_state(signal_event=False)
334 player_c.update_state(signal_event=False)
335
336 # Provider instance ID should expand to include all players from that provider
337 can_group = player_a.state.can_group_with
338 assert "player_b" in can_group
339 assert "player_c" in can_group
340
341
342class TestFinalActiveGroupNewModel:
343 """
344 The active_group derivation respects is_active_session and the powered signal.
345
346 Verifies the post-refactor contract:
347
348 - A group whose ``powered`` attribute is ``False`` (e.g. user explicitly
349 pinned it off via Fake control) never captures its members.
350 - A group whose ``powered`` is ``True`` (fake-pinned on) captures members
351 even without a live session.
352 - A group with ``powered=None`` (no power control assigned) only captures
353 members while ``is_active_session`` is ``True`` — i.e. while it has a
354 sync_leader, an active stream, or a pending idle-grace task.
355 """
356
357 def test_dormant_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
358 """No power signal, no session → member's active_group is None."""
359 controller = PlayerController(mock_mass)
360 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
361 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
362
363 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
364 # explicitly "no opinion" on power (matches new default for groups)
365 group._attr_powered = None
366 # listed as a configured member, but no active session
367 group._attr_group_members = ["member"]
368 # is_active_session base default is False → group is dormant
369 group._cache.clear()
370
371 member = MockPlayer(member_provider, "member", "Member")
372
373 controller._players = {"g1": group, "member": member}
374 mock_mass.players = controller
375
376 group.set_initialized()
377 member.set_initialized()
378 group.update_state(signal_event=False)
379 member.update_state(signal_event=False)
380
381 assert member.state.active_group is None
382
383 def test_powered_true_group_captures_members(self, mock_mass: MagicMock) -> None:
384 """Group with _attr_powered=True (fake pin) captures members regardless of session."""
385 controller = PlayerController(mock_mass)
386 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
387 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
388
389 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
390 group._attr_powered = True
391 group._attr_group_members = ["member"]
392 group._cache.clear()
393
394 member = MockPlayer(member_provider, "member", "Member")
395
396 controller._players = {"g1": group, "member": member}
397 mock_mass.players = controller
398
399 group.set_initialized()
400 member.set_initialized()
401 group.update_state(signal_event=False)
402 member.update_state(signal_event=False)
403
404 assert member.state.active_group == "g1"
405
406 def test_powered_false_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
407 """Group with _attr_powered=False (explicit off) does not capture, even with a session."""
408 controller = PlayerController(mock_mass)
409 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
410 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
411
412 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
413 group._attr_powered = False
414 group._attr_group_members = ["member"]
415 group._cache.clear()
416
417 member = MockPlayer(member_provider, "member", "Member")
418
419 controller._players = {"g1": group, "member": member}
420 mock_mass.players = controller
421
422 group.set_initialized()
423 member.set_initialized()
424 group.update_state(signal_event=False)
425 member.update_state(signal_event=False)
426
427 assert member.state.active_group is None
428
429 def test_session_active_group_captures_members(self, mock_mass: MagicMock) -> None:
430 """Group with powered=None but is_active_session=True captures members."""
431 controller = PlayerController(mock_mass)
432 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
433 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
434
435 # subclass MockPlayer to override is_active_session for this test
436 class _SessionedGroup(MockPlayer):
437 @property
438 def is_active_session(self) -> bool:
439 return True
440
441 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
442 group._attr_powered = None # no opinion on power
443 group._attr_group_members = ["member"]
444 group._cache.clear()
445
446 member = MockPlayer(member_provider, "member", "Member")
447
448 controller._players = {"g1": group, "member": member}
449 mock_mass.players = controller
450
451 group.set_initialized()
452 member.set_initialized()
453 group.update_state(signal_event=False)
454 member.update_state(signal_event=False)
455
456 assert member.state.active_group == "g1"
457
458
459class TestPlayerBaseIsActiveSession:
460 """The Player base class defaults is_active_session to False; only groups override it."""
461
462 def test_base_player_is_not_an_active_session(self, mock_mass: MagicMock) -> None:
463 """A regular MockPlayer should never claim to hold a captured session."""
464 provider = MockProvider("test", instance_id="test", mass=mock_mass)
465 player = MockPlayer(provider, "p1", "P1")
466 assert player.is_active_session is False
467
468
469def _make_ad_hoc_group(
470 controller: PlayerController, mock_mass: MagicMock, airplay_available: bool
471) -> None:
472 """
473 Register an ad-hoc group playing over AirPlay, with only member "b" on that domain.
474
475 Member "a" is a plain native player, member "b" is a native player with a linked
476 AirPlay protocol player whose availability is driven by ``airplay_available``.
477 """
478 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
479 airplay = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
480
481 leader = MockPlayer(sonos, "leader", "Leader")
482 leader_protocol = MockPlayer(
483 airplay, "leader_airplay", "Leader AirPlay", player_type=PlayerType.PROTOCOL
484 )
485 leader.set_linked_output_protocols([_airplay_link(leader_protocol.player_id)])
486 leader.set_active_output_protocol(leader_protocol.player_id)
487
488 member_a = MockPlayer(sonos, "a", "Member A")
489 member_b = MockPlayer(sonos, "b", "Member B")
490 member_b_protocol = MockPlayer(
491 airplay, "b_airplay", "B AirPlay", player_type=PlayerType.PROTOCOL
492 )
493 member_b_protocol._attr_available = airplay_available
494 member_b.set_linked_output_protocols([_airplay_link(member_b_protocol.player_id)])
495
496 for player in (leader, member_a, member_b):
497 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
498 player._cache.clear()
499
500 controller._players = {
501 p.player_id: p for p in (leader, leader_protocol, member_a, member_b, member_b_protocol)
502 }
503 mock_mass.players = controller
504
505
506def _airplay_link(protocol_id: str) -> LinkedOutputProtocol:
507 """Build an AirPlay link to the given protocol player."""
508 return LinkedOutputProtocol(
509 output_protocol_id=protocol_id,
510 protocol_domain="airplay",
511 priority=10,
512 )
513
514
515class TestAdHocLeadershipTransfer:
516 """Unjoining an ad-hoc sync leader transfers leadership instead of dissolving."""
517
518 def test_select_ad_hoc_leader_prefers_active_protocol(
519 self, controller: PlayerController, mock_mass: MagicMock
520 ) -> None:
521 """The new leader should be a member that supports the group's active protocol."""
522 # member_a can't do airplay, member_b has an airplay protocol player that is up
523 _make_ad_hoc_group(controller, mock_mass, airplay_available=True)
524
525 leader = controller.get_player("leader")
526 assert leader is not None
527 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "b"
528
529 def test_select_ad_hoc_leader_skips_offline_protocol(
530 self, controller: PlayerController, mock_mass: MagicMock
531 ) -> None:
532 """A member whose protocol player went offline must not inherit the session."""
533 # member_b still claims an airplay link, but its protocol player is gone
534 _make_ad_hoc_group(controller, mock_mass, airplay_available=False)
535
536 leader = controller.get_player("leader")
537 assert leader is not None
538
539 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
540
541 def test_select_ad_hoc_leader_accepts_native_member_on_active_domain(
542 self, controller: PlayerController, mock_mass: MagicMock
543 ) -> None:
544 """A member that plays the active protocol natively is still a valid leader."""
545 # a Chromecast speaker has no linked protocol player: it *is* the chromecast output
546 chromecast = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
547 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
548
549 leader = MockPlayer(sonos, "leader", "Leader")
550 leader_protocol = MockPlayer(
551 chromecast, "leader_cast", "Leader Cast", player_type=PlayerType.PROTOCOL
552 )
553 leader.set_linked_output_protocols(
554 [
555 LinkedOutputProtocol(
556 output_protocol_id=leader_protocol.player_id,
557 protocol_domain="chromecast",
558 priority=30,
559 )
560 ]
561 )
562 leader.set_active_output_protocol(leader_protocol.player_id)
563
564 member_a = MockPlayer(sonos, "a", "Member A")
565 member_c = MockPlayer(chromecast, "c", "Member C")
566 for player in (leader, member_a, member_c):
567 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
568 player._cache.clear()
569
570 controller._players = {
571 p.player_id: p for p in (leader, leader_protocol, member_a, member_c)
572 }
573 mock_mass.players = controller
574
575 assert controller._select_ad_hoc_leader(leader, ["a", "c"]) == "c"
576
577 def test_select_ad_hoc_leader_falls_back_to_first(self, controller: PlayerController) -> None:
578 """Without an active protocol to match, fall back to the first remaining member."""
579 leader = MagicMock()
580 leader.active_output_protocol = None
581 controller._players = {"a": MagicMock(), "b": MagicMock()}
582
583 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
584
585 async def test_handle_set_members_transfers_leader_when_playing(
586 self, mock_mass: MagicMock
587 ) -> None:
588 """Removing the leader from itself while playing routes to a leadership transfer."""
589 controller = PlayerController(mock_mass)
590 provider = MockProvider("test", instance_id="test", mass=mock_mass)
591
592 leader = MockPlayer(provider, "leader", "Leader")
593 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
594 leader._attr_can_group_with = {"member_a", "member_b"}
595 leader._attr_group_members = ["leader", "member_a", "member_b"]
596 member_a = MockPlayer(provider, "member_a", "Member A")
597 member_b = MockPlayer(provider, "member_b", "Member B")
598
599 controller._players = {"leader": leader, "member_a": member_a, "member_b": member_b}
600 mock_mass.players = controller
601 leader.update_state(signal_event=False)
602
603 playing_queue = MagicMock()
604 playing_queue.state = PlaybackState.PLAYING
605 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
606 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
607
608 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
609
610 controller._transfer_ad_hoc_leadership.assert_awaited_once()
611 called_leader, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
612 assert called_leader is leader
613 assert set(called_remaining) == {"member_a", "member_b"}
614
615 async def test_handle_set_members_dissolves_leader_when_idle(
616 self, mock_mass: MagicMock
617 ) -> None:
618 """Removing the leader from itself while idle dissolves the group and stops."""
619 controller = PlayerController(mock_mass)
620 provider = MockProvider("test", instance_id="test", mass=mock_mass)
621
622 leader = MockPlayer(provider, "leader", "Leader")
623 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
624 leader._attr_can_group_with = {"member_a"}
625 leader._attr_group_members = ["leader", "member_a"]
626 member_a = MockPlayer(provider, "member_a", "Member A")
627
628 controller._players = {"leader": leader, "member_a": member_a}
629 mock_mass.players = controller
630 leader.update_state(signal_event=False)
631
632 idle_queue = MagicMock()
633 idle_queue.state = PlaybackState.IDLE
634 controller.get_active_queue = MagicMock(return_value=idle_queue) # type: ignore[method-assign]
635 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
636 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
637 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
638
639 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
640
641 controller._transfer_ad_hoc_leadership.assert_not_awaited()
642 controller._handle_cmd_stop.assert_awaited_once_with("leader")
643
644
645if __name__ == "__main__":
646 pytest.main([__file__, "-v"])
647