/
/
/
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 member_a = MagicMock()
631 member_a.state.type = PlayerType.PLAYER
632 member_b = MagicMock()
633 member_b.state.type = PlayerType.PLAYER
634 controller._players = {"a": member_a, "b": member_b}
635
636 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
637
638 def test_select_ad_hoc_leader_never_picks_non_audio_member(
639 self, controller: PlayerController, mock_mass: MagicMock
640 ) -> None:
641 """A visualizer listed before an audio member must never inherit the queue."""
642 provider = MockProvider("test", instance_id="test", mass=mock_mass)
643 visualizer = MockPlayer(provider, "viz", "Visualizer", player_type=PlayerType.VISUALIZER)
644 member_b = MockPlayer(provider, "b", "Member B")
645 controller._players = {"viz": visualizer, "b": member_b}
646 # refresh each player's state snapshot so it reflects the mocked player type
647 for player in (visualizer, member_b):
648 player.update_state(signal_event=False)
649
650 leader = MagicMock()
651 leader.active_output_protocol = None
652
653 assert controller._select_ad_hoc_leader(leader, ["viz", "b"]) == "b"
654
655 async def test_handle_set_members_transfers_leader_when_playing(
656 self, mock_mass: MagicMock
657 ) -> None:
658 """Removing the leader from itself while playing routes to a leadership transfer."""
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", "member_b"}
665 leader._attr_group_members = ["leader", "member_a", "member_b"]
666 member_a = MockPlayer(provider, "member_a", "Member A")
667 member_b = MockPlayer(provider, "member_b", "Member B")
668
669 controller._players = {"leader": leader, "member_a": member_a, "member_b": member_b}
670 mock_mass.players = controller
671 leader.update_state(signal_event=False)
672
673 playing_queue = MagicMock()
674 playing_queue.state = PlaybackState.PLAYING
675 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
676 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
677
678 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
679
680 controller._transfer_ad_hoc_leadership.assert_awaited_once()
681 called_leader, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
682 assert called_leader is leader
683 assert set(called_remaining) == {"member_a", "member_b"}
684
685 async def test_handle_set_members_keeps_non_audio_member_as_follower(
686 self, mock_mass: MagicMock
687 ) -> None:
688 """A non-audio member does not block the transfer and stays in the regroup set."""
689 controller = PlayerController(mock_mass)
690 provider = MockProvider("test", instance_id="test", mass=mock_mass)
691
692 leader = MockPlayer(provider, "leader", "Leader")
693 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
694 leader._attr_can_group_with = {"member_a", "visualizer"}
695 leader._attr_group_members = ["leader", "member_a", "visualizer"]
696 member_a = MockPlayer(provider, "member_a", "Member A")
697 visualizer = MockPlayer(
698 provider, "visualizer", "Visualizer", player_type=PlayerType.VISUALIZER
699 )
700
701 controller._players = {
702 "leader": leader,
703 "member_a": member_a,
704 "visualizer": visualizer,
705 }
706 mock_mass.players = controller
707 # refresh each player's state snapshot so it reflects the mocked player type
708 for player in (leader, member_a, visualizer):
709 player.update_state(signal_event=False)
710
711 playing_queue = MagicMock()
712 playing_queue.state = PlaybackState.PLAYING
713 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
714 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
715
716 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
717
718 controller._transfer_ad_hoc_leadership.assert_awaited_once()
719 _, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
720 # the visualizer stays a group member (it follows the new leader), the
721 # heir itself is picked from the audio-capable members only
722 assert set(called_remaining) == {"member_a", "visualizer"}
723
724 async def test_handle_set_members_dissolves_when_only_non_audio_members_remain(
725 self, mock_mass: MagicMock
726 ) -> None:
727 """With only non-audio members left there is no heir: dissolve and stop."""
728 controller = PlayerController(mock_mass)
729 provider = MockProvider("test", instance_id="test", mass=mock_mass)
730
731 leader = MockPlayer(provider, "leader", "Leader")
732 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
733 leader._attr_can_group_with = {"visualizer"}
734 leader._attr_group_members = ["leader", "visualizer"]
735 visualizer = MockPlayer(
736 provider, "visualizer", "Visualizer", player_type=PlayerType.VISUALIZER
737 )
738
739 controller._players = {"leader": leader, "visualizer": visualizer}
740 mock_mass.players = controller
741 # refresh each player's state snapshot so it reflects the mocked player type
742 for player in (leader, visualizer):
743 player.update_state(signal_event=False)
744
745 playing_queue = MagicMock()
746 playing_queue.state = PlaybackState.PLAYING
747 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
748 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
749 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
750 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
751
752 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
753
754 controller._transfer_ad_hoc_leadership.assert_not_awaited()
755 # the dissolve removes the visualizer from the group and stops the leader
756 controller._handle_set_members_with_protocols.assert_awaited_once_with(
757 leader, [], ["visualizer"]
758 )
759 controller._handle_cmd_stop.assert_awaited_once_with("leader")
760
761 async def test_handle_set_members_dissolves_leader_when_idle(
762 self, mock_mass: MagicMock
763 ) -> None:
764 """Removing the leader from itself while idle dissolves the group and stops."""
765 controller = PlayerController(mock_mass)
766 provider = MockProvider("test", instance_id="test", mass=mock_mass)
767
768 leader = MockPlayer(provider, "leader", "Leader")
769 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
770 leader._attr_can_group_with = {"member_a"}
771 leader._attr_group_members = ["leader", "member_a"]
772 member_a = MockPlayer(provider, "member_a", "Member A")
773
774 controller._players = {"leader": leader, "member_a": member_a}
775 mock_mass.players = controller
776 leader.update_state(signal_event=False)
777
778 idle_queue = MagicMock()
779 idle_queue.state = PlaybackState.IDLE
780 controller.get_active_queue = MagicMock(return_value=idle_queue) # type: ignore[method-assign]
781 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
782 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
783 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
784
785 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
786
787 controller._transfer_ad_hoc_leadership.assert_not_awaited()
788 controller._handle_cmd_stop.assert_awaited_once_with("leader")
789
790
791class TestSessionBoundLeaderJoinsAnotherGroup:
792 """A leader whose members ride its own stream gives that group up when it joins another."""
793
794 def _build(
795 self, mock_mass: MagicMock, leader_class: type[MockPlayer]
796 ) -> tuple[PlayerController, MockPlayer, MockPlayer, MockPlayer]:
797 """Build an idle leader with one native member, plus the group it is about to join."""
798 controller = PlayerController(mock_mass)
799 provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
800
801 leader = leader_class(provider, "leader", "Living Room")
802 leader._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
803 leader._attr_can_group_with = {"member", "target"}
804 leader._attr_group_members = ["leader", "member"]
805
806 member = MockPlayer(provider, "member", "Kitchen")
807 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
808
809 target = MockPlayer(provider, "target", "Study")
810 target._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
811 target._attr_can_group_with = {"leader", "member"}
812
813 controller._players = {p.player_id: p for p in (leader, member, target)}
814 mock_mass.players = controller
815 for player in controller._players.values():
816 player.set_initialized()
817 player.update_state(signal_event=False)
818 assert member.synced_to == "leader"
819 return controller, leader, member, target
820
821 async def test_own_group_is_dissolved_before_it_joins(self, mock_mass: MagicMock) -> None:
822 """
823 The members are released before the leader becomes a member itself.
824
825 A native group outlives its session, so a leader that stopped still lists its
826 members. Joining another group leaves it unable to serve them, and they would be
827 silent while the UI still shows them grouped.
828 """
829 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
830
831 await controller._handle_set_members(target, player_ids_to_add=["leader"])
832
833 assert "member" not in leader.group_members
834 assert member.synced_to is None
835 assert target.group_members == ["target", "leader"]
836
837 async def test_a_leader_without_members_is_left_alone(self, mock_mass: MagicMock) -> None:
838 """A player that leads nothing has no group to give up."""
839 controller, leader, _member, target = self._build(mock_mass, SessionBoundMockPlayer)
840 leader._attr_group_members = []
841 leader.update_state(signal_event=False)
842 leader.set_members = AsyncMock() # type: ignore[method-assign]
843
844 await controller._handle_set_members(target, player_ids_to_add=["leader"])
845
846 leader.set_members.assert_not_awaited()
847 assert target.group_members == ["target", "leader"]
848
849 async def test_a_playing_group_is_never_torn_down(self, mock_mass: MagicMock) -> None:
850 """
851 A leader still serving its own group stays out instead of silencing its members.
852
853 Grouping never offers a rendering leader as a target, but the candidate list is a
854 snapshot: it is only recomputed when a group membership or availability changes, so
855 one taken before playback started still lists the leader.
856 """
857 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
858 leader._attr_playback_state = PlaybackState.PLAYING
859 leader.update_state(signal_event=False)
860 leader.set_members = AsyncMock() # type: ignore[method-assign]
861
862 await controller._handle_set_members(target, player_ids_to_add=["leader"])
863
864 leader.set_members.assert_not_awaited()
865 assert target.group_members == []
866 assert member.synced_to == "leader"
867
868 async def test_a_group_that_cannot_be_dissolved_keeps_the_player_out(
869 self, mock_mass: MagicMock
870 ) -> None:
871 """
872 A leader that could not give its group up stays out instead of joining on top of it.
873
874 Joining anyway leaves it leading a group it can no longer serve, and a player that
875 is synced refuses every later member change, so the state cannot be cleaned up.
876 """
877 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
878 leader.set_members = AsyncMock(side_effect=RuntimeError("speaker unreachable")) # type: ignore[method-assign]
879
880 await controller._handle_set_members(target, player_ids_to_add=["leader"])
881
882 assert target.group_members == []
883 assert member.synced_to == "leader"
884
885 async def test_a_group_that_survives_the_join_is_left_alone(self, mock_mass: MagicMock) -> None:
886 """A provider whose members do not ride the leader's own stream keeps its group."""
887 controller, leader, member, target = self._build(mock_mass, MockPlayer)
888
889 await controller._handle_set_members(target, player_ids_to_add=["leader"])
890
891 assert leader.group_members == ["leader", "member"]
892 assert member.synced_to == "leader"
893
894
895if __name__ == "__main__":
896 pytest.main([__file__, "-v"])
897