/
/
/
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(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
1661 """Build a controller with a single player using fake mute control."""
1662 mock_mass.config.get_raw_player_config_value = MagicMock(
1663 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
1664 )
1665 controller = PlayerController(mock_mass)
1666 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1667 player = MockPlayer(provider, "player_1", "Player 1")
1668 controller._players = {"player_1": player}
1669 mock_mass.players = controller
1670 mock_mass.player_queues.get = MagicMock(return_value=None)
1671 player.set_initialized()
1672 player._attr_volume_level = 40
1673 # let the mocked native volume control behave like a real device
1674 volume_set = AsyncMock(
1675 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
1676 )
1677 player.volume_set = volume_set # type: ignore[method-assign]
1678 player.update_state(signal_event=False)
1679 return controller, player, volume_set
1680
1681 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
1682 """Muting reports volume_muted=True and unmuting restores the previous volume."""
1683 controller, player, volume_set = self._make_player(mock_mass)
1684
1685 await controller.cmd_volume_mute("player_1", True)
1686 muted_state = player.state
1687 assert muted_state.volume_muted is True
1688 assert muted_state.volume_level == 0
1689 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1690
1691 await controller.cmd_volume_mute("player_1", False)
1692 volume_set.assert_awaited_with(40)
1693 # simulate the device reporting back its state after the volume command
1694 player.update_state()
1695 unmuted_state = player.state
1696 assert unmuted_state.volume_muted is False
1697 assert unmuted_state.volume_level == 40
1698
1699 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
1700 """A repeated mute command must not overwrite the stored volume with 0."""
1701 controller, player, volume_set = self._make_player(mock_mass)
1702
1703 await controller.cmd_volume_mute("player_1", True)
1704 await controller.cmd_volume_mute("player_1", True)
1705 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1706 assert player.state.volume_muted is True
1707
1708 await controller.cmd_volume_mute("player_1", False)
1709 volume_set.assert_awaited_with(40)
1710
1711 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
1712 """A regular volume change while fake muted implies an unmute."""
1713 controller, player, _volume_set = self._make_player(mock_mass)
1714
1715 await controller.cmd_volume_mute("player_1", True)
1716 muted_state = player.state
1717 assert muted_state.volume_muted is True
1718
1719 await controller.cmd_volume_set("player_1", 25)
1720 # simulate the device reporting back its state after the volume command
1721 player.update_state()
1722 unmuted_state = player.state
1723 assert unmuted_state.volume_muted is False
1724 assert unmuted_state.volume_level == 25
1725
1726
1727class TestFakeMuteInGroup:
1728 """A fake muted player in a group follows the mute lock, just like a native mute."""
1729
1730 def _make_synced_pair(
1731 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
1732 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
1733 """
1734 Build a leader synced to one member.
1735
1736 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
1737 """
1738
1739 def _conf(player_id: str, key: str, default: object = None) -> object:
1740 if key == CONF_MUTE_CONTROL and player_id == "member":
1741 return member_mute_control
1742 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
1743 player_id, key, default
1744 )
1745
1746 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1747 controller = PlayerController(mock_mass)
1748 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1749 players: dict[str, MockPlayer] = {}
1750 for player_id in ("leader", "member"):
1751 player = MockPlayer(provider, player_id, player_id.title())
1752 player._attr_supported_features = {
1753 PlayerFeature.VOLUME_SET,
1754 PlayerFeature.VOLUME_MUTE,
1755 }
1756 player._attr_volume_level = 50
1757 # let the mocked native volume control behave like a real device
1758 player.volume_set = AsyncMock( # type: ignore[method-assign]
1759 side_effect=lambda volume, _player=player: setattr(
1760 _player, "_attr_volume_level", volume
1761 )
1762 )
1763 players[player_id] = player
1764 players["leader"]._attr_group_members = ["member"]
1765 controller._players = dict(players)
1766 mock_mass.players = controller
1767 mock_mass.player_queues.get = MagicMock(return_value=None)
1768 for player in players.values():
1769 player.set_initialized()
1770 player._cache.clear()
1771 player.update_state(signal_event=False)
1772 # a second pass, so the group volume of the leader accounts for its member
1773 for player in players.values():
1774 player.update_state(signal_event=False)
1775 return controller, players
1776
1777 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
1778 """A group volume change may not bring a muted fake mute pair back to life."""
1779 controller, players = self._make_synced_pair(mock_mass)
1780 await controller.cmd_group_volume_mute("leader", True)
1781
1782 await controller.cmd_group_volume("leader", 30)
1783
1784 for player in players.values():
1785 player.update_state()
1786 assert player.state.volume_muted is True
1787 assert player.state.volume_level == 0
1788
1789 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
1790 """Turning a group down leaves a single muted member silent, at its own volume."""
1791 controller, players = self._make_synced_pair(mock_mass)
1792 await controller.cmd_volume_mute("member", True)
1793 # let the group volume of the leader account for the muted member
1794 players["leader"].update_state(signal_event=False)
1795
1796 await controller.cmd_group_volume("leader", 25)
1797
1798 for player in players.values():
1799 player.update_state()
1800 member_state = players["member"].state
1801 assert member_state.volume_muted is True
1802 assert member_state.volume_level == 0
1803 # the player that is not muted follows the group volume as usual
1804 assert players["leader"].state.volume_level == 25
1805 # unmuting brings the member back at the volume it had before it was muted
1806 await controller.cmd_volume_mute("member", False)
1807 players["member"].update_state()
1808 assert players["member"].state.volume_level == 50
1809
1810 async def test_unmute_restores_the_volume_from_before_the_mute(
1811 self, mock_mass: MagicMock
1812 ) -> None:
1813 """A group volume change while muted may not alter the volume to restore."""
1814 controller, players = self._make_synced_pair(mock_mass)
1815 await controller.cmd_group_volume_mute("leader", True)
1816 await controller.cmd_group_volume("leader", 30)
1817
1818 await controller.cmd_group_volume_mute("leader", False)
1819
1820 for player in players.values():
1821 player.update_state()
1822 assert player.state.volume_muted is False
1823 assert player.state.volume_level == 50
1824
1825 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
1826 """Members with a different mute control stay muted alike on a group volume change."""
1827 controller, players = self._make_synced_pair(
1828 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
1829 )
1830 mute = AsyncMock(
1831 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
1832 )
1833 players["member"].volume_mute = mute # type: ignore[method-assign]
1834 await controller.cmd_group_volume_mute("leader", True)
1835
1836 await controller.cmd_group_volume("leader", 30)
1837
1838 mute.assert_awaited_once_with(True)
1839 for player in players.values():
1840 player.update_state()
1841 assert player.state.volume_muted is True
1842
1843
1844class TestMuteControlGuard:
1845 """Muting is gated on the mute control, independently of the volume control."""
1846
1847 def _make_player(
1848 self,
1849 mock_mass: MagicMock,
1850 mute_control: str,
1851 volume_control: str,
1852 controls: dict[str, PlayerControl] | None = None,
1853 ) -> tuple[PlayerController, MockPlayer]:
1854 """Build a controller with a single player using the given control config."""
1855 mock_mass.config.get_raw_player_config_value = MagicMock(
1856 side_effect=_player_config_stub(
1857 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
1858 )
1859 )
1860 controller = PlayerController(mock_mass)
1861 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1862 player = MockPlayer(provider, "player_1", "Player 1")
1863 controller._players = {"player_1": player}
1864 controller._controls = controls or {}
1865 mock_mass.players = controller
1866 mock_mass.player_queues.get = MagicMock(return_value=None)
1867 player.set_initialized()
1868 player.update_state(signal_event=False)
1869 return controller, player
1870
1871 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
1872 """A player without volume control still mutes through an external PlayerControl."""
1873 mute_set = AsyncMock()
1874 control = PlayerControl(
1875 id="ext_mute",
1876 provider="test",
1877 name="External Mute",
1878 supports_mute=True,
1879 mute_set=mute_set,
1880 )
1881 controller, player = self._make_player(
1882 mock_mass,
1883 mute_control="ext_mute",
1884 volume_control=PLAYER_CONTROL_NONE,
1885 controls={"ext_mute": control},
1886 )
1887 assert player.mute_control == "ext_mute"
1888
1889 await controller.cmd_volume_mute("player_1", True)
1890 mute_set.assert_awaited_once_with(True)
1891
1892 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
1893 """A player without volume control still mutes natively."""
1894 controller, player = self._make_player(
1895 mock_mass,
1896 mute_control=PLAYER_CONTROL_NATIVE,
1897 volume_control=PLAYER_CONTROL_NONE,
1898 )
1899 volume_mute = AsyncMock()
1900 player.volume_mute = volume_mute # type: ignore[method-assign]
1901
1902 await controller.cmd_volume_mute("player_1", True)
1903 volume_mute.assert_awaited_once_with(True)
1904
1905 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
1906 """A player with volume control but no mute control rejects the command."""
1907 controller, player = self._make_player(
1908 mock_mass,
1909 mute_control=PLAYER_CONTROL_NONE,
1910 volume_control=PLAYER_CONTROL_NATIVE,
1911 )
1912 volume_mute = AsyncMock()
1913 player.volume_mute = volume_mute # type: ignore[method-assign]
1914
1915 with pytest.raises(UnsupportedFeaturedException):
1916 await controller.cmd_volume_mute("player_1", True)
1917 volume_mute.assert_not_awaited()
1918
1919 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
1920 """Fake mute needs a volume control to drive, so it rejects the command outright."""
1921 controller, player = self._make_player(
1922 mock_mass,
1923 mute_control=PLAYER_CONTROL_FAKE,
1924 volume_control=PLAYER_CONTROL_NONE,
1925 )
1926 player._attr_volume_level = 40
1927
1928 with pytest.raises(UnsupportedFeaturedException):
1929 await controller.cmd_volume_mute("player_1", True)
1930 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
1931 assert ATTR_FAKE_MUTE not in player.extra_data
1932
1933 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
1934 """A mute control that disappeared after being resolved is reported, not ignored."""
1935 control = PlayerControl(
1936 id="ext_mute",
1937 provider="test",
1938 name="External Mute",
1939 supports_mute=True,
1940 mute_set=AsyncMock(),
1941 )
1942 controller, player = self._make_player(
1943 mock_mass,
1944 mute_control="ext_mute",
1945 volume_control=PLAYER_CONTROL_NONE,
1946 controls={"ext_mute": control},
1947 )
1948 # the resolved control is cached on the player, so removing it here leaves
1949 # the player pointing at a control that no longer exists
1950 assert player.mute_control == "ext_mute"
1951 controller._controls = {}
1952
1953 with pytest.raises(UnsupportedFeaturedException):
1954 await controller.cmd_volume_mute("player_1", True)
1955
1956 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
1957 """Unmuting clears a mute lock left behind by a since-removed mute control."""
1958 controller, player = self._make_player(
1959 mock_mass,
1960 mute_control=PLAYER_CONTROL_NONE,
1961 volume_control=PLAYER_CONTROL_NATIVE,
1962 )
1963 player.extra_data[ATTR_MUTE_LOCK] = True
1964
1965 with pytest.raises(UnsupportedFeaturedException):
1966 await controller.cmd_volume_mute("player_1", False)
1967 assert ATTR_MUTE_LOCK not in player.extra_data
1968
1969 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
1970 """A grouped player whose mute command failed is not left holding a mute lock."""
1971 control = PlayerControl(
1972 id="ext_mute",
1973 provider="test",
1974 name="External Mute",
1975 supports_mute=False,
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 player.state.synced_to = "leader"
1984
1985 with pytest.raises(UnsupportedFeaturedException):
1986 await controller.cmd_volume_mute("player_1", True)
1987 assert ATTR_MUTE_LOCK not in player.extra_data
1988
1989 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
1990 """A failed mute leaves the lock of an earlier successful mute in place."""
1991 control = PlayerControl(
1992 id="ext_mute",
1993 provider="test",
1994 name="External Mute",
1995 supports_mute=False,
1996 )
1997 controller, player = self._make_player(
1998 mock_mass,
1999 mute_control="ext_mute",
2000 volume_control=PLAYER_CONTROL_NONE,
2001 controls={"ext_mute": control},
2002 )
2003 player.state.synced_to = "leader"
2004 player.extra_data[ATTR_MUTE_LOCK] = True
2005
2006 with pytest.raises(UnsupportedFeaturedException):
2007 await controller.cmd_volume_mute("player_1", True)
2008 assert player.extra_data[ATTR_MUTE_LOCK] is True
2009
2010
2011class TestGroupMuteMemberFilter:
2012 """Group mute skips members that have no mute control of their own."""
2013
2014 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
2015 """A member without a mute control must not fail the whole group command."""
2016 controller = PlayerController(mock_mass)
2017 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2018 leader = MockPlayer(provider, "leader", "Leader")
2019 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2020 leader._attr_group_members = ["leader", "member"]
2021 member = MockPlayer(provider, "member", "Member")
2022 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2023 controller._players = {"leader": leader, "member": member}
2024 mock_mass.players = controller
2025 mock_mass.player_queues.get = MagicMock(return_value=None)
2026 for player in (leader, member):
2027 player.set_initialized()
2028 player.update_state(signal_event=False)
2029 leader_mute = AsyncMock()
2030 leader.volume_mute = leader_mute # type: ignore[method-assign]
2031
2032 await controller.cmd_group_volume_mute("leader", True)
2033 leader_mute.assert_awaited_once_with(True)
2034
2035
2036class TestGroupPlayerMuteRedirect:
2037 """A mute command on a group player is handled at group level."""
2038
2039 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
2040 """Build a controller with a group player holding a single mute capable member."""
2041 controller = PlayerController(mock_mass)
2042 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2043 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
2044 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2045 group._attr_group_members = ["member"]
2046 member = MockPlayer(provider, "member", "Member")
2047 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
2048 controller._players = {"group": group, "member": member}
2049 mock_mass.players = controller
2050 mock_mass.player_queues.get = MagicMock(return_value=None)
2051 for player in (group, member):
2052 player.set_initialized()
2053 player.update_state(signal_event=False)
2054 return controller, group, member
2055
2056 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
2057 """A group player has no mute of its own, so the members must be muted instead."""
2058 controller, _group, member = self._setup(mock_mass)
2059 member_mute = AsyncMock()
2060 member.volume_mute = member_mute # type: ignore[method-assign]
2061
2062 await controller.cmd_volume_mute("group", True)
2063
2064 member_mute.assert_awaited_once_with(True)
2065
2066 async def test_mute_on_group_player_without_own_mute_control(
2067 self, mock_mass: MagicMock
2068 ) -> None:
2069 """A group that has no mute control of its own must still mute its members."""
2070 controller, group, member = self._setup(mock_mass)
2071 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
2072 group._cache.clear()
2073 group.update_state(signal_event=False)
2074 assert group.mute_control == PLAYER_CONTROL_NONE
2075 member_mute = AsyncMock()
2076 member.volume_mute = member_mute # type: ignore[method-assign]
2077
2078 await controller.cmd_volume_mute("group", True)
2079
2080 member_mute.assert_awaited_once_with(True)
2081
2082 async def test_mute_on_group_player_without_mute_capable_members(
2083 self, mock_mass: MagicMock
2084 ) -> None:
2085 """A group whose members cannot mute must not raise, just like group mute itself."""
2086 controller, _group, member = self._setup(mock_mass)
2087 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
2088 member._cache.clear()
2089 member.update_state(signal_event=False)
2090
2091 await controller.cmd_volume_mute("group", True)
2092
2093
2094class TestGroupMuteOnNonGroupPlayer:
2095 """A group mute command works on any player, just like the group volume command."""
2096
2097 def _setup(
2098 self, mock_mass: MagicMock, *members: str
2099 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2100 """Build a controller with a mute capable leader synced to the given members."""
2101 controller = PlayerController(mock_mass)
2102 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2103 players: dict[str, MockPlayer] = {}
2104 for player_id in ("leader", *members):
2105 player = MockPlayer(provider, player_id, player_id.title())
2106 player._attr_supported_features = {
2107 PlayerFeature.VOLUME_SET,
2108 PlayerFeature.VOLUME_MUTE,
2109 }
2110 player._attr_volume_level = 50
2111 players[player_id] = player
2112 if members:
2113 # the leader is not listed as its own member here, so the tests also cover
2114 # that a sync leader is injected into its own final group_members
2115 players["leader"]._attr_group_members = list(members)
2116 controller._players = dict(players)
2117 mock_mass.players = controller
2118 mock_mass.player_queues.get = MagicMock(return_value=None)
2119 for player in players.values():
2120 player.set_initialized()
2121 player._cache.clear()
2122 player.update_state(signal_event=False)
2123 return controller, players
2124
2125 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
2126 """Replace the native mute command of every given player with a mock."""
2127 mutes: dict[str, AsyncMock] = {}
2128 for player_id, player in players.items():
2129 mutes[player_id] = AsyncMock()
2130 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
2131 return mutes
2132
2133 async def test_group_mute_on_synced_member_redirects_to_leader(
2134 self, mock_mass: MagicMock
2135 ) -> None:
2136 """A member of a sync group mutes the whole group through its sync leader."""
2137 controller, players = self._setup(mock_mass, "member")
2138 assert players["member"].state.synced_to == "leader"
2139 mutes = self._stub_mutes(players)
2140
2141 await controller.cmd_group_volume_mute("member", True)
2142
2143 mutes["leader"].assert_awaited_once_with(True)
2144 mutes["member"].assert_awaited_once_with(True)
2145 assert ATTR_MUTE_LOCK in players["member"].extra_data
2146
2147 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
2148 self, mock_mass: MagicMock
2149 ) -> None:
2150 """A sync leader is part of its own member list, so it must be muted only once."""
2151 controller, players = self._setup(mock_mass, "member")
2152 mutes = self._stub_mutes(players)
2153
2154 await controller.cmd_group_volume_mute("leader", True)
2155
2156 mutes["leader"].assert_awaited_once_with(True)
2157 mutes["member"].assert_awaited_once_with(True)
2158
2159 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
2160 """A player that is not grouped at all is muted as a normal player."""
2161 controller, players = self._setup(mock_mass)
2162 mutes = self._stub_mutes(players)
2163
2164 await controller.cmd_group_volume_mute("leader", True)
2165
2166 mutes["leader"].assert_awaited_once_with(True)
2167
2168 async def test_group_mute_on_plain_player_without_mute_control(
2169 self, mock_mass: MagicMock
2170 ) -> None:
2171 """A plain player that cannot mute reports that, just like a normal mute command."""
2172 controller, players = self._setup(mock_mass)
2173 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
2174 players["leader"]._cache.clear()
2175 players["leader"].update_state(signal_event=False)
2176
2177 with pytest.raises(UnsupportedFeaturedException):
2178 await controller.cmd_group_volume_mute("leader", True)
2179
2180 async def test_group_unmute_on_synced_member_redirects_to_leader(
2181 self, mock_mass: MagicMock
2182 ) -> None:
2183 """Unmuting through a member clears the mute (and mute lock) of every group member."""
2184 controller, players = self._setup(mock_mass, "member")
2185 mutes = self._stub_mutes(players)
2186 players["member"].extra_data[ATTR_MUTE_LOCK] = True
2187
2188 await controller.cmd_group_volume_mute("member", False)
2189
2190 mutes["leader"].assert_awaited_once_with(False)
2191 mutes["member"].assert_awaited_once_with(False)
2192 assert ATTR_MUTE_LOCK not in players["member"].extra_data
2193
2194 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
2195 """A sync leader is as much part of the group as its members, so it is locked too."""
2196 controller, players = self._setup(mock_mass, "member")
2197 self._stub_mutes(players)
2198
2199 await controller.cmd_group_volume_mute("leader", True)
2200
2201 assert ATTR_MUTE_LOCK in players["leader"].extra_data
2202 assert ATTR_MUTE_LOCK in players["member"].extra_data
2203
2204 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
2205 """A group volume change may not half-unmute a muted pair of directly synced players."""
2206 controller, players = self._setup(mock_mass, "member")
2207 self._stub_mutes(players)
2208 await controller.cmd_group_volume_mute("leader", True)
2209 # the mock players do not act on the mute command, so reflect it in their state
2210 for player in players.values():
2211 player._attr_volume_muted = True
2212 player.update_state(signal_event=False)
2213 player.volume_set = AsyncMock() # type: ignore[method-assign]
2214 # re-stub so only the mute commands of the group volume change are counted
2215 mutes = self._stub_mutes(players)
2216
2217 await controller.cmd_group_volume("leader", 30)
2218
2219 for mute in mutes.values():
2220 mute.assert_not_awaited()
2221
2222
2223class TestMuteLockAfterUngroup:
2224 """A mute lock is only honored while the player it belongs to is still grouped."""
2225
2226 def _make_synced_pair(
2227 self, mock_mass: MagicMock, member_mute_control: str
2228 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2229 """
2230 Build a leader with one synced member.
2231
2232 :param member_mute_control: Mute control to configure on both players.
2233 """
2234 mock_mass.config.get_raw_player_config_value = MagicMock(
2235 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
2236 )
2237 controller = PlayerController(mock_mass)
2238 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2239 players: dict[str, MockPlayer] = {}
2240 for player_id in ("leader", "member"):
2241 player = MockPlayer(provider, player_id, player_id.title())
2242 player._attr_supported_features = {
2243 PlayerFeature.VOLUME_SET,
2244 PlayerFeature.VOLUME_MUTE,
2245 }
2246 player._attr_volume_level = 50
2247 player.volume_set = AsyncMock( # type: ignore[method-assign]
2248 side_effect=lambda volume, _player=player: setattr(
2249 _player, "_attr_volume_level", volume
2250 )
2251 )
2252 players[player_id] = player
2253 players["leader"]._attr_group_members = ["member"]
2254 controller._players = dict(players)
2255 mock_mass.players = controller
2256 mock_mass.player_queues.get = MagicMock(return_value=None)
2257 for player in players.values():
2258 player.set_initialized()
2259 player._cache.clear()
2260 player.update_state(signal_event=False)
2261 return controller, players
2262
2263 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
2264 """Drop the sync group, the way a provider side topology change does."""
2265 players["leader"]._attr_group_members = []
2266 for player in players.values():
2267 player.refresh_state(signal_event=False)
2268
2269 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
2270 """A fake muted player is no longer forced silent once its group is gone."""
2271 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2272 await controller.cmd_volume_mute("member", True)
2273 self._dissolve_group(players)
2274
2275 await controller.cmd_volume_set("member", 70)
2276
2277 players["member"].update_state()
2278 assert players["member"].state.volume_level == 70
2279 assert players["member"].state.volume_muted is False
2280
2281 async def test_natively_muted_player_is_unmuted_again(self, mock_mass: MagicMock) -> None:
2282 """A natively muted player is auto-unmuted by a volume change once its group is gone."""
2283 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2284 mute = AsyncMock(
2285 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
2286 )
2287 players["member"].volume_mute = mute # type: ignore[method-assign]
2288 await controller.cmd_volume_mute("member", True)
2289 self._dissolve_group(players)
2290
2291 await controller.cmd_volume_set("member", 70)
2292
2293 assert mute.await_args_list == [call(True), call(False)]
2294 players["member"].update_state()
2295 assert players["member"].state.volume_level == 70
2296 assert players["member"].state.volume_muted is False
2297
2298 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
2299 """A muted player that is still grouped stays silent on a volume change."""
2300 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
2301 await controller.cmd_volume_mute("member", True)
2302
2303 await controller.cmd_volume_set("member", 70)
2304
2305 players["member"].update_state()
2306 assert players["member"].state.volume_level == 0
2307 assert players["member"].state.volume_muted is True
2308
2309 async def test_protocol_player_follows_the_lock_of_its_parent(
2310 self, mock_mass: MagicMock
2311 ) -> None:
2312 """A protocol player inherits the lock of the parent it renders for, group and all."""
2313 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
2314 member = players["member"]
2315 protocol_player = MockPlayer(
2316 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
2317 "proto_member",
2318 "Member Protocol",
2319 player_type=PlayerType.PROTOCOL,
2320 )
2321 protocol_player._attr_supported_features = {
2322 PlayerFeature.VOLUME_SET,
2323 PlayerFeature.VOLUME_MUTE,
2324 }
2325 protocol_player._attr_volume_muted = True
2326 protocol_player.set_protocol_parent_id("member")
2327 # an unmute on a protocol player is redirected to the parent it renders for
2328 mute = AsyncMock()
2329 member.volume_mute = mute # type: ignore[method-assign]
2330 protocol_player.volume_mute = AsyncMock() # type: ignore[method-assign]
2331 protocol_player.volume_set = AsyncMock() # type: ignore[method-assign]
2332 controller._players["proto_member"] = protocol_player
2333 member.set_linked_output_protocols(
2334 [
2335 OutputProtocol(
2336 output_protocol_id="proto_member",
2337 name="Sendspin",
2338 protocol_domain="sendspin",
2339 priority=40,
2340 )
2341 ]
2342 )
2343 protocol_player.set_initialized()
2344 protocol_player.update_state(signal_event=False)
2345 member.refresh_state(signal_event=False)
2346 member.extra_data[ATTR_MUTE_LOCK] = True
2347
2348 # a group volume change reaches the protocol player through the internal handler
2349 await controller._handle_cmd_volume_set("proto_member", 70)
2350 mute.assert_not_awaited()
2351
2352 self._dissolve_group(players)
2353 await controller._handle_cmd_volume_set("proto_member", 70)
2354
2355 mute.assert_awaited_once_with(False)
2356
2357
2358class TestCurrentMediaTimeUpdates:
2359 """Playback-position anchor semantics of timing-only state updates."""
2360
2361 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
2362 """Build a controller with a single playing player with a known position anchor."""
2363 controller = PlayerController(mock_mass)
2364 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2365 player = MockPlayer(provider, "player_1", "Player 1")
2366 controller._players = {"player_1": player}
2367 mock_mass.players = controller
2368 # no queue registered: current_media resolves from the player's native media
2369 mock_mass.player_queues.get = MagicMock(return_value=None)
2370 player.set_initialized()
2371 now = time.time()
2372 player._attr_playback_state = PlaybackState.PLAYING
2373 player._attr_elapsed_time = 17
2374 player._attr_elapsed_time_last_updated = now
2375 player.set_current_media(uri="http://test/stream", title="Test")
2376 assert player._attr_current_media is not None
2377 player._attr_current_media.elapsed_time = 17
2378 player._attr_current_media.elapsed_time_last_updated = now
2379 player.update_state(signal_event=False)
2380 # isolate from the unrelated state-forwarding machinery
2381 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
2382 mock_mass.signal_event.reset_mock()
2383 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
2384 return controller, player
2385
2386 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
2387 """Return whether a PLAYER_UPDATED event was signalled."""
2388 return any(
2389 call.args and call.args[0] == EventType.PLAYER_UPDATED
2390 for call in mock_mass.signal_event.call_args_list
2391 )
2392
2393 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
2394 """A regular playback tick (position and anchor advance together) emits nothing."""
2395 _controller, player = self._make_player(mock_mass)
2396 assert player._attr_current_media is not None
2397 assert player._attr_elapsed_time_last_updated is not None
2398
2399 player._attr_elapsed_time = 18
2400 player._attr_elapsed_time_last_updated += 1
2401 player._attr_current_media.elapsed_time = 18
2402 assert player._attr_current_media.elapsed_time_last_updated is not None
2403 player._attr_current_media.elapsed_time_last_updated += 1
2404 player.update_state()
2405
2406 assert not self._player_updated_signalled(mock_mass)
2407 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2408 # the previous anchor was preserved: steady playback changes nothing
2409 assert player.state.elapsed_time == 17
2410
2411 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
2412 """An anchor-only change (no significant corrected position change) emits nothing."""
2413 _controller, player = self._make_player(mock_mass)
2414 assert player._attr_current_media is not None
2415 assert player._attr_elapsed_time_last_updated is not None
2416
2417 player._attr_elapsed_time_last_updated += 0.5
2418 assert player._attr_current_media.elapsed_time_last_updated is not None
2419 player._attr_current_media.elapsed_time_last_updated += 0.5
2420 player.update_state()
2421
2422 assert not self._player_updated_signalled(mock_mass)
2423 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
2424
2425 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
2426 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
2427 _controller, player = self._make_player(mock_mass)
2428 assert player._attr_current_media is not None
2429
2430 player._attr_current_media.elapsed_time = 61
2431 player._attr_current_media.elapsed_time_last_updated = time.time()
2432 player.update_state()
2433
2434 assert self._player_updated_signalled(mock_mass)
2435 # the adopted anchor is visible to consumers
2436 assert player.state.current_media is not None
2437 assert player.state.current_media.elapsed_time == 61
2438
2439 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
2440 """A player-level corrected-position jump re-bases the queue timing."""
2441 controller, player = self._make_player(mock_mass)
2442
2443 player._attr_elapsed_time = 61
2444 player._attr_elapsed_time_last_updated = time.time()
2445 player.update_state()
2446
2447 # the queue is corrected and a follow-up player update is scheduled
2448 # (which re-anchors current_media onto the corrected queue time),
2449 # but no full player update is emitted for the jump itself
2450 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2451 assert not self._player_updated_signalled(mock_mass)
2452 cast("MagicMock", controller._forward_state_update).assert_called_once()
2453 assert player.state.elapsed_time == 61
2454
2455 def test_simultaneous_player_and_media_jump_emits_immediately(
2456 self, mock_mass: MagicMock
2457 ) -> None:
2458 """A jump reaching player and current_media in one pass corrects the queue and emits."""
2459 _controller, player = self._make_player(mock_mass)
2460 assert player._attr_current_media is not None
2461 now = time.time()
2462
2463 player._attr_elapsed_time = 61
2464 player._attr_elapsed_time_last_updated = now
2465 player._attr_current_media.elapsed_time = 61
2466 player._attr_current_media.elapsed_time_last_updated = now
2467 player.update_state()
2468
2469 # the queue is re-based AND the full update is emitted right away
2470 # (current_media already holds the fresh position in the same pass)
2471 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2472 assert self._player_updated_signalled(mock_mass)
2473
2474
2475class TestPlayAnnouncementCleanup:
2476 """Test announcement data cleanup after play_announcement."""
2477
2478 def _make_player(
2479 self, mock_mass: MagicMock, announcements: dict[str, object]
2480 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
2481 """Create a controller and a player with native announcement support."""
2482 controller = PlayerController(mock_mass)
2483 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2484 player = MockPlayer(provider, "player_1", "Player 1")
2485 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
2486 player._cache.clear()
2487 controller._players = {"player_1": player}
2488 mock_mass.players = controller
2489 render = MagicMock()
2490 render.wait_ready = AsyncMock(return_value=True)
2491 render.wait_finished = AsyncMock(return_value=3.0)
2492
2493 # mimic the real renderer: it owns which announcement each player is playing
2494 def _register(player_id: str, announce_data: object) -> MagicMock:
2495 announcements[player_id] = announce_data
2496 return render
2497
2498 async def _unregister(player_id: str, _render: object) -> None:
2499 announcements.pop(player_id, None)
2500
2501 renderer = mock_mass.streams.announcement_renderer
2502 renderer.register = MagicMock(side_effect=_register)
2503 renderer.unregister = AsyncMock(side_effect=_unregister)
2504 mock_mass.streams.get_announcement_url = MagicMock(
2505 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
2506 )
2507 player.update_state(signal_event=False)
2508 return controller, player, render
2509
2510 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
2511 """The registered announcement data is released once playback finished."""
2512 announcements: dict[str, object] = {}
2513 controller, player, _render = self._make_player(mock_mass, announcements)
2514
2515 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
2516 # entry must exist while the announcement is being played/served
2517 assert "player_1" in announcements
2518
2519 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
2520
2521 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2522
2523 player.play_announcement.assert_awaited_once()
2524 assert announcements == {}
2525 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2526
2527 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
2528 """The registered announcement data is released even when playback fails."""
2529 announcements: dict[str, object] = {}
2530 controller, player, _render = self._make_player(mock_mass, announcements)
2531 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
2532
2533 with pytest.raises(PlayerCommandFailed):
2534 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2535
2536 assert announcements == {}
2537 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2538
2539 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
2540 """A native implementation is handed the url as soon as there is audio to serve."""
2541 announcements: dict[str, object] = {}
2542 controller, player, render = self._make_player(mock_mass, announcements)
2543 player.play_announcement = AsyncMock() # type: ignore[method-assign]
2544
2545 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2546
2547 player.play_announcement.assert_awaited_once()
2548 # waiting for the whole clip here would delay the player for a slow source;
2549 # the length is resolved downstream while it plays
2550 render.wait_ready.assert_awaited_once()
2551 render.wait_finished.assert_not_awaited()
2552
2553
2554class TestPlayAnnouncementRestore:
2555 """Test the state restore of the default (fallback) announcement implementation."""
2556
2557 def _make_player(
2558 self, mock_mass: MagicMock, prev_media: PlayerMedia
2559 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2560 """Create a controller and a playing player, returning the patched resume handler."""
2561 controller = PlayerController(mock_mass)
2562 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2563 player = MockPlayer(provider, "player_1", "Player 1")
2564 player._attr_playback_state = PlaybackState.PLAYING
2565 player._attr_current_media = prev_media
2566 player._cache.clear()
2567 controller._players = {"player_1": player}
2568 mock_mass.players = controller
2569 # the (temporary and restored) volume commands are dispatched through the
2570 # TaskManager, so background tasks must actually run in these tests
2571 mock_mass.create_task = MagicMock(
2572 side_effect=lambda coro, **_kwargs: asyncio.ensure_future(coro)
2573 )
2574 resume_mock = AsyncMock()
2575 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
2576 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2577 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
2578 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
2579 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
2580 player.set_initialized()
2581 player.update_state(signal_event=False)
2582 return controller, player, resume_mock
2583
2584 @staticmethod
2585 def _add_group(
2586 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
2587 ) -> MockPlayer:
2588 """Register a powered group player that holds the given player as its member."""
2589 group = MockPlayer(
2590 cast("MockProvider", player.provider),
2591 "group_1",
2592 "Group 1",
2593 player_type=PlayerType.GROUP,
2594 )
2595 group._attr_powered = True
2596 group._attr_group_members = [player.player_id]
2597 if supports_set_members:
2598 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2599 controller._players[group.player_id] = group
2600 group.set_initialized()
2601 group._cache.clear()
2602 group.update_state(signal_event=False)
2603 # the member has no own input change to trigger a recalculation of its
2604 # (group derived) state, so force it here - just like register() does
2605 player._cache.clear()
2606 player.update_state(force_update=True, signal_event=False)
2607 assert player.state.active_group == group.player_id
2608 return group
2609
2610 @staticmethod
2611 def _announcement() -> PlayerMedia:
2612 """Return the announcement to play."""
2613 return PlayerMedia(
2614 uri="http://ma/announcement/player_1.mp3",
2615 media_type=MediaType.ANNOUNCEMENT,
2616 title="Announcement",
2617 duration=3,
2618 )
2619
2620 @staticmethod
2621 def _mute_natively(player: MockPlayer) -> AsyncMock:
2622 """Give the player a native mute control, mute it and return its mute handler."""
2623 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
2624 player._attr_volume_muted = True
2625 mute_mock = AsyncMock(
2626 side_effect=lambda muted: setattr(player, "_attr_volume_muted", muted)
2627 )
2628 player.volume_mute = mute_mock # type: ignore[method-assign]
2629 player._cache.clear()
2630 player.update_state(force_update=True, signal_event=False)
2631 assert player.state.volume_muted is True
2632 return mute_mock
2633
2634 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
2635 """Content that was playing before the announcement is resumed afterwards."""
2636 controller, player, resume_mock = self._make_player(
2637 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2638 )
2639
2640 await controller._play_announcement(player, self._announcement())
2641
2642 resume_mock.assert_awaited_once()
2643
2644 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
2645 """A player still busy with an earlier announcement has no playback to restore."""
2646 controller, player, resume_mock = self._make_player(
2647 mock_mass,
2648 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
2649 )
2650
2651 await controller._play_announcement(player, self._announcement())
2652
2653 resume_mock.assert_not_awaited()
2654
2655 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
2656 """A failing announcement never leaves the player at the raised volume."""
2657 controller, player, _ = self._make_player(
2658 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2659 )
2660 player._attr_volume_level = 20
2661 player._cache.clear()
2662 player.update_state(force_update=True, signal_event=False)
2663 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
2664 volume_mock = AsyncMock()
2665 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2666 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2667 side_effect=PlayerCommandFailed("player went away")
2668 )
2669
2670 with pytest.raises(PlayerCommandFailed):
2671 await controller._play_announcement(player, self._announcement())
2672
2673 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
2674
2675 async def test_zero_announcement_volume_is_applied_and_restored(
2676 self, mock_mass: MagicMock
2677 ) -> None:
2678 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
2679 controller, player, _ = self._make_player(
2680 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2681 )
2682 player._attr_volume_level = 20
2683 player._cache.clear()
2684 player.update_state(force_update=True, signal_event=False)
2685 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
2686 volume_mock = AsyncMock()
2687 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2688
2689 await controller._play_announcement(player, self._announcement())
2690
2691 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
2692
2693 async def test_playback_is_restored_when_duration_is_unknown(
2694 self, mock_mass: MagicMock
2695 ) -> None:
2696 """An announcement of unknown length still hands the player back to its content."""
2697 controller, player, resume_mock = self._make_player(
2698 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2699 )
2700 announcement = self._announcement()
2701 announcement.duration = None
2702
2703 # an unknown length waits for the player to report it finished instead of failing
2704 await controller._play_announcement(player, announcement)
2705
2706 resume_mock.assert_awaited_once()
2707
2708 async def test_group_membership_is_restored_when_playback_fails(
2709 self, mock_mass: MagicMock
2710 ) -> None:
2711 """A failing announcement never leaves the player out of its group player."""
2712 controller, player, _ = self._make_player(
2713 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2714 )
2715 group = self._add_group(controller, player, supports_set_members=True)
2716 group.set_members = AsyncMock() # type: ignore[method-assign]
2717 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2718 side_effect=PlayerCommandFailed("player went away")
2719 )
2720
2721 with pytest.raises(PlayerCommandFailed):
2722 await controller._play_announcement(player, self._announcement())
2723
2724 assert group.set_members.await_args_list == [
2725 call(player_ids_to_remove=["player_1"]),
2726 call(player_ids_to_add=["player_1"]),
2727 ]
2728
2729 async def test_restore_failure_does_not_mask_the_announcement_error(
2730 self, mock_mass: MagicMock
2731 ) -> None:
2732 """A provider blowing up during the restore must not hide why the announcement failed."""
2733 controller, player, _ = self._make_player(
2734 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2735 )
2736 group = self._add_group(controller, player, supports_set_members=True)
2737 # set_members is a raw provider call: whatever its client library raises comes
2738 # through unwrapped, so the ungroup succeeds and the regroup times out
2739 group.set_members = AsyncMock( # type: ignore[method-assign]
2740 side_effect=[None, TimeoutError("provider timeout")]
2741 )
2742 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2743 side_effect=PlayerCommandFailed("player went away")
2744 )
2745
2746 with pytest.raises(PlayerCommandFailed, match="player went away"):
2747 await controller._play_announcement(player, self._announcement())
2748
2749 async def test_group_without_set_members_is_the_one_powered_off(
2750 self, mock_mass: MagicMock
2751 ) -> None:
2752 """A group that can not release members is powered off, not the announcement target."""
2753 controller, player, _ = self._make_player(
2754 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2755 )
2756 self._add_group(controller, player, supports_set_members=False)
2757 power_mock = AsyncMock()
2758 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
2759 play_mock = AsyncMock()
2760 controller.cmd_play = play_mock # type: ignore[method-assign]
2761
2762 await controller._play_announcement(player, self._announcement())
2763
2764 # the group is switched off for the announcement and restarted afterwards
2765 power_mock.assert_awaited_once_with("group_1", False)
2766 play_mock.assert_awaited_once_with("group_1")
2767
2768 async def test_idle_player_without_power_control_is_regrouped(
2769 self, mock_mass: MagicMock
2770 ) -> None:
2771 """An idle player that has no power state to restore is still put back in its group."""
2772 controller, player, _ = self._make_player(
2773 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2774 )
2775 player._attr_playback_state = PlaybackState.IDLE
2776 player._attr_powered = None
2777 group = self._add_group(controller, player, supports_set_members=True)
2778 assert player.state.power_control == PLAYER_CONTROL_NONE
2779 group.set_members = AsyncMock() # type: ignore[method-assign]
2780
2781 await controller._play_announcement(player, self._announcement())
2782
2783 assert group.set_members.await_args_list == [
2784 call(player_ids_to_remove=["player_1"]),
2785 call(player_ids_to_add=["player_1"]),
2786 ]
2787
2788 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
2789 """A muted player hears the announcement and is muted again afterwards."""
2790 controller, player, _ = self._make_player(
2791 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2792 )
2793 mute_mock = self._mute_natively(player)
2794
2795 await controller._play_announcement(player, self._announcement())
2796
2797 assert mute_mock.await_args_list == [call(False), call(True)]
2798
2799 async def test_player_without_volume_control_is_still_unmuted(
2800 self, mock_mass: MagicMock
2801 ) -> None:
2802 """A player that can only be muted is unmuted for the announcement all the same."""
2803 mock_mass.config.get_raw_player_config_value = MagicMock(
2804 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
2805 )
2806 controller, player, _ = self._make_player(
2807 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2808 )
2809 mute_mock = self._mute_natively(player)
2810 assert player.state.volume_control == PLAYER_CONTROL_NONE
2811
2812 await controller._play_announcement(player, self._announcement())
2813
2814 assert mute_mock.await_args_list == [call(False), call(True)]
2815
2816 async def test_mute_is_restored_before_the_player_is_regrouped(
2817 self, mock_mass: MagicMock
2818 ) -> None:
2819 """A player is handed back to its group already muted, holding on to its mute lock."""
2820 controller, player, _ = self._make_player(
2821 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2822 )
2823 group = self._add_group(controller, player, supports_set_members=True)
2824 real_set_members = group.set_members
2825
2826 async def _set_members(**kwargs: list[str]) -> None:
2827 # let the membership really change, so the player is ungrouped while the
2828 # announcement plays - just like it is in production. neither player picks
2829 # the new membership up on its own here, so publish it on both.
2830 await real_set_members(**kwargs)
2831 group.update_state(force_update=True, signal_event=False)
2832 player._cache.clear()
2833 player.update_state(force_update=True, signal_event=False)
2834
2835 set_members = AsyncMock(side_effect=_set_members)
2836 group.set_members = set_members # type: ignore[method-assign]
2837 player.extra_data[ATTR_MUTE_LOCK] = True
2838 recorder = MagicMock()
2839 recorder.attach_mock(self._mute_natively(player), "mute")
2840 recorder.attach_mock(set_members, "set_members")
2841
2842 await controller._play_announcement(player, self._announcement())
2843
2844 assert recorder.mock_calls == [
2845 call.set_members(player_ids_to_remove=["player_1"]),
2846 call.mute(False),
2847 call.mute(True),
2848 call.set_members(player_ids_to_add=["player_1"]),
2849 ]
2850 # the lock survives the announcement, so the regroup does not unmute the player
2851 assert player.extra_data[ATTR_MUTE_LOCK] is True
2852
2853 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
2854 """A player that was not muted is never sent a mute command."""
2855 controller, player, _ = self._make_player(
2856 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2857 )
2858 mute_mock = self._mute_natively(player)
2859 player._attr_volume_muted = False
2860 player._cache.clear()
2861 player.update_state(force_update=True, signal_event=False)
2862 mute_mock.reset_mock()
2863
2864 await controller._play_announcement(player, self._announcement())
2865
2866 mute_mock.assert_not_awaited()
2867
2868 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
2869 """A failing announcement never leaves the player unmuted."""
2870 controller, player, _ = self._make_player(
2871 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2872 )
2873 mute_mock = self._mute_natively(player)
2874 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2875 side_effect=PlayerCommandFailed("player went away")
2876 )
2877
2878 with pytest.raises(PlayerCommandFailed):
2879 await controller._play_announcement(player, self._announcement())
2880
2881 assert mute_mock.await_args_list == [call(False), call(True)]
2882
2883 async def test_muted_sync_group_members_all_hear_the_announcement(
2884 self, mock_mass: MagicMock
2885 ) -> None:
2886 """Every member of a muted sync group is unmuted, keeping its mute lock."""
2887 controller, leader, _ = self._make_player(
2888 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2889 )
2890 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
2891 controller._players["player_2"] = member
2892 member.set_initialized()
2893 leader._attr_group_members = ["player_1", "player_2"]
2894 mute_mocks = {player.player_id: self._mute_natively(player) for player in (leader, member)}
2895 # both members were muted while grouped, so both hold a mute lock
2896 for player in (leader, member):
2897 player.extra_data[ATTR_MUTE_LOCK] = True
2898
2899 await controller._play_announcement(leader, self._announcement())
2900
2901 for player_id, mute_mock in mute_mocks.items():
2902 assert mute_mock.await_args_list == [call(False), call(True)], player_id
2903 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
2904
2905 async def test_fake_muted_player_announces_at_its_real_volume(
2906 self, mock_mass: MagicMock
2907 ) -> None:
2908 """A fake muted player announces at its real volume, not at the zero it is parked on."""
2909 mock_mass.config.get_raw_player_config_value = MagicMock(
2910 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
2911 )
2912 controller, player, _ = self._make_player(
2913 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2914 )
2915
2916 def _apply_volume(volume: int) -> None:
2917 player._attr_volume_level = volume
2918 player.update_state(signal_event=False)
2919
2920 player._attr_volume_level = 40
2921 volume_set = AsyncMock(side_effect=_apply_volume)
2922 player.volume_set = volume_set # type: ignore[method-assign]
2923 player._cache.clear()
2924 player.update_state(force_update=True, signal_event=False)
2925 await controller.cmd_volume_mute("player_1", True)
2926 assert player.state.volume_muted is True
2927 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
2928
2929 await controller._play_announcement(player, self._announcement())
2930
2931 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
2932 assert volume_set.await_args_list == [
2933 call(0),
2934 call(40),
2935 call(80),
2936 call(40),
2937 call(0),
2938 ]
2939 assert player.state.volume_muted is True
2940 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2941
2942
2943class TestScheduleActiveOutputProtocolClear:
2944 """Test the deferred clear of a player's active output protocol."""
2945
2946 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
2947 """Scheduling defers the clear to a single, per-player, cancellable task."""
2948 controller = PlayerController(mock_mass)
2949 player = MagicMock()
2950 player.player_id = "player_1"
2951
2952 controller.schedule_active_output_protocol_clear(player)
2953
2954 mock_mass.create_task.assert_called_once()
2955 # close the coroutine passed to the mocked create_task to avoid a
2956 # "coroutine was never awaited" warning
2957 mock_mass.create_task.call_args.args[0].close()
2958 # no abort_existing: a duplicate schedule must reuse the pending clear
2959 # (deduped by task_id) instead of replacing it with an untracked task
2960 assert mock_mass.create_task.call_args.kwargs == {
2961 "task_id": "clear_active_protocol_player_1",
2962 }
2963
2964 @pytest.mark.asyncio
2965 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
2966 """The protocol is cleared after waiting for the player to reach IDLE."""
2967 controller = PlayerController(mock_mass)
2968 player = MagicMock()
2969 player.player_id = "player_1"
2970
2971 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
2972 await controller._clear_active_output_protocol_when_idle(player)
2973
2974 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
2975 player.set_active_output_protocol.assert_called_once_with(None)
2976
2977
2978@contextlib.asynccontextmanager
2979async def _skip_player_update_wait(
2980 *_args: object,
2981 **_kwargs: object,
2982) -> AsyncIterator[None]:
2983 """Skip provider-driven state propagation in command-routing tests."""
2984 yield
2985
2986
2987class TestRemovePlayerControl:
2988 """Test removing a registered player control."""
2989
2990 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
2991 """Test that only the players configured to use the removed control are refreshed."""
2992 mock_mass.loop = MagicMock()
2993 controller = PlayerController(mock_mass)
2994 using_control = MagicMock()
2995 using_control.state.power_control = "switch.amp"
2996 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
2997 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
2998 unrelated = MagicMock()
2999 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
3000 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
3001 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
3002 controller._players = {"using_control": using_control, "unrelated": unrelated}
3003 controller._controls = {
3004 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
3005 }
3006
3007 controller.remove_player_control("switch.amp")
3008
3009 assert controller.player_controls() == []
3010 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
3011
3012 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
3013 """Test that a control removed and registered again re-attaches to its player."""
3014 # run the scheduled refresh straight away so each step is observable
3015 mock_mass.loop = MagicMock()
3016 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
3017 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
3018 {CONF_POWER_CONTROL: "switch.amp"}
3019 )
3020 controller = PlayerController(mock_mass)
3021 mock_mass.players = controller
3022 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
3023 mock_mass.get_provider.return_value = provider
3024 player = MockPlayer(provider, "player", "Player")
3025 controller._players = {"player": player}
3026 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
3027
3028 await controller.register_or_update_player_control(control)
3029 assert player.state.power_control == "switch.amp"
3030
3031 # the Home Assistant plugin drops and re-registers its controls around a reload
3032 controller.remove_player_control(control.id)
3033 assert player.state.power_control == PLAYER_CONTROL_NONE
3034
3035 await controller.register_or_update_player_control(control)
3036 assert player.state.power_control == "switch.amp"
3037
3038 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
3039 """Test that removing a control that was never registered is a no-op."""
3040 mock_mass.loop = MagicMock()
3041 controller = PlayerController(mock_mass)
3042 player = MagicMock()
3043 player.state.power_control = PLAYER_CONTROL_NATIVE
3044 player.state.volume_control = PLAYER_CONTROL_NATIVE
3045 player.state.mute_control = PLAYER_CONTROL_NATIVE
3046 controller._players = {"player": player}
3047
3048 controller.remove_player_control("switch.gone")
3049
3050 mock_mass.loop.call_soon.assert_not_called()
3051
3052
3053if __name__ == "__main__":
3054 pytest.main([__file__, "-v"])
3055