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