/
/
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 def test_provider_instance_id_excludes_unknown_players(self, mock_mass: MagicMock) -> None:
342 """Test that players without an output type are not offered as grouping targets."""
343 controller = PlayerController(mock_mass)
344 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
345 mock_mass.get_provider = MagicMock(return_value=provider)
346
347 leader = MockPlayer(
348 provider,
349 "leader",
350 "Leader",
351 player_type=PlayerType.PROTOCOL,
352 )
353 leader._attr_can_group_with = {"test"}
354 public_player = MockPlayer(provider, "public", "Public Player")
355 unknown_player = MockPlayer(
356 provider,
357 "unknown",
358 "Unknown Player",
359 player_type=PlayerType.UNKNOWN,
360 )
361 controller._players = {
362 "leader": leader,
363 "public": public_player,
364 "unknown": unknown_player,
365 }
366 mock_mass.players = controller
367
368 for player in controller._players.values():
369 player.set_initialized()
370 for player in controller._players.values():
371 player.update_state(signal_event=False)
372
373 assert "public" in leader.state.can_group_with
374 assert "unknown" not in leader.state.can_group_with
375
376 leader._attr_can_group_with = {"unknown"}
377 leader.update_state(signal_event=False, force_update=True)
378
379 assert leader.state.can_group_with == set()
380
381
382class TestFinalActiveGroupNewModel:
383 """
384 The active_group derivation respects is_active_session and the powered signal.
385
386 Verifies the post-refactor contract:
387
388 - A group whose ``powered`` attribute is ``False`` (e.g. user explicitly
389 pinned it off via Fake control) never captures its members.
390 - A group whose ``powered`` is ``True`` (fake-pinned on) captures members
391 even without a live session.
392 - A group with ``powered=None`` (no power control assigned) only captures
393 members while ``is_active_session`` is ``True`` — i.e. while it has a
394 sync_leader, an active stream, or a pending idle-grace task.
395 """
396
397 def test_dormant_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
398 """No power signal, no session → member's active_group is None."""
399 controller = PlayerController(mock_mass)
400 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
401 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
402
403 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
404 # explicitly "no opinion" on power (matches new default for groups)
405 group._attr_powered = None
406 # listed as a configured member, but no active session
407 group._attr_group_members = ["member"]
408 # is_active_session base default is False → group is dormant
409 group._cache.clear()
410
411 member = MockPlayer(member_provider, "member", "Member")
412
413 controller._players = {"g1": group, "member": member}
414 mock_mass.players = controller
415
416 group.set_initialized()
417 member.set_initialized()
418 group.update_state(signal_event=False)
419 member.update_state(signal_event=False)
420
421 assert member.state.active_group is None
422
423 def test_powered_true_group_captures_members(self, mock_mass: MagicMock) -> None:
424 """Group with _attr_powered=True (fake pin) captures members regardless of session."""
425 controller = PlayerController(mock_mass)
426 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
427 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
428
429 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
430 group._attr_powered = True
431 group._attr_group_members = ["member"]
432 group._cache.clear()
433
434 member = MockPlayer(member_provider, "member", "Member")
435
436 controller._players = {"g1": group, "member": member}
437 mock_mass.players = controller
438
439 group.set_initialized()
440 member.set_initialized()
441 group.update_state(signal_event=False)
442 member.update_state(signal_event=False)
443
444 assert member.state.active_group == "g1"
445
446 def test_powered_false_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
447 """Group with _attr_powered=False (explicit off) does not capture, even with a session."""
448 controller = PlayerController(mock_mass)
449 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
450 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
451
452 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
453 group._attr_powered = False
454 group._attr_group_members = ["member"]
455 group._cache.clear()
456
457 member = MockPlayer(member_provider, "member", "Member")
458
459 controller._players = {"g1": group, "member": member}
460 mock_mass.players = controller
461
462 group.set_initialized()
463 member.set_initialized()
464 group.update_state(signal_event=False)
465 member.update_state(signal_event=False)
466
467 assert member.state.active_group is None
468
469 def test_session_active_group_captures_members(self, mock_mass: MagicMock) -> None:
470 """Group with powered=None but is_active_session=True captures members."""
471 controller = PlayerController(mock_mass)
472 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
473 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
474
475 # subclass MockPlayer to override is_active_session for this test
476 class _SessionedGroup(MockPlayer):
477 @property
478 def is_active_session(self) -> bool:
479 return True
480
481 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
482 group._attr_powered = None # no opinion on power
483 group._attr_group_members = ["member"]
484 group._cache.clear()
485
486 member = MockPlayer(member_provider, "member", "Member")
487
488 controller._players = {"g1": group, "member": member}
489 mock_mass.players = controller
490
491 group.set_initialized()
492 member.set_initialized()
493 group.update_state(signal_event=False)
494 member.update_state(signal_event=False)
495
496 assert member.state.active_group == "g1"
497
498
499class TestPlayerBaseIsActiveSession:
500 """The Player base class defaults is_active_session to False; only groups override it."""
501
502 def test_base_player_is_not_an_active_session(self, mock_mass: MagicMock) -> None:
503 """A regular MockPlayer should never claim to hold a captured session."""
504 provider = MockProvider("test", instance_id="test", mass=mock_mass)
505 player = MockPlayer(provider, "p1", "P1")
506 assert player.is_active_session is False
507
508
509def _make_ad_hoc_group(
510 controller: PlayerController, mock_mass: MagicMock, airplay_available: bool
511) -> None:
512 """
513 Register an ad-hoc group playing over AirPlay, with only member "b" on that domain.
514
515 Member "a" is a plain native player, member "b" is a native player with a linked
516 AirPlay protocol player whose availability is driven by ``airplay_available``.
517 """
518 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
519 airplay = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
520
521 leader = MockPlayer(sonos, "leader", "Leader")
522 leader_protocol = MockPlayer(
523 airplay, "leader_airplay", "Leader AirPlay", player_type=PlayerType.PROTOCOL
524 )
525 leader.set_linked_output_protocols([_airplay_link(leader_protocol.player_id)])
526 leader.set_active_output_protocol(leader_protocol.player_id)
527
528 member_a = MockPlayer(sonos, "a", "Member A")
529 member_b = MockPlayer(sonos, "b", "Member B")
530 member_b_protocol = MockPlayer(
531 airplay, "b_airplay", "B AirPlay", player_type=PlayerType.PROTOCOL
532 )
533 member_b_protocol._attr_available = airplay_available
534 member_b.set_linked_output_protocols([_airplay_link(member_b_protocol.player_id)])
535
536 for player in (leader, member_a, member_b):
537 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
538 player._cache.clear()
539
540 controller._players = {
541 p.player_id: p for p in (leader, leader_protocol, member_a, member_b, member_b_protocol)
542 }
543 mock_mass.players = controller
544
545
546def _airplay_link(protocol_id: str) -> LinkedOutputProtocol:
547 """Build an AirPlay link to the given protocol player."""
548 return LinkedOutputProtocol(
549 output_protocol_id=protocol_id,
550 protocol_domain="airplay",
551 priority=10,
552 )
553
554
555class TestAdHocLeadershipTransfer:
556 """Unjoining an ad-hoc sync leader transfers leadership instead of dissolving."""
557
558 def test_select_ad_hoc_leader_prefers_active_protocol(
559 self, controller: PlayerController, mock_mass: MagicMock
560 ) -> None:
561 """The new leader should be a member that supports the group's active protocol."""
562 # member_a can't do airplay, member_b has an airplay protocol player that is up
563 _make_ad_hoc_group(controller, mock_mass, airplay_available=True)
564
565 leader = controller.get_player("leader")
566 assert leader is not None
567 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "b"
568
569 def test_select_ad_hoc_leader_skips_offline_protocol(
570 self, controller: PlayerController, mock_mass: MagicMock
571 ) -> None:
572 """A member whose protocol player went offline must not inherit the session."""
573 # member_b still claims an airplay link, but its protocol player is gone
574 _make_ad_hoc_group(controller, mock_mass, airplay_available=False)
575
576 leader = controller.get_player("leader")
577 assert leader is not None
578
579 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
580
581 def test_select_ad_hoc_leader_accepts_native_member_on_active_domain(
582 self, controller: PlayerController, mock_mass: MagicMock
583 ) -> None:
584 """A member that plays the active protocol natively is still a valid leader."""
585 # a Chromecast speaker has no linked protocol player: it *is* the chromecast output
586 chromecast = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
587 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
588
589 leader = MockPlayer(sonos, "leader", "Leader")
590 leader_protocol = MockPlayer(
591 chromecast, "leader_cast", "Leader Cast", player_type=PlayerType.PROTOCOL
592 )
593 leader.set_linked_output_protocols(
594 [
595 LinkedOutputProtocol(
596 output_protocol_id=leader_protocol.player_id,
597 protocol_domain="chromecast",
598 priority=30,
599 )
600 ]
601 )
602 leader.set_active_output_protocol(leader_protocol.player_id)
603
604 member_a = MockPlayer(sonos, "a", "Member A")
605 member_c = MockPlayer(chromecast, "c", "Member C")
606 for player in (leader, member_a, member_c):
607 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
608 player._cache.clear()
609
610 controller._players = {
611 p.player_id: p for p in (leader, leader_protocol, member_a, member_c)
612 }
613 mock_mass.players = controller
614
615 assert controller._select_ad_hoc_leader(leader, ["a", "c"]) == "c"
616
617 def test_select_ad_hoc_leader_falls_back_to_first(self, controller: PlayerController) -> None:
618 """Without an active protocol to match, fall back to the first remaining member."""
619 leader = MagicMock()
620 leader.active_output_protocol = None
621 controller._players = {"a": MagicMock(), "b": MagicMock()}
622
623 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
624
625 async def test_handle_set_members_transfers_leader_when_playing(
626 self, mock_mass: MagicMock
627 ) -> None:
628 """Removing the leader from itself while playing routes to a leadership transfer."""
629 controller = PlayerController(mock_mass)
630 provider = MockProvider("test", instance_id="test", mass=mock_mass)
631
632 leader = MockPlayer(provider, "leader", "Leader")
633 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
634 leader._attr_can_group_with = {"member_a", "member_b"}
635 leader._attr_group_members = ["leader", "member_a", "member_b"]
636 member_a = MockPlayer(provider, "member_a", "Member A")
637 member_b = MockPlayer(provider, "member_b", "Member B")
638
639 controller._players = {"leader": leader, "member_a": member_a, "member_b": member_b}
640 mock_mass.players = controller
641 leader.update_state(signal_event=False)
642
643 playing_queue = MagicMock()
644 playing_queue.state = PlaybackState.PLAYING
645 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
646 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
647
648 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
649
650 controller._transfer_ad_hoc_leadership.assert_awaited_once()
651 called_leader, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
652 assert called_leader is leader
653 assert set(called_remaining) == {"member_a", "member_b"}
654
655 async def test_handle_set_members_dissolves_leader_when_idle(
656 self, mock_mass: MagicMock
657 ) -> None:
658 """Removing the leader from itself while idle dissolves the group and stops."""
659 controller = PlayerController(mock_mass)
660 provider = MockProvider("test", instance_id="test", mass=mock_mass)
661
662 leader = MockPlayer(provider, "leader", "Leader")
663 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
664 leader._attr_can_group_with = {"member_a"}
665 leader._attr_group_members = ["leader", "member_a"]
666 member_a = MockPlayer(provider, "member_a", "Member A")
667
668 controller._players = {"leader": leader, "member_a": member_a}
669 mock_mass.players = controller
670 leader.update_state(signal_event=False)
671
672 idle_queue = MagicMock()
673 idle_queue.state = PlaybackState.IDLE
674 controller.get_active_queue = MagicMock(return_value=idle_queue) # type: ignore[method-assign]
675 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
676 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
677 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
678
679 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
680
681 controller._transfer_ad_hoc_leadership.assert_not_awaited()
682 controller._handle_cmd_stop.assert_awaited_once_with("leader")
683
684
685if __name__ == "__main__":
686 pytest.main([__file__, "-v"])
687