/
/
/
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
564def _queue_stub(queue_id: str, state: PlaybackState = PlaybackState.PLAYING) -> MagicMock:
565 """
566 Build a queue stub carrying the id and state the set_members path reads.
567
568 :param queue_id: The id the queue reports, i.e. the player it belongs to.
569 :param state: Playback state the queue reports.
570 """
571 queue = MagicMock()
572 queue.queue_id = queue_id
573 queue.state = state
574 return queue
575
576
577def _ad_hoc_leader(
578 mock_mass: MagicMock, member_type: PlayerType = PlayerType.VISUALIZER
579) -> tuple[PlayerController, MockPlayer, AsyncMock, AsyncMock]:
580 """
581 Build a sync leader with a single member, ready to be removed from itself.
582
583 A non-audio member leaves no playback heir, so the group dissolves; pass
584 ``PlayerType.PLAYER`` to get a heir and reach the leadership transfer instead.
585
586 :param mock_mass: The mocked MusicAssistant instance to attach the controller to.
587 :param member_type: Type to register the group member as.
588 :return: The controller, the leader, its stubbed device stop and queue stop.
589 """
590 controller = PlayerController(mock_mass)
591 provider = MockProvider("test", instance_id="test", mass=mock_mass)
592
593 leader = MockPlayer(provider, "leader", "Leader")
594 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
595 leader._attr_can_group_with = {"member"}
596 leader._attr_group_members = ["leader", "member"]
597 member = MockPlayer(provider, "member", "Member", player_type=member_type)
598
599 controller._players = {"leader": leader, "member": member}
600 mock_mass.players = controller
601 # refresh each player's state snapshot so it reflects the mocked player type
602 for player in (leader, member):
603 player.update_state(signal_event=False)
604
605 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
606 device_stop = AsyncMock()
607 controller._handle_cmd_stop = device_stop # type: ignore[method-assign]
608 queue_stop = AsyncMock()
609 mock_mass.player_queues._handle_stop = queue_stop
610 return controller, leader, device_stop, queue_stop
611
612
613class TestAdHocLeadershipTransfer:
614 """Unjoining an ad-hoc sync leader transfers leadership instead of dissolving."""
615
616 def test_select_ad_hoc_leader_prefers_active_protocol(
617 self, controller: PlayerController, mock_mass: MagicMock
618 ) -> None:
619 """The new leader should be a member that supports the group's active protocol."""
620 # member_a can't do airplay, member_b has an airplay protocol player that is up
621 _make_ad_hoc_group(controller, mock_mass, airplay_available=True)
622
623 leader = controller.get_player("leader")
624 assert leader is not None
625 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "b"
626
627 def test_select_ad_hoc_leader_skips_offline_protocol(
628 self, controller: PlayerController, mock_mass: MagicMock
629 ) -> None:
630 """A member whose protocol player went offline must not inherit the session."""
631 # member_b still claims an airplay link, but its protocol player is gone
632 _make_ad_hoc_group(controller, mock_mass, airplay_available=False)
633
634 leader = controller.get_player("leader")
635 assert leader is not None
636
637 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
638
639 def test_select_ad_hoc_leader_accepts_native_member_on_active_domain(
640 self, controller: PlayerController, mock_mass: MagicMock
641 ) -> None:
642 """A member that plays the active protocol natively is still a valid leader."""
643 # a Chromecast speaker has no linked protocol player: it *is* the chromecast output
644 chromecast = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
645 sonos = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
646
647 leader = MockPlayer(sonos, "leader", "Leader")
648 leader_protocol = MockPlayer(
649 chromecast, "leader_cast", "Leader Cast", player_type=PlayerType.PROTOCOL
650 )
651 leader.set_linked_output_protocols(
652 [
653 LinkedOutputProtocol(
654 output_protocol_id=leader_protocol.player_id,
655 protocol_domain="chromecast",
656 priority=30,
657 )
658 ]
659 )
660 leader.set_active_output_protocol(leader_protocol.player_id)
661
662 member_a = MockPlayer(sonos, "a", "Member A")
663 member_c = MockPlayer(chromecast, "c", "Member C")
664 for player in (leader, member_a, member_c):
665 player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
666 player._cache.clear()
667
668 controller._players = {
669 p.player_id: p for p in (leader, leader_protocol, member_a, member_c)
670 }
671 mock_mass.players = controller
672
673 assert controller._select_ad_hoc_leader(leader, ["a", "c"]) == "c"
674
675 def test_select_ad_hoc_leader_falls_back_to_first(self, controller: PlayerController) -> None:
676 """Without an active protocol to match, fall back to the first remaining member."""
677 leader = MagicMock()
678 leader.active_output_protocol = None
679 member_a = MagicMock()
680 member_a.state.type = PlayerType.PLAYER
681 member_b = MagicMock()
682 member_b.state.type = PlayerType.PLAYER
683 controller._players = {"a": member_a, "b": member_b}
684
685 assert controller._select_ad_hoc_leader(leader, ["a", "b"]) == "a"
686
687 def test_select_ad_hoc_leader_never_picks_non_audio_member(
688 self, controller: PlayerController, mock_mass: MagicMock
689 ) -> None:
690 """A visualizer listed before an audio member must never inherit the queue."""
691 provider = MockProvider("test", instance_id="test", mass=mock_mass)
692 visualizer = MockPlayer(provider, "viz", "Visualizer", player_type=PlayerType.VISUALIZER)
693 member_b = MockPlayer(provider, "b", "Member B")
694 controller._players = {"viz": visualizer, "b": member_b}
695 # refresh each player's state snapshot so it reflects the mocked player type
696 for player in (visualizer, member_b):
697 player.update_state(signal_event=False)
698
699 leader = MagicMock()
700 leader.active_output_protocol = None
701
702 assert controller._select_ad_hoc_leader(leader, ["viz", "b"]) == "b"
703
704 async def test_handle_set_members_transfers_leader_when_playing(
705 self, mock_mass: MagicMock
706 ) -> None:
707 """Removing the leader from itself while playing routes to a leadership transfer."""
708 controller = PlayerController(mock_mass)
709 provider = MockProvider("test", instance_id="test", mass=mock_mass)
710
711 leader = MockPlayer(provider, "leader", "Leader")
712 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
713 leader._attr_can_group_with = {"member_a", "member_b"}
714 leader._attr_group_members = ["leader", "member_a", "member_b"]
715 member_a = MockPlayer(provider, "member_a", "Member A")
716 member_b = MockPlayer(provider, "member_b", "Member B")
717
718 controller._players = {"leader": leader, "member_a": member_a, "member_b": member_b}
719 mock_mass.players = controller
720 leader.update_state(signal_event=False)
721
722 playing_queue = MagicMock()
723 playing_queue.state = PlaybackState.PLAYING
724 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
725 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
726
727 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
728
729 controller._transfer_ad_hoc_leadership.assert_awaited_once()
730 called_leader, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
731 assert called_leader is leader
732 assert set(called_remaining) == {"member_a", "member_b"}
733
734 async def test_handle_set_members_keeps_non_audio_member_as_follower(
735 self, mock_mass: MagicMock
736 ) -> None:
737 """A non-audio member does not block the transfer and stays in the regroup set."""
738 controller = PlayerController(mock_mass)
739 provider = MockProvider("test", instance_id="test", mass=mock_mass)
740
741 leader = MockPlayer(provider, "leader", "Leader")
742 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
743 leader._attr_can_group_with = {"member_a", "visualizer"}
744 leader._attr_group_members = ["leader", "member_a", "visualizer"]
745 member_a = MockPlayer(provider, "member_a", "Member A")
746 visualizer = MockPlayer(
747 provider, "visualizer", "Visualizer", player_type=PlayerType.VISUALIZER
748 )
749
750 controller._players = {
751 "leader": leader,
752 "member_a": member_a,
753 "visualizer": visualizer,
754 }
755 mock_mass.players = controller
756 # refresh each player's state snapshot so it reflects the mocked player type
757 for player in (leader, member_a, visualizer):
758 player.update_state(signal_event=False)
759
760 playing_queue = MagicMock()
761 playing_queue.state = PlaybackState.PLAYING
762 controller.get_active_queue = MagicMock(return_value=playing_queue) # type: ignore[method-assign]
763 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
764
765 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
766
767 controller._transfer_ad_hoc_leadership.assert_awaited_once()
768 _, called_remaining = controller._transfer_ad_hoc_leadership.call_args.args
769 # the visualizer stays a group member (it follows the new leader), the
770 # heir itself is picked from the audio-capable members only
771 assert set(called_remaining) == {"member_a", "visualizer"}
772
773 async def test_handle_set_members_dissolves_when_only_non_audio_members_remain(
774 self, mock_mass: MagicMock
775 ) -> None:
776 """With only non-audio members left there is no heir: dissolve and stop."""
777 controller = PlayerController(mock_mass)
778 provider = MockProvider("test", instance_id="test", mass=mock_mass)
779
780 leader = MockPlayer(provider, "leader", "Leader")
781 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
782 leader._attr_can_group_with = {"visualizer"}
783 leader._attr_group_members = ["leader", "visualizer"]
784 visualizer = MockPlayer(
785 provider, "visualizer", "Visualizer", player_type=PlayerType.VISUALIZER
786 )
787
788 controller._players = {"leader": leader, "visualizer": visualizer}
789 mock_mass.players = controller
790 # refresh each player's state snapshot so it reflects the mocked player type
791 for player in (leader, visualizer):
792 player.update_state(signal_event=False)
793
794 controller.get_active_queue = MagicMock(return_value=_queue_stub("leader")) # type: ignore[method-assign]
795 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
796 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
797 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
798 queue_stop = AsyncMock()
799 mock_mass.player_queues._handle_stop = queue_stop
800
801 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
802
803 controller._transfer_ad_hoc_leadership.assert_not_awaited()
804 # the dissolve removes the visualizer from the group and ends the leader's queue
805 controller._handle_set_members_with_protocols.assert_awaited_once_with(
806 leader, [], ["visualizer"]
807 )
808 queue_stop.assert_awaited_once_with("leader")
809 controller._handle_cmd_stop.assert_not_awaited()
810
811 async def test_handle_set_members_dissolves_leader_when_idle(
812 self, mock_mass: MagicMock
813 ) -> None:
814 """Removing the leader from itself while idle dissolves the group and stops."""
815 controller = PlayerController(mock_mass)
816 provider = MockProvider("test", instance_id="test", mass=mock_mass)
817
818 leader = MockPlayer(provider, "leader", "Leader")
819 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
820 leader._attr_can_group_with = {"member_a"}
821 leader._attr_group_members = ["leader", "member_a"]
822 member_a = MockPlayer(provider, "member_a", "Member A")
823
824 controller._players = {"leader": leader, "member_a": member_a}
825 mock_mass.players = controller
826 leader.update_state(signal_event=False)
827
828 controller.get_active_queue = MagicMock( # type: ignore[method-assign]
829 return_value=_queue_stub("leader", state=PlaybackState.IDLE)
830 )
831 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
832 controller._handle_set_members_with_protocols = AsyncMock() # type: ignore[method-assign]
833 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
834 queue_stop = AsyncMock()
835 mock_mass.player_queues._handle_stop = queue_stop
836
837 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
838
839 controller._transfer_ad_hoc_leadership.assert_not_awaited()
840 # an idle queue is where leftovers hide: its session and item buffers outlive
841 # the playback that already ended, so the dissolve tears them down too
842 queue_stop.assert_awaited_once_with("leader")
843 controller._handle_cmd_stop.assert_not_awaited()
844
845 async def test_transfer_stops_nothing_on_the_way_out(self, mock_mass: MagicMock) -> None:
846 """
847 Handing the group to a new leader must not stop anything here.
848
849 transfer_queue moves the playback position to the new leader and stops the old
850 one itself, so a stop issued here would land on playback that already moved.
851 """
852 controller, leader, device_stop, queue_stop = _ad_hoc_leader(
853 mock_mass, member_type=PlayerType.PLAYER
854 )
855 controller.get_active_queue = MagicMock(return_value=_queue_stub("leader")) # type: ignore[method-assign]
856 controller._transfer_ad_hoc_leadership = AsyncMock() # type: ignore[method-assign]
857
858 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
859
860 controller._transfer_ad_hoc_leadership.assert_awaited_once_with(leader, ["member"])
861 device_stop.assert_not_awaited()
862 queue_stop.assert_not_awaited()
863
864
865class TestDissolvedLeaderEndsTheQueue:
866 """
867 Dissolving a sync leader has to end the queue it was playing, not just the device.
868
869 Stopping only the device leaves the queue session open, so its preloading keeps
870 pulling audio and a provider serving a live session (Spotify) stays tethered to
871 Music Assistant for another track or two.
872 """
873
874 async def test_dissolve_ends_the_leaders_own_queue(self, mock_mass: MagicMock) -> None:
875 """A leader dissolved mid-playback has its own queue ended, not just its device."""
876 controller, leader, device_stop, queue_stop = _ad_hoc_leader(mock_mass)
877 controller.get_active_queue = MagicMock(return_value=_queue_stub("leader")) # type: ignore[method-assign]
878
879 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
880
881 queue_stop.assert_awaited_once_with("leader")
882 # the queue stop issues the device stop itself
883 device_stop.assert_not_awaited()
884
885 async def test_dissolve_leaves_another_players_queue_alone(self, mock_mass: MagicMock) -> None:
886 """
887 A leader resolving to someone else's queue only gets its device stopped.
888
889 get_active_queue follows a sync link or protocol parent, and that queue is
890 playing for other players: ending it here would stop them too.
891 """
892 controller, leader, device_stop, queue_stop = _ad_hoc_leader(mock_mass)
893 controller.get_active_queue = MagicMock(return_value=_queue_stub("other_player")) # type: ignore[method-assign]
894
895 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
896
897 device_stop.assert_awaited_once_with("leader")
898 queue_stop.assert_not_awaited()
899
900 async def test_dissolve_without_a_queue_stops_the_device(self, mock_mass: MagicMock) -> None:
901 """A leader playing a live external source has no queue to end."""
902 controller, leader, device_stop, queue_stop = _ad_hoc_leader(mock_mass)
903 controller.get_active_queue = MagicMock(return_value=None) # type: ignore[method-assign]
904
905 await controller._handle_set_members(leader, player_ids_to_remove=["leader"])
906
907 device_stop.assert_awaited_once_with("leader")
908 queue_stop.assert_not_awaited()
909
910
911class TestSessionBoundLeaderJoinsAnotherGroup:
912 """A leader whose members ride its own stream gives that group up when it joins another."""
913
914 def _build(
915 self, mock_mass: MagicMock, leader_class: type[MockPlayer]
916 ) -> tuple[PlayerController, MockPlayer, MockPlayer, MockPlayer]:
917 """Build an idle leader with one native member, plus the group it is about to join."""
918 controller = PlayerController(mock_mass)
919 provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
920
921 leader = leader_class(provider, "leader", "Living Room")
922 leader._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
923 leader._attr_can_group_with = {"member", "target"}
924 leader._attr_group_members = ["leader", "member"]
925
926 member = MockPlayer(provider, "member", "Kitchen")
927 member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
928
929 target = MockPlayer(provider, "target", "Study")
930 target._attr_supported_features |= {PlayerFeature.SET_MEMBERS, PlayerFeature.PLAY_MEDIA}
931 target._attr_can_group_with = {"leader", "member"}
932
933 controller._players = {p.player_id: p for p in (leader, member, target)}
934 mock_mass.players = controller
935 for player in controller._players.values():
936 player.set_initialized()
937 player.update_state(signal_event=False)
938 assert member.synced_to == "leader"
939 return controller, leader, member, target
940
941 async def test_own_group_is_dissolved_before_it_joins(self, mock_mass: MagicMock) -> None:
942 """
943 The members are released before the leader becomes a member itself.
944
945 A native group outlives its session, so a leader that stopped still lists its
946 members. Joining another group leaves it unable to serve them, and they would be
947 silent while the UI still shows them grouped.
948 """
949 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
950
951 await controller._handle_set_members(target, player_ids_to_add=["leader"])
952
953 assert "member" not in leader.group_members
954 assert member.synced_to is None
955 assert target.group_members == ["target", "leader"]
956
957 async def test_a_leader_without_members_is_left_alone(self, mock_mass: MagicMock) -> None:
958 """A player that leads nothing has no group to give up."""
959 controller, leader, _member, target = self._build(mock_mass, SessionBoundMockPlayer)
960 leader._attr_group_members = []
961 leader.update_state(signal_event=False)
962 leader.set_members = AsyncMock() # type: ignore[method-assign]
963
964 await controller._handle_set_members(target, player_ids_to_add=["leader"])
965
966 leader.set_members.assert_not_awaited()
967 assert target.group_members == ["target", "leader"]
968
969 async def test_a_playing_group_is_never_torn_down(self, mock_mass: MagicMock) -> None:
970 """
971 A leader still serving its own group stays out instead of silencing its members.
972
973 Grouping never offers a rendering leader as a target, but the candidate list is a
974 snapshot: it is only recomputed when a group membership or availability changes, so
975 one taken before playback started still lists the leader.
976 """
977 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
978 leader._attr_playback_state = PlaybackState.PLAYING
979 leader.update_state(signal_event=False)
980 leader.set_members = AsyncMock() # type: ignore[method-assign]
981
982 await controller._handle_set_members(target, player_ids_to_add=["leader"])
983
984 leader.set_members.assert_not_awaited()
985 assert target.group_members == []
986 assert member.synced_to == "leader"
987
988 async def test_a_group_that_cannot_be_dissolved_keeps_the_player_out(
989 self, mock_mass: MagicMock
990 ) -> None:
991 """
992 A leader that could not give its group up stays out instead of joining on top of it.
993
994 Joining anyway leaves it leading a group it can no longer serve, and a player that
995 is synced refuses every later member change, so the state cannot be cleaned up.
996 """
997 controller, leader, member, target = self._build(mock_mass, SessionBoundMockPlayer)
998 leader.set_members = AsyncMock(side_effect=RuntimeError("speaker unreachable")) # type: ignore[method-assign]
999
1000 await controller._handle_set_members(target, player_ids_to_add=["leader"])
1001
1002 assert target.group_members == []
1003 assert member.synced_to == "leader"
1004
1005 async def test_a_group_that_survives_the_join_is_left_alone(self, mock_mass: MagicMock) -> None:
1006 """A provider whose members do not ride the leader's own stream keeps its group."""
1007 controller, leader, member, target = self._build(mock_mass, MockPlayer)
1008
1009 await controller._handle_set_members(target, player_ids_to_add=["leader"])
1010
1011 assert leader.group_members == ["leader", "member"]
1012 assert member.synced_to == "leader"
1013
1014
1015if __name__ == "__main__":
1016 pytest.main([__file__, "-v"])
1017