/
/
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 SessionBoundMockPlayer(MockPlayer):
58 """Mock player whose native members ride its own stream session (e.g. AirPlay)."""
59
60 @property
61 def native_grouping_requires_own_stream(self) -> bool:
62 """Return True: native members are attached to this player's own stream session."""
63 return True
64
65
66class TestCanGroupWithBasics:
67 """Test basic can_group_with filtering logic."""
68
69 def test_ungrouped_players_can_group(self, mock_mass: MagicMock) -> None:
70 """Test that two ungrouped players can group with each other."""
71 controller = PlayerController(mock_mass)
72 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
73
74 player_a = MockPlayer(provider, "player_a", "Player A")
75 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
76 # Use explicit player IDs instead of provider instance ID for simpler test
77 player_a._attr_can_group_with = {"player_b"}
78
79 player_b = MockPlayer(provider, "player_b", "Player B")
80 player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
81 player_b._attr_can_group_with = {"player_a"}
82
83 controller._players = {"player_a": player_a, "player_b": player_b}
84 mock_mass.players = controller
85
86 # Trigger state calculation
87 player_a.update_state(signal_event=False)
88 player_b.update_state(signal_event=False)
89
90 # Both players should be able to group with each other
91 assert "player_b" in player_a.state.can_group_with
92 assert "player_a" in player_b.state.can_group_with
93
94 def test_unavailable_players_excluded(self, mock_mass: MagicMock) -> None:
95 """Test that unavailable players are excluded from can_group_with."""
96 controller = PlayerController(mock_mass)
97 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
98
99 player_a = MockPlayer(provider, "player_a", "Player A")
100 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
101 player_a._attr_can_group_with = {"player_b"}
102
103 player_b = MockPlayer(provider, "player_b", "Player B")
104 player_b._attr_available = False # Mark as unavailable
105
106 controller._players = {"player_a": player_a, "player_b": player_b}
107 mock_mass.players = controller
108
109 # Trigger state calculation
110 player_a.update_state(signal_event=False)
111 player_b.update_state(signal_event=False)
112
113 # Unavailable player should be excluded
114 assert "player_b" not in player_a.state.can_group_with
115
116 def test_playing_players_with_different_source_excluded(self, mock_mass: MagicMock) -> None:
117 """
118 Test that players playing different sources are NOT excluded (behavior changed).
119
120 Note: Previously, players with different active sources were excluded from grouping,
121 but this was removed as it was difficult to track reliably.
122 """
123 controller = PlayerController(mock_mass)
124 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
125
126 player_a = MockPlayer(provider, "player_a", "Player A")
127 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
128 player_a._attr_can_group_with = {"player_b"}
129 player_a._attr_playback_state = PlaybackState.PLAYING
130 player_a._attr_active_source = "player_a"
131
132 player_b = MockPlayer(provider, "player_b", "Player B")
133 player_b._attr_playback_state = PlaybackState.PLAYING
134 player_b._attr_active_source = "player_b" # Different source
135
136 controller._players = {"player_a": player_a, "player_b": player_b}
137 mock_mass.players = controller
138
139 # Trigger state calculation
140 player_a.update_state(signal_event=False)
141 player_b.update_state(signal_event=False)
142
143 # Player with different active source is now ALLOWED (behavior changed)
144 assert "player_b" in player_a.state.can_group_with
145
146
147class TestSyncedPlayers:
148 """Test behavior with synced/grouped players."""
149
150 def test_sync_leader_excludes_itself_from_members_can_group_with(
151 self, mock_mass: MagicMock
152 ) -> None:
153 """Test that sync leader doesn't appear in its members' can_group_with."""
154 controller = PlayerController(mock_mass)
155 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
156
157 leader = MockPlayer(provider, "leader", "Leader")
158 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
159 leader._attr_can_group_with = {"member"}
160 leader._attr_group_members = ["leader", "member"]
161
162 member = MockPlayer(provider, "member", "Member")
163
164 controller._players = {"leader": leader, "member": member}
165 mock_mass.players = controller
166
167 # Trigger synced_to calculation
168 leader.update_state(signal_event=False)
169 member.update_state(signal_event=False)
170
171 # Member is synced, so can_group_with should be empty
172 assert member.state.can_group_with == set()
173
174 def test_group_members_included_in_leader_can_group_with(self, mock_mass: MagicMock) -> None:
175 """
176 Test that group members appear in sync leader's can_group_with.
177
178 This allows ungrouping members from the leader.
179 """
180 controller = PlayerController(mock_mass)
181 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
182
183 leader = MockPlayer(provider, "leader", "Leader")
184 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
185 leader._attr_can_group_with = {"member_a", "member_b"}
186 leader._attr_group_members = ["leader", "member_a", "member_b"]
187
188 member_a = MockPlayer(provider, "member_a", "Member A")
189 member_b = MockPlayer(provider, "member_b", "Member B")
190
191 controller._players = {
192 "leader": leader,
193 "member_a": member_a,
194 "member_b": member_b,
195 }
196 mock_mass.players = controller
197
198 # Trigger synced_to calculation
199 leader.update_state(signal_event=False)
200 member_a.update_state(signal_event=False)
201 member_b.update_state(signal_event=False)
202
203 # Leader should be able to see its own members (for ungrouping)
204 assert "member_a" in leader.state.can_group_with
205 assert "member_b" in leader.state.can_group_with
206
207
208class TestSyncLeaderBehavior:
209 """Test sync leader specific behavior."""
210
211 def test_sync_leader_excluded_from_can_group_with(self, mock_mass: MagicMock) -> None:
212 """Test that players with group members (sync leaders) are excluded."""
213 controller = PlayerController(mock_mass)
214 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
215
216 leader = MockPlayer(provider, "leader", "Leader")
217 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
218 leader._attr_can_group_with = {"member", "other"}
219 leader._attr_group_members = ["leader", "member"]
220 leader._attr_playback_state = PlaybackState.PLAYING # Make it playing so it gets excluded
221
222 member = MockPlayer(provider, "member", "Member")
223
224 other = MockPlayer(provider, "other", "Other")
225 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
226 other._attr_can_group_with = {"leader", "member"}
227
228 controller._players = {"leader": leader, "member": member, "other": other}
229 mock_mass.players = controller
230
231 # Trigger synced_to calculation
232 leader.update_state(signal_event=False)
233 member.update_state(signal_event=False)
234 other.update_state(signal_event=False)
235
236 # Leader should NOT appear in other's can_group_with (has group members)
237 assert "leader" not in other.state.can_group_with
238
239
240class TestCircularDependency:
241 """Test that circular dependencies are avoided."""
242
243 def test_no_circular_dependency_in_synced_to(self, mock_mass: MagicMock) -> None:
244 """
245 Test that synced_to calculation doesn't cause circular dependency.
246
247 Regression test for: synced_to calling group_members causing infinite recursion.
248 """
249 controller = PlayerController(mock_mass)
250 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
251
252 leader = MockPlayer(provider, "leader", "Leader")
253 leader._attr_group_members = ["leader", "member"]
254
255 member = MockPlayer(provider, "member", "Member")
256
257 controller._players = {"leader": leader, "member": member}
258 mock_mass.players = controller
259
260 # Mark players as initialized so they are returned by all_players()
261 leader.set_initialized()
262 member.set_initialized()
263
264 # Trigger synced_to calculation via update_state
265 leader.update_state(signal_event=False)
266 member.update_state(signal_event=False)
267
268 # This should not cause infinite recursion
269 assert member.state.synced_to == "leader"
270 assert leader.state.synced_to is None
271
272
273class TestCacheInvalidation:
274 """Test that caches are invalidated correctly."""
275
276 def test_can_group_with_cache_cleared_on_update_state(self, mock_mass: MagicMock) -> None:
277 """Test that can_group_with cache is cleared when update_state is called."""
278 controller = PlayerController(mock_mass)
279 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
280
281 player_a = MockPlayer(provider, "player_a", "Player A")
282 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
283 player_a._attr_can_group_with = {"player_b"}
284
285 player_b = MockPlayer(provider, "player_b", "Player B")
286
287 controller._players = {"player_a": player_a, "player_b": player_b}
288 mock_mass.players = controller
289
290 # Update state after setting attributes and registering with controller
291 player_a.update_state(signal_event=False)
292 player_b.update_state(signal_event=False)
293
294 # Get can_group_with to populate cache
295 initial = player_a.state.can_group_with
296 assert "player_b" in initial
297
298 # Modify underlying data
299 player_a._attr_can_group_with = set()
300
301 # Cache should still have old value
302 assert player_a.state.can_group_with == initial
303
304 # Clear cache via update_state
305 player_a.update_state(signal_event=False)
306
307 # Cache should be cleared, new value should be returned
308 assert player_a.state.can_group_with == set()
309
310
311class TestProviderInstanceIdExpansion:
312 """Test expansion of provider instance IDs in can_group_with."""
313
314 def test_provider_instance_id_expands_to_all_players(self, mock_mass: MagicMock) -> None:
315 """Test that provider instance IDs expand to all available players from that provider."""
316 controller = PlayerController(mock_mass)
317 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
318
319 player_a = MockPlayer(provider, "player_a", "Player A")
320 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
321 player_a._attr_can_group_with = {"test"} # Provider instance ID
322
323 player_b = MockPlayer(provider, "player_b", "Player B")
324 player_c = MockPlayer(provider, "player_c", "Player C")
325
326 controller._players = {
327 "player_a": player_a,
328 "player_b": player_b,
329 "player_c": player_c,
330 }
331 mock_mass.players = controller
332 # Set up get_provider to return the provider for instance ID
333 mock_mass.get_provider = MagicMock(return_value=provider)
334
335 # Mark players as initialized so they are returned by all_players()
336 player_a.set_initialized()
337 player_b.set_initialized()
338 player_c.set_initialized()
339
340 # Trigger state calculation
341 player_a.update_state(signal_event=False)
342 player_b.update_state(signal_event=False)
343 player_c.update_state(signal_event=False)
344
345 # Provider instance ID should expand to include all players from that provider
346 can_group = player_a.state.can_group_with
347 assert "player_b" in can_group
348 assert "player_c" in can_group
349
350 def test_provider_instance_id_excludes_unknown_players(self, mock_mass: MagicMock) -> None:
351 """Test that players without an output type are not offered as grouping targets."""
352 controller = PlayerController(mock_mass)
353 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
354 mock_mass.get_provider = MagicMock(return_value=provider)
355
356 leader = MockPlayer(
357 provider,
358 "leader",
359 "Leader",
360 player_type=PlayerType.PROTOCOL,
361 )
362 leader._attr_can_group_with = {"test"}
363 public_player = MockPlayer(provider, "public", "Public Player")
364 unknown_player = MockPlayer(
365 provider,
366 "unknown",
367 "Unknown Player",
368 player_type=PlayerType.UNKNOWN,
369 )
370 controller._players = {
371 "leader": leader,
372 "public": public_player,
373 "unknown": unknown_player,
374 }
375 mock_mass.players = controller
376
377 for player in controller._players.values():
378 player.set_initialized()
379 for player in controller._players.values():
380 player.update_state(signal_event=False)
381
382 assert "public" in leader.state.can_group_with
383 assert "unknown" not in leader.state.can_group_with
384
385 leader._attr_can_group_with = {"unknown"}
386 leader.update_state(signal_event=False, force_update=True)
387
388 assert leader.state.can_group_with == set()
389
390
391class TestFinalActiveGroupNewModel:
392 """
393 The active_group derivation respects is_active_session and the powered signal.
394
395 Verifies the post-refactor contract:
396
397 - A group whose ``powered`` attribute is ``False`` (e.g. user explicitly
398 pinned it off via Fake control) never captures its members.
399 - A group whose ``powered`` is ``True`` (fake-pinned on) captures members
400 even without a live session.
401 - A group with ``powered=None`` (no power control assigned) only captures
402 members while ``is_active_session`` is ``True`` — i.e. while it has a
403 sync_leader, an active stream, or a pending idle-grace task.
404 """
405
406 def test_dormant_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
407 """No power signal, no session → member's active_group is None."""
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 # explicitly "no opinion" on power (matches new default for groups)
414 group._attr_powered = None
415 # listed as a configured member, but no active session
416 group._attr_group_members = ["member"]
417 # is_active_session base default is False → group is dormant
418 group._cache.clear()
419
420 member = MockPlayer(member_provider, "member", "Member")
421
422 controller._players = {"g1": group, "member": member}
423 mock_mass.players = controller
424
425 group.set_initialized()
426 member.set_initialized()
427 group.update_state(signal_event=False)
428 member.update_state(signal_event=False)
429
430 assert member.state.active_group is None
431
432 def test_powered_true_group_captures_members(self, mock_mass: MagicMock) -> None:
433 """Group with _attr_powered=True (fake pin) captures members regardless of session."""
434 controller = PlayerController(mock_mass)
435 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
436 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
437
438 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
439 group._attr_powered = True
440 group._attr_group_members = ["member"]
441 group._cache.clear()
442
443 member = MockPlayer(member_provider, "member", "Member")
444
445 controller._players = {"g1": group, "member": member}
446 mock_mass.players = controller
447
448 group.set_initialized()
449 member.set_initialized()
450 group.update_state(signal_event=False)
451 member.update_state(signal_event=False)
452
453 assert member.state.active_group == "g1"
454
455 def test_powered_false_group_does_not_capture_members(self, mock_mass: MagicMock) -> None:
456 """Group with _attr_powered=False (explicit off) does not capture, even with a session."""
457 controller = PlayerController(mock_mass)
458 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
459 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
460
461 group = MockPlayer(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
462 group._attr_powered = False
463 group._attr_group_members = ["member"]
464 group._cache.clear()
465
466 member = MockPlayer(member_provider, "member", "Member")
467
468 controller._players = {"g1": group, "member": member}
469 mock_mass.players = controller
470
471 group.set_initialized()
472 member.set_initialized()
473 group.update_state(signal_event=False)
474 member.update_state(signal_event=False)
475
476 assert member.state.active_group is None
477
478 def test_session_active_group_captures_members(self, mock_mass: MagicMock) -> None:
479 """Group with powered=None but is_active_session=True captures members."""
480 controller = PlayerController(mock_mass)
481 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
482 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
483
484 # subclass MockPlayer to override is_active_session for this test
485 class _SessionedGroup(MockPlayer):
486 @property
487 def is_active_session(self) -> bool:
488 return True
489
490 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
491 group._attr_powered = None # no opinion on power
492 group._attr_group_members = ["member"]
493 group._cache.clear()
494
495 member = MockPlayer(member_provider, "member", "Member")
496
497 controller._players = {"g1": group, "member": member}
498 mock_mass.players = controller
499
500 group.set_initialized()
501 member.set_initialized()
502 group.update_state(signal_event=False)
503 member.update_state(signal_event=False)
504
505 assert member.state.active_group == "g1"
506
507
508class TestPlayerBaseIsActiveSession:
509 """The Player base class defaults is_active_session to False; only groups override it."""
510
511 def test_base_player_is_not_an_active_session(self, mock_mass: MagicMock) -> None:
512 """A regular MockPlayer should never claim to hold a captured session."""
513 provider = MockProvider("test", instance_id="test", mass=mock_mass)
514 player = MockPlayer(provider, "p1", "P1")
515 assert player.is_active_session is False
516
517
518def _make_ad_hoc_group(
519 controller: PlayerController, mock_mass: MagicMock, airplay_available: bool
520) -> None:
521 """
522 Register an ad-hoc group playing over AirPlay, with only member "b" on that domain.
523
524 Member "a" is a plain native player, member "b" is a native player with a linked
525 AirPlay protocol player whose availability is driven by ``airplay_available``.
526 """
527 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
528 airplay = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
529
530 leader = MockPlayer(sonos, "leader", "Leader")
531 leader_protocol = MockPlayer(
532 airplay, "leader_airplay", "Leader AirPlay", player_type=PlayerType.PROTOCOL
533 )
534 leader.set_linked_output_protocols([_airplay_link(leader_protocol.player_id)])
535 leader.set_active_output_protocol(leader_protocol.player_id)
536
537 member_a = MockPlayer(sonos, "a", "Member A")
538 member_b = MockPlayer(sonos, "b", "Member B")
539 member_b_protocol = MockPlayer(
540 airplay, "b_airplay", "B AirPlay", player_type=PlayerType.PROTOCOL
541 )
542 member_b_protocol._attr_available = airplay_available
543 member_b.set_linked_output_protocols([_airplay_link(member_b_protocol.player_id)])
544
545 for player in (leader, member_a, member_b):
546 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
547 player._cache.clear()
548
549 controller._players = {
550 p.player_id: p for p in (leader, leader_protocol, member_a, member_b, member_b_protocol)
551 }
552 mock_mass.players = controller
553
554
555def _airplay_link(protocol_id: str) -> LinkedOutputProtocol:
556 """Build an AirPlay link to the given protocol player."""
557 return LinkedOutputProtocol(
558 output_protocol_id=protocol_id,
559 protocol_domain="airplay",
560 priority=10,
561 )
562
563
564class TestAdHocLeadershipTransfer:
565 """Unjoining an ad-hoc sync leader transfers leadership instead of dissolving."""
566
567 def test_select_ad_hoc_leader_prefers_active_protocol(
568 self, controller: PlayerController, mock_mass: MagicMock
569 ) -> None:
570 """The new leader should be a member that supports the group's active protocol."""
571 # member_a can't do airplay, member_b has an airplay protocol player that is up
572 _make_ad_hoc_group(controller, mock_mass, airplay_available=True)
573
574 leader = controller.get_player("leader")
575 assert leader is not None
576 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "b"
577
578 def test_select_ad_hoc_leader_skips_offline_protocol(
579 self, controller: PlayerController, mock_mass: MagicMock
580 ) -> None:
581 """A member whose protocol player went offline must not inherit the session."""
582 # member_b still claims an airplay link, but its protocol player is gone
583 _make_ad_hoc_group(controller, mock_mass, airplay_available=False)
584
585 leader = controller.get_player("leader")
586 assert leader is not None
587
588 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
589
590 def test_select_ad_hoc_leader_accepts_native_member_on_active_domain(
591 self, controller: PlayerController, mock_mass: MagicMock
592 ) -> None:
593 """A member that plays the active protocol natively is still a valid leader."""
594 # a Chromecast speaker has no linked protocol player: it *is* the chromecast output
595 chromecast = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
596 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
597
598 leader = MockPlayer(sonos, "leader", "Leader")
599 leader_protocol = MockPlayer(
600 chromecast, "leader_cast", "Leader Cast", player_type=PlayerType.PROTOCOL
601 )
602 leader.set_linked_output_protocols(
603 [
604 LinkedOutputProtocol(
605 output_protocol_id=leader_protocol.player_id,
606 protocol_domain="chromecast",
607 priority=30,
608 )
609 ]
610 )
611 leader.set_active_output_protocol(leader_protocol.player_id)
612
613 member_a = MockPlayer(sonos, "a", "Member A")
614 member_c = MockPlayer(chromecast, "c", "Member C")
615 for player in (leader, member_a, member_c):
616 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
617 player._cache.clear()
618
619 controller._players = {
620 p.player_id: p for p in (leader, leader_protocol, member_a, member_c)
621 }
622 mock_mass.players = controller
623
624 assert controller._select_ad_hoc_leader(leader, ["a", "c"]) == "c"
625
626 def test_select_ad_hoc_leader_falls_back_to_first(self, controller: PlayerController) -> None:
627 """Without an active protocol to match, fall back to the first remaining member."""
628 leader = MagicMock()
629 leader.active_output_protocol = None
630 controller._players = {"a": MagicMock(), "b": MagicMock()}
631
632 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
633
634 async def test_handle_set_members_transfers_leader_when_playing(
635 self, mock_mass: MagicMock
636 ) -> None:
637 """Removing the leader from itself while playing routes to a leadership transfer."""
638 controller = PlayerController(mock_mass)
639 provider = MockProvider("test", instance_id="test", mass=mock_mass)
640
641 leader = MockPlayer(provider, "leader", "Leader")
642 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
643 leader._attr_can_group_with = {"member_a", "member_b"}
644 leader._attr_group_members = ["leader", "member_a", "member_b"]
645 member_a = MockPlayer(provider, "member_a", "Member A")
646 member_b = MockPlayer(provider, "member_b", "Member B")
647
648 controller._players = {"leader": leader, "member_a": member_a, "member_b": member_b}
649 mock_mass.players = controller
650 leader.update_state(signal_event=False)
651
652 playing_queue = MagicMock()
653 playing_queue.state = PlaybackState.PLAYING
654 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
655 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
656
657 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
658
659 controller._transfer_ad_hoc_leadership.assert_awaited_once()
660 called_leader, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
661 assert called_leader is leader
662 assert set(called_remaining) == {"member_a", "member_b"}
663
664 async def test_handle_set_members_dissolves_leader_when_idle(
665 self, mock_mass: MagicMock
666 ) -> None:
667 """Removing the leader from itself while idle dissolves the group and stops."""
668 controller = PlayerController(mock_mass)
669 provider = MockProvider("test", instance_id="test", mass=mock_mass)
670
671 leader = MockPlayer(provider, "leader", "Leader")
672 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
673 leader._attr_can_group_with = {"member_a"}
674 leader._attr_group_members = ["leader", "member_a"]
675 member_a = MockPlayer(provider, "member_a", "Member A")
676
677 controller._players = {"leader": leader, "member_a": member_a}
678 mock_mass.players = controller
679 leader.update_state(signal_event=False)
680
681 idle_queue = MagicMock()
682 idle_queue.state = PlaybackState.IDLE
683 controller.get_active_queue = MagicMock(return_value=idle_queue) # type: ignore[method-assign]
684 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
685 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
686 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
687
688 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
689
690 controller._transfer_ad_hoc_leadership.assert_not_awaited()
691 controller._handle_cmd_stop.assert_awaited_once_with("leader")
692
693
694class TestSessionBoundLeaderJoinsAnotherGroup:
695 """A leader whose members ride its own stream gives that group up when it joins another."""
696
697 def _build(
698 self, mock_mass: MagicMock, leader_class: type[MockPlayer]
699 ) -> tuple[PlayerController, MockPlayer, MockPlayer, MockPlayer]:
700 """Build an idle leader with one native member, plus the group it is about to join."""
701 controller = PlayerController(mock_mass)
702 provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
703
704 leader = leader_class(provider, "leader", "Living Room")
705 leader._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
706 leader._attr_can_group_with = {"member", "target"}
707 leader._attr_group_members = ["leader", "member"]
708
709 member = MockPlayer(provider, "member", "Kitchen")
710 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
711
712 target = MockPlayer(provider, "target", "Study")
713 target._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
714 target._attr_can_group_with = {"leader", "member"}
715
716 controller._players = {p.player_id: p for p in (leader, member, target)}
717 mock_mass.players = controller
718 for player in controller._players.values():
719 player.set_initialized()
720 player.update_state(signal_event=False)
721 assert member.synced_to == "leader"
722 return controller, leader, member, target
723
724 async def test_own_group_is_dissolved_before_it_joins(self, mock_mass: MagicMock) -> None:
725 """
726 The members are released before the leader becomes a member itself.
727
728 A native group outlives its session, so a leader that stopped still lists its
729 members. Joining another group leaves it unable to serve them, and they would be
730 silent while the UI still shows them grouped.
731 """
732 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
733
734 await controller._handle_set_members(target, player_ids_to_add=["leader"])
735
736 assert "member" not in leader.group_members
737 assert member.synced_to is None
738 assert target.group_members == ["target", "leader"]
739
740 async def test_a_leader_without_members_is_left_alone(self, mock_mass: MagicMock) -> None:
741 """A player that leads nothing has no group to give up."""
742 controller, leader, _member, target = self._build(mock_mass, SessionBoundMockPlayer)
743 leader._attr_group_members = []
744 leader.update_state(signal_event=False)
745 leader.set_members = AsyncMock() # type: ignore[method-assign]
746
747 await controller._handle_set_members(target, player_ids_to_add=["leader"])
748
749 leader.set_members.assert_not_awaited()
750 assert target.group_members == ["target", "leader"]
751
752 async def test_a_playing_group_is_never_torn_down(self, mock_mass: MagicMock) -> None:
753 """
754 A leader still serving its own group stays out instead of silencing its members.
755
756 Grouping never offers a rendering leader as a target, but the candidate list is a
757 snapshot: it is only recomputed when a group membership or availability changes, so
758 one taken before playback started still lists the leader.
759 """
760 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
761 leader._attr_playback_state = PlaybackState.PLAYING
762 leader.update_state(signal_event=False)
763 leader.set_members = AsyncMock() # type: ignore[method-assign]
764
765 await controller._handle_set_members(target, player_ids_to_add=["leader"])
766
767 leader.set_members.assert_not_awaited()
768 assert target.group_members == []
769 assert member.synced_to == "leader"
770
771 async def test_a_group_that_cannot_be_dissolved_keeps_the_player_out(
772 self, mock_mass: MagicMock
773 ) -> None:
774 """
775 A leader that could not give its group up stays out instead of joining on top of it.
776
777 Joining anyway leaves it leading a group it can no longer serve, and a player that
778 is synced refuses every later member change, so the state cannot be cleaned up.
779 """
780 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
781 leader.set_members = AsyncMock(side_effect=RuntimeError("speaker unreachable")) # type: ignore[method-assign]
782
783 await controller._handle_set_members(target, player_ids_to_add=["leader"])
784
785 assert target.group_members == []
786 assert member.synced_to == "leader"
787
788 async def test_a_group_that_survives_the_join_is_left_alone(self, mock_mass: MagicMock) -> None:
789 """A provider whose members do not ride the leader's own stream keeps its group."""
790 controller, leader, member, target = self._build(mock_mass, MockPlayer)
791
792 await controller._handle_set_members(target, player_ids_to_add=["leader"])
793
794 assert leader.group_members == ["leader", "member"]
795 assert member.synced_to == "leader"
796
797
798if __name__ == "__main__":
799 pytest.main([__file__, "-v"])
800