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