/
/
/
1"""
2Tests for PlayerController high-level operations.
3
4This module tests:
5- cmd_set_members validation and execution
6- Group/ungroup commands
7- Player state management
8- Cache invalidation after grouping operations
9"""
10
11from __future__ import annotations
12
13import asyncio
14import contextlib
15import time
16from collections.abc import AsyncIterator, Callable, Iterator
17from types import SimpleNamespace
18from typing import cast
19from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
20
21import pytest
22from music_assistant_models.auth import User, UserRole
23from music_assistant_models.constants import (
24 PLAYER_CONTROL_FAKE,
25 PLAYER_CONTROL_NATIVE,
26 PLAYER_CONTROL_NONE,
27)
28from music_assistant_models.enums import (
29 EventType,
30 MediaType,
31 PlaybackState,
32 PlayerFeature,
33 PlayerType,
34)
35from music_assistant_models.errors import (
36 InvalidDataError,
37 PlayerCommandFailed,
38 UnsupportedFeaturedException,
39)
40from music_assistant_models.player import OutputProtocol, PlayerMedia, PlayerSource
41from music_assistant_models.player_control import PlayerControl
42
43from music_assistant.constants import (
44 ATTR_FAKE_MUTE,
45 ATTR_MUTE_LOCK,
46 ATTR_PREVIOUS_VOLUME,
47 CONF_AUTO_PLAY,
48 CONF_MAX_VOLUME,
49 CONF_MIN_VOLUME,
50 CONF_MUTE_CONTROL,
51 CONF_POWER_CONTROL,
52 CONF_VOLUME_CONTROL,
53)
54from music_assistant.controllers.players import PlayerController
55from music_assistant.controllers.webserver.helpers.auth_middleware import current_user
56from music_assistant.models.player_provider import PlayerProvider
57from tests.common import MockPlayer, MockProvider, create_mock_config
58
59
60def _player_config_stub(
61 values: dict[str, object] | None = None,
62 *,
63 min_volume: int = 0,
64 max_volume: int = 100,
65) -> Callable[..., object]:
66 """
67 Build a ``get_raw_player_config_value`` side effect.
68
69 :param values: Extra config keys to answer, e.g. ``{CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE}``.
70 :param min_volume: Value returned for the ``min_volume`` key.
71 :param max_volume: Value returned for the ``max_volume`` key.
72 """
73 config: dict[str, object] = {
74 CONF_MIN_VOLUME: min_volume,
75 CONF_MAX_VOLUME: max_volume,
76 **(values or {}),
77 }
78
79 def _conf(_player_id: str, key: str, default: object = None) -> object:
80 if key in config:
81 return config[key]
82 return default
83
84 return _conf
85
86
87def _announcement() -> PlayerMedia:
88 """Return the announcement to play."""
89 return PlayerMedia(
90 uri="http://ma/announcement/player_1.mp3",
91 media_type=MediaType.ANNOUNCEMENT,
92 title="Announcement",
93 duration=3,
94 )
95
96
97def _mute_natively(player: MockPlayer) -> AsyncMock:
98 """Give the player a native mute control, mute it and return its mute handler."""
99 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
100 player._attr_volume_muted = True
101 mute_mock = AsyncMock(side_effect=lambda muted: setattr(player, "_attr_volume_muted", muted))
102 player.volume_mute = mute_mock # type: ignore[method-assign]
103 player._cache.clear()
104 player.update_state(force_update=True, signal_event=False)
105 assert player.state.volume_muted is True
106 return mute_mock
107
108
109@pytest.fixture
110def mock_mass() -> MagicMock:
111 """Create a mock MusicAssistant instance."""
112 mass = MagicMock()
113 mass.closing = False
114 mass.loop = None
115 mass.config = MagicMock()
116 mass.config.get = MagicMock(return_value=[])
117 mass.config.get_raw_player_config_value = MagicMock(side_effect=_player_config_stub())
118 # Return "GLOBAL" for log level config (standard default)
119 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
120 mass.config.set = MagicMock()
121 mass.signal_event = MagicMock()
122 mass.get_providers = MagicMock(return_value=[])
123 return mass
124
125
126@pytest.fixture
127def controller(mock_mass: MagicMock) -> PlayerController:
128 """Create a PlayerController instance."""
129 return PlayerController(mock_mass)
130
131
132class TestSetMembersValidation:
133 """Test cmd_set_members validation logic."""
134
135 def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
136 """Test that set_members requires SET_MEMBERS feature."""
137 controller = PlayerController(mock_mass)
138 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
139
140 leader = MockPlayer(provider, "leader", "Leader")
141 # Note: NOT adding SET_MEMBERS feature
142
143 member = MockPlayer(provider, "member", "Member")
144
145 controller._players = {"leader": leader, "member": member}
146 mock_mass.players = controller
147
148 # Should raise exception because leader doesn't support SET_MEMBERS
149 with pytest.raises(UnsupportedFeaturedException):
150 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
151
152 def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
153 """Test that incompatible players cannot be grouped."""
154 controller = PlayerController(mock_mass)
155 provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
156 provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
157
158 player_a = MockPlayer(provider_a, "player_a", "Player A")
159 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
160 player_a._attr_can_group_with = {"provider_a"} # Only same provider
161
162 player_b = MockPlayer(provider_b, "player_b", "Player B")
163
164 controller._players = {"player_a": player_a, "player_b": player_b}
165 mock_mass.players = controller
166
167 # Should raise exception because players are incompatible
168 with pytest.raises(UnsupportedFeaturedException):
169 asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
170
171
172class TestCacheInvalidationAfterGrouping:
173 """Test that caches are invalidated after grouping operations."""
174
175 async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
176 """
177 Test that all players' caches are cleared after set_members.
178
179 Regression test for: Stale can_group_with cache after grouping changes.
180 """
181 controller = PlayerController(mock_mass)
182 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
183
184 leader = MockPlayer(provider, "leader", "Leader")
185 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
186 leader._attr_can_group_with = {"test"}
187 leader._attr_group_members = []
188
189 member = MockPlayer(provider, "member", "Member")
190
191 other = MockPlayer(provider, "other", "Other")
192 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
193 other._attr_can_group_with = {"test"}
194
195 controller._players = {"leader": leader, "member": member, "other": other}
196 mock_mass.players = controller
197
198 # Populate caches
199 _ = leader.state.can_group_with
200 _ = other.state.can_group_with
201
202 # Simulate grouping (normally done by provider's set_members implementation)
203 leader._attr_group_members = ["leader", "member"]
204
205 # Call set_members to trigger cache invalidation
206 await controller._handle_set_members_with_protocols(
207 leader, player_ids_to_add=["member"], player_ids_to_remove=[]
208 )
209
210 # Note: The actual cache clearing happens via trigger_player_update
211 # which schedules update_state to be called later
212 # In a real scenario, this would clear all players' caches
213
214
215class TestNativeSetMembersGuard:
216 """Test the SET_MEMBERS feature guard on native set_members forwarding."""
217
218 async def test_native_set_members_skipped_without_feature(self, mock_mass: MagicMock) -> None:
219 """
220 Test that set_members is not called on a player without SET_MEMBERS support.
221
222 Regression test for: NotImplementedError raised from
223 _cleanup_player_memberships when removing a member from a native player
224 whose group membership is managed externally (e.g. a Google Cast group,
225 which never advertises SET_MEMBERS).
226 """
227 controller = PlayerController(mock_mass)
228 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
229
230 # a native group-like player WITHOUT SET_MEMBERS in supported_features,
231 # whose set_members behaves like the Player base class (raises)
232 parent = MockPlayer(provider, "cast_group", "Cast Group")
233 parent._attr_group_members = ["cast_group", "member"]
234 parent.set_members = AsyncMock( # type: ignore[method-assign]
235 side_effect=NotImplementedError(
236 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
237 )
238 )
239 member = MockPlayer(provider, "member", "Member")
240
241 controller._players = {"cast_group": parent, "member": member}
242 mock_mass.players = controller
243
244 # must complete without raising NotImplementedError
245 await controller._handle_set_members_with_protocols(
246 parent, player_ids_to_add=[], player_ids_to_remove=["member"]
247 )
248 parent.set_members.assert_not_called()
249
250
251class TestGroupUngroup:
252 """Test group and ungroup commands."""
253
254 async def test_group_command(self, mock_mass: MagicMock) -> None:
255 """Test the group command (cmd_group)."""
256 controller = PlayerController(mock_mass)
257 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
258
259 leader = MockPlayer(provider, "leader", "Leader")
260 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
261 leader._attr_can_group_with = {"member"} # Leader can group with member
262
263 member = MockPlayer(provider, "member", "Member")
264 # Make sure member is already powered on to skip power handling
265 member._attr_powered = True
266
267 controller._players = {"leader": leader, "member": member}
268 mock_mass.players = controller
269
270 # Update state after modifying attributes and registering with controller
271 leader.update_state(signal_event=False)
272 member.update_state(signal_event=False)
273
274 # Track if set_members was called
275 set_members_called = False
276 original_set_members = leader.set_members
277
278 async def mock_set_members(
279 player_ids_to_add: list[str] | None = None,
280 player_ids_to_remove: list[str] | None = None,
281 ) -> None:
282 nonlocal set_members_called
283 set_members_called = True
284 # Call the original to update group_members
285 await original_set_members(player_ids_to_add, player_ids_to_remove)
286
287 leader.set_members = mock_set_members # type: ignore[method-assign]
288
289 # Mock power handling to skip power control (focus is on grouping logic)
290 async def mock_handle_cmd_power(
291 player_id: str, powered: bool, skip_auto_play: bool = False
292 ) -> None:
293 pass
294
295 controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
296
297 # Execute group command
298 await controller.cmd_group("member", "leader")
299
300 # Verify set_members was called
301 assert set_members_called
302 # Verify member was added to leader's group
303 assert "member" in leader._attr_group_members
304
305
306class TestPlayerAvailability:
307 """Test player availability checks in grouping."""
308
309 def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
310 """Test that unavailable players are rejected when grouping."""
311 controller = PlayerController(mock_mass)
312 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
313
314 leader = MockPlayer(provider, "leader", "Leader")
315 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
316 leader._attr_can_group_with = {"test"}
317
318 member = MockPlayer(provider, "member", "Member")
319 member._attr_available = False # Mark as unavailable
320
321 controller._players = {"leader": leader, "member": member}
322 mock_mass.players = controller
323
324 # Attempting to group with unavailable player should be handled
325 # (either silently ignored or raise exception depending on implementation)
326 # This should either skip the unavailable player or raise an exception
327 with contextlib.suppress(Exception):
328 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
329
330
331def _group_with_member(
332 mock_mass: MagicMock, *, initialize_group: bool = True
333) -> tuple[PlayerController, MockPlayer, MockPlayer]:
334 """
335 Build a controller holding one group player with a single member.
336
337 :param mock_mass: The mocked MusicAssistant instance to attach the controller to.
338 :param initialize_group: Whether the group player is marked as fully registered.
339
340 :return: The controller, the group player and its member.
341 """
342 controller = PlayerController(mock_mass)
343 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
344
345 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
346 member = MockPlayer(provider, "member", "Member")
347
348 controller._players = {"group": group_player, "member": member}
349 mock_mass.players = controller
350
351 group_player._attr_group_members = ["member"]
352 member.initialized.set()
353 if initialize_group:
354 group_player.initialized.set()
355 for player in (group_player, member):
356 player.update_state(signal_event=False)
357 return controller, group_player, member
358
359
360@contextlib.contextmanager
361def _restricted_user(visible_player_ids: list[str]) -> Iterator[None]:
362 """
363 Run the wrapped block as a non-admin user that may only see the given players.
364
365 :param visible_player_ids: The player ids the user is allowed to see.
366 """
367 # the contextvar is copied into the task an API command runs in, so it stays
368 # live for everything that command reaches, internal bookkeeping included
369 token = current_user.set(
370 User(
371 user_id="user_1",
372 username="restricted",
373 role=UserRole.USER,
374 player_filter=visible_player_ids,
375 )
376 )
377 try:
378 yield
379 finally:
380 current_user.reset(token)
381
382
383class TestStateForwarding:
384 """Test forwarding of player state changes to related players."""
385
386 def test_sync_leader_updates_are_forwarded_to_sync_children(self, mock_mass: MagicMock) -> None:
387 """A regular sync leader must notify children via the sync-parent callback."""
388 controller = PlayerController(mock_mass)
389 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
390
391 leader = MockPlayer(provider, "leader", "Leader")
392 child = MockPlayer(provider, "child", "Child")
393
394 controller._players = {"leader": leader, "child": child}
395 mock_mass.players = controller
396
397 leader._attr_group_members = ["leader", "child"]
398 leader.update_state(signal_event=False)
399 child.update_state(signal_event=False)
400
401 with (
402 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
403 patch.object(child, "on_group_updated") as on_group_updated,
404 ):
405 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
406 controller._forward_state_update(leader, changed_values)
407
408 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
409 on_group_updated.assert_not_called()
410
411 def test_group_updates_are_forwarded_to_children_via_group_callback(
412 self, mock_mass: MagicMock
413 ) -> None:
414 """A group player must continue to notify children via the group callback."""
415 controller = PlayerController(mock_mass)
416 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
417
418 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
419 child = MockPlayer(provider, "child", "Child")
420
421 controller._players = {"group": group_player, "child": child}
422 mock_mass.players = controller
423
424 group_player._attr_group_members = ["group", "child"]
425 group_player.update_state(signal_event=False)
426 child.update_state(signal_event=False)
427
428 with (
429 patch.object(child, "on_group_updated") as on_group_updated,
430 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
431 ):
432 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
433 controller._forward_state_update(group_player, changed_values)
434
435 on_group_updated.assert_called_once_with(
436 group_player,
437 changed_values,
438 )
439 on_sync_parent_updated.assert_not_called()
440
441 def test_sync_leader_updates_also_reach_its_group_player(self, mock_mass: MagicMock) -> None:
442 """A sync leader must notify its group player as well as its own sync children."""
443 controller = PlayerController(mock_mass)
444 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
445
446 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
447 leader = MockPlayer(provider, "leader", "Leader")
448 follower = MockPlayer(provider, "follower", "Follower")
449
450 controller._players = {"group": group_player, "leader": leader, "follower": follower}
451 mock_mass.players = controller
452
453 group_player._attr_group_members = ["leader", "follower"]
454 leader._attr_group_members = ["leader", "follower"]
455 for player in (group_player, leader, follower):
456 # _get_player_groups only considers players the controller finished registering
457 player.initialized.set()
458 player.update_state(signal_event=False)
459
460 with (
461 patch.object(group_player, "on_group_member_updated") as on_group_member_updated,
462 patch.object(follower, "on_sync_parent_updated") as on_sync_parent_updated,
463 ):
464 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
465 controller._forward_state_update(leader, changed_values)
466
467 on_group_member_updated.assert_called_once_with(leader, changed_values)
468 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
469
470 def test_group_player_is_notified_under_restricted_user_context(
471 self, mock_mass: MagicMock
472 ) -> None:
473 """The fan-out must not be narrowed by the user that triggered the update."""
474 controller, group_player, member = _group_with_member(mock_mass)
475
476 with _restricted_user(["member"]):
477 assert group_player not in controller.all_players()
478 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
479 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
480 controller._forward_state_update(member, changed_values)
481
482 on_group_member_updated.assert_called_once_with(member, changed_values)
483
484 def test_unavailable_group_player_is_still_notified(self, mock_mass: MagicMock) -> None:
485 """A group player mirrors its members, so it must update while unavailable too."""
486 controller, group_player, member = _group_with_member(mock_mass)
487 group_player._attr_available = False
488 group_player.update_state(signal_event=False)
489 assert group_player.state.available is False
490
491 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
492 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
493 controller._forward_state_update(member, changed_values)
494
495 on_group_member_updated.assert_called_once_with(member, changed_values)
496
497 def test_disabled_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
498 """A disabled player takes no part, unlike one that is merely unavailable."""
499 controller, group_player, member = _group_with_member(mock_mass)
500 group_player._config.enabled = False
501 group_player.update_state(signal_event=False, force_update=True)
502 assert group_player.state.enabled is False
503
504 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
505 controller._forward_state_update(
506 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
507 )
508
509 on_group_member_updated.assert_not_called()
510
511 def test_uninitialized_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
512 """A player the controller is still registering is not fully set up yet."""
513 controller, group_player, member = _group_with_member(mock_mass, initialize_group=False)
514
515 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
516 controller._forward_state_update(
517 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
518 )
519
520 on_group_member_updated.assert_not_called()
521
522
523class TestUnscopedPlayerLookups:
524 """Derived player state must not depend on who triggered the recalculation."""
525
526 def test_iter_players_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
527 """iter_players is the internal view: every registered player, no user filter."""
528 controller, group_player, member = _group_with_member(mock_mass)
529
530 with _restricted_user(["member"]):
531 assert controller.all_players() == [member]
532 assert set(controller.iter_players()) == {group_player, member}
533
534 def test_provider_filter_still_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
535 """A lookup scoped to one provider must still ignore the user filter."""
536 controller, group_player, member = _group_with_member(mock_mass)
537
538 with _restricted_user(["member"]):
539 found = set(controller.iter_players(provider_filter="test"))
540
541 assert found == {group_player, member}
542
543 def test_provider_sees_all_of_its_own_players(self, mock_mass: MagicMock) -> None:
544 """A provider owns its players, so a user filter must never hide them from it."""
545 controller = PlayerController(mock_mass)
546 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
547
548 plain = MockPlayer(provider, "plain", "Plain")
549 # a protocol player id is picked from a list that hides protocol players, so it
550 # is never in a user's filter - the whole set used to disappear for any non-admin
551 protocol = MockPlayer(provider, "protocol", "Protocol", player_type=PlayerType.PROTOCOL)
552 controller._players = {"plain": plain, "protocol": protocol}
553 mock_mass.players = controller
554 for player in (plain, protocol):
555 player.initialized.set()
556 player.update_state(signal_event=False)
557
558 with _restricted_user(["plain"]):
559 owned = set(PlayerProvider.players.fget(provider)) # type: ignore[attr-defined]
560
561 assert owned == {plain, protocol}
562
563 def test_active_group_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
564 """A member must still know its group when the triggering user cannot see it."""
565 _controller, group_player, member = _group_with_member(mock_mass)
566 group_player._attr_group_members = ["member"]
567 group_player._attr_powered = True
568 group_player.update_state(signal_event=False)
569
570 with _restricted_user(["member"]):
571 member.update_state(signal_event=False, force_update=True)
572 assert member.state.active_group == "group"
573
574 def test_synced_to_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
575 """A follower must still resolve its sync leader across a user filter."""
576 controller = PlayerController(mock_mass)
577 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
578
579 leader = MockPlayer(provider, "leader", "Leader")
580 follower = MockPlayer(provider, "follower", "Follower")
581 controller._players = {"leader": leader, "follower": follower}
582 mock_mass.players = controller
583
584 leader._attr_group_members = ["leader", "follower"]
585 for player in (leader, follower):
586 player.initialized.set()
587 player.update_state(signal_event=False)
588
589 with _restricted_user(["follower"]):
590 follower.update_state(signal_event=False, force_update=True)
591 assert follower.state.synced_to == "leader"
592
593 def test_can_group_with_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
594 """Expanding a provider id into players must not drop players the user cannot see."""
595 controller = PlayerController(mock_mass)
596 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
597 mock_mass.get_provider.return_value = provider
598
599 player_a = MockPlayer(provider, "player_a", "Player A")
600 player_b = MockPlayer(provider, "player_b", "Player B")
601 controller._players = {"player_a": player_a, "player_b": player_b}
602 mock_mass.players = controller
603
604 # a provider instance id stands for "every player of that provider"
605 player_a._attr_can_group_with = {"test"}
606 for player in (player_a, player_b):
607 player.initialized.set()
608 player.update_state(signal_event=False)
609
610 with _restricted_user(["player_a"]):
611 player_a.update_state(signal_event=False, force_update=True)
612 assert "player_b" in player_a.state.can_group_with
613
614
615class TestSleepTimer:
616 """Test native sleep timer handling."""
617
618 def test_set_and_clear_sleep_timer(self, mock_mass: MagicMock) -> None:
619 """Setting a sleep timer exposes state and schedules the stop callback."""
620 controller = PlayerController(mock_mass)
621 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
622 player = MockPlayer(provider, "player_1", "Player 1")
623 controller._players = {"player_1": player}
624 mock_mass.players = controller
625
626 expires_at = controller.set_sleep_timer("player_1", 30)
627
628 assert controller.get_sleep_timer("player_1") == expires_at
629 assert player.sleep_timer_expires_at == expires_at
630 mock_mass.call_later.assert_called_with(
631 30,
632 controller._handle_sleep_timer_expired,
633 "player_1",
634 task_id="player_sleep_timer_player_1",
635 )
636 mock_mass.signal_event.assert_any_call(
637 EventType.PLAYER_SLEEP_TIMER_UPDATED,
638 object_id="player_1",
639 data=expires_at,
640 )
641
642 controller.clear_sleep_timer("player_1")
643
644 # get_sleep_timer reads the model field directly, so this also asserts it cleared
645 assert controller.get_sleep_timer("player_1") is None
646 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
647 mock_mass.signal_event.assert_any_call(
648 EventType.PLAYER_SLEEP_TIMER_UPDATED,
649 object_id="player_1",
650 data=None,
651 )
652
653 async def test_sleep_timer_removed_when_player_unregistered(self, mock_mass: MagicMock) -> None:
654 """Unregistering a player cancels and clears its sleep timer."""
655 controller = PlayerController(mock_mass)
656 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
657 player = MockPlayer(provider, "player_1", "Player 1")
658 controller._players = {"player_1": player}
659 player.set_sleep_timer_expires_at(123.0)
660
661 await controller.unregister("player_1")
662
663 assert player.sleep_timer_expires_at is None
664 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
665
666 async def test_sleep_timer_expiry_stops_player(self, mock_mass: MagicMock) -> None:
667 """An expired sleep timer clears its state and stops playback."""
668 controller = PlayerController(mock_mass)
669 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
670 player = MockPlayer(provider, "player_1", "Player 1")
671 controller._players = {"player_1": player}
672 player.set_sleep_timer_expires_at(123.0)
673 controller.cmd_stop = AsyncMock() # type: ignore[method-assign]
674
675 await controller._handle_sleep_timer_expired("player_1")
676
677 assert controller.get_sleep_timer("player_1") is None
678 assert player.sleep_timer_expires_at is None
679 controller.cmd_stop.assert_awaited_once_with("player_1")
680 mock_mass.signal_event.assert_any_call(
681 EventType.PLAYER_SLEEP_TIMER_UPDATED,
682 object_id="player_1",
683 data=None,
684 )
685
686 def test_set_sleep_timer_rejects_invalid_duration(self, mock_mass: MagicMock) -> None:
687 """A non-positive or float-overflowing duration raises and schedules nothing."""
688 controller = PlayerController(mock_mass)
689 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
690 player = MockPlayer(provider, "player_1", "Player 1")
691 controller._players = {"player_1": player}
692
693 # 0/-30 are non-positive; 10**400 exceeds the float range for the expiry math
694 for invalid in (0, -30, 10**400):
695 with pytest.raises(InvalidDataError):
696 controller.set_sleep_timer("player_1", invalid)
697
698 assert controller.get_sleep_timer("player_1") is None
699 mock_mass.call_later.assert_not_called()
700
701
702class TestUnregisterCleanup:
703 """Test that unregister cleans up leaked internal state."""
704
705 def test_command_locks_removed(self, mock_mass: MagicMock) -> None:
706 """Unregistering a player removes its command lock entries."""
707 controller = PlayerController(mock_mass)
708 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
709 player = MockPlayer(provider, "player_1", "Player 1")
710
711 controller._players = {"player_1": player}
712 controller._player_command_locks = {
713 "playback_player_1": asyncio.Lock(),
714 "volume_player_1": asyncio.Lock(),
715 }
716
717 asyncio.run(controller.unregister("player_1"))
718
719 assert "playback_player_1" not in controller._player_command_locks
720 assert "volume_player_1" not in controller._player_command_locks
721
722 def test_other_players_state_untouched(self, mock_mass: MagicMock) -> None:
723 """Unregistering one player does not affect another player's state."""
724 controller = PlayerController(mock_mass)
725 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
726 player_a = MockPlayer(provider, "player_a", "Player A")
727 player_b = MockPlayer(provider, "player_b", "Player B")
728
729 controller._players = {"player_a": player_a, "player_b": player_b}
730 controller._player_command_locks = {
731 "playback_player_a": asyncio.Lock(),
732 "playback_player_b": asyncio.Lock(),
733 }
734
735 asyncio.run(controller.unregister("player_a"))
736
737 assert "playback_player_b" in controller._player_command_locks
738 assert "playback_player_a" not in controller._player_command_locks
739
740 def test_suffix_player_id_not_over_matched(self, mock_mass: MagicMock) -> None:
741 """Removing player 'b' must not remove locks for player 'a_b' (no suffix matching)."""
742 controller = PlayerController(mock_mass)
743 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
744 player_b = MockPlayer(provider, "b", "Player B")
745 player_a_b = MockPlayer(provider, "a_b", "Player A_B")
746
747 controller._players = {"b": player_b, "a_b": player_a_b}
748 controller._player_command_locks = {
749 "playback_b": asyncio.Lock(),
750 "playback_a_b": asyncio.Lock(),
751 }
752
753 asyncio.run(controller.unregister("b"))
754
755 assert "playback_a_b" in controller._player_command_locks
756 assert "playback_b" not in controller._player_command_locks
757
758 def test_pending_protocol_evaluation_cancelled(self, mock_mass: MagicMock) -> None:
759 """Unregistering a player cancels and removes its pending protocol evaluation."""
760 controller = PlayerController(mock_mass)
761 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
762 player = MockPlayer(provider, "player_1", "Player 1")
763
764 mock_handle = MagicMock()
765 controller._players = {"player_1": player}
766 controller._pending_protocol_evaluations = {"player_1": mock_handle}
767
768 asyncio.run(controller.unregister("player_1"))
769
770 mock_handle.cancel.assert_called_once()
771 assert "player_1" not in controller._pending_protocol_evaluations
772
773 def test_unregister_nonexistent_player_is_noop(self, mock_mass: MagicMock) -> None:
774 """Unregistering a player that doesn't exist is silently ignored."""
775 controller = PlayerController(mock_mass)
776 controller._player_command_locks = {"set_members_other": asyncio.Lock()}
777
778 asyncio.run(controller.unregister("nonexistent"))
779
780 assert "set_members_other" in controller._player_command_locks
781
782
783class TestRegisterUnregisterRace:
784 """Test registration that is interrupted by an unregister of the same player."""
785
786 @staticmethod
787 def _stub_register_calls(mock_mass: MagicMock) -> None:
788 """Stub the awaited mass calls made during register/unregister."""
789 # registration reads config keys with differently typed defaults (mapping for the
790 # player config store, str | None for the cached MAC addresses)
791 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
792 mock_mass.cache.get = AsyncMock(return_value=None)
793 mock_mass.config.get_player_config = AsyncMock(return_value=create_mock_config("Player 1"))
794 mock_mass.player_queues.on_player_register = AsyncMock()
795 mock_mass.player_queues.on_player_remove = MagicMock()
796
797 @staticmethod
798 def _player_added_signalled(mock_mass: MagicMock) -> bool:
799 """Return True if a PLAYER_ADDED event was signalled."""
800 return any(
801 call_args.args and call_args.args[0] == EventType.PLAYER_ADDED
802 for call_args in mock_mass.signal_event.call_args_list
803 )
804
805 async def test_register_aborts_when_unregistered_during_config_load(
806 self, mock_mass: MagicMock
807 ) -> None:
808 """A player unregistered while its config loads is not announced as added."""
809 controller = PlayerController(mock_mass)
810 self._stub_register_calls(mock_mass)
811 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
812 player = MockPlayer(provider, "player_1", "Player 1")
813
814 async def _unregister_midway(player_id: str) -> MagicMock:
815 # stands in for a provider unload or device disconnect landing
816 # while register awaits the player config
817 await controller.unregister(player_id)
818 return create_mock_config("Player 1")
819
820 mock_mass.config.get_player_config = AsyncMock(side_effect=_unregister_midway)
821 config_hook = AsyncMock()
822
823 with (
824 patch(
825 "music_assistant.controllers.players.controller.enrich_device_mac_address",
826 AsyncMock(),
827 ),
828 patch.object(player, "on_config_updated", config_hook),
829 ):
830 await controller.register(player)
831
832 assert "player_1" not in controller._players
833 assert not player.initialized.is_set()
834 # setup stops right away: the provider hook must not run on a player
835 # whose on_unload already ran
836 config_hook.assert_not_called()
837 mock_mass.player_queues.on_player_register.assert_not_called()
838 assert not self._player_added_signalled(mock_mass)
839
840 async def test_register_aborts_when_unregistered_during_config_hook(
841 self, mock_mass: MagicMock
842 ) -> None:
843 """A player unregistered while its on_config_updated hook runs is not announced."""
844 controller = PlayerController(mock_mass)
845 self._stub_register_calls(mock_mass)
846 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
847 player = MockPlayer(provider, "player_1", "Player 1")
848
849 async def _unregister_midway() -> None:
850 await controller.unregister("player_1")
851
852 with (
853 patch(
854 "music_assistant.controllers.players.controller.enrich_device_mac_address",
855 AsyncMock(),
856 ),
857 patch.object(player, "on_config_updated", AsyncMock(side_effect=_unregister_midway)),
858 ):
859 await controller.register(player)
860
861 assert "player_1" not in controller._players
862 assert not player.initialized.is_set()
863 mock_mass.player_queues.on_player_register.assert_not_called()
864 assert not self._player_added_signalled(mock_mass)
865
866 async def test_register_drops_queue_recreated_after_unregister(
867 self, mock_mass: MagicMock
868 ) -> None:
869 """A queue restored after the unregister already removed it is dropped again."""
870 controller = PlayerController(mock_mass)
871 self._stub_register_calls(mock_mass)
872 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
873 player = MockPlayer(provider, "player_1", "Player 1")
874
875 events: list[str] = []
876
877 async def _unregister_midway(registering_player: MockPlayer) -> None:
878 # on_player_register restores the queue from cache before storing it, so an
879 # unregister can land in between and have its cleanup undone
880 await controller.unregister(registering_player.player_id)
881 events.append("queue_created")
882
883 def _track_removal(*_args: object, **_kwargs: object) -> None:
884 events.append("queue_removed")
885
886 mock_mass.player_queues.on_player_remove = MagicMock(side_effect=_track_removal)
887 mock_mass.player_queues.on_player_register = AsyncMock(side_effect=_unregister_midway)
888
889 with patch(
890 "music_assistant.controllers.players.controller.enrich_device_mac_address",
891 AsyncMock(),
892 ):
893 await controller.register(player)
894
895 assert "player_1" not in controller._players
896 # the queue recreated for the removed player must be cleaned up again
897 assert events == ["queue_removed", "queue_created", "queue_removed"]
898
899 async def test_register_or_update_marks_replacement_initialized(
900 self, mock_mass: MagicMock
901 ) -> None:
902 """Replacing a registered player carries the initialized state to the new object."""
903 controller = PlayerController(mock_mass)
904 self._stub_register_calls(mock_mass)
905 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
906 existing = MockPlayer(provider, "player_1", "Player 1")
907 existing.set_initialized()
908 controller._players = {"player_1": existing}
909 replacement = MockPlayer(provider, "player_1", "Player 1")
910
911 await controller.register_or_update(replacement)
912
913 assert controller._players["player_1"] is replacement
914 assert replacement.initialized.is_set()
915
916 async def test_register_or_update_hands_config_to_replacement(
917 self, mock_mass: MagicMock
918 ) -> None:
919 """A replacement instance inherits the config of the player it takes over."""
920 controller = PlayerController(mock_mass)
921 self._stub_register_calls(mock_mass)
922 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
923 existing = MockPlayer(provider, "player_1", "Player 1")
924 resolved_config = create_mock_config("Player 1")
925 existing.set_config(resolved_config)
926 existing.set_initialized()
927 controller._players = {"player_1": existing}
928 replacement = MockPlayer(provider, "player_1", "Player 1")
929 config_hook = AsyncMock()
930
931 with patch.object(replacement, "on_config_updated", config_hook):
932 await controller.register_or_update(replacement)
933
934 # without the resolved config the replacement would read defaults for every
935 # config backed setting (group members, flow mode, visibility, ...)
936 assert replacement.config is resolved_config
937 config_hook.assert_awaited_once()
938
939 async def test_register_or_update_aborts_when_replacement_is_unregistered(
940 self, mock_mass: MagicMock
941 ) -> None:
942 """A replacement unregistered while its config hook runs is not marked ready."""
943 controller = PlayerController(mock_mass)
944 self._stub_register_calls(mock_mass)
945 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
946 existing = MockPlayer(provider, "player_1", "Player 1")
947 existing.set_config(create_mock_config("Player 1"))
948 existing.set_initialized()
949 controller._players = {"player_1": existing}
950 replacement = MockPlayer(provider, "player_1", "Player 1")
951
952 async def _unregister_midway() -> None:
953 await controller.unregister("player_1")
954
955 with patch.object(
956 replacement, "on_config_updated", AsyncMock(side_effect=_unregister_midway)
957 ):
958 await controller.register_or_update(replacement)
959
960 assert "player_1" not in controller._players
961 assert not replacement.initialized.is_set()
962
963 async def test_register_or_update_leaves_same_instance_untouched(
964 self, mock_mass: MagicMock
965 ) -> None:
966 """Re-announcing the same instance does not re-run its config hook."""
967 controller = PlayerController(mock_mass)
968 self._stub_register_calls(mock_mass)
969 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
970 player = MockPlayer(provider, "player_1", "Player 1")
971 player.set_initialized()
972 controller._players = {"player_1": player}
973 config_hook = AsyncMock()
974
975 with patch.object(player, "on_config_updated", config_hook):
976 await controller.register_or_update(player)
977
978 assert controller._players["player_1"] is player
979 config_hook.assert_not_called()
980
981 async def test_register_or_update_waits_for_inflight_register(
982 self, mock_mass: MagicMock
983 ) -> None:
984 """A player is never swapped out while register() is still setting it up."""
985 controller = PlayerController(mock_mass)
986 self._stub_register_calls(mock_mass)
987 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
988 player = MockPlayer(provider, "player_1", "Player 1")
989 replacement = MockPlayer(provider, "player_1", "Player 1")
990 release = asyncio.Event()
991 registering = asyncio.Event()
992
993 async def _blocked_config(*_args: object) -> MagicMock:
994 registering.set()
995 await release.wait()
996 return create_mock_config("Player 1")
997
998 mock_mass.config.get_player_config = AsyncMock(side_effect=_blocked_config)
999
1000 with patch(
1001 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1002 AsyncMock(),
1003 ):
1004 register_task = asyncio.create_task(controller.register(player))
1005 # only proceed once register() is provably inside its critical section
1006 await registering.wait()
1007 update_task = asyncio.create_task(controller.register_or_update(replacement))
1008 await _yield_to_loop()
1009
1010 # register() is still in flight, so the replacement must not be swapped in yet
1011 assert controller._players["player_1"] is player
1012 assert not update_task.done()
1013
1014 release.set()
1015 await register_task
1016 await update_task
1017
1018 assert controller._players["player_1"] is replacement
1019 assert player.initialized.is_set()
1020 assert replacement.initialized.is_set()
1021
1022
1023async def _yield_to_loop() -> None:
1024 """Give other pending tasks a chance to run up to their next suspension point."""
1025 for _ in range(5):
1026 await asyncio.sleep(0)
1027
1028
1029def _set_play_media_override(mock_mass: MagicMock, value: bool) -> None:
1030 """
1031 Configure get_raw_player_config_value to return ``value`` for the play-media override key.
1032
1033 Other keys keep the existing defaults from the shared fixture. Use this in
1034 tests for ``play_media`` override behavior so the legacy/new branch is
1035 selected deterministically.
1036 """
1037 original = mock_mass.config.get_raw_player_config_value.side_effect
1038
1039 def _side_effect(player_id: str, key: str, default: object = None) -> object:
1040 if key == "play_media_overrides_group":
1041 return value
1042 if callable(original):
1043 return original(player_id, key, default)
1044 return default
1045
1046 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_side_effect)
1047
1048
1049class TestCmdUngroupNewBranches:
1050 """
1051 Regression tests for the post-refactor cmd_ungroup flow.
1052
1053 The refactor changed two things:
1054
1055 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
1056 (which would hit the "Cannot remove static member" guard); instead it
1057 stops or powers off the group entirely.
1058 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
1059 group, because static members cannot be released individually.
1060 """
1061
1062 @pytest.mark.asyncio
1063 async def test_ungroup_group_player_with_power_uses_power_off(
1064 self, mock_mass: MagicMock
1065 ) -> None:
1066 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
1067 controller = PlayerController(mock_mass)
1068 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1069 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1070 group._attr_powered = True
1071 group._attr_group_members = ["member"]
1072 group._attr_supported_features = {PlayerFeature.POWER}
1073
1074 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
1075 mock_mass.config.get_raw_player_config_value = MagicMock(
1076 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
1077 )
1078
1079 controller._players = {"g1": group}
1080 mock_mass.players = controller
1081
1082 # populate state.type / state.power_control / state.group_members
1083 group.set_initialized()
1084 group._cache.clear()
1085 group.update_state(signal_event=False)
1086
1087 called: dict[str, bool | str] = {}
1088
1089 async def _power(
1090 player_id: str,
1091 powered: bool,
1092 skip_auto_play: bool = False, # noqa: ARG001
1093 ) -> None:
1094 called["player_id"] = player_id
1095 called["powered"] = powered
1096
1097 controller._handle_cmd_power = _power # type: ignore[method-assign]
1098
1099 await controller.cmd_ungroup("g1")
1100
1101 assert called == {"player_id": "g1", "powered": False}
1102
1103 @pytest.mark.asyncio
1104 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
1105 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
1106 controller = PlayerController(mock_mass)
1107 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1108 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1109 group._attr_powered = None # no power control
1110 group._attr_group_members = ["member"]
1111 # no POWER feature → power_control auto-selects to NONE
1112
1113 controller._players = {"g1": group}
1114 mock_mass.players = controller
1115
1116 group.set_initialized()
1117 group._cache.clear()
1118 group.update_state(signal_event=False)
1119
1120 stop_called: list[str] = []
1121
1122 async def _stop(player_id: str) -> None:
1123 stop_called.append(player_id)
1124
1125 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1126 # also stub power to make sure we did NOT go down that branch
1127 power_called: list[str] = []
1128
1129 async def _power(
1130 player_id: str,
1131 powered: bool, # noqa: ARG001
1132 skip_auto_play: bool = False, # noqa: ARG001
1133 ) -> None:
1134 power_called.append(player_id)
1135
1136 controller._handle_cmd_power = _power # type: ignore[method-assign]
1137
1138 await controller.cmd_ungroup("g1")
1139
1140 assert stop_called == ["g1"]
1141 assert power_called == [] # powerless group → never goes through cmd_power
1142
1143
1144class TestExternalPowerOffUnsync:
1145 """
1146 Tests for unsyncing a player when its power is turned off outside of MA.
1147
1148 When a player's (final) power state flips on->off because its linked power
1149 control was switched off directly - rather than via an MA power command -
1150 the player must be removed from any (sync)group it is part of.
1151 """
1152
1153 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1154 """Build a controller with a player synced to a registered leader."""
1155 controller = PlayerController(mock_mass)
1156 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1157 leader = MockPlayer(provider, "leader", "Leader")
1158 leader._attr_group_members = ["leader", "p1"]
1159 player = MockPlayer(provider, "p1", "Player")
1160 controller._players = {"leader": leader, "p1": player}
1161 mock_mass.players = controller
1162 for _player in (leader, player):
1163 _player.set_initialized()
1164 _player._cache.clear()
1165 _player.update_state(signal_event=False)
1166 # isolate the unsync branch from the unrelated state-forwarding machinery
1167 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1168 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1169 return controller, player
1170
1171 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
1172 """An on->off power transition ungroups a synced player."""
1173 controller, player = self._make_synced_player(mock_mass)
1174 assert player.state.synced_to == "leader"
1175
1176 controller.signal_player_state_update(player, {"powered": (True, False)})
1177
1178 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
1179
1180 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
1181 """An off->on power transition leaves the player synced."""
1182 controller, player = self._make_synced_player(mock_mass)
1183
1184 controller.signal_player_state_update(player, {"powered": (False, True)})
1185
1186 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1187
1188 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
1189 """A None->off transition (player without power control) is ignored."""
1190 controller, player = self._make_synced_player(mock_mass)
1191
1192 controller.signal_player_state_update(player, {"powered": (None, False)})
1193
1194 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1195
1196 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
1197 """Powering off a player that is not in any group does nothing."""
1198 controller = PlayerController(mock_mass)
1199 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1200 player = MockPlayer(provider, "p1", "Player")
1201 controller._players = {"p1": player}
1202 mock_mass.players = controller
1203 player.set_initialized()
1204 player._cache.clear()
1205 player.update_state(signal_event=False)
1206 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1207 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1208
1209 controller.signal_player_state_update(player, {"powered": (True, False)})
1210
1211 controller.cmd_ungroup.assert_not_called()
1212
1213
1214class TestPlayMediaOverride:
1215 """
1216 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
1217
1218 When a captured child player receives an explicit play_media command, the
1219 default behavior is to *release* it from the active group/sync and play
1220 directly on the targeted player. The legacy behavior (forward to the
1221 leader) is preserved via the per-player config opt-out.
1222 """
1223
1224 @pytest.mark.asyncio
1225 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
1226 """With override disabled, play_media on a captured child redirects to the group."""
1227 controller = PlayerController(mock_mass)
1228 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1229 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1230
1231 class _SessionedGroup(MockPlayer):
1232 @property
1233 def is_active_session(self) -> bool:
1234 return True
1235
1236 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1237 group._attr_powered = None
1238 group._attr_group_members = ["member"]
1239
1240 member = MockPlayer(member_provider, "member", "Member")
1241
1242 controller._players = {"g1": group, "member": member}
1243 mock_mass.players = controller
1244
1245 group.set_initialized()
1246 member.set_initialized()
1247 group.update_state(signal_event=False)
1248 member.update_state(signal_event=False)
1249 # sanity: the member is captured by the group
1250 assert member.state.active_group == "g1"
1251
1252 _set_play_media_override(mock_mass, False)
1253
1254 played_on: list[str] = []
1255
1256 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1257 played_on.append(player_id)
1258
1259 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1260 # the play_media wrapper acquires a playback lock; stub it out
1261 controller._player_command_locks = {}
1262
1263 media = MagicMock(uri="x", source_id="src")
1264 await controller.play_media("member", media)
1265
1266 # legacy behavior: redirected to the group leader
1267 assert played_on == ["g1"]
1268
1269 @pytest.mark.asyncio
1270 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
1271 """With override enabled, play_media on a dynamic group member releases it first."""
1272 controller = PlayerController(mock_mass)
1273 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1274 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1275
1276 class _SessionedGroup(MockPlayer):
1277 @property
1278 def is_active_session(self) -> bool:
1279 return True
1280
1281 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1282 group._attr_powered = None
1283 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1284 group._attr_group_members = ["member"]
1285 # NOT a static member ⇒ dynamic — can be removed via set_members
1286 group._attr_static_group_members = []
1287
1288 member = MockPlayer(member_provider, "member", "Member")
1289
1290 controller._players = {"g1": group, "member": member}
1291 mock_mass.players = controller
1292
1293 group.set_initialized()
1294 member.set_initialized()
1295 group.update_state(signal_event=False)
1296 member.update_state(signal_event=False)
1297 assert member.state.active_group == "g1"
1298
1299 # default: override enabled
1300 _set_play_media_override(mock_mass, True)
1301
1302 set_members_calls: list[dict[str, object]] = []
1303
1304 async def _cmd_set_members(
1305 target_player: str,
1306 player_ids_to_add: list[str] | None = None, # noqa: ARG001
1307 player_ids_to_remove: list[str] | None = None,
1308 ) -> None:
1309 set_members_calls.append(
1310 {"player_id": target_player, "remove": player_ids_to_remove or []}
1311 )
1312
1313 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
1314
1315 played_on: list[str] = []
1316
1317 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1318 played_on.append(player_id)
1319
1320 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1321 controller._player_command_locks = {}
1322
1323 media = MagicMock(uri="x", source_id="src")
1324 with patch.object(
1325 controller,
1326 "wait_for_player_update",
1327 _skip_player_update_wait,
1328 ):
1329 await controller.play_media("member", media)
1330
1331 # the member was removed from the group ...
1332 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
1333 # ... and then play_media was issued directly on the member, NOT on the group
1334 assert played_on == ["member"]
1335
1336 @pytest.mark.asyncio
1337 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
1338 """With override enabled, play_media on a STATIC group member stops the group."""
1339 controller = PlayerController(mock_mass)
1340 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1341 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1342
1343 class _SessionedGroup(MockPlayer):
1344 @property
1345 def is_active_session(self) -> bool:
1346 return True
1347
1348 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1349 group._attr_powered = None # no power control ⇒ stop, not power-off
1350 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1351 group._attr_group_members = ["member"]
1352 # static member - cannot be removed individually
1353 group._attr_static_group_members = ["member"]
1354
1355 member = MockPlayer(member_provider, "member", "Member")
1356
1357 controller._players = {"g1": group, "member": member}
1358 mock_mass.players = controller
1359
1360 group.set_initialized()
1361 member.set_initialized()
1362 group.update_state(signal_event=False)
1363 member.update_state(signal_event=False)
1364 assert member.state.active_group == "g1"
1365
1366 _set_play_media_override(mock_mass, True)
1367
1368 stop_calls: list[str] = []
1369 power_calls: list[tuple[str, bool]] = []
1370
1371 async def _stop(player_id: str) -> None:
1372 stop_calls.append(player_id)
1373
1374 async def _power(
1375 player_id: str,
1376 powered: bool,
1377 skip_auto_play: bool = False, # noqa: ARG001
1378 ) -> None:
1379 power_calls.append((player_id, powered))
1380
1381 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1382 controller._handle_cmd_power = _power # type: ignore[method-assign]
1383
1384 played_on: list[str] = []
1385
1386 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1387 played_on.append(player_id)
1388
1389 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1390 controller._player_command_locks = {}
1391
1392 media = MagicMock(uri="x", source_id="src")
1393 with patch.object(
1394 controller,
1395 "wait_for_player_update",
1396 _skip_player_update_wait,
1397 ):
1398 await controller.play_media("member", media)
1399
1400 # powerless group + static member: we should have stopped the group ...
1401 assert stop_calls == ["g1"]
1402 # ... not powered it off ...
1403 assert power_calls == []
1404 # ... and play_media was issued directly on the member
1405 assert played_on == ["member"]
1406
1407
1408class TestExternalSourcePlayPause:
1409 """Pause/play handling for externally-initiated sources (no active output protocol)."""
1410
1411 @staticmethod
1412 def _make_external_source_player(
1413 provider: MockProvider,
1414 controller: PlayerController,
1415 mock_mass: MagicMock,
1416 *,
1417 playback_state: PlaybackState,
1418 can_play_pause: bool = True,
1419 supports_pause: bool = True,
1420 ) -> MockPlayer:
1421 """Build a player playing a passive external source, with no active output protocol."""
1422 player = MockPlayer(provider, "player_1", "Test Player")
1423 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
1424 player._attr_source_list = [
1425 PlayerSource(
1426 id="spotify",
1427 name="Spotify",
1428 passive=True,
1429 can_play_pause=can_play_pause,
1430 can_next_previous=True,
1431 can_seek=True,
1432 )
1433 ]
1434 player._attr_active_source = "spotify"
1435 player._attr_playback_state = playback_state
1436 player._cache.clear()
1437 controller._players = {"player_1": player}
1438 mock_mass.players = controller
1439 mock_mass.player_queues = MagicMock()
1440 mock_mass.player_queues.get = MagicMock(return_value=None)
1441 player.update_state(signal_event=False)
1442 return player
1443
1444 def test_pause_external_source_forwards_to_player(
1445 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1446 ) -> None:
1447 """Pausing a pausable external source forwards to the player, not STOP."""
1448 player = self._make_external_source_player(
1449 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
1450 )
1451 player.pause = AsyncMock() # type: ignore[method-assign]
1452 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1453
1454 asyncio.run(controller._handle_cmd_pause("player_1"))
1455
1456 player.pause.assert_awaited_once()
1457 controller._handle_cmd_stop.assert_not_called()
1458
1459 def test_play_external_source_unpauses_player(
1460 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1461 ) -> None:
1462 """Unpausing a paused external source forwards to the player, not a restart."""
1463 player = self._make_external_source_player(
1464 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
1465 )
1466 player.play = AsyncMock() # type: ignore[method-assign]
1467 player.play_media = AsyncMock() # type: ignore[method-assign]
1468 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1469
1470 asyncio.run(controller._handle_cmd_play("player_1"))
1471
1472 player.play.assert_awaited_once()
1473 player.play_media.assert_not_called()
1474 controller._handle_select_source.assert_not_called()
1475
1476 def test_pause_falls_back_to_stop_without_pause_support(
1477 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1478 ) -> None:
1479 """A player that cannot pause natively still falls back to STOP."""
1480 player = self._make_external_source_player(
1481 provider,
1482 controller,
1483 mock_mass,
1484 playback_state=PlaybackState.PLAYING,
1485 supports_pause=False,
1486 )
1487 player.pause = AsyncMock() # type: ignore[method-assign]
1488 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1489
1490 asyncio.run(controller._handle_cmd_pause("player_1"))
1491
1492 controller._handle_cmd_stop.assert_awaited_once()
1493 player.pause.assert_not_called()
1494
1495
1496class TestProtocolOutputPlayPause:
1497 """Play/pause on a player rendering through a linked output protocol."""
1498
1499 @staticmethod
1500 def _make_player_on_protocol(
1501 mock_mass: MagicMock,
1502 controller: PlayerController,
1503 *,
1504 playback_state: PlaybackState,
1505 ) -> MockPlayer:
1506 """Build a player playing the MA queue through a protocol that cannot pause."""
1507 native_provider = MockProvider("chromecast", mass=mock_mass)
1508 player = MockPlayer(native_provider, "player_1", "Test Player")
1509 player._attr_supported_features.add(PlayerFeature.PAUSE)
1510 player._attr_playback_state = playback_state
1511
1512 protocol_provider = MockProvider("sendspin", mass=mock_mass)
1513 protocol_player = MockPlayer(
1514 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
1515 )
1516 protocol_player._attr_playback_state = playback_state
1517
1518 controller._players = {"player_1": player, "proto_1": protocol_player}
1519 mock_mass.players = controller
1520 mock_mass.player_queues = MagicMock()
1521 # a non-empty queue, so the MA queue source advertises play/pause support
1522 queue = MagicMock()
1523 queue.items = [MagicMock()]
1524 mock_mass.player_queues.get = MagicMock(return_value=queue)
1525 player.set_linked_output_protocols(
1526 [
1527 OutputProtocol(
1528 output_protocol_id="proto_1",
1529 name="Sendspin",
1530 protocol_domain="sendspin",
1531 priority=40,
1532 )
1533 ]
1534 )
1535 player.set_active_output_protocol("proto_1")
1536 player.set_active_mass_source("player_1")
1537 protocol_player.update_state(signal_event=False)
1538 player.refresh_state(signal_event=False)
1539 return player
1540
1541 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
1542 self, mock_mass: MagicMock, controller: PlayerController
1543 ) -> None:
1544 """The native transport has no session to pause while a protocol renders the audio."""
1545 player = self._make_player_on_protocol(
1546 mock_mass, controller, playback_state=PlaybackState.PLAYING
1547 )
1548 player.pause = AsyncMock() # type: ignore[method-assign]
1549 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1550
1551 await controller._handle_cmd_pause("player_1")
1552
1553 player.pause.assert_not_called()
1554 # STOP goes to the visible player, not the protocol player
1555 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
1556
1557 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
1558 self, mock_mass: MagicMock, controller: PlayerController
1559 ) -> None:
1560 """Unpausing must not hit the native transport either; the source is restarted."""
1561 player = self._make_player_on_protocol(
1562 mock_mass, controller, playback_state=PlaybackState.PAUSED
1563 )
1564 player.play = AsyncMock() # type: ignore[method-assign]
1565 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1566
1567 await controller._handle_cmd_play("player_1")
1568
1569 player.play.assert_not_called()
1570 # the MA queue source is restarted, not some other source
1571 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
1572
1573
1574class TestMirrorsParentMedia:
1575 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
1576
1577 @staticmethod
1578 def _fake_player(
1579 *,
1580 player_id: str = "p1",
1581 active_group: str | None = None,
1582 synced_to: str | None = None,
1583 player_type: PlayerType = PlayerType.PLAYER,
1584 protocol_parent_id: str | None = None,
1585 ) -> SimpleNamespace:
1586 return SimpleNamespace(
1587 player_id=player_id,
1588 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
1589 protocol_parent_id=protocol_parent_id,
1590 )
1591
1592 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
1593 """A standalone player resolves its own media (and palette)."""
1594 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
1595
1596 def test_group_member_mirrors(self, controller: PlayerController) -> None:
1597 """A group member borrows its parent's media."""
1598 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
1599
1600 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
1601 """A synced member borrows its leader's media."""
1602 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
1603
1604 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
1605 """A protocol child borrows its parent's media."""
1606 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
1607 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
1608
1609 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
1610 """A protocol player with no parent resolves its own media."""
1611 player = self._fake_player(player_type=PlayerType.PROTOCOL)
1612 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1613
1614 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
1615 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
1616 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
1617 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1618
1619
1620class TestVolumeScalingOnRedirect:
1621 """min/max volume scaling must survive a redirect to a protocol player or external control."""
1622
1623 @staticmethod
1624 def _volume_player(
1625 player_id: str,
1626 volume_control: str,
1627 volume_set: AsyncMock | None = None,
1628 ) -> SimpleNamespace:
1629 return SimpleNamespace(
1630 player_id=player_id,
1631 type=PlayerType.PLAYER,
1632 protocol_parent_id=None,
1633 extra_data={},
1634 volume_control=volume_control,
1635 mute_control=PLAYER_CONTROL_NONE,
1636 volume_set=volume_set or AsyncMock(),
1637 update_state=MagicMock(),
1638 provider=MagicMock(),
1639 state=SimpleNamespace(
1640 name=player_id,
1641 volume_control=volume_control,
1642 volume_muted=False,
1643 mute_control=PLAYER_CONTROL_NONE,
1644 ),
1645 )
1646
1647 @pytest.mark.asyncio
1648 async def test_protocol_redirect_forwards_scaled_volume(
1649 self, controller: PlayerController, mock_mass: MagicMock
1650 ) -> None:
1651 """A volume command redirected to a protocol player honors the user-facing max_volume."""
1652
1653 def _conf(player_id: str, key: str, default: object = None) -> object:
1654 if key == "min_volume":
1655 return 0
1656 if key == "max_volume":
1657 # user-facing player caps at 50, the protocol player has no limits of its own
1658 return 50 if player_id == "user_player" else 100
1659 return default
1660
1661 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1662
1663 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
1664 user = self._volume_player("user_player", "protocol_player")
1665 players = {"user_player": user, "protocol_player": protocol}
1666
1667 with (
1668 patch.object(controller, "get_player", side_effect=players.get),
1669 patch.object(controller, "_get_active_audio_source", return_value=None),
1670 ):
1671 controller._controls = {}
1672 await controller._handle_cmd_volume_set("user_player", 100)
1673
1674 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
1675 protocol.volume_set.assert_awaited_once_with(50)
1676
1677 @pytest.mark.asyncio
1678 async def test_external_control_redirect_forwards_scaled_volume(
1679 self, controller: PlayerController, mock_mass: MagicMock
1680 ) -> None:
1681 """A volume command redirected to an external control honors the user-facing max_volume."""
1682 mock_mass.config.get_raw_player_config_value = MagicMock(
1683 side_effect=_player_config_stub(max_volume=50)
1684 )
1685
1686 volume_set = AsyncMock()
1687 control = PlayerControl(
1688 id="ext_control",
1689 provider="test",
1690 name="External Amp",
1691 supports_volume=True,
1692 volume_set=volume_set,
1693 )
1694 user = self._volume_player("user_player", "ext_control")
1695 players = {"user_player": user}
1696
1697 with (
1698 patch.object(controller, "get_player", side_effect=players.get),
1699 patch.object(controller, "_get_active_audio_source", return_value=None),
1700 ):
1701 controller._controls = {"ext_control": control}
1702 await controller._handle_cmd_volume_set("user_player", 100)
1703
1704 volume_set.assert_awaited_once_with(50)
1705
1706 @pytest.mark.asyncio
1707 async def test_external_control_without_volume_support_raises(
1708 self, controller: PlayerController, mock_mass: MagicMock
1709 ) -> None:
1710 """A volume command redirected to a control lacking volume support is rejected."""
1711 mock_mass.config.get_raw_player_config_value = MagicMock(
1712 side_effect=_player_config_stub(max_volume=50)
1713 )
1714
1715 volume_set = AsyncMock()
1716 control = PlayerControl(
1717 id="ext_control",
1718 provider="test",
1719 name="External Amp",
1720 supports_volume=False,
1721 volume_set=volume_set,
1722 )
1723 user = self._volume_player("user_player", "ext_control")
1724 players = {"user_player": user}
1725
1726 with (
1727 patch.object(controller, "get_player", side_effect=players.get),
1728 patch.object(controller, "_get_active_audio_source", return_value=None),
1729 ):
1730 controller._controls = {"ext_control": control}
1731 with pytest.raises(UnsupportedFeaturedException):
1732 await controller._handle_cmd_volume_set("user_player", 100)
1733
1734 volume_set.assert_not_awaited()
1735
1736
1737class TestExternalPowerControl:
1738 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
1739
1740 def _make_player(
1741 self, mock_mass: MagicMock, control: PlayerControl
1742 ) -> tuple[PlayerController, MockPlayer]:
1743 """Build a controller with a single player whose power control is the given control."""
1744 mock_mass.config.get_raw_player_config_value = MagicMock(
1745 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
1746 )
1747 controller = PlayerController(mock_mass)
1748 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1749 player = MockPlayer(provider, "player_1", "Player 1")
1750 controller._controls = {control.id: control}
1751 controller._players = {"player_1": player}
1752 mock_mass.players = controller
1753 # auto-play would otherwise resume the (mocked) player queue on power on
1754 config_get_value = player.config.get_value
1755 player.config.get_value = MagicMock( # type: ignore[method-assign]
1756 side_effect=lambda key, *args, **kwargs: (
1757 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
1758 )
1759 )
1760 player.set_initialized()
1761 player.update_state(signal_event=False)
1762 return controller, player
1763
1764 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
1765 """Powering on a player redirects to its external control's power_on callback."""
1766 power_on = AsyncMock()
1767 power_off = AsyncMock()
1768 control = PlayerControl(
1769 id="ext_power",
1770 provider="test",
1771 name="External Power",
1772 supports_power=True,
1773 power_on=power_on,
1774 power_off=power_off,
1775 )
1776
1777 def _report_powered_on() -> None:
1778 control.power_state = True
1779
1780 # the control only reports on once switched on, which releases wait_for_power_on
1781 power_on.side_effect = _report_powered_on
1782 controller, player = self._make_player(mock_mass, control)
1783 assert player.state.powered is False
1784
1785 await controller._handle_cmd_power("player_1", True)
1786
1787 power_on.assert_awaited_once()
1788 power_off.assert_not_awaited()
1789
1790 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
1791 """Powering on waits for the control to report on, not for the player itself."""
1792 control = PlayerControl(
1793 id="ext_power",
1794 provider="test",
1795 name="External Power",
1796 supports_power=True,
1797 power_on=AsyncMock(),
1798 power_off=AsyncMock(),
1799 )
1800 controller, player = self._make_player(mock_mass, control)
1801 assert player.state.powered is False
1802
1803 with patch(
1804 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
1805 ) as wait_for_power_on:
1806 await controller._handle_cmd_power("player_1", True)
1807
1808 wait_for_power_on.assert_awaited_once()
1809 assert wait_for_power_on.await_args is not None
1810 assert wait_for_power_on.await_args.args[2] is control
1811
1812 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
1813 """Powering off a player redirects to its external control's power_off callback."""
1814 power_on = AsyncMock()
1815 power_off = AsyncMock()
1816 control = PlayerControl(
1817 id="ext_power",
1818 provider="test",
1819 name="External Power",
1820 supports_power=True,
1821 power_state=True,
1822 power_on=power_on,
1823 power_off=power_off,
1824 )
1825 controller, player = self._make_player(mock_mass, control)
1826 assert player.state.powered is True
1827
1828 await controller._handle_cmd_power("player_1", False)
1829
1830 power_off.assert_awaited_once()
1831 power_on.assert_not_awaited()
1832
1833 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
1834 """A power command redirected to a control lacking power support is rejected."""
1835 power_on = AsyncMock()
1836 power_off = AsyncMock()
1837 control = PlayerControl(
1838 id="ext_power",
1839 provider="test",
1840 name="External Power",
1841 supports_power=False,
1842 power_on=power_on,
1843 power_off=power_off,
1844 )
1845 controller, player = self._make_player(mock_mass, control)
1846 assert player.state.powered is False
1847
1848 with pytest.raises(UnsupportedFeaturedException):
1849 await controller._handle_cmd_power("player_1", True)
1850
1851 power_on.assert_not_awaited()
1852 power_off.assert_not_awaited()
1853
1854
1855class TestEnforceVolumeLimits:
1856 """External volume changes outside the min/max range must be corrected."""
1857
1858 @staticmethod
1859 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
1860 mock_mass.config.get_raw_player_config_value = MagicMock(
1861 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
1862 )
1863
1864 @staticmethod
1865 def _player(logical_volume: int | None) -> SimpleNamespace:
1866 return SimpleNamespace(
1867 player_id="user_player",
1868 state=SimpleNamespace(volume_level=logical_volume),
1869 )
1870
1871 def test_out_of_range_volume_is_corrected(
1872 self, controller: PlayerController, mock_mass: MagicMock
1873 ) -> None:
1874 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
1875 self._set_limits(mock_mass, 0, 80)
1876 # device volume 100 with max 80 resolves to logical 125
1877 player = self._player(125)
1878 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1879 controller._enforce_volume_limits(cast("MockPlayer", player))
1880 cmd.assert_called_once_with("user_player", 100)
1881 mock_mass.create_task.assert_called_once()
1882
1883 def test_below_min_volume_is_corrected(
1884 self, controller: PlayerController, mock_mass: MagicMock
1885 ) -> None:
1886 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
1887 self._set_limits(mock_mass, 20, 100)
1888 # device volume 10 with min 20 resolves to a negative logical volume
1889 player = self._player(-13)
1890 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1891 controller._enforce_volume_limits(cast("MockPlayer", player))
1892 cmd.assert_called_once_with("user_player", 0)
1893
1894 def test_in_range_volume_is_untouched(
1895 self, controller: PlayerController, mock_mass: MagicMock
1896 ) -> None:
1897 """A logical volume within 0-100 needs no correction."""
1898 self._set_limits(mock_mass, 0, 80)
1899 player = self._player(100)
1900 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1901 controller._enforce_volume_limits(cast("MockPlayer", player))
1902 cmd.assert_not_called()
1903
1904 def test_no_limits_configured_is_noop(
1905 self, controller: PlayerController, mock_mass: MagicMock
1906 ) -> None:
1907 """Default 0-100 limits skip enforcement entirely."""
1908 self._set_limits(mock_mass, 0, 100)
1909 player = self._player(100)
1910 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1911 controller._enforce_volume_limits(cast("MockPlayer", player))
1912 cmd.assert_not_called()
1913
1914 def test_unknown_volume_is_noop(
1915 self, controller: PlayerController, mock_mass: MagicMock
1916 ) -> None:
1917 """A player without a resolved volume level is left alone."""
1918 self._set_limits(mock_mass, 0, 80)
1919 player = self._player(None)
1920 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1921 controller._enforce_volume_limits(cast("MockPlayer", player))
1922 cmd.assert_not_called()
1923
1924
1925class TestFakeMuteControl:
1926 """Fake mute must report the muted state and restore the volume on unmute."""
1927
1928 def _make_player(
1929 self, mock_mass: MagicMock, volume_level: int | None = 40
1930 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
1931 """
1932 Build a controller with a single player using fake mute control.
1933
1934 :param mock_mass: the mocked MusicAssistant instance.
1935 :param volume_level: initial volume level of the player, None if unknown.
1936 """
1937 mock_mass.config.get_raw_player_config_value = MagicMock(
1938 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
1939 )
1940 controller = PlayerController(mock_mass)
1941 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1942 player = MockPlayer(provider, "player_1", "Player 1")
1943 controller._players = {"player_1": player}
1944 mock_mass.players = controller
1945 mock_mass.player_queues.get = MagicMock(return_value=None)
1946 player.set_initialized()
1947 player._attr_volume_level = volume_level
1948 # let the mocked native volume control behave like a real device
1949 volume_set = AsyncMock(
1950 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
1951 )
1952 player.volume_set = volume_set # type: ignore[method-assign]
1953 player.update_state(signal_event=False)
1954 return controller, player, volume_set
1955
1956 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
1957 """Muting reports volume_muted=True and unmuting restores the previous volume."""
1958 controller, player, volume_set = self._make_player(mock_mass)
1959
1960 await controller.cmd_volume_mute("player_1", True)
1961 muted_state = player.state
1962 assert muted_state.volume_muted is True
1963 assert muted_state.volume_level == 0
1964 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1965
1966 await controller.cmd_volume_mute("player_1", False)
1967 volume_set.assert_awaited_with(40)
1968 # simulate the device reporting back its state after the volume command
1969 player.update_state()
1970 unmuted_state = player.state
1971 assert unmuted_state.volume_muted is False
1972 assert unmuted_state.volume_level == 40
1973
1974 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
1975 """A repeated mute command must not overwrite the stored volume with 0."""
1976 controller, player, volume_set = self._make_player(mock_mass)
1977
1978 await controller.cmd_volume_mute("player_1", True)
1979 await controller.cmd_volume_mute("player_1", True)
1980 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1981 assert player.state.volume_muted is True
1982
1983 await controller.cmd_volume_mute("player_1", False)
1984 volume_set.assert_awaited_with(40)
1985
1986 async def test_unmute_with_unknown_previous_volume(self, mock_mass: MagicMock) -> None:
1987 """Unmuting a player whose volume was unknown at mute time restores a low volume."""
1988 controller, player, volume_set = self._make_player(mock_mass, volume_level=None)
1989
1990 await controller.cmd_volume_mute("player_1", True)
1991 assert player.extra_data[ATTR_PREVIOUS_VOLUME] is None
1992
1993 await controller.cmd_volume_mute("player_1", False)
1994 volume_set.assert_awaited_with(1)
1995 player.update_state()
1996 assert player.state.volume_muted is False
1997
1998 async def test_unmute_of_unmuted_player_keeps_volume(self, mock_mass: MagicMock) -> None:
1999 """An unmute command for a player that is not muted may not touch the volume."""
2000 controller, player, volume_set = self._make_player(mock_mass, volume_level=50)
2001
2002 await controller.cmd_volume_mute("player_1", False)
2003 volume_set.assert_not_awaited()
2004 assert player.state.volume_level == 50
2005
2006 async def test_unmute_restores_a_stored_zero_volume(self, mock_mass: MagicMock) -> None:
2007 """A player that was already silent stays silent after mute and unmute."""
2008 controller, _player, volume_set = self._make_player(mock_mass, volume_level=0)
2009
2010 await controller.cmd_volume_mute("player_1", True)
2011 await controller.cmd_volume_mute("player_1", False)
2012 volume_set.assert_awaited_with(0)
2013
2014 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
2015 """A regular volume change while fake muted implies an unmute."""
2016 controller, player, _volume_set = self._make_player(mock_mass)
2017
2018 await controller.cmd_volume_mute("player_1", True)
2019 muted_state = player.state
2020 assert muted_state.volume_muted is True
2021
2022 await controller.cmd_volume_set("player_1", 25)
2023 # simulate the device reporting back its state after the volume command
2024 player.update_state()
2025 unmuted_state = player.state
2026 assert unmuted_state.volume_muted is False
2027 assert unmuted_state.volume_level == 25
2028
2029
2030class TestFakeMuteInGroup:
2031 """A fake muted player in a group follows the mute lock, just like a native mute."""
2032
2033 def _make_synced_pair(
2034 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
2035 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2036 """
2037 Build a leader synced to one member.
2038
2039 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
2040 """
2041
2042 def _conf(player_id: str, key: str, default: object = None) -> object:
2043 if key == CONF_MUTE_CONTROL and player_id == "member":
2044 return member_mute_control
2045 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
2046 player_id, key, default
2047 )
2048
2049 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
2050 controller = PlayerController(mock_mass)
2051 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2052 players: dict[str, MockPlayer] = {}
2053 for player_id in ("leader", "member"):
2054 player = MockPlayer(provider, player_id, player_id.title())
2055 player._attr_supported_features = {
2056 PlayerFeature.VOLUME_SET,
2057 PlayerFeature.VOLUME_MUTE,
2058 }
2059 player._attr_volume_level = 50
2060 # let the mocked native volume control behave like a real device
2061 player.volume_set = AsyncMock( # type: ignore[method-assign]
2062 side_effect=lambda volume, _player=player: setattr(
2063 _player, "_attr_volume_level", volume
2064 )
2065 )
2066 players[player_id] = player
2067 players["leader"]._attr_group_members = ["member"]
2068 controller._players = dict(players)
2069 mock_mass.players = controller
2070 mock_mass.player_queues.get = MagicMock(return_value=None)
2071 for player in players.values():
2072 player.set_initialized()
2073 player._cache.clear()
2074 player.update_state(signal_event=False)
2075 # a second pass, so the group volume of the leader accounts for its member
2076 for player in players.values():
2077 player.update_state(signal_event=False)
2078 return controller, players
2079
2080 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
2081 """A group volume change may not bring a muted fake mute pair back to life."""
2082 controller, players = self._make_synced_pair(mock_mass)
2083 await controller.cmd_group_volume_mute("leader", True)
2084
2085 await controller.cmd_group_volume("leader", 30)
2086
2087 for player in players.values():
2088 player.update_state()
2089 assert player.state.volume_muted is True
2090 assert player.state.volume_level == 0
2091
2092 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
2093 """Turning a group down leaves a single muted member silent, at its own volume."""
2094 controller, players = self._make_synced_pair(mock_mass)
2095 await controller.cmd_volume_mute("member", True)
2096 # let the group volume of the leader account for the muted member
2097 players["leader"].update_state(signal_event=False)
2098
2099 await controller.cmd_group_volume("leader", 25)
2100
2101 for player in players.values():
2102 player.update_state()
2103 member_state = players["member"].state
2104 assert member_state.volume_muted is True
2105 assert member_state.volume_level == 0
2106 # the player that is not muted follows the group volume as usual
2107 assert players["leader"].state.volume_level == 25
2108 # unmuting brings the member back at the volume it had before it was muted
2109 await controller.cmd_volume_mute("member", False)
2110 players["member"].update_state()
2111 assert players["member"].state.volume_level == 50
2112
2113 async def test_unmute_restores_the_volume_from_before_the_mute(
2114 self, mock_mass: MagicMock
2115 ) -> None:
2116 """A group volume change while muted may not alter the volume to restore."""
2117 controller, players = self._make_synced_pair(mock_mass)
2118 await controller.cmd_group_volume_mute("leader", True)
2119 await controller.cmd_group_volume("leader", 30)
2120
2121 await controller.cmd_group_volume_mute("leader", False)
2122
2123 for player in players.values():
2124 player.update_state()
2125 assert player.state.volume_muted is False
2126 assert player.state.volume_level == 50
2127
2128 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
2129 """Members with a different mute control stay muted alike on a group volume change."""
2130 controller, players = self._make_synced_pair(
2131 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
2132 )
2133 mute = AsyncMock(
2134 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2135 )
2136 players["member"].volume_mute = mute # type: ignore[method-assign]
2137 await controller.cmd_group_volume_mute("leader", True)
2138
2139 await controller.cmd_group_volume("leader", 30)
2140
2141 mute.assert_awaited_once_with(True)
2142 for player in players.values():
2143 player.update_state()
2144 assert player.state.volume_muted is True
2145
2146
2147class TestMuteControlGuard:
2148 """Muting is gated on the mute control, independently of the volume control."""
2149
2150 def _make_player(
2151 self,
2152 mock_mass: MagicMock,
2153 mute_control: str,
2154 volume_control: str,
2155 controls: dict[str, PlayerControl] | None = None,
2156 ) -> tuple[PlayerController, MockPlayer]:
2157 """Build a controller with a single player using the given control config."""
2158 mock_mass.config.get_raw_player_config_value = MagicMock(
2159 side_effect=_player_config_stub(
2160 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
2161 )
2162 )
2163 controller = PlayerController(mock_mass)
2164 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2165 player = MockPlayer(provider, "player_1", "Player 1")
2166 controller._players = {"player_1": player}
2167 controller._controls = controls or {}
2168 mock_mass.players = controller
2169 mock_mass.player_queues.get = MagicMock(return_value=None)
2170 player.set_initialized()
2171 player.update_state(signal_event=False)
2172 return controller, player
2173
2174 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2175 """A player without volume control still mutes through an external PlayerControl."""
2176 mute_set = AsyncMock()
2177 control = PlayerControl(
2178 id="ext_mute",
2179 provider="test",
2180 name="External Mute",
2181 supports_mute=True,
2182 mute_set=mute_set,
2183 )
2184 controller, player = self._make_player(
2185 mock_mass,
2186 mute_control="ext_mute",
2187 volume_control=PLAYER_CONTROL_NONE,
2188 controls={"ext_mute": control},
2189 )
2190 assert player.mute_control == "ext_mute"
2191
2192 await controller.cmd_volume_mute("player_1", True)
2193 mute_set.assert_awaited_once_with(True)
2194
2195 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
2196 """A player without volume control still mutes natively."""
2197 controller, player = self._make_player(
2198 mock_mass,
2199 mute_control=PLAYER_CONTROL_NATIVE,
2200 volume_control=PLAYER_CONTROL_NONE,
2201 )
2202 volume_mute = AsyncMock()
2203 player.volume_mute = volume_mute # type: ignore[method-assign]
2204
2205 await controller.cmd_volume_mute("player_1", True)
2206 volume_mute.assert_awaited_once_with(True)
2207
2208 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
2209 """A player with volume control but no mute control rejects the command."""
2210 controller, player = self._make_player(
2211 mock_mass,
2212 mute_control=PLAYER_CONTROL_NONE,
2213 volume_control=PLAYER_CONTROL_NATIVE,
2214 )
2215 volume_mute = AsyncMock()
2216 player.volume_mute = volume_mute # type: ignore[method-assign]
2217
2218 with pytest.raises(UnsupportedFeaturedException):
2219 await controller.cmd_volume_mute("player_1", True)
2220 volume_mute.assert_not_awaited()
2221
2222 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
2223 """Fake mute needs a volume control to drive, so it rejects the command outright."""
2224 controller, player = self._make_player(
2225 mock_mass,
2226 mute_control=PLAYER_CONTROL_FAKE,
2227 volume_control=PLAYER_CONTROL_NONE,
2228 )
2229 player._attr_volume_level = 40
2230
2231 with pytest.raises(UnsupportedFeaturedException):
2232 await controller.cmd_volume_mute("player_1", True)
2233 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
2234 assert ATTR_FAKE_MUTE not in player.extra_data
2235
2236 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
2237 """A mute control that disappeared after being resolved is reported, not ignored."""
2238 control = PlayerControl(
2239 id="ext_mute",
2240 provider="test",
2241 name="External Mute",
2242 supports_mute=True,
2243 mute_set=AsyncMock(),
2244 )
2245 controller, player = self._make_player(
2246 mock_mass,
2247 mute_control="ext_mute",
2248 volume_control=PLAYER_CONTROL_NONE,
2249 controls={"ext_mute": control},
2250 )
2251 # the resolved control is cached on the player, so removing it here leaves
2252 # the player pointing at a control that no longer exists
2253 assert player.mute_control == "ext_mute"
2254 controller._controls = {}
2255
2256 with pytest.raises(UnsupportedFeaturedException):
2257 await controller.cmd_volume_mute("player_1", True)
2258
2259 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
2260 """Unmuting clears a mute lock left behind by a since-removed mute control."""
2261 controller, player = self._make_player(
2262 mock_mass,
2263 mute_control=PLAYER_CONTROL_NONE,
2264 volume_control=PLAYER_CONTROL_NATIVE,
2265 )
2266 player.extra_data[ATTR_MUTE_LOCK] = True
2267
2268 with pytest.raises(UnsupportedFeaturedException):
2269 await controller.cmd_volume_mute("player_1", False)
2270 assert ATTR_MUTE_LOCK not in player.extra_data
2271
2272 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
2273 """A grouped player whose mute command failed is not left holding a mute lock."""
2274 control = PlayerControl(
2275 id="ext_mute",
2276 provider="test",
2277 name="External Mute",
2278 supports_mute=False,
2279 )
2280 controller, player = self._make_player(
2281 mock_mass,
2282 mute_control="ext_mute",
2283 volume_control=PLAYER_CONTROL_NONE,
2284 controls={"ext_mute": control},
2285 )
2286 player.state.synced_to = "leader"
2287
2288 with pytest.raises(UnsupportedFeaturedException):
2289 await controller.cmd_volume_mute("player_1", True)
2290 assert ATTR_MUTE_LOCK not in player.extra_data
2291
2292 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
2293 """A failed mute leaves the lock of an earlier successful mute in place."""
2294 control = PlayerControl(
2295 id="ext_mute",
2296 provider="test",
2297 name="External Mute",
2298 supports_mute=False,
2299 )
2300 controller, player = self._make_player(
2301 mock_mass,
2302 mute_control="ext_mute",
2303 volume_control=PLAYER_CONTROL_NONE,
2304 controls={"ext_mute": control},
2305 )
2306 player.state.synced_to = "leader"
2307 player.extra_data[ATTR_MUTE_LOCK] = True
2308
2309 with pytest.raises(UnsupportedFeaturedException):
2310 await controller.cmd_volume_mute("player_1", True)
2311 assert player.extra_data[ATTR_MUTE_LOCK] is True
2312
2313
2314class TestGroupMuteMemberFilter:
2315 """Group mute skips members that have no mute control of their own."""
2316
2317 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
2318 """A member without a mute control must not fail the whole group command."""
2319 controller = PlayerController(mock_mass)
2320 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2321 leader = MockPlayer(provider, "leader", "Leader")
2322 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2323 leader._attr_group_members = ["leader", "member"]
2324 member = MockPlayer(provider, "member", "Member")
2325 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2326 controller._players = {"leader": leader, "member": member}
2327 mock_mass.players = controller
2328 mock_mass.player_queues.get = MagicMock(return_value=None)
2329 for player in (leader, member):
2330 player.set_initialized()
2331 player.update_state(signal_event=False)
2332 leader_mute = AsyncMock()
2333 leader.volume_mute = leader_mute # type: ignore[method-assign]
2334
2335 await controller.cmd_group_volume_mute("leader", True)
2336 leader_mute.assert_awaited_once_with(True)
2337
2338
2339class TestGroupPlayerMuteRedirect:
2340 """A mute command on a group player is handled at group level."""
2341
2342 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
2343 """Build a controller with a group player holding a single mute capable member."""
2344 controller = PlayerController(mock_mass)
2345 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2346 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
2347 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2348 group._attr_group_members = ["member"]
2349 member = MockPlayer(provider, "member", "Member")
2350 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2351 controller._players = {"group": group, "member": member}
2352 mock_mass.players = controller
2353 mock_mass.player_queues.get = MagicMock(return_value=None)
2354 for player in (group, member):
2355 player.set_initialized()
2356 player.update_state(signal_event=False)
2357 return controller, group, member
2358
2359 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
2360 """A group player has no mute of its own, so the members must be muted instead."""
2361 controller, _group, member = self._setup(mock_mass)
2362 member_mute = AsyncMock()
2363 member.volume_mute = member_mute # type: ignore[method-assign]
2364
2365 await controller.cmd_volume_mute("group", True)
2366
2367 member_mute.assert_awaited_once_with(True)
2368
2369 async def test_mute_on_group_player_without_own_mute_control(
2370 self, mock_mass: MagicMock
2371 ) -> None:
2372 """A group that has no mute control of its own must still mute its members."""
2373 controller, group, member = self._setup(mock_mass)
2374 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
2375 group._cache.clear()
2376 group.update_state(signal_event=False)
2377 assert group.mute_control == PLAYER_CONTROL_NONE
2378 member_mute = AsyncMock()
2379 member.volume_mute = member_mute # type: ignore[method-assign]
2380
2381 await controller.cmd_volume_mute("group", True)
2382
2383 member_mute.assert_awaited_once_with(True)
2384
2385 async def test_mute_on_group_player_without_mute_capable_members(
2386 self, mock_mass: MagicMock
2387 ) -> None:
2388 """A group whose members cannot mute must not raise, just like group mute itself."""
2389 controller, _group, member = self._setup(mock_mass)
2390 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2391 member._cache.clear()
2392 member.update_state(signal_event=False)
2393
2394 await controller.cmd_volume_mute("group", True)
2395
2396
2397class TestGroupMuteOnNonGroupPlayer:
2398 """A group mute command works on any player, just like the group volume command."""
2399
2400 def _setup(
2401 self, mock_mass: MagicMock, *members: str
2402 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2403 """Build a controller with a mute capable leader synced to the given members."""
2404 controller = PlayerController(mock_mass)
2405 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2406 players: dict[str, MockPlayer] = {}
2407 for player_id in ("leader", *members):
2408 player = MockPlayer(provider, player_id, player_id.title())
2409 player._attr_supported_features = {
2410 PlayerFeature.VOLUME_SET,
2411 PlayerFeature.VOLUME_MUTE,
2412 }
2413 player._attr_volume_level = 50
2414 players[player_id] = player
2415 if members:
2416 # the leader is not listed as its own member here, so the tests also cover
2417 # that a sync leader is injected into its own final group_members
2418 players["leader"]._attr_group_members = list(members)
2419 controller._players = dict(players)
2420 mock_mass.players = controller
2421 mock_mass.player_queues.get = MagicMock(return_value=None)
2422 for player in players.values():
2423 player.set_initialized()
2424 player._cache.clear()
2425 player.update_state(signal_event=False)
2426 return controller, players
2427
2428 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
2429 """Replace the native mute command of every given player with a mock."""
2430 mutes: dict[str, AsyncMock] = {}
2431 for player_id, player in players.items():
2432 mutes[player_id] = AsyncMock()
2433 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
2434 return mutes
2435
2436 async def test_group_mute_on_synced_member_redirects_to_leader(
2437 self, mock_mass: MagicMock
2438 ) -> None:
2439 """A member of a sync group mutes the whole group through its sync leader."""
2440 controller, players = self._setup(mock_mass, "member")
2441 assert players["member"].state.synced_to == "leader"
2442 mutes = self._stub_mutes(players)
2443
2444 await controller.cmd_group_volume_mute("member", True)
2445
2446 mutes["leader"].assert_awaited_once_with(True)
2447 mutes["member"].assert_awaited_once_with(True)
2448 assert ATTR_MUTE_LOCK in players["member"].extra_data
2449
2450 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
2451 self, mock_mass: MagicMock
2452 ) -> None:
2453 """A sync leader is part of its own member list, so it must be muted only once."""
2454 controller, players = self._setup(mock_mass, "member")
2455 mutes = self._stub_mutes(players)
2456
2457 await controller.cmd_group_volume_mute("leader", True)
2458
2459 mutes["leader"].assert_awaited_once_with(True)
2460 mutes["member"].assert_awaited_once_with(True)
2461
2462 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
2463 """A player that is not grouped at all is muted as a normal player."""
2464 controller, players = self._setup(mock_mass)
2465 mutes = self._stub_mutes(players)
2466
2467 await controller.cmd_group_volume_mute("leader", True)
2468
2469 mutes["leader"].assert_awaited_once_with(True)
2470
2471 async def test_group_mute_on_plain_player_without_mute_control(
2472 self, mock_mass: MagicMock
2473 ) -> None:
2474 """A plain player that cannot mute reports that, just like a normal mute command."""
2475 controller, players = self._setup(mock_mass)
2476 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
2477 players["leader"]._cache.clear()
2478 players["leader"].update_state(signal_event=False)
2479
2480 with pytest.raises(UnsupportedFeaturedException):
2481 await controller.cmd_group_volume_mute("leader", True)
2482
2483 async def test_group_unmute_on_synced_member_redirects_to_leader(
2484 self, mock_mass: MagicMock
2485 ) -> None:
2486 """Unmuting through a member clears the mute (and mute lock) of every group member."""
2487 controller, players = self._setup(mock_mass, "member")
2488 mutes = self._stub_mutes(players)
2489 players["member"].extra_data[ATTR_MUTE_LOCK] = True
2490
2491 await controller.cmd_group_volume_mute("member", False)
2492
2493 mutes["leader"].assert_awaited_once_with(False)
2494 mutes["member"].assert_awaited_once_with(False)
2495 assert ATTR_MUTE_LOCK not in players["member"].extra_data
2496
2497 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
2498 """A sync leader is as much part of the group as its members, so it is locked too."""
2499 controller, players = self._setup(mock_mass, "member")
2500 self._stub_mutes(players)
2501
2502 await controller.cmd_group_volume_mute("leader", True)
2503
2504 assert ATTR_MUTE_LOCK in players["leader"].extra_data
2505 assert ATTR_MUTE_LOCK in players["member"].extra_data
2506
2507 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
2508 """A group volume change may not half-unmute a muted pair of directly synced players."""
2509 controller, players = self._setup(mock_mass, "member")
2510 self._stub_mutes(players)
2511 await controller.cmd_group_volume_mute("leader", True)
2512 # the mock players do not act on the mute command, so reflect it in their state
2513 for player in players.values():
2514 player._attr_volume_muted = True
2515 player.update_state(signal_event=False)
2516 player.volume_set = AsyncMock() # type: ignore[method-assign]
2517 # re-stub so only the mute commands of the group volume change are counted
2518 mutes = self._stub_mutes(players)
2519
2520 await controller.cmd_group_volume("leader", 30)
2521
2522 for mute in mutes.values():
2523 mute.assert_not_awaited()
2524
2525
2526class TestMuteLockAfterUngroup:
2527 """A mute lock is only honored while the player it belongs to is still grouped."""
2528
2529 def _make_synced_pair(
2530 self, mock_mass: MagicMock, member_mute_control: str
2531 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2532 """
2533 Build a leader with one synced member.
2534
2535 :param member_mute_control: Mute control to configure on both players.
2536 """
2537 mock_mass.config.get_raw_player_config_value = MagicMock(
2538 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
2539 )
2540 controller = PlayerController(mock_mass)
2541 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2542 players: dict[str, MockPlayer] = {}
2543 for player_id in ("leader", "member"):
2544 player = MockPlayer(provider, player_id, player_id.title())
2545 player._attr_supported_features = {
2546 PlayerFeature.VOLUME_SET,
2547 PlayerFeature.VOLUME_MUTE,
2548 }
2549 player._attr_volume_level = 50
2550 player.volume_set = AsyncMock( # type: ignore[method-assign]
2551 side_effect=lambda volume, _player=player: setattr(
2552 _player, "_attr_volume_level", volume
2553 )
2554 )
2555 players[player_id] = player
2556 players["leader"]._attr_group_members = ["member"]
2557 controller._players = dict(players)
2558 mock_mass.players = controller
2559 mock_mass.player_queues.get = MagicMock(return_value=None)
2560 for player in players.values():
2561 player.set_initialized()
2562 player._cache.clear()
2563 player.update_state(signal_event=False)
2564 return controller, players
2565
2566 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
2567 """Drop the sync group, the way a provider side topology change does."""
2568 players["leader"]._attr_group_members = []
2569 for player in players.values():
2570 player.refresh_state(signal_event=False)
2571
2572 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
2573 """A fake muted player is no longer forced silent once its group is gone."""
2574 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2575 await controller.cmd_volume_mute("member", True)
2576 self._dissolve_group(players)
2577
2578 await controller.cmd_volume_set("member", 70)
2579
2580 players["member"].update_state()
2581 assert players["member"].state.volume_level == 70
2582 assert players["member"].state.volume_muted is False
2583
2584 async def test_natively_muted_player_is_unmuted_again(self, mock_mass: MagicMock) -> None:
2585 """A natively muted player is auto-unmuted by a volume change once its group is gone."""
2586 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2587 mute = AsyncMock(
2588 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2589 )
2590 players["member"].volume_mute = mute # type: ignore[method-assign]
2591 await controller.cmd_volume_mute("member", True)
2592 self._dissolve_group(players)
2593
2594 await controller.cmd_volume_set("member", 70)
2595
2596 assert mute.await_args_list == [call(True), call(False)]
2597 players["member"].update_state()
2598 assert players["member"].state.volume_level == 70
2599 assert players["member"].state.volume_muted is False
2600
2601 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
2602 """A muted player that is still grouped stays silent on a volume change."""
2603 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2604 await controller.cmd_volume_mute("member", True)
2605
2606 await controller.cmd_volume_set("member", 70)
2607
2608 players["member"].update_state()
2609 assert players["member"].state.volume_level == 0
2610 assert players["member"].state.volume_muted is True
2611
2612 async def test_protocol_player_follows_the_lock_of_its_parent(
2613 self, mock_mass: MagicMock
2614 ) -> None:
2615 """A protocol player inherits the lock of the parent it renders for, group and all."""
2616 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2617 member = players["member"]
2618 protocol_player = MockPlayer(
2619 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
2620 "proto_member",
2621 "Member Protocol",
2622 player_type=PlayerType.PROTOCOL,
2623 )
2624 protocol_player._attr_supported_features = {
2625 PlayerFeature.VOLUME_SET,
2626 PlayerFeature.VOLUME_MUTE,
2627 }
2628 protocol_player._attr_volume_muted = True
2629 protocol_player.set_protocol_parent_id("member")
2630 # an unmute on a protocol player is redirected to the parent it renders for
2631 mute = AsyncMock()
2632 member.volume_mute = mute # type: ignore[method-assign]
2633 protocol_player.volume_mute = AsyncMock() # type: ignore[method-assign]
2634 protocol_player.volume_set = AsyncMock() # type: ignore[method-assign]
2635 controller._players["proto_member"] = protocol_player
2636 member.set_linked_output_protocols(
2637 [
2638 OutputProtocol(
2639 output_protocol_id="proto_member",
2640 name="Sendspin",
2641 protocol_domain="sendspin",
2642 priority=40,
2643 )
2644 ]
2645 )
2646 protocol_player.set_initialized()
2647 protocol_player.update_state(signal_event=False)
2648 member.refresh_state(signal_event=False)
2649 member.extra_data[ATTR_MUTE_LOCK] = True
2650
2651 # a group volume change reaches the protocol player through the internal handler
2652 await controller._handle_cmd_volume_set("proto_member", 70)
2653 mute.assert_not_awaited()
2654
2655 self._dissolve_group(players)
2656 await controller._handle_cmd_volume_set("proto_member", 70)
2657
2658 mute.assert_awaited_once_with(False)
2659
2660
2661class TestCurrentMediaTimeUpdates:
2662 """Playback-position anchor semantics of timing-only state updates."""
2663
2664 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
2665 """Build a controller with a single playing player with a known position anchor."""
2666 controller = PlayerController(mock_mass)
2667 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2668 player = MockPlayer(provider, "player_1", "Player 1")
2669 controller._players = {"player_1": player}
2670 mock_mass.players = controller
2671 # no queue registered: current_media resolves from the player's native media
2672 mock_mass.player_queues.get = MagicMock(return_value=None)
2673 player.set_initialized()
2674 now = time.time()
2675 player._attr_playback_state = PlaybackState.PLAYING
2676 player._attr_elapsed_time = 17
2677 player._attr_elapsed_time_last_updated = now
2678 player.set_current_media(uri="http://test/stream", title="Test")
2679 assert player._attr_current_media is not None
2680 player._attr_current_media.elapsed_time = 17
2681 player._attr_current_media.elapsed_time_last_updated = now
2682 player.update_state(signal_event=False)
2683 # isolate from the unrelated state-forwarding machinery
2684 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
2685 mock_mass.signal_event.reset_mock()
2686 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
2687 return controller, player
2688
2689 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
2690 """Return whether a PLAYER_UPDATED event was signalled."""
2691 return any(
2692 call.args and call.args[0] == EventType.PLAYER_UPDATED
2693 for call in mock_mass.signal_event.call_args_list
2694 )
2695
2696 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
2697 """A regular playback tick (position and anchor advance together) emits nothing."""
2698 _controller, player = self._make_player(mock_mass)
2699 assert player._attr_current_media is not None
2700 assert player._attr_elapsed_time_last_updated is not None
2701
2702 player._attr_elapsed_time = 18
2703 player._attr_elapsed_time_last_updated += 1
2704 player._attr_current_media.elapsed_time = 18
2705 assert player._attr_current_media.elapsed_time_last_updated is not None
2706 player._attr_current_media.elapsed_time_last_updated += 1
2707 player.update_state()
2708
2709 assert not self._player_updated_signalled(mock_mass)
2710 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2711 # the previous anchor was preserved: steady playback changes nothing
2712 assert player.state.elapsed_time == 17
2713
2714 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
2715 """An anchor-only change (no significant corrected position change) emits nothing."""
2716 _controller, player = self._make_player(mock_mass)
2717 assert player._attr_current_media is not None
2718 assert player._attr_elapsed_time_last_updated is not None
2719
2720 player._attr_elapsed_time_last_updated += 0.5
2721 assert player._attr_current_media.elapsed_time_last_updated is not None
2722 player._attr_current_media.elapsed_time_last_updated += 0.5
2723 player.update_state()
2724
2725 assert not self._player_updated_signalled(mock_mass)
2726 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2727
2728 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
2729 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
2730 _controller, player = self._make_player(mock_mass)
2731 assert player._attr_current_media is not None
2732
2733 player._attr_current_media.elapsed_time = 61
2734 player._attr_current_media.elapsed_time_last_updated = time.time()
2735 player.update_state()
2736
2737 assert self._player_updated_signalled(mock_mass)
2738 # the adopted anchor is visible to consumers
2739 assert player.state.current_media is not None
2740 assert player.state.current_media.elapsed_time == 61
2741
2742 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
2743 """A player-level corrected-position jump re-bases the queue timing."""
2744 controller, player = self._make_player(mock_mass)
2745
2746 player._attr_elapsed_time = 61
2747 player._attr_elapsed_time_last_updated = time.time()
2748 player.update_state()
2749
2750 # the queue is corrected and a follow-up player update is scheduled
2751 # (which re-anchors current_media onto the corrected queue time),
2752 # but no full player update is emitted for the jump itself
2753 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2754 assert not self._player_updated_signalled(mock_mass)
2755 cast("MagicMock", controller._forward_state_update).assert_called_once()
2756 assert player.state.elapsed_time == 61
2757
2758 def test_simultaneous_player_and_media_jump_emits_immediately(
2759 self, mock_mass: MagicMock
2760 ) -> None:
2761 """A jump reaching player and current_media in one pass corrects the queue and emits."""
2762 _controller, player = self._make_player(mock_mass)
2763 assert player._attr_current_media is not None
2764 now = time.time()
2765
2766 player._attr_elapsed_time = 61
2767 player._attr_elapsed_time_last_updated = now
2768 player._attr_current_media.elapsed_time = 61
2769 player._attr_current_media.elapsed_time_last_updated = now
2770 player.update_state()
2771
2772 # the queue is re-based AND the full update is emitted right away
2773 # (current_media already holds the fresh position in the same pass)
2774 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2775 assert self._player_updated_signalled(mock_mass)
2776
2777
2778class TestPlayAnnouncementCleanup:
2779 """Test announcement data cleanup after play_announcement."""
2780
2781 def _make_player(
2782 self, mock_mass: MagicMock, announcements: dict[str, object]
2783 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
2784 """Create a controller and a player with native announcement support."""
2785 controller = PlayerController(mock_mass)
2786 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2787 player = MockPlayer(provider, "player_1", "Player 1")
2788 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
2789 player._cache.clear()
2790 controller._players = {"player_1": player}
2791 mock_mass.players = controller
2792 render = MagicMock()
2793 render.wait_ready = AsyncMock(return_value=True)
2794 render.wait_finished = AsyncMock(return_value=3.0)
2795
2796 # mimic the real renderer: it owns which announcement each player is playing
2797 def _register(player_id: str, announce_data: object) -> MagicMock:
2798 announcements[player_id] = announce_data
2799 return render
2800
2801 async def _unregister(player_id: str, _render: object) -> None:
2802 announcements.pop(player_id, None)
2803
2804 renderer = mock_mass.streams.announcement_renderer
2805 renderer.register = MagicMock(side_effect=_register)
2806 renderer.unregister = AsyncMock(side_effect=_unregister)
2807 mock_mass.streams.get_announcement_url = MagicMock(
2808 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
2809 )
2810 player.update_state(signal_event=False)
2811 return controller, player, render
2812
2813 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
2814 """The registered announcement data is released once playback finished."""
2815 announcements: dict[str, object] = {}
2816 controller, player, _render = self._make_player(mock_mass, announcements)
2817
2818 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
2819 # entry must exist while the announcement is being played/served
2820 assert "player_1" in announcements
2821
2822 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
2823
2824 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2825
2826 player.play_announcement.assert_awaited_once()
2827 assert announcements == {}
2828 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2829
2830 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
2831 """The registered announcement data is released even when playback fails."""
2832 announcements: dict[str, object] = {}
2833 controller, player, _render = self._make_player(mock_mass, announcements)
2834 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
2835
2836 with pytest.raises(PlayerCommandFailed):
2837 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2838
2839 assert announcements == {}
2840 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2841
2842 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
2843 """A native implementation is handed the url as soon as there is audio to serve."""
2844 announcements: dict[str, object] = {}
2845 controller, player, render = self._make_player(mock_mass, announcements)
2846 player.play_announcement = AsyncMock() # type: ignore[method-assign]
2847
2848 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2849
2850 player.play_announcement.assert_awaited_once()
2851 # waiting for the whole clip here would delay the player for a slow source;
2852 # the length is resolved downstream while it plays
2853 render.wait_ready.assert_awaited_once()
2854 render.wait_finished.assert_not_awaited()
2855
2856
2857class TestPlayAnnouncementRestore:
2858 """Test the state restore of the default (fallback) announcement implementation."""
2859
2860 def _make_player(
2861 self, mock_mass: MagicMock, prev_media: PlayerMedia
2862 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2863 """Create a controller and a playing player, returning the patched resume handler."""
2864 controller = PlayerController(mock_mass)
2865 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2866 player = MockPlayer(provider, "player_1", "Player 1")
2867 player._attr_playback_state = PlaybackState.PLAYING
2868 player._attr_current_media = prev_media
2869 player._cache.clear()
2870 controller._players = {"player_1": player}
2871 mock_mass.players = controller
2872 # the (temporary and restored) volume commands are dispatched through the
2873 # TaskManager, so background tasks must actually run in these tests
2874 mock_mass.create_task = MagicMock(
2875 side_effect=lambda coro, **_kwargs: asyncio.ensure_future(coro)
2876 )
2877 resume_mock = AsyncMock()
2878 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
2879 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2880 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
2881 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
2882 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
2883 player.set_initialized()
2884 player.update_state(signal_event=False)
2885 return controller, player, resume_mock
2886
2887 @staticmethod
2888 def _add_group(
2889 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
2890 ) -> MockPlayer:
2891 """Register a powered group player that holds the given player as its member."""
2892 group = MockPlayer(
2893 cast("MockProvider", player.provider),
2894 "group_1",
2895 "Group 1",
2896 player_type=PlayerType.GROUP,
2897 )
2898 group._attr_powered = True
2899 group._attr_group_members = [player.player_id]
2900 if supports_set_members:
2901 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2902 controller._players[group.player_id] = group
2903 group.set_initialized()
2904 group._cache.clear()
2905 group.update_state(signal_event=False)
2906 # the member has no own input change to trigger a recalculation of its
2907 # (group derived) state, so force it here - just like register() does
2908 player._cache.clear()
2909 player.update_state(force_update=True, signal_event=False)
2910 assert player.state.active_group == group.player_id
2911 return group
2912
2913 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
2914 """Content that was playing before the announcement is resumed afterwards."""
2915 controller, player, resume_mock = self._make_player(
2916 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2917 )
2918
2919 await controller._play_announcement(player, _announcement())
2920
2921 resume_mock.assert_awaited_once()
2922
2923 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
2924 """A player still busy with an earlier announcement has no playback to restore."""
2925 controller, player, resume_mock = self._make_player(
2926 mock_mass,
2927 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
2928 )
2929
2930 await controller._play_announcement(player, _announcement())
2931
2932 resume_mock.assert_not_awaited()
2933
2934 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
2935 """A failing announcement never leaves the player at the raised volume."""
2936 controller, player, _ = self._make_player(
2937 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2938 )
2939 player._attr_volume_level = 20
2940 player._cache.clear()
2941 player.update_state(force_update=True, signal_event=False)
2942 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
2943 volume_mock = AsyncMock()
2944 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2945 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2946 side_effect=PlayerCommandFailed("player went away")
2947 )
2948
2949 with pytest.raises(PlayerCommandFailed):
2950 await controller._play_announcement(player, _announcement())
2951
2952 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
2953
2954 async def test_zero_announcement_volume_is_applied_and_restored(
2955 self, mock_mass: MagicMock
2956 ) -> None:
2957 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
2958 controller, player, _ = self._make_player(
2959 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2960 )
2961 player._attr_volume_level = 20
2962 player._cache.clear()
2963 player.update_state(force_update=True, signal_event=False)
2964 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
2965 volume_mock = AsyncMock()
2966 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2967
2968 await controller._play_announcement(player, _announcement())
2969
2970 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
2971
2972 async def test_playback_is_restored_when_duration_is_unknown(
2973 self, mock_mass: MagicMock
2974 ) -> None:
2975 """An announcement of unknown length still hands the player back to its content."""
2976 controller, player, resume_mock = self._make_player(
2977 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2978 )
2979 announcement = _announcement()
2980 announcement.duration = None
2981
2982 # an unknown length waits for the player to report it finished instead of failing
2983 await controller._play_announcement(player, announcement)
2984
2985 resume_mock.assert_awaited_once()
2986
2987 async def test_group_membership_is_restored_when_playback_fails(
2988 self, mock_mass: MagicMock
2989 ) -> None:
2990 """A failing announcement never leaves the player out of its group player."""
2991 controller, player, _ = self._make_player(
2992 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2993 )
2994 group = self._add_group(controller, player, supports_set_members=True)
2995 group.set_members = AsyncMock() # type: ignore[method-assign]
2996 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2997 side_effect=PlayerCommandFailed("player went away")
2998 )
2999
3000 with pytest.raises(PlayerCommandFailed):
3001 await controller._play_announcement(player, _announcement())
3002
3003 assert group.set_members.await_args_list == [
3004 call(player_ids_to_remove=["player_1"]),
3005 call(player_ids_to_add=["player_1"]),
3006 ]
3007
3008 async def test_restore_failure_does_not_mask_the_announcement_error(
3009 self, mock_mass: MagicMock
3010 ) -> None:
3011 """A provider blowing up during the restore must not hide why the announcement failed."""
3012 controller, player, _ = self._make_player(
3013 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3014 )
3015 group = self._add_group(controller, player, supports_set_members=True)
3016 # set_members is a raw provider call: whatever its client library raises comes
3017 # through unwrapped, so the ungroup succeeds and the regroup times out
3018 group.set_members = AsyncMock( # type: ignore[method-assign]
3019 side_effect=[None, TimeoutError("provider timeout")]
3020 )
3021 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3022 side_effect=PlayerCommandFailed("player went away")
3023 )
3024
3025 with pytest.raises(PlayerCommandFailed, match="player went away"):
3026 await controller._play_announcement(player, _announcement())
3027
3028 async def test_group_without_set_members_is_the_one_powered_off(
3029 self, mock_mass: MagicMock
3030 ) -> None:
3031 """A group that can not release members is powered off, not the announcement target."""
3032 controller, player, _ = self._make_player(
3033 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3034 )
3035 self._add_group(controller, player, supports_set_members=False)
3036 power_mock = AsyncMock()
3037 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
3038 play_mock = AsyncMock()
3039 controller.cmd_play = play_mock # type: ignore[method-assign]
3040
3041 await controller._play_announcement(player, _announcement())
3042
3043 # the group is switched off for the announcement and restarted afterwards
3044 power_mock.assert_awaited_once_with("group_1", False)
3045 play_mock.assert_awaited_once_with("group_1")
3046
3047 async def test_idle_player_without_power_control_is_regrouped(
3048 self, mock_mass: MagicMock
3049 ) -> None:
3050 """An idle player that has no power state to restore is still put back in its group."""
3051 controller, player, _ = self._make_player(
3052 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3053 )
3054 player._attr_playback_state = PlaybackState.IDLE
3055 player._attr_powered = None
3056 group = self._add_group(controller, player, supports_set_members=True)
3057 assert player.state.power_control == PLAYER_CONTROL_NONE
3058 group.set_members = AsyncMock() # type: ignore[method-assign]
3059
3060 await controller._play_announcement(player, _announcement())
3061
3062 assert group.set_members.await_args_list == [
3063 call(player_ids_to_remove=["player_1"]),
3064 call(player_ids_to_add=["player_1"]),
3065 ]
3066
3067 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
3068 """A muted player hears the announcement and is muted again afterwards."""
3069 controller, player, _ = self._make_player(
3070 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3071 )
3072 mute_mock = _mute_natively(player)
3073
3074 await controller._play_announcement(player, _announcement())
3075
3076 assert mute_mock.await_args_list == [call(False), call(True)]
3077
3078 async def test_player_without_volume_control_is_still_unmuted(
3079 self, mock_mass: MagicMock
3080 ) -> None:
3081 """A player that can only be muted is unmuted for the announcement all the same."""
3082 mock_mass.config.get_raw_player_config_value = MagicMock(
3083 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
3084 )
3085 controller, player, _ = self._make_player(
3086 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3087 )
3088 mute_mock = _mute_natively(player)
3089 assert player.state.volume_control == PLAYER_CONTROL_NONE
3090
3091 await controller._play_announcement(player, _announcement())
3092
3093 assert mute_mock.await_args_list == [call(False), call(True)]
3094
3095 async def test_mute_is_restored_before_the_player_is_regrouped(
3096 self, mock_mass: MagicMock
3097 ) -> None:
3098 """A player is handed back to its group already muted, holding on to its mute lock."""
3099 controller, player, _ = self._make_player(
3100 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3101 )
3102 group = self._add_group(controller, player, supports_set_members=True)
3103 real_set_members = group.set_members
3104
3105 async def _set_members(**kwargs: list[str]) -> None:
3106 # let the membership really change, so the player is ungrouped while the
3107 # announcement plays - just like it is in production. neither player picks
3108 # the new membership up on its own here, so publish it on both.
3109 await real_set_members(**kwargs)
3110 group.update_state(force_update=True, signal_event=False)
3111 player._cache.clear()
3112 player.update_state(force_update=True, signal_event=False)
3113
3114 set_members = AsyncMock(side_effect=_set_members)
3115 group.set_members = set_members # type: ignore[method-assign]
3116 player.extra_data[ATTR_MUTE_LOCK] = True
3117 recorder = MagicMock()
3118 recorder.attach_mock(_mute_natively(player), "mute")
3119 recorder.attach_mock(set_members, "set_members")
3120
3121 await controller._play_announcement(player, _announcement())
3122
3123 assert recorder.mock_calls == [
3124 call.set_members(player_ids_to_remove=["player_1"]),
3125 call.mute(False),
3126 call.mute(True),
3127 call.set_members(player_ids_to_add=["player_1"]),
3128 ]
3129 # the lock survives the announcement, so the regroup does not unmute the player
3130 assert player.extra_data[ATTR_MUTE_LOCK] is True
3131
3132 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
3133 """A player that was not muted is never sent a mute command."""
3134 controller, player, _ = self._make_player(
3135 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3136 )
3137 mute_mock = _mute_natively(player)
3138 player._attr_volume_muted = False
3139 player._cache.clear()
3140 player.update_state(force_update=True, signal_event=False)
3141 mute_mock.reset_mock()
3142
3143 await controller._play_announcement(player, _announcement())
3144
3145 mute_mock.assert_not_awaited()
3146
3147 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
3148 """A failing announcement never leaves the player unmuted."""
3149 controller, player, _ = self._make_player(
3150 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3151 )
3152 mute_mock = _mute_natively(player)
3153 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
3154 side_effect=PlayerCommandFailed("player went away")
3155 )
3156
3157 with pytest.raises(PlayerCommandFailed):
3158 await controller._play_announcement(player, _announcement())
3159
3160 assert mute_mock.await_args_list == [call(False), call(True)]
3161
3162 async def test_muted_sync_group_members_all_hear_the_announcement(
3163 self, mock_mass: MagicMock
3164 ) -> None:
3165 """Every member of a muted sync group is unmuted, keeping its mute lock."""
3166 controller, leader, _ = self._make_player(
3167 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3168 )
3169 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
3170 controller._players["player_2"] = member
3171 member.set_initialized()
3172 leader._attr_group_members = ["player_1", "player_2"]
3173 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
3174 # both members were muted while grouped, so both hold a mute lock
3175 for player in (leader, member):
3176 player.extra_data[ATTR_MUTE_LOCK] = True
3177
3178 await controller._play_announcement(leader, _announcement())
3179
3180 for player_id, mute_mock in mute_mocks.items():
3181 assert mute_mock.await_args_list == [call(False), call(True)], player_id
3182 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
3183
3184 async def test_fake_muted_player_announces_at_its_real_volume(
3185 self, mock_mass: MagicMock
3186 ) -> None:
3187 """A fake muted player announces at its real volume, not at the zero it is parked on."""
3188 mock_mass.config.get_raw_player_config_value = MagicMock(
3189 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
3190 )
3191 controller, player, _ = self._make_player(
3192 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
3193 )
3194
3195 def _apply_volume(volume: int) -> None:
3196 player._attr_volume_level = volume
3197 player.update_state(signal_event=False)
3198
3199 player._attr_volume_level = 40
3200 volume_set = AsyncMock(side_effect=_apply_volume)
3201 player.volume_set = volume_set # type: ignore[method-assign]
3202 player._cache.clear()
3203 player.update_state(force_update=True, signal_event=False)
3204 await controller.cmd_volume_mute("player_1", True)
3205 assert player.state.volume_muted is True
3206 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
3207
3208 await controller._play_announcement(player, _announcement())
3209
3210 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
3211 assert volume_set.await_args_list == [
3212 call(0),
3213 call(40),
3214 call(80),
3215 call(40),
3216 call(0),
3217 ]
3218 assert player.state.volume_muted is True
3219 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
3220
3221
3222class TestPlayNativeAnnouncement:
3223 """Test the mute handling around an announcement that a player plays natively."""
3224
3225 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
3226 """Create a controller and a player with native announcement support."""
3227 controller = PlayerController(mock_mass)
3228 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3229 player = MockPlayer(provider, "player_1", "Player 1")
3230 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3231 player._cache.clear()
3232 controller._players = {"player_1": player}
3233 mock_mass.players = controller
3234 # the mute commands are dispatched through the TaskManager,
3235 # so background tasks must actually run in these tests
3236 mock_mass.create_task = MagicMock(
3237 side_effect=lambda coro, **_kwargs: asyncio.ensure_future(coro)
3238 )
3239 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
3240 announce_mock = AsyncMock()
3241 player.play_announcement = announce_mock # type: ignore[method-assign]
3242 player.set_initialized()
3243 player.update_state(signal_event=False)
3244 return controller, player, announce_mock
3245
3246 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
3247 """A muted player hears the announcement and is muted again afterwards."""
3248 controller, player, announce_mock = self._make_player(mock_mass)
3249 recorder = MagicMock()
3250 recorder.attach_mock(_mute_natively(player), "mute")
3251 recorder.attach_mock(announce_mock, "announce")
3252
3253 await controller._play_native_announcement(player, player, _announcement(), None)
3254
3255 assert recorder.mock_calls == [
3256 call.mute(False),
3257 call.announce(ANY, None),
3258 call.mute(True),
3259 ]
3260
3261 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
3262 """A player that was not muted is never sent a mute command."""
3263 controller, player, _ = self._make_player(mock_mass)
3264 mute_mock = _mute_natively(player)
3265 player._attr_volume_muted = False
3266 player._cache.clear()
3267 player.update_state(force_update=True, signal_event=False)
3268 mute_mock.reset_mock()
3269
3270 await controller._play_native_announcement(player, player, _announcement(), None)
3271
3272 mute_mock.assert_not_awaited()
3273
3274 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
3275 """A failing announcement never leaves the player unmuted."""
3276 controller, player, announce_mock = self._make_player(mock_mass)
3277 mute_mock = _mute_natively(player)
3278 announce_mock.side_effect = PlayerCommandFailed("player went away")
3279
3280 with pytest.raises(PlayerCommandFailed):
3281 await controller._play_native_announcement(player, player, _announcement(), None)
3282
3283 assert mute_mock.await_args_list == [call(False), call(True)]
3284
3285 async def test_muted_sync_group_members_all_hear_the_announcement(
3286 self, mock_mass: MagicMock
3287 ) -> None:
3288 """Every member of a muted sync group is unmuted, keeping its mute lock."""
3289 controller, leader, _ = self._make_player(mock_mass)
3290 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
3291 controller._players["player_2"] = member
3292 member.set_initialized()
3293 leader._attr_group_members = ["player_1", "player_2"]
3294 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
3295 # both members were muted while grouped, so both hold a mute lock
3296 for player in (leader, member):
3297 player.extra_data[ATTR_MUTE_LOCK] = True
3298
3299 await controller._play_native_announcement(leader, leader, _announcement(), None)
3300
3301 for player_id, mute_mock in mute_mocks.items():
3302 assert mute_mock.await_args_list == [call(False), call(True)], player_id
3303 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
3304
3305 async def test_fake_muted_player_announces_at_its_real_volume(
3306 self, mock_mass: MagicMock
3307 ) -> None:
3308 """The announcement volume is resolved after the unmute, not from the parked zero."""
3309 mock_mass.config.get_raw_player_config_value = MagicMock(
3310 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
3311 )
3312 controller, player, announce_mock = self._make_player(mock_mass)
3313
3314 def _apply_volume(volume: int) -> None:
3315 player._attr_volume_level = volume
3316 player.update_state(signal_event=False)
3317
3318 player._attr_volume_level = 40
3319 volume_set = AsyncMock(side_effect=_apply_volume)
3320 player.volume_set = volume_set # type: ignore[method-assign]
3321 player._cache.clear()
3322 player.update_state(force_update=True, signal_event=False)
3323 await controller.cmd_volume_mute("player_1", True)
3324 assert player.state.volume_muted is True
3325 # stand in for the configured strategy, which reads the volume of the player
3326 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
3327 side_effect=lambda _player_id, _volume_level: player.state.volume_level
3328 )
3329
3330 await controller._play_native_announcement(player, player, _announcement(), None)
3331
3332 assert announce_mock.await_args == call(ANY, 40)
3333 # unmute to 40 for the announcement, park back on 0 for the fake mute
3334 assert volume_set.await_args_list == [call(0), call(40), call(0)]
3335 assert player.state.volume_muted is True
3336 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
3337
3338
3339class TestScheduleActiveOutputProtocolClear:
3340 """Test the deferred clear of a player's active output protocol."""
3341
3342 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
3343 """Scheduling defers the clear to a single, per-player, cancellable task."""
3344 controller = PlayerController(mock_mass)
3345 player = MagicMock()
3346 player.player_id = "player_1"
3347
3348 controller.schedule_active_output_protocol_clear(player)
3349
3350 mock_mass.create_task.assert_called_once()
3351 # close the coroutine passed to the mocked create_task to avoid a
3352 # "coroutine was never awaited" warning
3353 mock_mass.create_task.call_args.args[0].close()
3354 # no abort_existing: a duplicate schedule must reuse the pending clear
3355 # (deduped by task_id) instead of replacing it with an untracked task
3356 assert mock_mass.create_task.call_args.kwargs == {
3357 "task_id": "clear_active_protocol_player_1",
3358 }
3359
3360 @pytest.mark.asyncio
3361 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
3362 """The protocol is cleared after waiting for the player to reach IDLE."""
3363 controller = PlayerController(mock_mass)
3364 player = MagicMock()
3365 player.player_id = "player_1"
3366
3367 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
3368 await controller._clear_active_output_protocol_when_idle(player)
3369
3370 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
3371 player.set_active_output_protocol.assert_called_once_with(None)
3372
3373
3374@contextlib.asynccontextmanager
3375async def _skip_player_update_wait(
3376 *_args: object,
3377 **_kwargs: object,
3378) -> AsyncIterator[None]:
3379 """Skip provider-driven state propagation in command-routing tests."""
3380 yield
3381
3382
3383class TestRemovePlayerControl:
3384 """Test removing a registered player control."""
3385
3386 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
3387 """Test that only the players configured to use the removed control are refreshed."""
3388 mock_mass.loop = MagicMock()
3389 controller = PlayerController(mock_mass)
3390 using_control = MagicMock()
3391 using_control.state.power_control = "switch.amp"
3392 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
3393 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
3394 unrelated = MagicMock()
3395 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
3396 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
3397 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
3398 controller._players = {"using_control": using_control, "unrelated": unrelated}
3399 controller._controls = {
3400 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
3401 }
3402
3403 controller.remove_player_control("switch.amp")
3404
3405 assert controller.player_controls() == []
3406 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
3407
3408 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
3409 """Test that a control removed and registered again re-attaches to its player."""
3410 # run the scheduled refresh straight away so each step is observable
3411 mock_mass.loop = MagicMock()
3412 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
3413 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
3414 {CONF_POWER_CONTROL: "switch.amp"}
3415 )
3416 controller = PlayerController(mock_mass)
3417 mock_mass.players = controller
3418 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
3419 mock_mass.get_provider.return_value = provider
3420 player = MockPlayer(provider, "player", "Player")
3421 controller._players = {"player": player}
3422 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
3423
3424 await controller.register_or_update_player_control(control)
3425 assert player.state.power_control == "switch.amp"
3426
3427 # the Home Assistant plugin drops and re-registers its controls around a reload
3428 controller.remove_player_control(control.id)
3429 assert player.state.power_control == PLAYER_CONTROL_NONE
3430
3431 await controller.register_or_update_player_control(control)
3432 assert player.state.power_control == "switch.amp"
3433
3434 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
3435 """Test that removing a control that was never registered is a no-op."""
3436 mock_mass.loop = MagicMock()
3437 controller = PlayerController(mock_mass)
3438 player = MagicMock()
3439 player.state.power_control = PLAYER_CONTROL_NATIVE
3440 player.state.volume_control = PLAYER_CONTROL_NATIVE
3441 player.state.mute_control = PLAYER_CONTROL_NATIVE
3442 controller._players = {"player": player}
3443
3444 controller.remove_player_control("switch.gone")
3445
3446 mock_mass.loop.call_soon.assert_not_called()
3447
3448
3449class _FailingTeardownPlayer(MockPlayer):
3450 """Player whose provider fails to release it."""
3451
3452 unloaded = False
3453
3454 async def on_unload(self) -> None:
3455 """Handle logic when the player is unloaded from the Player controller."""
3456 self.unloaded = True
3457 msg = "device is gone"
3458 raise RuntimeError(msg)
3459
3460
3461class TestUnregisterTeardown:
3462 """Test that a failing player teardown stays contained."""
3463
3464 async def test_failing_on_unload_still_unregisters_the_player(
3465 self, mock_mass: MagicMock
3466 ) -> None:
3467 """Test that a provider raising while releasing its player does not break unregister."""
3468 controller = PlayerController(mock_mass)
3469 mock_mass.players = controller
3470 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
3471 player = _FailingTeardownPlayer(provider, "boom", "Boom")
3472 controller._players = {"boom": player}
3473
3474 await controller.unregister("boom")
3475
3476 assert "boom" not in controller._players
3477 assert player.unloaded
3478
3479
3480if __name__ == "__main__":
3481 pytest.main([__file__, "-v"])
3482