/
/
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 Any, NamedTuple, cast
19from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
20
21import pytest
22from music_assistant_models.auth import User, UserRole
23from music_assistant_models.config_entries import ConfigEntry, CoreConfig, PlayerConfig
24from music_assistant_models.constants import (
25 PLAYER_CONTROL_FAKE,
26 PLAYER_CONTROL_NATIVE,
27 PLAYER_CONTROL_NONE,
28)
29from music_assistant_models.enums import (
30 ConfigEntryType,
31 EventType,
32 MediaType,
33 PlaybackState,
34 PlayerFeature,
35 PlayerType,
36)
37from music_assistant_models.errors import (
38 InvalidDataError,
39 MusicAssistantError,
40 PlayerCommandFailed,
41 UnsupportedFeaturedException,
42)
43from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
44from music_assistant_models.player_control import PlayerControl
45
46from music_assistant.constants import (
47 ANNOUNCE_ALERT_FILE,
48 ATTR_FAKE_MUTE,
49 ATTR_MUTE_LOCK,
50 ATTR_PREVIOUS_VOLUME,
51 CONF_AUTO_PLAY,
52 CONF_ENTRY_TTS_PRE_ANNOUNCE,
53 CONF_ICON,
54 CONF_MAX_VOLUME,
55 CONF_MIN_VOLUME,
56 CONF_MUTE_CONTROL,
57 CONF_OUTPUT_CODEC,
58 CONF_PLAYERS,
59 CONF_POWER_CONTROL,
60 CONF_PROTOCOL_PARENT_ID,
61 CONF_VOLUME_CONTROL,
62 CONF_VOLUME_STEP,
63)
64from music_assistant.controllers.players import PlayerController
65from music_assistant.controllers.players import controller as players_controller
66from music_assistant.controllers.players.announcements import ANNOUNCEMENT_TTS_TIMEOUT
67from music_assistant.controllers.webserver.helpers.auth_middleware import current_user
68from music_assistant.helpers.tts import TTS_QUERY_TIMEOUT_SECONDS
69from music_assistant.models.player import LinkedOutputProtocol, Player
70from music_assistant.models.player_provider import PlayerProvider
71from music_assistant.providers.universal_player.player import UniversalPlayer
72from tests.common import MockPlayer, MockProvider, create_mock_config, use_real_create_task
73
74
75def _player_config_stub(
76 values: dict[str, object] | None = None,
77 *,
78 min_volume: int = 0,
79 max_volume: int = 100,
80) -> Callable[..., object]:
81 """
82 Build a ``get_raw_player_config_value`` side effect.
83
84 :param values: Extra config keys to answer, e.g. ``{CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE}``.
85 :param min_volume: Value returned for the ``min_volume`` key.
86 :param max_volume: Value returned for the ``max_volume`` key.
87 """
88 config: dict[str, object] = {
89 CONF_MIN_VOLUME: min_volume,
90 CONF_MAX_VOLUME: max_volume,
91 **(values or {}),
92 }
93
94 def _conf(_player_id: str, key: str, default: object = None) -> object:
95 if key in config:
96 return config[key]
97 return default
98
99 return _conf
100
101
102def _announcement() -> PlayerMedia:
103 """Return the announcement to play."""
104 return PlayerMedia(
105 uri="http://ma/announcement/player_1.mp3",
106 media_type=MediaType.ANNOUNCEMENT,
107 title="Announcement",
108 duration=3,
109 )
110
111
112def _volume_step_config(step: int | None) -> CoreConfig:
113 """
114 Build a "players" CoreConfig carrying the given ``volume_step`` value.
115
116 Pass ``None`` to leave the entry unresolved and genuinely exercise the default
117 (``CoreConfig.get_value`` returns the entry's ``value`` verbatim, not its
118 ``default_value``, so a real "not configured" state is ``value=None``).
119 """
120 return CoreConfig(
121 domain="players",
122 values={
123 CONF_VOLUME_STEP: ConfigEntry(
124 key=CONF_VOLUME_STEP,
125 type=ConfigEntryType.INTEGER,
126 default_value=0,
127 value=step,
128 )
129 },
130 )
131
132
133@pytest.fixture
134def running_background_tasks(mock_mass: MagicMock) -> Iterator[None]:
135 """
136 Really run the tasks that ``mass.create_task`` is handed, and raise what they raise.
137
138 The real implementation only logs the exception of a background task, which would
139 leave a test that dispatches its work through the TaskManager passing regardless.
140 """
141 tasks: list[asyncio.Task[Any]] = []
142 use_real_create_task(mock_mass)
143 real_create_task = mock_mass.create_task
144
145 def _create_task(target: Any, *args: Any, **kwargs: Any) -> Any:
146 task = real_create_task(target, *args, **kwargs)
147 if isinstance(task, asyncio.Task):
148 tasks.append(task)
149 return task
150
151 mock_mass.create_task = MagicMock(side_effect=_create_task)
152 yield
153 errors = [
154 err
155 for task in tasks
156 if task.done() and not task.cancelled() and (err := task.exception()) is not None
157 ]
158 if len(errors) == 1:
159 raise errors[0]
160 if errors:
161 raise BaseExceptionGroup("background tasks failed", errors)
162
163
164def _mute_natively(player: MockPlayer) -> AsyncMock:
165 """Give the player a native mute control, mute it and return its mute handler."""
166 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
167 player._attr_volume_muted = True
168 mute_mock = AsyncMock(side_effect=lambda muted: setattr(player, "_attr_volume_muted", muted))
169 player.volume_mute = mute_mock # type: ignore[method-assign]
170 player._cache.clear()
171 player.update_state(force_update=True, signal_event=False)
172 assert player.state.volume_muted is True
173 return mute_mock
174
175
176@pytest.fixture
177def mock_mass() -> MagicMock:
178 """Create a mock MusicAssistant instance."""
179 mass = MagicMock()
180 mass.closing = False
181 mass.loop = None
182 mass.config = MagicMock()
183 mass.config.get = MagicMock(return_value=[])
184 mass.config.get_raw_player_config_value = MagicMock(side_effect=_player_config_stub())
185 # Return "GLOBAL" for log level config (standard default)
186 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
187 mass.config.set = MagicMock()
188 mass.signal_event = MagicMock()
189 mass.get_providers = MagicMock(return_value=[])
190 return mass
191
192
193@pytest.fixture
194def controller(mock_mass: MagicMock) -> PlayerController:
195 """Create a PlayerController instance."""
196 return PlayerController(mock_mass)
197
198
199class TestSetMembersValidation:
200 """Test cmd_set_members validation logic."""
201
202 def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
203 """Test that set_members requires SET_MEMBERS feature."""
204 controller = PlayerController(mock_mass)
205 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
206
207 leader = MockPlayer(provider, "leader", "Leader")
208 # Note: NOT adding SET_MEMBERS feature
209
210 member = MockPlayer(provider, "member", "Member")
211
212 controller._players = {"leader": leader, "member": member}
213 mock_mass.players = controller
214
215 # Should raise exception because leader doesn't support SET_MEMBERS
216 with pytest.raises(UnsupportedFeaturedException):
217 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
218
219 def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
220 """Test that incompatible players cannot be grouped."""
221 controller = PlayerController(mock_mass)
222 provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
223 provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
224
225 player_a = MockPlayer(provider_a, "player_a", "Player A")
226 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
227 player_a._attr_can_group_with = {"provider_a"} # Only same provider
228
229 player_b = MockPlayer(provider_b, "player_b", "Player B")
230
231 controller._players = {"player_a": player_a, "player_b": player_b}
232 mock_mass.players = controller
233
234 # Should raise exception because players are incompatible
235 with pytest.raises(UnsupportedFeaturedException):
236 asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
237
238
239class TestCacheInvalidationAfterGrouping:
240 """Test that caches are invalidated after grouping operations."""
241
242 async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
243 """
244 Test that all players' caches are cleared after set_members.
245
246 Regression test for: Stale can_group_with cache after grouping changes.
247 """
248 controller = PlayerController(mock_mass)
249 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
250
251 leader = MockPlayer(provider, "leader", "Leader")
252 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
253 leader._attr_can_group_with = {"test"}
254 leader._attr_group_members = []
255
256 member = MockPlayer(provider, "member", "Member")
257
258 other = MockPlayer(provider, "other", "Other")
259 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
260 other._attr_can_group_with = {"test"}
261
262 controller._players = {"leader": leader, "member": member, "other": other}
263 mock_mass.players = controller
264
265 # Populate caches
266 _ = leader.state.can_group_with
267 _ = other.state.can_group_with
268
269 # Simulate grouping (normally done by provider's set_members implementation)
270 leader._attr_group_members = ["leader", "member"]
271
272 # Call set_members to trigger cache invalidation
273 await controller._handle_set_members_with_protocols(
274 leader, player_ids_to_add=["member"], player_ids_to_remove=[]
275 )
276
277 # Note: The actual cache clearing happens via trigger_player_update
278 # which schedules update_state to be called later
279 # In a real scenario, this would clear all players' caches
280
281
282class TestNativeSetMembersGuard:
283 """Test the SET_MEMBERS feature guard on native set_members forwarding."""
284
285 async def test_native_set_members_skipped_without_feature(self, mock_mass: MagicMock) -> None:
286 """
287 Test that set_members is not called on a player without SET_MEMBERS support.
288
289 Regression test for: NotImplementedError raised from
290 _cleanup_player_memberships when removing a member from a native player
291 whose group membership is managed externally (e.g. a Google Cast group,
292 which never advertises SET_MEMBERS).
293 """
294 controller = PlayerController(mock_mass)
295 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
296
297 # a native group-like player WITHOUT SET_MEMBERS in supported_features,
298 # whose set_members behaves like the Player base class (raises)
299 parent = MockPlayer(provider, "cast_group", "Cast Group")
300 parent._attr_group_members = ["cast_group", "member"]
301 parent.set_members = AsyncMock( # type: ignore[method-assign]
302 side_effect=NotImplementedError(
303 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
304 )
305 )
306 member = MockPlayer(provider, "member", "Member")
307
308 controller._players = {"cast_group": parent, "member": member}
309 mock_mass.players = controller
310
311 # must complete without raising NotImplementedError
312 await controller._handle_set_members_with_protocols(
313 parent, player_ids_to_add=[], player_ids_to_remove=["member"]
314 )
315 parent.set_members.assert_not_called()
316
317
318class TestGroupUngroup:
319 """Test group and ungroup commands."""
320
321 async def test_group_command(self, mock_mass: MagicMock) -> None:
322 """Test the group command (cmd_group)."""
323 controller = PlayerController(mock_mass)
324 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
325
326 leader = MockPlayer(provider, "leader", "Leader")
327 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
328 leader._attr_can_group_with = {"member"} # Leader can group with member
329
330 member = MockPlayer(provider, "member", "Member")
331 # Make sure member is already powered on to skip power handling
332 member._attr_powered = True
333
334 controller._players = {"leader": leader, "member": member}
335 mock_mass.players = controller
336
337 # Update state after modifying attributes and registering with controller
338 leader.update_state(signal_event=False)
339 member.update_state(signal_event=False)
340
341 # Track if set_members was called
342 set_members_called = False
343 original_set_members = leader.set_members
344
345 async def mock_set_members(
346 player_ids_to_add: list[str] | None = None,
347 player_ids_to_remove: list[str] | None = None,
348 ) -> None:
349 nonlocal set_members_called
350 set_members_called = True
351 # Call the original to update group_members
352 await original_set_members(player_ids_to_add, player_ids_to_remove)
353
354 leader.set_members = mock_set_members # type: ignore[method-assign]
355
356 # Mock power handling to skip power control (focus is on grouping logic)
357 async def mock_handle_cmd_power(
358 player_id: str, powered: bool, skip_auto_play: bool = False
359 ) -> None:
360 pass
361
362 controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
363
364 # Execute group command
365 await controller.cmd_group("member", "leader")
366
367 # Verify set_members was called
368 assert set_members_called
369 # Verify member was added to leader's group
370 assert "member" in leader._attr_group_members
371
372
373class TestPlayerAvailability:
374 """Test player availability checks in grouping."""
375
376 def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
377 """Test that unavailable players are rejected when grouping."""
378 controller = PlayerController(mock_mass)
379 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
380
381 leader = MockPlayer(provider, "leader", "Leader")
382 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
383 leader._attr_can_group_with = {"test"}
384
385 member = MockPlayer(provider, "member", "Member")
386 member._attr_available = False # Mark as unavailable
387
388 controller._players = {"leader": leader, "member": member}
389 mock_mass.players = controller
390
391 # Attempting to group with unavailable player should be handled
392 # (either silently ignored or raise exception depending on implementation)
393 # This should either skip the unavailable player or raise an exception
394 with contextlib.suppress(Exception):
395 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
396
397
398def _group_with_member(
399 mock_mass: MagicMock, *, initialize_group: bool = True
400) -> tuple[PlayerController, MockPlayer, MockPlayer]:
401 """
402 Build a controller holding one group player with a single member.
403
404 :param mock_mass: The mocked MusicAssistant instance to attach the controller to.
405 :param initialize_group: Whether the group player is marked as fully registered.
406
407 :return: The controller, the group player and its member.
408 """
409 controller = PlayerController(mock_mass)
410 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
411
412 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
413 member = MockPlayer(provider, "member", "Member")
414
415 controller._players = {"group": group_player, "member": member}
416 mock_mass.players = controller
417
418 group_player._attr_group_members = ["member"]
419 member.initialized.set()
420 if initialize_group:
421 group_player.initialized.set()
422 for player in (group_player, member):
423 player.update_state(signal_event=False)
424 return controller, group_player, member
425
426
427@contextlib.contextmanager
428def _restricted_user(visible_player_ids: list[str]) -> Iterator[None]:
429 """
430 Run the wrapped block as a non-admin user that may only see the given players.
431
432 :param visible_player_ids: The player ids the user is allowed to see.
433 """
434 # the contextvar is copied into the task an API command runs in, so it stays
435 # live for everything that command reaches, internal bookkeeping included
436 token = current_user.set(
437 User(
438 user_id="user_1",
439 username="restricted",
440 role=UserRole.USER,
441 player_filter=visible_player_ids,
442 )
443 )
444 try:
445 yield
446 finally:
447 current_user.reset(token)
448
449
450class TestStateForwarding:
451 """Test forwarding of player state changes to related players."""
452
453 def test_sync_leader_updates_are_forwarded_to_sync_children(self, mock_mass: MagicMock) -> None:
454 """A regular sync leader must notify children via the sync-parent callback."""
455 controller = PlayerController(mock_mass)
456 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
457
458 leader = MockPlayer(provider, "leader", "Leader")
459 child = MockPlayer(provider, "child", "Child")
460
461 controller._players = {"leader": leader, "child": child}
462 mock_mass.players = controller
463
464 leader._attr_group_members = ["leader", "child"]
465 leader.update_state(signal_event=False)
466 child.update_state(signal_event=False)
467
468 with (
469 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
470 patch.object(child, "on_group_updated") as on_group_updated,
471 ):
472 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
473 controller._forward_state_update(leader, changed_values)
474
475 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
476 on_group_updated.assert_not_called()
477
478 def test_group_updates_are_forwarded_to_children_via_group_callback(
479 self, mock_mass: MagicMock
480 ) -> None:
481 """A group player must continue to notify children via the group callback."""
482 controller = PlayerController(mock_mass)
483 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
484
485 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
486 child = MockPlayer(provider, "child", "Child")
487
488 controller._players = {"group": group_player, "child": child}
489 mock_mass.players = controller
490
491 group_player._attr_group_members = ["group", "child"]
492 group_player.update_state(signal_event=False)
493 child.update_state(signal_event=False)
494
495 with (
496 patch.object(child, "on_group_updated") as on_group_updated,
497 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
498 ):
499 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
500 controller._forward_state_update(group_player, changed_values)
501
502 on_group_updated.assert_called_once_with(
503 group_player,
504 changed_values,
505 )
506 on_sync_parent_updated.assert_not_called()
507
508 def test_sync_leader_updates_also_reach_its_group_player(self, mock_mass: MagicMock) -> None:
509 """A sync leader must notify its group player as well as its own sync children."""
510 controller = PlayerController(mock_mass)
511 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
512
513 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
514 leader = MockPlayer(provider, "leader", "Leader")
515 follower = MockPlayer(provider, "follower", "Follower")
516
517 controller._players = {"group": group_player, "leader": leader, "follower": follower}
518 mock_mass.players = controller
519
520 group_player._attr_group_members = ["leader", "follower"]
521 leader._attr_group_members = ["leader", "follower"]
522 for player in (group_player, leader, follower):
523 # _get_player_groups only considers players the controller finished registering
524 player.initialized.set()
525 player.update_state(signal_event=False)
526
527 with (
528 patch.object(group_player, "on_group_member_updated") as on_group_member_updated,
529 patch.object(follower, "on_sync_parent_updated") as on_sync_parent_updated,
530 ):
531 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
532 controller._forward_state_update(leader, changed_values)
533
534 on_group_member_updated.assert_called_once_with(leader, changed_values)
535 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
536
537 def test_group_player_is_notified_under_restricted_user_context(
538 self, mock_mass: MagicMock
539 ) -> None:
540 """The fan-out must not be narrowed by the user that triggered the update."""
541 controller, group_player, member = _group_with_member(mock_mass)
542
543 with _restricted_user(["member"]):
544 assert group_player not in controller.all_players()
545 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
546 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
547 controller._forward_state_update(member, changed_values)
548
549 on_group_member_updated.assert_called_once_with(member, changed_values)
550
551 def test_unavailable_group_player_is_still_notified(self, mock_mass: MagicMock) -> None:
552 """A group player mirrors its members, so it must update while unavailable too."""
553 controller, group_player, member = _group_with_member(mock_mass)
554 group_player._attr_available = False
555 group_player.update_state(signal_event=False)
556 assert group_player.state.available is False
557
558 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
559 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
560 controller._forward_state_update(member, changed_values)
561
562 on_group_member_updated.assert_called_once_with(member, changed_values)
563
564 def test_disabled_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
565 """A disabled player takes no part, unlike one that is merely unavailable."""
566 controller, group_player, member = _group_with_member(mock_mass)
567 group_player._config.enabled = False
568 group_player.update_state(signal_event=False, force_update=True)
569 assert group_player.state.enabled is False
570
571 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
572 controller._forward_state_update(
573 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
574 )
575
576 on_group_member_updated.assert_not_called()
577
578 def test_uninitialized_group_player_is_not_notified(self, mock_mass: MagicMock) -> None:
579 """A player the controller is still registering is not fully set up yet."""
580 controller, group_player, member = _group_with_member(mock_mass, initialize_group=False)
581
582 with patch.object(group_player, "on_group_member_updated") as on_group_member_updated:
583 controller._forward_state_update(
584 member, {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
585 )
586
587 on_group_member_updated.assert_not_called()
588
589
590class TestUnscopedPlayerLookups:
591 """Derived player state must not depend on who triggered the recalculation."""
592
593 def test_iter_players_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
594 """iter_players is the internal view: every registered player, no user filter."""
595 controller, group_player, member = _group_with_member(mock_mass)
596
597 with _restricted_user(["member"]):
598 assert controller.all_players() == [member]
599 assert set(controller.iter_players()) == {group_player, member}
600
601 def test_provider_filter_still_ignores_the_user_filter(self, mock_mass: MagicMock) -> None:
602 """A lookup scoped to one provider must still ignore the user filter."""
603 controller, group_player, member = _group_with_member(mock_mass)
604
605 with _restricted_user(["member"]):
606 found = set(controller.iter_players(provider_filter="test"))
607
608 assert found == {group_player, member}
609
610 def test_provider_sees_all_of_its_own_players(self, mock_mass: MagicMock) -> None:
611 """A provider owns its players, so a user filter must never hide them from it."""
612 controller = PlayerController(mock_mass)
613 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
614
615 plain = MockPlayer(provider, "plain", "Plain")
616 # a protocol player id is picked from a list that hides protocol players, so it
617 # is never in a user's filter - the whole set used to disappear for any non-admin
618 protocol = MockPlayer(provider, "protocol", "Protocol", player_type=PlayerType.PROTOCOL)
619 controller._players = {"plain": plain, "protocol": protocol}
620 mock_mass.players = controller
621 for player in (plain, protocol):
622 player.initialized.set()
623 player.update_state(signal_event=False)
624
625 with _restricted_user(["plain"]):
626 owned = set(PlayerProvider.players.fget(provider)) # type: ignore[attr-defined]
627
628 assert owned == {plain, protocol}
629
630 def test_active_group_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
631 """A member must still know its group when the triggering user cannot see it."""
632 _controller, group_player, member = _group_with_member(mock_mass)
633 group_player._attr_group_members = ["member"]
634 group_player._attr_powered = True
635 group_player.update_state(signal_event=False)
636
637 with _restricted_user(["member"]):
638 member.update_state(signal_event=False, force_update=True)
639 assert member.state.active_group == "group"
640
641 def test_synced_to_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
642 """A follower must still resolve its sync leader across a user filter."""
643 controller = PlayerController(mock_mass)
644 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
645
646 leader = MockPlayer(provider, "leader", "Leader")
647 follower = MockPlayer(provider, "follower", "Follower")
648 controller._players = {"leader": leader, "follower": follower}
649 mock_mass.players = controller
650
651 leader._attr_group_members = ["leader", "follower"]
652 for player in (leader, follower):
653 player.initialized.set()
654 player.update_state(signal_event=False)
655
656 with _restricted_user(["follower"]):
657 follower.update_state(signal_event=False, force_update=True)
658 assert follower.state.synced_to == "leader"
659
660 def test_can_group_with_survives_a_restricted_user_context(self, mock_mass: MagicMock) -> None:
661 """Expanding a provider id into players must not drop players the user cannot see."""
662 controller = PlayerController(mock_mass)
663 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
664 mock_mass.get_provider.return_value = provider
665
666 player_a = MockPlayer(provider, "player_a", "Player A")
667 player_b = MockPlayer(provider, "player_b", "Player B")
668 controller._players = {"player_a": player_a, "player_b": player_b}
669 mock_mass.players = controller
670
671 # a provider instance id stands for "every player of that provider"
672 player_a._attr_can_group_with = {"test"}
673 for player in (player_a, player_b):
674 player.initialized.set()
675 player.update_state(signal_event=False)
676
677 with _restricted_user(["player_a"]):
678 player_a.update_state(signal_event=False, force_update=True)
679 assert "player_b" in player_a.state.can_group_with
680
681
682class TestSleepTimer:
683 """Test native sleep timer handling."""
684
685 def test_set_and_clear_sleep_timer(self, mock_mass: MagicMock) -> None:
686 """Setting a sleep timer exposes state and schedules the stop callback."""
687 controller = PlayerController(mock_mass)
688 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
689 player = MockPlayer(provider, "player_1", "Player 1")
690 controller._players = {"player_1": player}
691 mock_mass.players = controller
692
693 expires_at = controller.set_sleep_timer("player_1", 30)
694
695 assert controller.get_sleep_timer("player_1") == expires_at
696 assert player.sleep_timer_expires_at == expires_at
697 mock_mass.call_later.assert_called_with(
698 30,
699 controller._handle_sleep_timer_expired,
700 "player_1",
701 task_id="player_sleep_timer_player_1",
702 )
703 mock_mass.signal_event.assert_any_call(
704 EventType.PLAYER_SLEEP_TIMER_UPDATED,
705 object_id="player_1",
706 data=expires_at,
707 )
708
709 controller.clear_sleep_timer("player_1")
710
711 # get_sleep_timer reads the model field directly, so this also asserts it cleared
712 assert controller.get_sleep_timer("player_1") is None
713 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
714 mock_mass.signal_event.assert_any_call(
715 EventType.PLAYER_SLEEP_TIMER_UPDATED,
716 object_id="player_1",
717 data=None,
718 )
719
720 async def test_sleep_timer_removed_when_player_unregistered(self, mock_mass: MagicMock) -> None:
721 """Unregistering a player cancels and clears its sleep timer."""
722 controller = PlayerController(mock_mass)
723 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
724 player = MockPlayer(provider, "player_1", "Player 1")
725 controller._players = {"player_1": player}
726 player.set_sleep_timer_expires_at(123.0)
727
728 await controller.unregister("player_1")
729
730 assert player.sleep_timer_expires_at is None
731 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
732
733 async def test_sleep_timer_expiry_stops_player(self, mock_mass: MagicMock) -> None:
734 """An expired sleep timer clears its state and stops playback."""
735 controller = PlayerController(mock_mass)
736 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
737 player = MockPlayer(provider, "player_1", "Player 1")
738 controller._players = {"player_1": player}
739 player.set_sleep_timer_expires_at(123.0)
740 controller.cmd_stop = AsyncMock() # type: ignore[method-assign]
741
742 await controller._handle_sleep_timer_expired("player_1")
743
744 assert controller.get_sleep_timer("player_1") is None
745 assert player.sleep_timer_expires_at is None
746 controller.cmd_stop.assert_awaited_once_with("player_1")
747 mock_mass.signal_event.assert_any_call(
748 EventType.PLAYER_SLEEP_TIMER_UPDATED,
749 object_id="player_1",
750 data=None,
751 )
752
753 def test_set_sleep_timer_rejects_invalid_duration(self, mock_mass: MagicMock) -> None:
754 """A non-positive or float-overflowing duration raises and schedules nothing."""
755 controller = PlayerController(mock_mass)
756 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
757 player = MockPlayer(provider, "player_1", "Player 1")
758 controller._players = {"player_1": player}
759
760 # 0/-30 are non-positive; 10**400 exceeds the float range for the expiry math
761 for invalid in (0, -30, 10**400):
762 with pytest.raises(InvalidDataError):
763 controller.set_sleep_timer("player_1", invalid)
764
765 assert controller.get_sleep_timer("player_1") is None
766 mock_mass.call_later.assert_not_called()
767
768
769class TestUnregisterCleanup:
770 """Test that unregister cleans up leaked internal state."""
771
772 def test_command_locks_removed(self, mock_mass: MagicMock) -> None:
773 """Unregistering a player removes its command lock entries."""
774 controller = PlayerController(mock_mass)
775 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
776 player = MockPlayer(provider, "player_1", "Player 1")
777
778 controller._players = {"player_1": player}
779 controller._player_command_locks = {
780 "playback_player_1": asyncio.Lock(),
781 "volume_player_1": asyncio.Lock(),
782 }
783
784 asyncio.run(controller.unregister("player_1"))
785
786 assert "playback_player_1" not in controller._player_command_locks
787 assert "volume_player_1" not in controller._player_command_locks
788
789 def test_other_players_state_untouched(self, mock_mass: MagicMock) -> None:
790 """Unregistering one player does not affect another player's state."""
791 controller = PlayerController(mock_mass)
792 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
793 player_a = MockPlayer(provider, "player_a", "Player A")
794 player_b = MockPlayer(provider, "player_b", "Player B")
795
796 controller._players = {"player_a": player_a, "player_b": player_b}
797 controller._player_command_locks = {
798 "playback_player_a": asyncio.Lock(),
799 "playback_player_b": asyncio.Lock(),
800 }
801
802 asyncio.run(controller.unregister("player_a"))
803
804 assert "playback_player_b" in controller._player_command_locks
805 assert "playback_player_a" not in controller._player_command_locks
806
807 def test_suffix_player_id_not_over_matched(self, mock_mass: MagicMock) -> None:
808 """Removing player 'b' must not remove locks for player 'a_b' (no suffix matching)."""
809 controller = PlayerController(mock_mass)
810 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
811 player_b = MockPlayer(provider, "b", "Player B")
812 player_a_b = MockPlayer(provider, "a_b", "Player A_B")
813
814 controller._players = {"b": player_b, "a_b": player_a_b}
815 controller._player_command_locks = {
816 "playback_b": asyncio.Lock(),
817 "playback_a_b": asyncio.Lock(),
818 }
819
820 asyncio.run(controller.unregister("b"))
821
822 assert "playback_a_b" in controller._player_command_locks
823 assert "playback_b" not in controller._player_command_locks
824
825 def test_pending_protocol_evaluation_cancelled(self, mock_mass: MagicMock) -> None:
826 """Unregistering a player cancels and removes its pending protocol evaluation."""
827 controller = PlayerController(mock_mass)
828 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
829 player = MockPlayer(provider, "player_1", "Player 1")
830
831 mock_handle = MagicMock()
832 controller._players = {"player_1": player}
833 controller._pending_protocol_evaluations = {"player_1": mock_handle}
834
835 asyncio.run(controller.unregister("player_1"))
836
837 mock_handle.cancel.assert_called_once()
838 assert "player_1" not in controller._pending_protocol_evaluations
839
840 def test_unregister_nonexistent_player_is_noop(self, mock_mass: MagicMock) -> None:
841 """Unregistering a player that doesn't exist is silently ignored."""
842 controller = PlayerController(mock_mass)
843 controller._player_command_locks = {"set_members_other": asyncio.Lock()}
844
845 asyncio.run(controller.unregister("nonexistent"))
846
847 assert "set_members_other" in controller._player_command_locks
848
849
850class TestRegisterUnregisterRace:
851 """Test registration that is interrupted by an unregister of the same player."""
852
853 @staticmethod
854 def _stub_register_calls(mock_mass: MagicMock) -> None:
855 """Stub the awaited mass calls made during register/unregister."""
856 # registration reads config keys with differently typed defaults (mapping for the
857 # player config store, str | None for the cached MAC addresses)
858 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
859 mock_mass.cache.get = AsyncMock(return_value=None)
860 mock_mass.config.get_player_config = AsyncMock(return_value=create_mock_config("Player 1"))
861 mock_mass.player_queues.on_player_register = AsyncMock()
862 mock_mass.player_queues.on_player_remove = MagicMock()
863
864 @staticmethod
865 def _player_added_signalled(mock_mass: MagicMock) -> bool:
866 """Return True if a PLAYER_ADDED event was signalled."""
867 return any(
868 call_args.args and call_args.args[0] == EventType.PLAYER_ADDED
869 for call_args in mock_mass.signal_event.call_args_list
870 )
871
872 async def test_register_aborts_when_unregistered_during_config_load(
873 self, mock_mass: MagicMock
874 ) -> None:
875 """A player unregistered while its config loads is not announced as added."""
876 controller = PlayerController(mock_mass)
877 self._stub_register_calls(mock_mass)
878 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
879 player = MockPlayer(provider, "player_1", "Player 1")
880
881 async def _unregister_midway(player_id: str) -> MagicMock:
882 # stands in for a provider unload or device disconnect landing
883 # while register awaits the player config
884 await controller.unregister(player_id)
885 return create_mock_config("Player 1")
886
887 mock_mass.config.get_player_config = AsyncMock(side_effect=_unregister_midway)
888 config_hook = AsyncMock()
889
890 with (
891 patch(
892 "music_assistant.controllers.players.controller.enrich_device_mac_address",
893 AsyncMock(),
894 ),
895 patch.object(player, "on_config_updated", config_hook),
896 ):
897 await controller.register(player)
898
899 assert "player_1" not in controller._players
900 assert not player.initialized.is_set()
901 # setup stops right away: the provider hook must not run on a player
902 # whose on_unload already ran
903 config_hook.assert_not_called()
904 mock_mass.player_queues.on_player_register.assert_not_called()
905 assert not self._player_added_signalled(mock_mass)
906
907 async def test_register_aborts_when_unregistered_during_config_hook(
908 self, mock_mass: MagicMock
909 ) -> None:
910 """A player unregistered while its on_config_updated hook runs is not announced."""
911 controller = PlayerController(mock_mass)
912 self._stub_register_calls(mock_mass)
913 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
914 player = MockPlayer(provider, "player_1", "Player 1")
915
916 async def _unregister_midway() -> None:
917 await controller.unregister("player_1")
918
919 with (
920 patch(
921 "music_assistant.controllers.players.controller.enrich_device_mac_address",
922 AsyncMock(),
923 ),
924 patch.object(player, "on_config_updated", AsyncMock(side_effect=_unregister_midway)),
925 ):
926 await controller.register(player)
927
928 assert "player_1" not in controller._players
929 assert not player.initialized.is_set()
930 mock_mass.player_queues.on_player_register.assert_not_called()
931 assert not self._player_added_signalled(mock_mass)
932
933 async def test_register_drops_queue_recreated_after_unregister(
934 self, mock_mass: MagicMock
935 ) -> None:
936 """A queue restored after the unregister already removed it is dropped again."""
937 controller = PlayerController(mock_mass)
938 self._stub_register_calls(mock_mass)
939 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
940 player = MockPlayer(provider, "player_1", "Player 1")
941
942 events: list[str] = []
943
944 async def _unregister_midway(registering_player: MockPlayer) -> None:
945 # on_player_register restores the queue from cache before storing it, so an
946 # unregister can land in between and have its cleanup undone
947 await controller.unregister(registering_player.player_id)
948 events.append("queue_created")
949
950 def _track_removal(*_args: object, **_kwargs: object) -> None:
951 events.append("queue_removed")
952
953 mock_mass.player_queues.on_player_remove = MagicMock(side_effect=_track_removal)
954 mock_mass.player_queues.on_player_register = AsyncMock(side_effect=_unregister_midway)
955
956 with patch(
957 "music_assistant.controllers.players.controller.enrich_device_mac_address",
958 AsyncMock(),
959 ):
960 await controller.register(player)
961
962 assert "player_1" not in controller._players
963 # the queue recreated for the removed player must be cleaned up again
964 assert events == ["queue_removed", "queue_created", "queue_removed"]
965
966 async def test_register_or_update_marks_replacement_initialized(
967 self, mock_mass: MagicMock
968 ) -> None:
969 """Replacing a registered player carries the initialized state to the new object."""
970 controller = PlayerController(mock_mass)
971 self._stub_register_calls(mock_mass)
972 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
973 existing = MockPlayer(provider, "player_1", "Player 1")
974 existing.set_initialized()
975 controller._players = {"player_1": existing}
976 replacement = MockPlayer(provider, "player_1", "Player 1")
977
978 await controller.register_or_update(replacement)
979
980 assert controller._players["player_1"] is replacement
981 assert replacement.initialized.is_set()
982
983 async def test_register_or_update_hands_config_to_replacement(
984 self, mock_mass: MagicMock
985 ) -> None:
986 """A replacement instance inherits the config of the player it takes over."""
987 controller = PlayerController(mock_mass)
988 self._stub_register_calls(mock_mass)
989 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
990 existing = MockPlayer(provider, "player_1", "Player 1")
991 resolved_config = create_mock_config("Player 1")
992 existing.set_config(resolved_config)
993 existing.set_initialized()
994 controller._players = {"player_1": existing}
995 replacement = MockPlayer(provider, "player_1", "Player 1")
996 config_hook = AsyncMock()
997
998 with patch.object(replacement, "on_config_updated", config_hook):
999 await controller.register_or_update(replacement)
1000
1001 # without the resolved config the replacement would read defaults for every
1002 # config backed setting (group members, flow mode, visibility, ...)
1003 assert replacement.config is resolved_config
1004 config_hook.assert_awaited_once()
1005
1006 async def test_register_or_update_aborts_when_replacement_is_unregistered(
1007 self, mock_mass: MagicMock
1008 ) -> None:
1009 """A replacement unregistered while its config hook runs is not marked ready."""
1010 controller = PlayerController(mock_mass)
1011 self._stub_register_calls(mock_mass)
1012 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1013 existing = MockPlayer(provider, "player_1", "Player 1")
1014 existing.set_config(create_mock_config("Player 1"))
1015 existing.set_initialized()
1016 controller._players = {"player_1": existing}
1017 replacement = MockPlayer(provider, "player_1", "Player 1")
1018
1019 async def _unregister_midway() -> None:
1020 await controller.unregister("player_1")
1021
1022 with patch.object(
1023 replacement, "on_config_updated", AsyncMock(side_effect=_unregister_midway)
1024 ):
1025 await controller.register_or_update(replacement)
1026
1027 assert "player_1" not in controller._players
1028 assert not replacement.initialized.is_set()
1029
1030 async def test_register_or_update_leaves_same_instance_untouched(
1031 self, mock_mass: MagicMock
1032 ) -> None:
1033 """Re-announcing the same instance does not re-run its config hook."""
1034 controller = PlayerController(mock_mass)
1035 self._stub_register_calls(mock_mass)
1036 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1037 player = MockPlayer(provider, "player_1", "Player 1")
1038 player.set_initialized()
1039 controller._players = {"player_1": player}
1040 config_hook = AsyncMock()
1041
1042 with patch.object(player, "on_config_updated", config_hook):
1043 await controller.register_or_update(player)
1044
1045 assert controller._players["player_1"] is player
1046 config_hook.assert_not_called()
1047
1048 async def test_register_or_update_waits_for_inflight_register(
1049 self, mock_mass: MagicMock
1050 ) -> None:
1051 """A player is never swapped out while register() is still setting it up."""
1052 controller = PlayerController(mock_mass)
1053 self._stub_register_calls(mock_mass)
1054 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1055 player = MockPlayer(provider, "player_1", "Player 1")
1056 replacement = MockPlayer(provider, "player_1", "Player 1")
1057 release = asyncio.Event()
1058 registering = asyncio.Event()
1059
1060 async def _blocked_config(*_args: object) -> MagicMock:
1061 registering.set()
1062 await release.wait()
1063 return create_mock_config("Player 1")
1064
1065 mock_mass.config.get_player_config = AsyncMock(side_effect=_blocked_config)
1066
1067 with patch(
1068 "music_assistant.controllers.players.controller.enrich_device_mac_address",
1069 AsyncMock(),
1070 ):
1071 register_task = asyncio.create_task(controller.register(player))
1072 # only proceed once register() is provably inside its critical section
1073 await registering.wait()
1074 update_task = asyncio.create_task(controller.register_or_update(replacement))
1075 await _yield_to_loop()
1076
1077 # register() is still in flight, so the replacement must not be swapped in yet
1078 assert controller._players["player_1"] is player
1079 assert not update_task.done()
1080
1081 release.set()
1082 await register_task
1083 await update_task
1084
1085 assert controller._players["player_1"] is replacement
1086 assert player.initialized.is_set()
1087 assert replacement.initialized.is_set()
1088
1089
1090async def _yield_to_loop() -> None:
1091 """Give other pending tasks a chance to run up to their next suspension point."""
1092 for _ in range(5):
1093 await asyncio.sleep(0)
1094
1095
1096def _set_play_media_override(mock_mass: MagicMock, value: bool) -> None:
1097 """
1098 Configure get_raw_player_config_value to return ``value`` for the play-media override key.
1099
1100 Other keys keep the existing defaults from the shared fixture. Use this in
1101 tests for ``play_media`` override behavior so the legacy/new branch is
1102 selected deterministically.
1103 """
1104 original = mock_mass.config.get_raw_player_config_value.side_effect
1105
1106 def _side_effect(player_id: str, key: str, default: object = None) -> object:
1107 if key == "play_media_overrides_group":
1108 return value
1109 if callable(original):
1110 return original(player_id, key, default)
1111 return default
1112
1113 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_side_effect)
1114
1115
1116class TestRegisterOrUpdateTypeTransition:
1117 """Tests for a registered player moving in or out of the protocol role."""
1118
1119 @staticmethod
1120 def _prepare(mock_mass: MagicMock) -> PlayerController:
1121 """Build a controller with the calls a re-registration makes stubbed out."""
1122 mock_mass.loop = MagicMock()
1123 mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
1124 mock_mass.player_queues.on_player_register = AsyncMock()
1125 mock_mass.player_queues.on_player_remove = MagicMock()
1126 controller = PlayerController(mock_mass)
1127 mock_mass.players = controller
1128 return controller
1129
1130 @staticmethod
1131 def _register(
1132 controller: PlayerController, provider: MockProvider, player_id: str, type_: PlayerType
1133 ) -> MockPlayer:
1134 """Add a player to the registry with its state calculated for the given type."""
1135 player = MockPlayer(provider, player_id, player_id, type_)
1136 player.set_initialized()
1137 controller._players[player_id] = player
1138 # MockPlayer assigns the type after Player.__init__ built the initial state,
1139 # so the state only reports it once it is recalculated
1140 player.update_state(signal_event=False)
1141 assert player.state.type is type_
1142 return player
1143
1144 @staticmethod
1145 def _signalled(mock_mass: MagicMock, event: EventType) -> bool:
1146 """Return True if the given event was signalled."""
1147 return any(
1148 call_args.args and call_args.args[0] == event
1149 for call_args in mock_mass.signal_event.call_args_list
1150 )
1151
1152 async def test_protocol_to_player_registers_queue(self, mock_mass: MagicMock) -> None:
1153 """A protocol player that becomes a standalone player gets a queue and is announced."""
1154 controller = self._prepare(mock_mass)
1155 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1156 player = self._register(controller, provider, "player_1", PlayerType.PROTOCOL)
1157
1158 player._attr_type = PlayerType.PLAYER
1159 await controller.register_or_update(player)
1160
1161 assert player.state.type is PlayerType.PLAYER
1162 # without a queue the player is registered but cannot play anything
1163 mock_mass.player_queues.on_player_register.assert_awaited_once_with(player)
1164 assert self._signalled(mock_mass, EventType.PLAYER_ADDED)
1165
1166 async def test_protocol_to_player_keeps_state_pipeline_intact(
1167 self, mock_mass: MagicMock
1168 ) -> None:
1169 """The regular state update still runs for the tick that changes the type."""
1170 controller = self._prepare(mock_mass)
1171 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1172 player = self._register(controller, provider, "player_1", PlayerType.PROTOCOL)
1173 updates: list[dict[str, tuple[Any, Any]]] = []
1174 controller.subscribe_player_state_update(lambda _player, changed: updates.append(changed))
1175
1176 player._attr_type = PlayerType.PLAYER
1177 await controller.register_or_update(player)
1178
1179 # the bridges and wait_for_player_update hang off this dispatch, and the changed
1180 # values are consumed by it: a suppressed update is never replayed by a later one
1181 assert any("type" in changed for changed in updates)
1182
1183 async def test_player_to_protocol_removes_queue(self, mock_mass: MagicMock) -> None:
1184 """A player that becomes a protocol child loses its queue and is announced removed."""
1185 controller = self._prepare(mock_mass)
1186 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1187 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1188
1189 player._attr_type = PlayerType.PROTOCOL
1190 await controller.register_or_update(player)
1191
1192 assert player.state.type is PlayerType.PROTOCOL
1193 mock_mass.player_queues.on_player_remove.assert_called_once_with(
1194 "player_1", permanent=False
1195 )
1196 assert self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1197
1198 async def test_unchanged_type_leaves_queue_alone(self, mock_mass: MagicMock) -> None:
1199 """Re-registering a player without a type change does not touch its queue."""
1200 controller = self._prepare(mock_mass)
1201 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1202 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1203
1204 await controller.register_or_update(player)
1205
1206 mock_mass.player_queues.on_player_register.assert_not_awaited()
1207 mock_mass.player_queues.on_player_remove.assert_not_called()
1208 assert not self._signalled(mock_mass, EventType.PLAYER_ADDED)
1209 assert not self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1210
1211 async def test_group_type_change_is_not_a_role_change(self, mock_mass: MagicMock) -> None:
1212 """A player that turns into a group keeps its queue (Chromecast reports both)."""
1213 controller = self._prepare(mock_mass)
1214 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1215 player = self._register(controller, provider, "player_1", PlayerType.PLAYER)
1216
1217 player._attr_type = PlayerType.GROUP
1218 await controller.register_or_update(player)
1219
1220 assert player.state.type is PlayerType.GROUP
1221 mock_mass.player_queues.on_player_register.assert_not_awaited()
1222 mock_mass.player_queues.on_player_remove.assert_not_called()
1223 assert not self._signalled(mock_mass, EventType.PLAYER_ADDED)
1224 assert not self._signalled(mock_mass, EventType.PLAYER_REMOVED)
1225
1226 async def test_protocol_to_player_unlinks_from_parent(self, mock_mass: MagicMock) -> None:
1227 """A protocol player that becomes standalone is released by its parent."""
1228 controller = self._prepare(mock_mass)
1229 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1230 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1231 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1232 child.set_protocol_parent_id("parent")
1233 parent.set_linked_output_protocols(
1234 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1235 )
1236
1237 child._attr_type = PlayerType.PLAYER
1238 await controller.register_or_update(child)
1239
1240 # a parent that keeps the link would still route audio to a player that is
1241 # now standalone
1242 assert parent.linked_output_protocols == []
1243 assert child.protocol_parent_id is None
1244
1245 async def test_protocol_to_player_unlinks_parent_cleared_ahead(
1246 self, mock_mass: MagicMock
1247 ) -> None:
1248 """The parent is released even when the provider already dropped the live link."""
1249 controller = self._prepare(mock_mass)
1250 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1251 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1252 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1253 parent.set_linked_output_protocols(
1254 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1255 )
1256 # Sendspin clears protocol_parent_id before it announces the new type, so only
1257 # the persisted link is left to find the parent by
1258 cached_key = f"{CONF_PLAYERS}/child/values/{CONF_PROTOCOL_PARENT_ID}"
1259 mock_mass.config.get = MagicMock(
1260 side_effect=lambda key, default=None: "parent" if key == cached_key else default
1261 )
1262 assert child.protocol_parent_id is None
1263
1264 child._attr_type = PlayerType.PLAYER
1265 await controller.register_or_update(child)
1266
1267 assert parent.linked_output_protocols == []
1268 assert child.protocol_parent_id is None
1269
1270 async def test_protocol_to_player_clears_persisted_parent_link(
1271 self, mock_mass: MagicMock
1272 ) -> None:
1273 """The persisted parent link is dropped so a restart cannot restore the old role."""
1274 controller = self._prepare(mock_mass)
1275 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1276 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1277 parent_key = f"{CONF_PLAYERS}/child/values/{CONF_PROTOCOL_PARENT_ID}"
1278
1279 def _config_get(key: str, default: object = None) -> object:
1280 if key == parent_key:
1281 return "parent"
1282 # the parent id is only cleared for a player that still has a config
1283 return {"provider": "test"} if key == f"{CONF_PLAYERS}/child" else default
1284
1285 mock_mass.config.get = MagicMock(side_effect=_config_get)
1286
1287 child._attr_type = PlayerType.PLAYER
1288 await controller.register_or_update(child)
1289
1290 # a leftover parent id makes the startup repair pass heal the type back to protocol
1291 mock_mass.config.set.assert_any_call(parent_key, None)
1292
1293 async def test_universal_parent_hands_over_to_the_promoted_player(
1294 self, mock_mass: MagicMock
1295 ) -> None:
1296 """A universal parent hands its config to the player that replaces it."""
1297 controller = self._prepare(mock_mass)
1298 provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
1299 universal_provider = MockProvider(
1300 "universal_player", instance_id="universal_player", mass=mock_mass
1301 )
1302 mock_mass.config.get_base_player_config.return_value = create_mock_config("Universal")
1303 universal = UniversalPlayer(
1304 cast("Any", universal_provider), "universal_1", "Universal", DeviceInfo(), ["child"]
1305 )
1306 universal.set_initialized()
1307 controller._players["universal_1"] = universal
1308 universal.update_state(signal_event=False)
1309 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1310 child.set_protocol_parent_id("universal_1")
1311 universal.set_linked_output_protocols(
1312 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1313 )
1314 migrated: list[tuple[str, str]] = []
1315
1316 with patch.object(
1317 controller,
1318 "_migrate_universal_player_config",
1319 side_effect=lambda old, new: migrated.append((old, new)),
1320 ):
1321 child._attr_type = PlayerType.PLAYER
1322 await controller.register_or_update(child)
1323
1324 # a leftover active link makes the wrapper refuse the handover to the player it
1325 # is being replaced by, stranding the user's settings on a player on its way out
1326 assert migrated == [("universal_1", "child")]
1327 assert child.protocol_parent_id is None
1328
1329 async def test_player_to_protocol_detaches_its_children(self, mock_mass: MagicMock) -> None:
1330 """A player that becomes a protocol child releases the protocols it owned."""
1331 controller = self._prepare(mock_mass)
1332 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1333 parent = self._register(controller, provider, "parent", PlayerType.PLAYER)
1334 child = self._register(controller, provider, "child", PlayerType.PROTOCOL)
1335 child.set_protocol_parent_id("parent")
1336 parent.set_linked_output_protocols(
1337 [LinkedOutputProtocol(output_protocol_id="child", protocol_domain="sendspin")]
1338 )
1339
1340 parent._attr_type = PlayerType.PROTOCOL
1341 await controller.register_or_update(parent)
1342
1343 # a protocol player cannot own protocol players of its own
1344 assert parent.linked_output_protocols == []
1345 assert child.protocol_parent_id is None
1346
1347
1348class TestCmdUngroupNewBranches:
1349 """
1350 Regression tests for the post-refactor cmd_ungroup flow.
1351
1352 The refactor changed two things:
1353
1354 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
1355 (which would hit the "Cannot remove static member" guard); instead it
1356 stops or powers off the group entirely.
1357 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
1358 group, because static members cannot be released individually.
1359 """
1360
1361 @pytest.mark.asyncio
1362 async def test_ungroup_group_player_with_power_uses_power_off(
1363 self, mock_mass: MagicMock
1364 ) -> None:
1365 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
1366 controller = PlayerController(mock_mass)
1367 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1368 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1369 group._attr_powered = True
1370 group._attr_group_members = ["member"]
1371 group._attr_supported_features = {PlayerFeature.POWER}
1372
1373 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
1374 mock_mass.config.get_raw_player_config_value = MagicMock(
1375 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
1376 )
1377
1378 controller._players = {"g1": group}
1379 mock_mass.players = controller
1380
1381 # populate state.type / state.power_control / state.group_members
1382 group.set_initialized()
1383 group._cache.clear()
1384 group.update_state(signal_event=False)
1385
1386 called: dict[str, bool | str] = {}
1387
1388 async def _power(
1389 player_id: str,
1390 powered: bool,
1391 skip_auto_play: bool = False, # noqa: ARG001
1392 ) -> None:
1393 called["player_id"] = player_id
1394 called["powered"] = powered
1395
1396 controller._handle_cmd_power = _power # type: ignore[method-assign]
1397
1398 await controller.cmd_ungroup("g1")
1399
1400 assert called == {"player_id": "g1", "powered": False}
1401
1402 @pytest.mark.asyncio
1403 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
1404 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
1405 controller = PlayerController(mock_mass)
1406 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1407 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
1408 group._attr_powered = None # no power control
1409 group._attr_group_members = ["member"]
1410 # no POWER feature → power_control auto-selects to NONE
1411
1412 controller._players = {"g1": group}
1413 mock_mass.players = controller
1414
1415 group.set_initialized()
1416 group._cache.clear()
1417 group.update_state(signal_event=False)
1418
1419 stop_called: list[str] = []
1420
1421 async def _stop(player_id: str) -> None:
1422 stop_called.append(player_id)
1423
1424 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1425 # also stub power to make sure we did NOT go down that branch
1426 power_called: list[str] = []
1427
1428 async def _power(
1429 player_id: str,
1430 powered: bool, # noqa: ARG001
1431 skip_auto_play: bool = False, # noqa: ARG001
1432 ) -> None:
1433 power_called.append(player_id)
1434
1435 controller._handle_cmd_power = _power # type: ignore[method-assign]
1436
1437 await controller.cmd_ungroup("g1")
1438
1439 assert stop_called == ["g1"]
1440 assert power_called == [] # powerless group → never goes through cmd_power
1441
1442
1443class TestExternalPowerOffUnsync:
1444 """
1445 Tests for unsyncing a player when its power is turned off outside of MA.
1446
1447 When a player's (final) power state flips on->off because its linked power
1448 control was switched off directly - rather than via an MA power command -
1449 the player must be removed from any (sync)group it is part of.
1450 """
1451
1452 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1453 """Build a controller with a player synced to a registered leader."""
1454 controller = PlayerController(mock_mass)
1455 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1456 leader = MockPlayer(provider, "leader", "Leader")
1457 leader._attr_group_members = ["leader", "p1"]
1458 player = MockPlayer(provider, "p1", "Player")
1459 controller._players = {"leader": leader, "p1": player}
1460 mock_mass.players = controller
1461 for _player in (leader, player):
1462 _player.set_initialized()
1463 _player._cache.clear()
1464 _player.update_state(signal_event=False)
1465 # isolate the unsync branch from the unrelated state-forwarding machinery
1466 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1467 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1468 return controller, player
1469
1470 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
1471 """An on->off power transition ungroups a synced player."""
1472 controller, player = self._make_synced_player(mock_mass)
1473 assert player.state.synced_to == "leader"
1474
1475 controller.signal_player_state_update(player, {"powered": (True, False)})
1476
1477 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
1478
1479 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
1480 """An off->on power transition leaves the player synced."""
1481 controller, player = self._make_synced_player(mock_mass)
1482
1483 controller.signal_player_state_update(player, {"powered": (False, True)})
1484
1485 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1486
1487 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
1488 """A None->off transition (player without power control) is ignored."""
1489 controller, player = self._make_synced_player(mock_mass)
1490
1491 controller.signal_player_state_update(player, {"powered": (None, False)})
1492
1493 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
1494
1495 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
1496 """Powering off a player that is not in any group does nothing."""
1497 controller = PlayerController(mock_mass)
1498 provider = MockProvider("test", instance_id="test", mass=mock_mass)
1499 player = MockPlayer(provider, "p1", "Player")
1500 controller._players = {"p1": player}
1501 mock_mass.players = controller
1502 player.set_initialized()
1503 player._cache.clear()
1504 player.update_state(signal_event=False)
1505 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1506 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
1507
1508 controller.signal_player_state_update(player, {"powered": (True, False)})
1509
1510 controller.cmd_ungroup.assert_not_called()
1511
1512
1513class TestPlayMediaOverride:
1514 """
1515 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
1516
1517 When a captured child player receives an explicit play_media command, the
1518 default behavior is to *release* it from the active group/sync and play
1519 directly on the targeted player. The legacy behavior (forward to the
1520 leader) is preserved via the per-player config opt-out.
1521 """
1522
1523 @pytest.mark.asyncio
1524 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
1525 """With override disabled, play_media on a captured child redirects to the group."""
1526 controller = PlayerController(mock_mass)
1527 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1528 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1529
1530 class _SessionedGroup(MockPlayer):
1531 @property
1532 def is_active_session(self) -> bool:
1533 return True
1534
1535 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1536 group._attr_powered = None
1537 group._attr_group_members = ["member"]
1538
1539 member = MockPlayer(member_provider, "member", "Member")
1540
1541 controller._players = {"g1": group, "member": member}
1542 mock_mass.players = controller
1543
1544 group.set_initialized()
1545 member.set_initialized()
1546 group.update_state(signal_event=False)
1547 member.update_state(signal_event=False)
1548 # sanity: the member is captured by the group
1549 assert member.state.active_group == "g1"
1550
1551 _set_play_media_override(mock_mass, False)
1552
1553 played_on: list[str] = []
1554
1555 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1556 played_on.append(player_id)
1557
1558 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1559 # the play_media wrapper acquires a playback lock; stub it out
1560 controller._player_command_locks = {}
1561
1562 media = MagicMock(uri="x", source_id="src")
1563 await controller.play_media("member", media)
1564
1565 # legacy behavior: redirected to the group leader
1566 assert played_on == ["g1"]
1567
1568 @pytest.mark.asyncio
1569 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
1570 """With override enabled, play_media on a dynamic group member releases it first."""
1571 controller = PlayerController(mock_mass)
1572 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1573 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1574
1575 class _SessionedGroup(MockPlayer):
1576 @property
1577 def is_active_session(self) -> bool:
1578 return True
1579
1580 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1581 group._attr_powered = None
1582 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1583 group._attr_group_members = ["member"]
1584 # NOT a static member ⇒ dynamic — can be removed via set_members
1585 group._attr_static_group_members = []
1586
1587 member = MockPlayer(member_provider, "member", "Member")
1588
1589 controller._players = {"g1": group, "member": member}
1590 mock_mass.players = controller
1591
1592 group.set_initialized()
1593 member.set_initialized()
1594 group.update_state(signal_event=False)
1595 member.update_state(signal_event=False)
1596 assert member.state.active_group == "g1"
1597
1598 # default: override enabled
1599 _set_play_media_override(mock_mass, True)
1600
1601 set_members_calls: list[dict[str, object]] = []
1602
1603 async def _cmd_set_members(
1604 target_player: str,
1605 player_ids_to_add: list[str] | None = None, # noqa: ARG001
1606 player_ids_to_remove: list[str] | None = None,
1607 ) -> None:
1608 set_members_calls.append(
1609 {"player_id": target_player, "remove": player_ids_to_remove or []}
1610 )
1611
1612 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
1613
1614 played_on: list[str] = []
1615
1616 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1617 played_on.append(player_id)
1618
1619 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1620 controller._player_command_locks = {}
1621
1622 media = MagicMock(uri="x", source_id="src")
1623 with patch.object(
1624 controller,
1625 "wait_for_player_update",
1626 _skip_player_update_wait,
1627 ):
1628 await controller.play_media("member", media)
1629
1630 # the member was removed from the group ...
1631 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
1632 # ... and then play_media was issued directly on the member, NOT on the group
1633 assert played_on == ["member"]
1634
1635 @pytest.mark.asyncio
1636 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
1637 """With override enabled, play_media on a STATIC group member stops the group."""
1638 controller = PlayerController(mock_mass)
1639 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
1640 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
1641
1642 class _SessionedGroup(MockPlayer):
1643 @property
1644 def is_active_session(self) -> bool:
1645 return True
1646
1647 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
1648 group._attr_powered = None # no power control ⇒ stop, not power-off
1649 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1650 group._attr_group_members = ["member"]
1651 # static member - cannot be removed individually
1652 group._attr_static_group_members = ["member"]
1653
1654 member = MockPlayer(member_provider, "member", "Member")
1655
1656 controller._players = {"g1": group, "member": member}
1657 mock_mass.players = controller
1658
1659 group.set_initialized()
1660 member.set_initialized()
1661 group.update_state(signal_event=False)
1662 member.update_state(signal_event=False)
1663 assert member.state.active_group == "g1"
1664
1665 _set_play_media_override(mock_mass, True)
1666
1667 stop_calls: list[str] = []
1668 power_calls: list[tuple[str, bool]] = []
1669
1670 async def _stop(player_id: str) -> None:
1671 stop_calls.append(player_id)
1672
1673 async def _power(
1674 player_id: str,
1675 powered: bool,
1676 skip_auto_play: bool = False, # noqa: ARG001
1677 ) -> None:
1678 power_calls.append((player_id, powered))
1679
1680 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
1681 controller._handle_cmd_power = _power # type: ignore[method-assign]
1682
1683 played_on: list[str] = []
1684
1685 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
1686 played_on.append(player_id)
1687
1688 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
1689 controller._player_command_locks = {}
1690
1691 media = MagicMock(uri="x", source_id="src")
1692 with patch.object(
1693 controller,
1694 "wait_for_player_update",
1695 _skip_player_update_wait,
1696 ):
1697 await controller.play_media("member", media)
1698
1699 # powerless group + static member: we should have stopped the group ...
1700 assert stop_calls == ["g1"]
1701 # ... not powered it off ...
1702 assert power_calls == []
1703 # ... and play_media was issued directly on the member
1704 assert played_on == ["member"]
1705
1706
1707class TestExternalSourcePlayPause:
1708 """Pause/play handling for externally-initiated sources (no active output protocol)."""
1709
1710 @staticmethod
1711 def _make_external_source_player(
1712 provider: MockProvider,
1713 controller: PlayerController,
1714 mock_mass: MagicMock,
1715 *,
1716 playback_state: PlaybackState,
1717 can_play_pause: bool = True,
1718 supports_pause: bool = True,
1719 ) -> MockPlayer:
1720 """Build a player playing a passive external source, with no active output protocol."""
1721 player = MockPlayer(provider, "player_1", "Test Player")
1722 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
1723 player._attr_source_list = [
1724 PlayerSource(
1725 id="spotify",
1726 name="Spotify",
1727 passive=True,
1728 can_play_pause=can_play_pause,
1729 can_next_previous=True,
1730 can_seek=True,
1731 )
1732 ]
1733 player._attr_active_source = "spotify"
1734 player._attr_playback_state = playback_state
1735 player._cache.clear()
1736 controller._players = {"player_1": player}
1737 mock_mass.players = controller
1738 mock_mass.player_queues = MagicMock()
1739 mock_mass.player_queues.get = MagicMock(return_value=None)
1740 player.update_state(signal_event=False)
1741 return player
1742
1743 def test_pause_external_source_forwards_to_player(
1744 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1745 ) -> None:
1746 """Pausing a pausable external source forwards to the player, not STOP."""
1747 player = self._make_external_source_player(
1748 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
1749 )
1750 player.pause = AsyncMock() # type: ignore[method-assign]
1751 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1752
1753 asyncio.run(controller._handle_cmd_pause("player_1"))
1754
1755 player.pause.assert_awaited_once()
1756 controller._handle_cmd_stop.assert_not_called()
1757
1758 def test_play_external_source_unpauses_player(
1759 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1760 ) -> None:
1761 """Unpausing a paused external source forwards to the player, not a restart."""
1762 player = self._make_external_source_player(
1763 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
1764 )
1765 player.play = AsyncMock() # type: ignore[method-assign]
1766 player.play_media = AsyncMock() # type: ignore[method-assign]
1767 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1768
1769 asyncio.run(controller._handle_cmd_play("player_1"))
1770
1771 player.play.assert_awaited_once()
1772 player.play_media.assert_not_called()
1773 controller._handle_select_source.assert_not_called()
1774
1775 def test_pause_falls_back_to_stop_without_pause_support(
1776 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1777 ) -> None:
1778 """A player that cannot pause natively still falls back to STOP."""
1779 player = self._make_external_source_player(
1780 provider,
1781 controller,
1782 mock_mass,
1783 playback_state=PlaybackState.PLAYING,
1784 supports_pause=False,
1785 )
1786 player.pause = AsyncMock() # type: ignore[method-assign]
1787 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1788
1789 asyncio.run(controller._handle_cmd_pause("player_1"))
1790
1791 controller._handle_cmd_stop.assert_awaited_once()
1792 player.pause.assert_not_called()
1793
1794
1795class TestProtocolOutputPlayPause:
1796 """Play/pause on a player rendering through a linked output protocol."""
1797
1798 @staticmethod
1799 def _make_player_on_protocol(
1800 mock_mass: MagicMock,
1801 controller: PlayerController,
1802 *,
1803 playback_state: PlaybackState,
1804 ) -> MockPlayer:
1805 """Build a player playing the MA queue through a protocol that cannot pause."""
1806 native_provider = MockProvider("chromecast", mass=mock_mass)
1807 player = MockPlayer(native_provider, "player_1", "Test Player")
1808 player._attr_supported_features.add(PlayerFeature.PAUSE)
1809 player._attr_playback_state = playback_state
1810
1811 protocol_provider = MockProvider("sendspin", mass=mock_mass)
1812 protocol_player = MockPlayer(
1813 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
1814 )
1815 protocol_player._attr_playback_state = playback_state
1816
1817 controller._players = {"player_1": player, "proto_1": protocol_player}
1818 mock_mass.players = controller
1819 mock_mass.player_queues = MagicMock()
1820 # a non-empty queue, so the MA queue source advertises play/pause support
1821 queue = MagicMock()
1822 queue.items = [MagicMock()]
1823 mock_mass.player_queues.get = MagicMock(return_value=queue)
1824 player.set_linked_output_protocols(
1825 [
1826 LinkedOutputProtocol(
1827 output_protocol_id="proto_1",
1828 protocol_domain="sendspin",
1829 priority=40,
1830 )
1831 ]
1832 )
1833 player.set_active_output_protocol("proto_1")
1834 player.set_active_mass_source("player_1")
1835 protocol_player.update_state(signal_event=False)
1836 player.refresh_state(signal_event=False)
1837 return player
1838
1839 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
1840 self, mock_mass: MagicMock, controller: PlayerController
1841 ) -> None:
1842 """The native transport has no session to pause while a protocol renders the audio."""
1843 player = self._make_player_on_protocol(
1844 mock_mass, controller, playback_state=PlaybackState.PLAYING
1845 )
1846 player.pause = AsyncMock() # type: ignore[method-assign]
1847 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1848
1849 await controller._handle_cmd_pause("player_1")
1850
1851 player.pause.assert_not_called()
1852 # STOP goes to the visible player, not the protocol player
1853 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
1854
1855 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
1856 self, mock_mass: MagicMock, controller: PlayerController
1857 ) -> None:
1858 """Unpausing must not hit the native transport either; the source is restarted."""
1859 player = self._make_player_on_protocol(
1860 mock_mass, controller, playback_state=PlaybackState.PAUSED
1861 )
1862 player.play = AsyncMock() # type: ignore[method-assign]
1863 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1864
1865 await controller._handle_cmd_play("player_1")
1866
1867 player.play.assert_not_called()
1868 # the MA queue source is restarted, not some other source
1869 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
1870
1871
1872class TestMirrorsParentMedia:
1873 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
1874
1875 @staticmethod
1876 def _fake_player(
1877 *,
1878 player_id: str = "p1",
1879 active_group: str | None = None,
1880 synced_to: str | None = None,
1881 player_type: PlayerType = PlayerType.PLAYER,
1882 protocol_parent_id: str | None = None,
1883 ) -> SimpleNamespace:
1884 return SimpleNamespace(
1885 player_id=player_id,
1886 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
1887 protocol_parent_id=protocol_parent_id,
1888 )
1889
1890 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
1891 """A standalone player resolves its own media (and palette)."""
1892 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
1893
1894 def test_group_member_mirrors(self, controller: PlayerController) -> None:
1895 """A group member borrows its parent's media."""
1896 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
1897
1898 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
1899 """A synced member borrows its leader's media."""
1900 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
1901
1902 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
1903 """A protocol child borrows its parent's media."""
1904 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
1905 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
1906
1907 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
1908 """A protocol player with no parent resolves its own media."""
1909 player = self._fake_player(player_type=PlayerType.PROTOCOL)
1910 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1911
1912 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
1913 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
1914 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
1915 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1916
1917
1918class TestVolumeScalingOnRedirect:
1919 """min/max volume scaling must survive a redirect to a protocol player or external control."""
1920
1921 @staticmethod
1922 def _volume_player(
1923 player_id: str,
1924 volume_control: str,
1925 volume_set: AsyncMock | None = None,
1926 ) -> SimpleNamespace:
1927 return SimpleNamespace(
1928 player_id=player_id,
1929 type=PlayerType.PLAYER,
1930 protocol_parent_id=None,
1931 extra_data={},
1932 volume_control=volume_control,
1933 mute_control=PLAYER_CONTROL_NONE,
1934 volume_set=volume_set or AsyncMock(),
1935 update_state=MagicMock(),
1936 provider=MagicMock(),
1937 state=SimpleNamespace(
1938 name=player_id,
1939 volume_control=volume_control,
1940 volume_muted=False,
1941 mute_control=PLAYER_CONTROL_NONE,
1942 ),
1943 )
1944
1945 @pytest.mark.asyncio
1946 async def test_protocol_redirect_forwards_scaled_volume(
1947 self, controller: PlayerController, mock_mass: MagicMock
1948 ) -> None:
1949 """A volume command redirected to a protocol player honors the user-facing max_volume."""
1950
1951 def _conf(player_id: str, key: str, default: object = None) -> object:
1952 if key == "min_volume":
1953 return 0
1954 if key == "max_volume":
1955 # user-facing player caps at 50, the protocol player has no limits of its own
1956 return 50 if player_id == "user_player" else 100
1957 return default
1958
1959 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1960
1961 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
1962 user = self._volume_player("user_player", "protocol_player")
1963 players = {"user_player": user, "protocol_player": protocol}
1964
1965 with (
1966 patch.object(controller, "get_player", side_effect=players.get),
1967 patch.object(controller, "_get_active_audio_source", return_value=None),
1968 ):
1969 controller._controls = {}
1970 await controller._handle_cmd_volume_set("user_player", 100)
1971
1972 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
1973 protocol.volume_set.assert_awaited_once_with(50)
1974
1975 @pytest.mark.asyncio
1976 async def test_protocol_redirect_applies_the_limits_only_once(
1977 self, controller: PlayerController, mock_mass: MagicMock
1978 ) -> None:
1979 """Limits configured on the protocol player do not scale the command a second time."""
1980
1981 def _conf(_player_id: str, key: str, default: object = None) -> object:
1982 if key == "min_volume":
1983 return 0
1984 if key == "max_volume":
1985 # both players carry a limit; only the addressed one may apply
1986 return 50
1987 return default
1988
1989 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1990
1991 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
1992 user = self._volume_player("user_player", "protocol_player")
1993 players = {"user_player": user, "protocol_player": protocol}
1994
1995 with (
1996 patch.object(controller, "get_player", side_effect=players.get),
1997 patch.object(controller, "_get_active_audio_source", return_value=None),
1998 ):
1999 controller._controls = {}
2000 await controller._handle_cmd_volume_set("user_player", 100)
2001
2002 protocol.volume_set.assert_awaited_once_with(50)
2003
2004 @pytest.mark.asyncio
2005 async def test_external_control_redirect_forwards_scaled_volume(
2006 self, controller: PlayerController, mock_mass: MagicMock
2007 ) -> None:
2008 """A volume command redirected to an external control honors the user-facing max_volume."""
2009 mock_mass.config.get_raw_player_config_value = MagicMock(
2010 side_effect=_player_config_stub(max_volume=50)
2011 )
2012
2013 volume_set = AsyncMock()
2014 control = PlayerControl(
2015 id="ext_control",
2016 provider="test",
2017 name="External Amp",
2018 supports_volume=True,
2019 volume_set=volume_set,
2020 )
2021 user = self._volume_player("user_player", "ext_control")
2022 players = {"user_player": user}
2023
2024 with (
2025 patch.object(controller, "get_player", side_effect=players.get),
2026 patch.object(controller, "_get_active_audio_source", return_value=None),
2027 ):
2028 controller._controls = {"ext_control": control}
2029 await controller._handle_cmd_volume_set("user_player", 100)
2030
2031 volume_set.assert_awaited_once_with(50)
2032
2033 @pytest.mark.asyncio
2034 async def test_external_control_without_volume_support_raises(
2035 self, controller: PlayerController, mock_mass: MagicMock
2036 ) -> None:
2037 """A volume command redirected to a control lacking volume support is rejected."""
2038 mock_mass.config.get_raw_player_config_value = MagicMock(
2039 side_effect=_player_config_stub(max_volume=50)
2040 )
2041
2042 volume_set = AsyncMock()
2043 control = PlayerControl(
2044 id="ext_control",
2045 provider="test",
2046 name="External Amp",
2047 supports_volume=False,
2048 volume_set=volume_set,
2049 )
2050 user = self._volume_player("user_player", "ext_control")
2051 players = {"user_player": user}
2052
2053 with (
2054 patch.object(controller, "get_player", side_effect=players.get),
2055 patch.object(controller, "_get_active_audio_source", return_value=None),
2056 ):
2057 controller._controls = {"ext_control": control}
2058 with pytest.raises(UnsupportedFeaturedException):
2059 await controller._handle_cmd_volume_set("user_player", 100)
2060
2061 volume_set.assert_not_awaited()
2062
2063
2064class TestExternalPowerControl:
2065 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
2066
2067 def _make_player(
2068 self, mock_mass: MagicMock, control: PlayerControl
2069 ) -> tuple[PlayerController, MockPlayer]:
2070 """Build a controller with a single player whose power control is the given control."""
2071 mock_mass.config.get_raw_player_config_value = MagicMock(
2072 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
2073 )
2074 controller = PlayerController(mock_mass)
2075 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2076 player = MockPlayer(provider, "player_1", "Player 1")
2077 controller._controls = {control.id: control}
2078 controller._players = {"player_1": player}
2079 mock_mass.players = controller
2080 # auto-play would otherwise resume the (mocked) player queue on power on
2081 config_get_value = player.config.get_value
2082 player.config.get_value = MagicMock( # type: ignore[method-assign]
2083 side_effect=lambda key, *args, **kwargs: (
2084 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
2085 )
2086 )
2087 player.set_initialized()
2088 player.update_state(signal_event=False)
2089 return controller, player
2090
2091 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
2092 """Powering on a player redirects to its external control's power_on callback."""
2093 power_on = AsyncMock()
2094 power_off = AsyncMock()
2095 control = PlayerControl(
2096 id="ext_power",
2097 provider="test",
2098 name="External Power",
2099 supports_power=True,
2100 power_on=power_on,
2101 power_off=power_off,
2102 )
2103
2104 def _report_powered_on() -> None:
2105 control.power_state = True
2106
2107 # the control only reports on once switched on, which releases wait_for_power_on
2108 power_on.side_effect = _report_powered_on
2109 controller, player = self._make_player(mock_mass, control)
2110 assert player.state.powered is False
2111
2112 await controller._handle_cmd_power("player_1", True)
2113
2114 power_on.assert_awaited_once()
2115 power_off.assert_not_awaited()
2116
2117 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
2118 """Powering on waits for the control to report on, not for the player itself."""
2119 control = PlayerControl(
2120 id="ext_power",
2121 provider="test",
2122 name="External Power",
2123 supports_power=True,
2124 power_on=AsyncMock(),
2125 power_off=AsyncMock(),
2126 )
2127 controller, player = self._make_player(mock_mass, control)
2128 assert player.state.powered is False
2129
2130 with patch(
2131 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
2132 ) as wait_for_power_on:
2133 await controller._handle_cmd_power("player_1", True)
2134
2135 wait_for_power_on.assert_awaited_once()
2136 assert wait_for_power_on.await_args is not None
2137 assert wait_for_power_on.await_args.args[2] is control
2138
2139 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
2140 """Powering off a player redirects to its external control's power_off callback."""
2141 power_on = AsyncMock()
2142 power_off = AsyncMock()
2143 control = PlayerControl(
2144 id="ext_power",
2145 provider="test",
2146 name="External Power",
2147 supports_power=True,
2148 power_state=True,
2149 power_on=power_on,
2150 power_off=power_off,
2151 )
2152 controller, player = self._make_player(mock_mass, control)
2153 assert player.state.powered is True
2154
2155 await controller._handle_cmd_power("player_1", False)
2156
2157 power_off.assert_awaited_once()
2158 power_on.assert_not_awaited()
2159
2160 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
2161 """A power command redirected to a control lacking power support is rejected."""
2162 power_on = AsyncMock()
2163 power_off = AsyncMock()
2164 control = PlayerControl(
2165 id="ext_power",
2166 provider="test",
2167 name="External Power",
2168 supports_power=False,
2169 power_on=power_on,
2170 power_off=power_off,
2171 )
2172 controller, player = self._make_player(mock_mass, control)
2173 assert player.state.powered is False
2174
2175 with pytest.raises(UnsupportedFeaturedException):
2176 await controller._handle_cmd_power("player_1", True)
2177
2178 power_on.assert_not_awaited()
2179 power_off.assert_not_awaited()
2180
2181
2182class TestEnforceVolumeLimits:
2183 """External volume changes outside the min/max range must be corrected."""
2184
2185 @staticmethod
2186 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
2187 mock_mass.config.get_raw_player_config_value = MagicMock(
2188 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
2189 )
2190
2191 @staticmethod
2192 def _player(logical_volume: int | None) -> SimpleNamespace:
2193 return SimpleNamespace(
2194 player_id="user_player",
2195 state=SimpleNamespace(volume_level=logical_volume),
2196 )
2197
2198 def test_out_of_range_volume_is_corrected(
2199 self, controller: PlayerController, mock_mass: MagicMock
2200 ) -> None:
2201 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
2202 self._set_limits(mock_mass, 0, 80)
2203 # device volume 100 with max 80 resolves to logical 125
2204 player = self._player(125)
2205 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2206 controller._enforce_volume_limits(cast("MockPlayer", player))
2207 cmd.assert_called_once_with("user_player", 100)
2208 mock_mass.create_task.assert_called_once()
2209
2210 def test_below_min_volume_is_corrected(
2211 self, controller: PlayerController, mock_mass: MagicMock
2212 ) -> None:
2213 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
2214 self._set_limits(mock_mass, 20, 100)
2215 # device volume 10 with min 20 resolves to a negative logical volume
2216 player = self._player(-13)
2217 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2218 controller._enforce_volume_limits(cast("MockPlayer", player))
2219 cmd.assert_called_once_with("user_player", 0)
2220
2221 def test_in_range_volume_is_untouched(
2222 self, controller: PlayerController, mock_mass: MagicMock
2223 ) -> None:
2224 """A logical volume within 0-100 needs no correction."""
2225 self._set_limits(mock_mass, 0, 80)
2226 player = self._player(100)
2227 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2228 controller._enforce_volume_limits(cast("MockPlayer", player))
2229 cmd.assert_not_called()
2230
2231 def test_no_limits_configured_is_noop(
2232 self, controller: PlayerController, mock_mass: MagicMock
2233 ) -> None:
2234 """Default 0-100 limits skip enforcement entirely."""
2235 self._set_limits(mock_mass, 0, 100)
2236 player = self._player(100)
2237 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2238 controller._enforce_volume_limits(cast("MockPlayer", player))
2239 cmd.assert_not_called()
2240
2241 def test_unknown_volume_is_noop(
2242 self, controller: PlayerController, mock_mass: MagicMock
2243 ) -> None:
2244 """A player without a resolved volume level is left alone."""
2245 self._set_limits(mock_mass, 0, 80)
2246 player = self._player(None)
2247 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
2248 controller._enforce_volume_limits(cast("MockPlayer", player))
2249 cmd.assert_not_called()
2250
2251
2252class TestFakeMuteControl:
2253 """Fake mute must report the muted state and restore the volume on unmute."""
2254
2255 def _make_player(
2256 self, mock_mass: MagicMock, volume_level: int | None = 40
2257 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2258 """
2259 Build a controller with a single player using fake mute control.
2260
2261 :param mock_mass: the mocked MusicAssistant instance.
2262 :param volume_level: initial volume level of the player, None if unknown.
2263 """
2264 mock_mass.config.get_raw_player_config_value = MagicMock(
2265 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
2266 )
2267 controller = PlayerController(mock_mass)
2268 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2269 player = MockPlayer(provider, "player_1", "Player 1")
2270 controller._players = {"player_1": player}
2271 mock_mass.players = controller
2272 mock_mass.player_queues.get = MagicMock(return_value=None)
2273 player.set_initialized()
2274 player._attr_volume_level = volume_level
2275 # let the mocked native volume control behave like a real device
2276 volume_set = AsyncMock(
2277 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2278 )
2279 player.volume_set = volume_set # type: ignore[method-assign]
2280 player.update_state(signal_event=False)
2281 return controller, player, volume_set
2282
2283 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
2284 """Muting reports volume_muted=True and unmuting restores the previous volume."""
2285 controller, player, volume_set = self._make_player(mock_mass)
2286
2287 await controller.cmd_volume_mute("player_1", True)
2288 muted_state = player.state
2289 assert muted_state.volume_muted is True
2290 assert muted_state.volume_level == 0
2291 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2292
2293 await controller.cmd_volume_mute("player_1", False)
2294 volume_set.assert_awaited_with(40)
2295 # simulate the device reporting back its state after the volume command
2296 player.update_state()
2297 unmuted_state = player.state
2298 assert unmuted_state.volume_muted is False
2299 assert unmuted_state.volume_level == 40
2300
2301 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
2302 """A repeated mute command must not overwrite the stored volume with 0."""
2303 controller, player, volume_set = self._make_player(mock_mass)
2304
2305 await controller.cmd_volume_mute("player_1", True)
2306 await controller.cmd_volume_mute("player_1", True)
2307 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
2308 assert player.state.volume_muted is True
2309
2310 await controller.cmd_volume_mute("player_1", False)
2311 volume_set.assert_awaited_with(40)
2312
2313 async def test_unmute_with_unknown_previous_volume(self, mock_mass: MagicMock) -> None:
2314 """Unmuting a player whose volume was unknown at mute time restores a low volume."""
2315 controller, player, volume_set = self._make_player(mock_mass, volume_level=None)
2316
2317 await controller.cmd_volume_mute("player_1", True)
2318 assert player.extra_data[ATTR_PREVIOUS_VOLUME] is None
2319
2320 await controller.cmd_volume_mute("player_1", False)
2321 volume_set.assert_awaited_with(1)
2322 player.update_state()
2323 assert player.state.volume_muted is False
2324
2325 async def test_unmute_of_unmuted_player_keeps_volume(self, mock_mass: MagicMock) -> None:
2326 """An unmute command for a player that is not muted may not touch the volume."""
2327 controller, player, volume_set = self._make_player(mock_mass, volume_level=50)
2328
2329 await controller.cmd_volume_mute("player_1", False)
2330 volume_set.assert_not_awaited()
2331 assert player.state.volume_level == 50
2332
2333 async def test_unmute_restores_a_stored_zero_volume(self, mock_mass: MagicMock) -> None:
2334 """A player that was already silent stays silent after mute and unmute."""
2335 controller, _player, volume_set = self._make_player(mock_mass, volume_level=0)
2336
2337 await controller.cmd_volume_mute("player_1", True)
2338 await controller.cmd_volume_mute("player_1", False)
2339 volume_set.assert_awaited_with(0)
2340
2341 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
2342 """A regular volume change while fake muted implies an unmute."""
2343 controller, player, _volume_set = self._make_player(mock_mass)
2344
2345 await controller.cmd_volume_mute("player_1", True)
2346 muted_state = player.state
2347 assert muted_state.volume_muted is True
2348
2349 await controller.cmd_volume_set("player_1", 25)
2350 # simulate the device reporting back its state after the volume command
2351 player.update_state()
2352 unmuted_state = player.state
2353 assert unmuted_state.volume_muted is False
2354 assert unmuted_state.volume_level == 25
2355
2356
2357class TestVolumeStep:
2358 """The volume_step core config setting controls the size of a single volume nudge."""
2359
2360 def _make_player(
2361 self, mock_mass: MagicMock, step: int | None, volume_level: int
2362 ) -> tuple[PlayerController, MockPlayer]:
2363 """Build a controller with a single player and the given volume_step config."""
2364 controller = PlayerController(mock_mass)
2365 controller.config = _volume_step_config(step)
2366 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2367 player = MockPlayer(provider, "player_1", "Player 1")
2368 player._attr_volume_level = volume_level
2369 # let the mocked native volume control behave like a real device
2370 player.volume_set = AsyncMock( # type: ignore[method-assign]
2371 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
2372 )
2373 controller._players = {"player_1": player}
2374 mock_mass.players = controller
2375 mock_mass.player_queues.get = MagicMock(return_value=None)
2376 player.set_initialized()
2377 player.update_state(signal_event=False)
2378 return controller, player
2379
2380 def _make_synced_pair(
2381 self, mock_mass: MagicMock, step: int | None
2382 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2383 """Build a leader synced to one member, both at volume 50, with a volume_step config."""
2384 controller = PlayerController(mock_mass)
2385 controller.config = _volume_step_config(step)
2386 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2387 players: dict[str, MockPlayer] = {}
2388 for player_id in ("leader", "member"):
2389 player = MockPlayer(provider, player_id, player_id.title())
2390 player._attr_volume_level = 50
2391 # let the mocked native volume control behave like a real device
2392 player.volume_set = AsyncMock( # type: ignore[method-assign]
2393 side_effect=lambda volume, _player=player: setattr(
2394 _player, "_attr_volume_level", volume
2395 )
2396 )
2397 players[player_id] = player
2398 players["leader"]._attr_group_members = ["member"]
2399 controller._players = dict(players)
2400 mock_mass.players = controller
2401 mock_mass.player_queues.get = MagicMock(return_value=None)
2402 for player in players.values():
2403 player.set_initialized()
2404 player._cache.clear()
2405 player.update_state(signal_event=False)
2406 # a second, forced pass: update_state() only recalculates when a player's own
2407 # attributes changed, so the leader must be forced to re-derive its group_volume
2408 # from the now-initialized member.
2409 for player in players.values():
2410 player.update_state(force_update=True, signal_event=False)
2411 return controller, players
2412
2413 @pytest.mark.parametrize(
2414 ("start", "expected"),
2415 [(5, 6), (20, 22), (50, 53), (80, 82), (95, 96)],
2416 )
2417 async def test_default_step_up_matches_the_adaptive_ladder(
2418 self, mock_mass: MagicMock, start: int, expected: int
2419 ) -> None:
2420 """With volume_step at its default (0), volume_up keeps today's adaptive ladder."""
2421 controller, player = self._make_player(mock_mass, None, start)
2422
2423 await controller.cmd_volume_up("player_1")
2424
2425 player.update_state()
2426 assert player.state.volume_level == expected
2427
2428 @pytest.mark.parametrize(
2429 ("start", "expected"),
2430 [(5, 4), (20, 18), (50, 47), (80, 78), (95, 94)],
2431 )
2432 async def test_default_step_down_matches_the_adaptive_ladder(
2433 self, mock_mass: MagicMock, start: int, expected: int
2434 ) -> None:
2435 """With volume_step at its default (0), volume_down keeps today's adaptive ladder."""
2436 controller, player = self._make_player(mock_mass, None, start)
2437
2438 await controller.cmd_volume_down("player_1")
2439
2440 player.update_state()
2441 assert player.state.volume_level == expected
2442
2443 async def test_configured_step_moves_up_by_a_flat_amount_mid_range(
2444 self, mock_mass: MagicMock
2445 ) -> None:
2446 """A configured flat step of 5 moves by exactly 5 in the middle of the range."""
2447 controller, player = self._make_player(mock_mass, 5, 50)
2448
2449 await controller.cmd_volume_up("player_1")
2450
2451 player.update_state()
2452 assert player.state.volume_level == 55
2453
2454 async def test_configured_step_moves_up_by_a_flat_amount_near_the_extreme(
2455 self, mock_mass: MagicMock
2456 ) -> None:
2457 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2458 controller, player = self._make_player(mock_mass, 5, 5)
2459
2460 await controller.cmd_volume_up("player_1")
2461
2462 player.update_state()
2463 assert player.state.volume_level == 10
2464
2465 async def test_configured_step_moves_down_by_a_flat_amount_mid_range(
2466 self, mock_mass: MagicMock
2467 ) -> None:
2468 """A configured flat step of 5 moves down by exactly 5 in the middle of the range."""
2469 controller, player = self._make_player(mock_mass, 5, 50)
2470
2471 await controller.cmd_volume_down("player_1")
2472
2473 player.update_state()
2474 assert player.state.volume_level == 45
2475
2476 async def test_configured_step_moves_down_by_a_flat_amount_near_the_extreme(
2477 self, mock_mass: MagicMock
2478 ) -> None:
2479 """A configured flat step of 5 near the extreme overrides the finer ladder step."""
2480 controller, player = self._make_player(mock_mass, 5, 95)
2481
2482 await controller.cmd_volume_down("player_1")
2483
2484 player.update_state()
2485 assert player.state.volume_level == 90
2486
2487 async def test_large_configured_step_clamps_up_at_the_maximum(
2488 self, mock_mass: MagicMock
2489 ) -> None:
2490 """A large configured step clamps volume_up at 100."""
2491 controller, player = self._make_player(mock_mass, 10, 95)
2492
2493 await controller.cmd_volume_up("player_1")
2494
2495 player.update_state()
2496 assert player.state.volume_level == 100
2497
2498 async def test_large_configured_step_clamps_down_at_zero(self, mock_mass: MagicMock) -> None:
2499 """A large configured step clamps volume_down at 0."""
2500 controller, player = self._make_player(mock_mass, 10, 5)
2501
2502 await controller.cmd_volume_down("player_1")
2503
2504 player.update_state()
2505 assert player.state.volume_level == 0
2506
2507 async def test_group_volume_up_with_default_step_uses_the_ladder(
2508 self, mock_mass: MagicMock
2509 ) -> None:
2510 """cmd_group_volume_up honours the default (0) adaptive ladder too."""
2511 controller, players = self._make_synced_pair(mock_mass, None)
2512
2513 await controller.cmd_group_volume_up("leader")
2514
2515 for player in players.values():
2516 player.update_state()
2517 assert player.state.volume_level == 53
2518
2519 async def test_group_volume_down_with_default_step_uses_the_ladder(
2520 self, mock_mass: MagicMock
2521 ) -> None:
2522 """cmd_group_volume_down honours the default (0) adaptive ladder too."""
2523 controller, players = self._make_synced_pair(mock_mass, None)
2524
2525 await controller.cmd_group_volume_down("leader")
2526
2527 for player in players.values():
2528 player.update_state()
2529 assert player.state.volume_level == 47
2530
2531 async def test_group_volume_up_with_configured_step(self, mock_mass: MagicMock) -> None:
2532 """cmd_group_volume_up honours a configured flat step."""
2533 controller, players = self._make_synced_pair(mock_mass, 5)
2534
2535 await controller.cmd_group_volume_up("leader")
2536
2537 for player in players.values():
2538 player.update_state()
2539 assert player.state.volume_level == 55
2540
2541 async def test_group_volume_down_with_configured_step(self, mock_mass: MagicMock) -> None:
2542 """cmd_group_volume_down honours a configured flat step."""
2543 controller, players = self._make_synced_pair(mock_mass, 5)
2544
2545 await controller.cmd_group_volume_down("leader")
2546
2547 for player in players.values():
2548 player.update_state()
2549 assert player.state.volume_level == 45
2550
2551 async def test_config_entry_exposes_default_and_range(
2552 self, controller: PlayerController
2553 ) -> None:
2554 """get_config_entries returns the volume_step entry with its default and range."""
2555 entries = await controller.get_config_entries()
2556
2557 entry = next(entry for entry in entries if entry.key == CONF_VOLUME_STEP)
2558 assert entry.type == ConfigEntryType.INTEGER
2559 assert entry.default_value == 0
2560 assert entry.range == (0, 10)
2561
2562
2563class SlowDevice(NamedTuple):
2564 """A mocked device that takes its time to answer its first volume command."""
2565
2566 player_id: str
2567 reached: asyncio.Event
2568
2569
2570class TestGroupVolumeOrdering:
2571 """Group volume commands that overlap are handled one after the other."""
2572
2573 def _make_synced_pair(
2574 self,
2575 mock_mass: MagicMock,
2576 slow_device: SlowDevice | None = None,
2577 volumes: dict[str, int] | None = None,
2578 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2579 """
2580 Build a mute capable leader synced to one member.
2581
2582 :param slow_device: When given, the named player takes its time to answer its
2583 first volume command and reports as soon as it received that command.
2584 :param volumes: Volume level per player, defaults to 50 for both.
2585 """
2586 controller = PlayerController(mock_mass)
2587 controller.config = _volume_step_config(5)
2588 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2589 players: dict[str, MockPlayer] = {}
2590 for player_id in ("leader", "member"):
2591 player = MockPlayer(provider, player_id, player_id.title())
2592 player._attr_supported_features = {
2593 PlayerFeature.VOLUME_SET,
2594 PlayerFeature.VOLUME_MUTE,
2595 }
2596 player._attr_volume_level = (volumes or {}).get(player_id, 50)
2597
2598 # let the mocked native volume control behave like a real device, that may
2599 # take long enough to answer for a later command to overtake it
2600 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
2601 if (
2602 slow_device is not None
2603 and slow_device.player_id == _player.player_id
2604 and not slow_device.reached.is_set()
2605 ):
2606 slow_device.reached.set()
2607 await asyncio.sleep(0.2)
2608 _player._attr_volume_level = volume
2609
2610 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
2611 player.volume_mute = AsyncMock( # type: ignore[method-assign]
2612 side_effect=lambda muted, _player=player: setattr(
2613 _player, "_attr_volume_muted", muted
2614 )
2615 )
2616 players[player_id] = player
2617 players["leader"]._attr_group_members = ["member"]
2618 controller._players = dict(players)
2619 mock_mass.players = controller
2620 mock_mass.player_queues.get = MagicMock(return_value=None)
2621 for player in players.values():
2622 player.set_initialized()
2623 player._cache.clear()
2624 player.update_state(signal_event=False)
2625 # a second, forced pass so the leader derives its group volume from the member
2626 for player in players.values():
2627 player.update_state(force_update=True, signal_event=False)
2628 return controller, players
2629
2630 async def test_the_last_command_decides_the_group_volume(self, mock_mass: MagicMock) -> None:
2631 """A slow command may not overrule the volume of a later, faster one."""
2632 slow_leader = SlowDevice("leader", asyncio.Event())
2633 controller, players = self._make_synced_pair(mock_mass, slow_leader)
2634
2635 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2636 # only send the second command once the first one reached the device
2637 await slow_leader.reached.wait()
2638 second = asyncio.create_task(controller.cmd_group_volume("leader", 30))
2639 await asyncio.gather(first, second)
2640
2641 for player in players.values():
2642 player.update_state()
2643 assert player.state.volume_level == 30
2644
2645 async def test_a_command_for_a_member_waits_for_one_for_its_leader(
2646 self, mock_mass: MagicMock
2647 ) -> None:
2648 """Addressing the same group by member or by leader may not overlap."""
2649 slow_leader = SlowDevice("leader", asyncio.Event())
2650 controller, players = self._make_synced_pair(mock_mass, slow_leader)
2651 in_flight = 0
2652 overlapped = False
2653 set_group_volume = controller.set_group_volume
2654
2655 async def _track_overlap(group_player: Player, volume_level: int) -> None:
2656 nonlocal in_flight, overlapped
2657 in_flight += 1
2658 overlapped = overlapped or in_flight > 1
2659 try:
2660 await set_group_volume(group_player, volume_level)
2661 finally:
2662 in_flight -= 1
2663
2664 controller.set_group_volume = _track_overlap # type: ignore[method-assign]
2665
2666 first = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2667 await slow_leader.reached.wait()
2668 # a synced member adjusts the very same group as its leader
2669 second = asyncio.create_task(controller.cmd_group_volume("member", 30))
2670 await asyncio.gather(first, second)
2671
2672 assert overlapped is False
2673 for player in players.values():
2674 player.update_state()
2675 assert player.state.volume_level == 30
2676
2677 async def test_a_nudge_from_a_member_steps_the_volume_of_the_group(
2678 self, mock_mass: MagicMock
2679 ) -> None:
2680 """A group nudge addressed to a member steps the group, not the member itself."""
2681 controller, players = self._make_synced_pair(mock_mass, volumes={"member": 40})
2682 # the group sits at the volume of its loudest member, the member at its own
2683 assert players["leader"].state.group_volume == 50
2684 assert players["member"].state.group_volume == 40
2685
2686 await controller.cmd_group_volume_up("member")
2687
2688 for player in players.values():
2689 player.update_state()
2690 assert players["leader"].state.volume_level == 55
2691
2692 async def test_an_individual_volume_command_waits_for_the_group(
2693 self, mock_mass: MagicMock
2694 ) -> None:
2695 """A member's own volume command may not be overtaken by a group change."""
2696 slow_member = SlowDevice("member", asyncio.Event())
2697 controller, players = self._make_synced_pair(mock_mass, slow_member)
2698
2699 group = asyncio.create_task(controller.cmd_group_volume("leader", 80))
2700 await slow_member.reached.wait()
2701 individual = asyncio.create_task(controller.cmd_volume_set("member", 10))
2702 await asyncio.gather(group, individual)
2703
2704 for player in players.values():
2705 player.update_state()
2706 assert players["member"].state.volume_level == 10
2707 assert players["leader"].state.volume_level == 80
2708
2709 async def test_a_muted_leader_keeps_its_mute_on_a_group_volume_change(
2710 self, mock_mass: MagicMock
2711 ) -> None:
2712 """A muted sync leader keeps its mute through a group volume change without blocking."""
2713 controller, players = self._make_synced_pair(mock_mass)
2714 players["leader"]._attr_volume_muted = True
2715 players["leader"].update_state(force_update=True, signal_event=False)
2716
2717 # a sync leader is a member of its own group, so the fan-out sets the volume
2718 # of the very player the group command is running for. This must not deadlock
2719 # on a nested cmd_volume_mute call under the group's own volume lock.
2720 async with asyncio.timeout(5):
2721 await controller.cmd_group_volume("leader", 30)
2722
2723 players["leader"].update_state()
2724 assert players["leader"].state.volume_muted is True
2725 assert players["leader"].state.volume_level == 30
2726
2727
2728class LateReportingDevice:
2729 """A mocked device that only reports the volume it was given back when told to."""
2730
2731 def __init__(self, command_delay: float = 0) -> None:
2732 """
2733 Initialize the mocked device.
2734
2735 :param command_delay: Seconds a single volume command takes to reach the device.
2736 """
2737 self.commands: list[int] = []
2738 self._command_delay = command_delay
2739 self._pending: dict[MockPlayer, int] = {}
2740
2741 def bind(self, player: MockPlayer) -> None:
2742 """Make the given player answer its volume commands like this device."""
2743
2744 async def _volume_set(volume: int, _player: MockPlayer = player) -> None:
2745 self.commands.append(volume)
2746 if self._command_delay:
2747 await asyncio.sleep(self._command_delay)
2748 self._pending[_player] = volume
2749
2750 player.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
2751
2752 def report(self) -> None:
2753 """Report the volume of every command received so far back to the player state."""
2754 for player, volume in self._pending.items():
2755 player._attr_volume_level = volume
2756 player.update_state(force_update=True, signal_event=False)
2757 self._pending.clear()
2758
2759
2760class TestVolumeNudgeTarget:
2761 """Volume nudges step from the level last commanded, not from a lagging report."""
2762
2763 def _make_player(
2764 self, mock_mass: MagicMock, command_delay: float = 0
2765 ) -> tuple[PlayerController, MockPlayer, LateReportingDevice]:
2766 """
2767 Build a single player at volume 50 whose device reports back on request.
2768
2769 :param command_delay: Seconds a single volume command takes to reach the device.
2770 """
2771 controller = PlayerController(mock_mass)
2772 controller.config = _volume_step_config(5)
2773 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2774 player = MockPlayer(provider, "player_1", "Player 1")
2775 player._attr_volume_level = 50
2776 device = LateReportingDevice(command_delay)
2777 device.bind(player)
2778 controller._players = {"player_1": player}
2779 mock_mass.players = controller
2780 mock_mass.player_queues.get = MagicMock(return_value=None)
2781 player.set_initialized()
2782 player.update_state(signal_event=False)
2783 # a second, forced pass so the player derives its group volume from its own state
2784 player.update_state(force_update=True, signal_event=False)
2785 return controller, player, device
2786
2787 def _make_synced_pair(
2788 self, mock_mass: MagicMock, command_delay: float = 0
2789 ) -> tuple[PlayerController, dict[str, MockPlayer], LateReportingDevice]:
2790 """
2791 Build a leader synced to one member, both at volume 50, both late reporting.
2792
2793 :param command_delay: Seconds a single volume command takes to reach the device.
2794 """
2795 controller = PlayerController(mock_mass)
2796 controller.config = _volume_step_config(5)
2797 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2798 device = LateReportingDevice(command_delay)
2799 players: dict[str, MockPlayer] = {}
2800 for player_id in ("leader", "member"):
2801 player = MockPlayer(provider, player_id, player_id.title())
2802 player._attr_volume_level = 50
2803 device.bind(player)
2804 players[player_id] = player
2805 players["leader"]._attr_group_members = ["member"]
2806 controller._players = dict(players)
2807 mock_mass.players = controller
2808 mock_mass.player_queues.get = MagicMock(return_value=None)
2809 for player in players.values():
2810 player.set_initialized()
2811 player._cache.clear()
2812 player.update_state(signal_event=False)
2813 # a second, forced pass so the leader derives its group volume from the member
2814 for player in players.values():
2815 player.update_state(force_update=True, signal_event=False)
2816 return controller, players, device
2817
2818 async def test_nudges_up_stack_before_the_player_reports_back(
2819 self, mock_mass: MagicMock
2820 ) -> None:
2821 """Three volume ups in a row climb, even with no report in between."""
2822 controller, _player, device = self._make_player(mock_mass)
2823
2824 for _ in range(3):
2825 await controller.cmd_volume_up("player_1")
2826
2827 assert device.commands == [55, 60, 65]
2828
2829 async def test_nudges_down_stack_before_the_player_reports_back(
2830 self, mock_mass: MagicMock
2831 ) -> None:
2832 """Three volume downs in a row descend, even with no report in between."""
2833 controller, _player, device = self._make_player(mock_mass)
2834
2835 for _ in range(3):
2836 await controller.cmd_volume_down("player_1")
2837
2838 assert device.commands == [45, 40, 35]
2839
2840 async def test_nudges_that_overlap_each_get_their_own_step(self, mock_mass: MagicMock) -> None:
2841 """Volume ups that arrive while an earlier one is still on its way all count."""
2842 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
2843
2844 await asyncio.gather(*(controller.cmd_volume_up("player_1") for _ in range(3)))
2845
2846 assert device.commands == [55, 60, 65]
2847
2848 async def test_a_queued_nudge_does_not_undo_the_level_a_later_one_claimed(
2849 self, mock_mass: MagicMock
2850 ) -> None:
2851 """A nudge waiting for the volume lock may not take the level back down."""
2852 controller, _player, device = self._make_player(mock_mass, command_delay=0.05)
2853 tasks = [asyncio.create_task(controller.cmd_volume_up("player_1")) for _ in range(3)]
2854 # let all three claim their level; the second and third then queue on the lock
2855 await asyncio.sleep(0)
2856 # wait for the second one to reach the device, so a nudge sent now reads whatever
2857 # that (by then oldest) command left behind
2858 while len(device.commands) < 2:
2859 await asyncio.sleep(0.005)
2860 tasks.append(asyncio.create_task(controller.cmd_volume_up("player_1")))
2861
2862 await asyncio.gather(*tasks)
2863
2864 assert device.commands == [55, 60, 65, 70]
2865
2866 async def test_a_group_nudge_moves_every_member_up(self, mock_mass: MagicMock) -> None:
2867 """A group nudge up may not send a member the other way."""
2868 controller, players, device = self._make_synced_pair(mock_mass)
2869 # the loudest member is the one that was just turned down on its own, so the
2870 # level it still reports sits above the level the group is being stepped to
2871 players["member"]._attr_volume_level = 80
2872 for _ in range(3):
2873 for player in players.values():
2874 player._cache.clear()
2875 player.update_state(force_update=True, signal_event=False)
2876
2877 await controller.cmd_volume_set("member", 40)
2878 await controller.cmd_group_volume_up("leader")
2879
2880 assert device.commands == [40, 55, 46]
2881
2882 async def test_a_group_nudge_on_an_ungrouped_player_steps_its_own_volume(
2883 self, mock_mass: MagicMock
2884 ) -> None:
2885 """A group nudge falls back to the player itself, which has no group to step."""
2886 controller, _player, device = self._make_player(mock_mass)
2887
2888 for _ in range(3):
2889 await controller.cmd_group_volume_up("player_1")
2890
2891 assert device.commands == [55, 60, 65]
2892
2893 async def test_a_nudge_steps_from_the_level_the_slider_was_left_at(
2894 self, mock_mass: MagicMock
2895 ) -> None:
2896 """A nudge right after a volume set steps from that set level."""
2897 controller, _player, device = self._make_player(mock_mass)
2898
2899 await controller.cmd_volume_set("player_1", 20)
2900 await controller.cmd_volume_up("player_1")
2901
2902 assert device.commands == [20, 25]
2903
2904 async def test_a_change_on_the_device_wins_once_the_last_command_ages_out(
2905 self, mock_mass: MagicMock
2906 ) -> None:
2907 """A volume turned down on the device itself is the base of the next nudge."""
2908 controller, player, device = self._make_player(mock_mass)
2909 await controller.cmd_volume_up("player_1")
2910 device.report()
2911 # the volume is turned down on the device itself, well after that command
2912 player._attr_volume_level = 20
2913 player.update_state(force_update=True, signal_event=False)
2914
2915 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
2916 await controller.cmd_volume_up("player_1")
2917
2918 assert device.commands[-1] == 25
2919
2920 async def test_group_nudges_up_stack_before_the_players_report_back(
2921 self, mock_mass: MagicMock
2922 ) -> None:
2923 """Three group volume ups in a row climb, even with no report in between."""
2924 controller, _players, device = self._make_synced_pair(mock_mass)
2925
2926 for _ in range(3):
2927 await controller.cmd_group_volume_up("leader")
2928
2929 assert device.commands == [55, 55, 60, 60, 65, 65]
2930
2931 async def test_group_nudges_down_stack_before_the_players_report_back(
2932 self, mock_mass: MagicMock
2933 ) -> None:
2934 """Three group volume downs in a row descend, even with no report in between."""
2935 controller, _players, device = self._make_synced_pair(mock_mass)
2936
2937 for _ in range(3):
2938 await controller.cmd_group_volume_down("leader")
2939
2940 assert device.commands == [45, 45, 40, 40, 35, 35]
2941
2942 async def test_a_group_nudge_after_a_member_was_set_on_its_own_keeps_the_step(
2943 self, mock_mass: MagicMock
2944 ) -> None:
2945 """Setting one member does not send the group back to the level it reports."""
2946 controller, _players, device = self._make_synced_pair(mock_mass)
2947
2948 await controller.cmd_group_volume_up("leader")
2949 await controller.cmd_volume_set("member", 10)
2950 await controller.cmd_group_volume_up("leader")
2951
2952 # the loudest member was commanded to 55, so the group steps from there, and the
2953 # member that was just turned down keeps its share of the group volume
2954 assert device.commands == [55, 55, 10, 60, 20]
2955
2956 async def test_a_group_volume_beyond_the_range_does_not_pin_the_next_nudge(
2957 self, mock_mass: MagicMock
2958 ) -> None:
2959 """An out of range group volume leaves the members at 100, not above it."""
2960 controller, _players, device = self._make_synced_pair(mock_mass)
2961
2962 await controller.cmd_group_volume("leader", 200)
2963 await controller.cmd_group_volume_down("leader")
2964
2965 assert device.commands == [100, 100, 95, 95]
2966
2967 async def test_a_group_nudge_from_a_member_shares_the_target_of_its_leader(
2968 self, mock_mass: MagicMock
2969 ) -> None:
2970 """Group nudges addressed to a member and to its leader step the same group."""
2971 controller, _players, device = self._make_synced_pair(mock_mass)
2972
2973 await controller.cmd_group_volume_up("leader")
2974 await controller.cmd_group_volume_up("member")
2975
2976 assert device.commands == [55, 55, 60, 60]
2977
2978
2979class TestGroupVolumeReference:
2980 """A group volume adjustment interpolates from the levels its members are really at."""
2981
2982 def _make_group(
2983 self, mock_mass: MagicMock, first_volume: int = 50, second_volume: int = 50
2984 ) -> tuple[PlayerController, dict[str, MockPlayer], dict[str, list[int]]]:
2985 """
2986 Build a group player with two members, at the given volumes.
2987
2988 :param first_volume: Volume level the first member starts at.
2989 :param second_volume: Volume level the second member starts at.
2990 :return: The controller, the players by id and the volumes commanded per member.
2991 """
2992 controller = PlayerController(mock_mass)
2993 controller.config = _volume_step_config(5)
2994 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2995 commands: dict[str, list[int]] = {}
2996 members: dict[str, MockPlayer] = {}
2997 for player_id, volume in (("member_1", first_volume), ("member_2", second_volume)):
2998 member = MockPlayer(provider, player_id, player_id.title())
2999 member._attr_volume_level = volume
3000 commands[player_id] = []
3001
3002 async def _volume_set(volume: int, _recorded: list[int] = commands[player_id]) -> None:
3003 _recorded.append(volume)
3004
3005 member.volume_set = AsyncMock(side_effect=_volume_set) # type: ignore[method-assign]
3006 members[player_id] = member
3007 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3008 group._attr_group_members = list(members)
3009 players = {"group": group, **members}
3010 controller._players = dict(players)
3011 provider.players = list(players.values())
3012 mock_mass.players = controller
3013 mock_mass.player_queues.get = MagicMock(return_value=None)
3014 for player in players.values():
3015 player.set_initialized()
3016 player._cache.clear()
3017 player.update_state(signal_event=False)
3018 # a second, forced pass so the group derives its group volume from the members
3019 for player in players.values():
3020 player.update_state(force_update=True, signal_event=False)
3021 return controller, players, commands
3022
3023 def _report(self, player: MockPlayer, volume: int) -> None:
3024 """Let the player report the given volume level, the way its provider would."""
3025 player._attr_volume_level = volume
3026 player._cache.clear()
3027 player.update_state(force_update=True)
3028
3029 async def test_a_member_turned_down_on_the_device_keeps_its_level(
3030 self, mock_mass: MagicMock
3031 ) -> None:
3032 """A group nudge up may not undo a member that was turned down on the device."""
3033 controller, players, commands = self._make_group(mock_mass)
3034 await controller.cmd_group_volume_up("group")
3035 self._report(players["member_1"], 55)
3036 self._report(players["member_2"], 55)
3037
3038 with patch.object(players_controller, "VOLUME_TARGET_EXPIRY", 0):
3039 # the member is turned down on the device itself, well after that command
3040 self._report(players["member_2"], 20)
3041 await controller.cmd_group_volume_up("group")
3042
3043 # the group steps from 55 to 60, and the member keeps its (much lower) share
3044 assert commands["member_1"] == [55, 60]
3045 assert commands["member_2"] == [55, 29]
3046
3047 async def test_a_member_reporting_the_level_it_was_given_keeps_the_balance(
3048 self, mock_mass: MagicMock
3049 ) -> None:
3050 """Members confirming a group nudge may not become the reference themselves."""
3051 controller, players, commands = self._make_group(mock_mass)
3052 await controller.cmd_volume_set("member_2", 20)
3053 self._report(players["member_2"], 20)
3054
3055 await controller.cmd_group_volume_up("group")
3056 self._report(players["member_1"], 55)
3057 self._report(players["member_2"], 28)
3058 await controller.cmd_group_volume_down("group")
3059
3060 # stepping back down to where the group started restores the balance it had
3061 assert commands["member_1"] == [55, 50]
3062 assert commands["member_2"] == [20, 28, 20]
3063
3064 async def test_a_member_that_dropped_off_no_longer_sets_the_reference(
3065 self, mock_mass: MagicMock
3066 ) -> None:
3067 """A group nudge up may not turn the group down over an unreachable member."""
3068 controller, players, commands = self._make_group(
3069 mock_mass, first_volume=30, second_volume=80
3070 )
3071 await controller.cmd_group_volume_up("group")
3072 self._report(players["member_1"], 48)
3073 self._report(players["member_2"], 85)
3074
3075 # the loudest member drops off the network; a permanent group keeps it as a member
3076 players["member_2"]._attr_available = False
3077 players["member_2"]._cache.clear()
3078 players["member_2"].update_state(force_update=True)
3079 await controller.cmd_group_volume_up("group")
3080
3081 assert commands["member_1"] == [48, 53]
3082 assert commands["member_2"] == [85]
3083
3084 async def test_a_group_reporting_its_own_volume_keeps_the_balance(
3085 self, mock_mass: MagicMock
3086 ) -> None:
3087 """A group that reports a volume of its own may not reset its own reference."""
3088 controller, players, commands = self._make_group(
3089 mock_mass, first_volume=50, second_volume=20
3090 )
3091 await controller.cmd_group_volume_up("group")
3092 self._report(players["member_1"], 55)
3093 self._report(players["member_2"], 28)
3094 # a cast group reports the volume of its members as its own
3095 self._report(players["group"], 55)
3096 await controller.cmd_group_volume_down("group")
3097
3098 assert commands["member_1"] == [55, 50]
3099 assert commands["member_2"] == [28, 20]
3100
3101 async def test_a_member_clamped_to_its_volume_limit_keeps_that_level(
3102 self, mock_mass: MagicMock
3103 ) -> None:
3104 """Correcting a volume that ran past its limit is not a level the group commanded."""
3105 mock_mass.config.get_raw_player_config_value = MagicMock(
3106 side_effect=_player_config_stub(min_volume=20)
3107 )
3108 use_real_create_task(mock_mass)
3109 # with a min volume of 20 the members report device volumes of 20-100 for 0-100
3110 controller, players, commands = self._make_group(
3111 mock_mass, first_volume=60, second_volume=60
3112 )
3113 await controller.cmd_group_volume_up("group")
3114 self._report(players["member_1"], 64)
3115 self._report(players["member_2"], 64)
3116
3117 # the member is turned below its own minimum, so it is corrected back up to 0
3118 self._report(players["member_2"], 10)
3119 await controller.cmd_group_volume_up("group")
3120
3121 assert commands["member_1"] == [64, 68]
3122 assert commands["member_2"] == [64, 20, 28]
3123
3124
3125class TestFakeMuteInGroup:
3126 """A fake muted player in a group follows the mute lock, just like a native mute."""
3127
3128 def _make_synced_pair(
3129 self, mock_mass: MagicMock, *, member_mute_control: str = PLAYER_CONTROL_FAKE
3130 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3131 """
3132 Build a leader synced to one member.
3133
3134 :param member_mute_control: Mute control of the member, the leader always uses fake mute.
3135 """
3136
3137 def _conf(player_id: str, key: str, default: object = None) -> object:
3138 if key == CONF_MUTE_CONTROL and player_id == "member":
3139 return member_mute_control
3140 return _player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})(
3141 player_id, key, default
3142 )
3143
3144 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
3145 controller = PlayerController(mock_mass)
3146 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3147 players: dict[str, MockPlayer] = {}
3148 for player_id in ("leader", "member"):
3149 player = MockPlayer(provider, player_id, player_id.title())
3150 player._attr_supported_features = {
3151 PlayerFeature.VOLUME_SET,
3152 PlayerFeature.VOLUME_MUTE,
3153 }
3154 player._attr_volume_level = 50
3155 # let the mocked native volume control behave like a real device
3156 player.volume_set = AsyncMock( # type: ignore[method-assign]
3157 side_effect=lambda volume, _player=player: setattr(
3158 _player, "_attr_volume_level", volume
3159 )
3160 )
3161 players[player_id] = player
3162 players["leader"]._attr_group_members = ["member"]
3163 controller._players = dict(players)
3164 mock_mass.players = controller
3165 mock_mass.player_queues.get = MagicMock(return_value=None)
3166 for player in players.values():
3167 player.set_initialized()
3168 player._cache.clear()
3169 player.update_state(signal_event=False)
3170 # a second pass, so the group volume of the leader accounts for its member
3171 for player in players.values():
3172 player.update_state(signal_event=False)
3173 return controller, players
3174
3175 async def test_group_volume_keeps_a_muted_pair_muted(self, mock_mass: MagicMock) -> None:
3176 """A group volume change may not bring a muted fake mute pair back to life."""
3177 controller, players = self._make_synced_pair(mock_mass)
3178 await controller.cmd_group_volume_mute("leader", True)
3179
3180 await controller.cmd_group_volume("leader", 30)
3181
3182 for player in players.values():
3183 player.update_state()
3184 assert player.state.volume_muted is True
3185 assert player.state.volume_level == 0
3186
3187 async def test_a_muted_member_does_not_inflate_a_group_nudge(
3188 self, mock_mass: MagicMock
3189 ) -> None:
3190 """A volume level a muted member never receives may not step the group."""
3191 controller, players = self._make_synced_pair(mock_mass)
3192 controller.config = _volume_step_config(5)
3193 await controller.cmd_volume_mute("member", True)
3194 # let the group volume of the leader account for the muted member
3195 players["leader"].update_state(signal_event=False)
3196 # the member is held silent, so this level never reaches it
3197 await controller.cmd_volume_set("member", 60)
3198
3199 await controller.cmd_group_volume_up("leader")
3200
3201 players["leader"].update_state()
3202 assert players["leader"].state.volume_level == 55
3203
3204 async def test_a_nudge_after_unmuting_steps_from_the_restored_volume(
3205 self, mock_mass: MagicMock
3206 ) -> None:
3207 """Unmuting hands the next nudge the volume it restored, not the muted 0."""
3208 controller, players = self._make_synced_pair(mock_mass)
3209 controller.config = _volume_step_config(5)
3210 await controller.cmd_volume_mute("member", True)
3211 players["leader"].update_state(signal_event=False)
3212 # a level set while muted never reaches the member
3213 await controller.cmd_volume_set("member", 60)
3214
3215 await controller.cmd_volume_mute("member", False)
3216 await controller.cmd_volume_up("member")
3217
3218 players["member"].update_state()
3219 assert players["member"].state.volume_level == 55
3220
3221 async def test_group_volume_down_keeps_a_muted_member_muted(self, mock_mass: MagicMock) -> None:
3222 """Turning a group down leaves a single muted member silent, at its own volume."""
3223 controller, players = self._make_synced_pair(mock_mass)
3224 await controller.cmd_volume_mute("member", True)
3225 # let the group volume of the leader account for the muted member
3226 players["leader"].update_state(signal_event=False)
3227
3228 await controller.cmd_group_volume("leader", 25)
3229
3230 for player in players.values():
3231 player.update_state()
3232 member_state = players["member"].state
3233 assert member_state.volume_muted is True
3234 assert member_state.volume_level == 0
3235 # the player that is not muted follows the group volume as usual
3236 assert players["leader"].state.volume_level == 25
3237 # unmuting brings the member back at the volume it had before it was muted
3238 await controller.cmd_volume_mute("member", False)
3239 players["member"].update_state()
3240 assert players["member"].state.volume_level == 50
3241
3242 async def test_unmute_restores_the_volume_from_before_the_mute(
3243 self, mock_mass: MagicMock
3244 ) -> None:
3245 """A group volume change while muted may not alter the volume to restore."""
3246 controller, players = self._make_synced_pair(mock_mass)
3247 await controller.cmd_group_volume_mute("leader", True)
3248 await controller.cmd_group_volume("leader", 30)
3249
3250 await controller.cmd_group_volume_mute("leader", False)
3251
3252 for player in players.values():
3253 player.update_state()
3254 assert player.state.volume_muted is False
3255 assert player.state.volume_level == 50
3256
3257 async def test_group_volume_keeps_a_mixed_pair_muted(self, mock_mass: MagicMock) -> None:
3258 """Members with a different mute control stay muted alike on a group volume change."""
3259 controller, players = self._make_synced_pair(
3260 mock_mass, member_mute_control=PLAYER_CONTROL_NATIVE
3261 )
3262 mute = AsyncMock(
3263 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3264 )
3265 players["member"].volume_mute = mute # type: ignore[method-assign]
3266 await controller.cmd_group_volume_mute("leader", True)
3267
3268 await controller.cmd_group_volume("leader", 30)
3269
3270 mute.assert_awaited_once_with(True)
3271 for player in players.values():
3272 player.update_state()
3273 assert player.state.volume_muted is True
3274
3275
3276class TestMuteControlGuard:
3277 """Muting is gated on the mute control, independently of the volume control."""
3278
3279 def _make_player(
3280 self,
3281 mock_mass: MagicMock,
3282 mute_control: str,
3283 volume_control: str,
3284 controls: dict[str, PlayerControl] | None = None,
3285 features: set[PlayerFeature] | None = None,
3286 ) -> tuple[PlayerController, MockPlayer]:
3287 """Build a controller with a single player using the given control config."""
3288 mock_mass.config.get_raw_player_config_value = MagicMock(
3289 side_effect=_player_config_stub(
3290 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
3291 )
3292 )
3293 controller = PlayerController(mock_mass)
3294 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3295 player = MockPlayer(provider, "player_1", "Player 1")
3296 if features is not None:
3297 player._attr_supported_features = features
3298 controller._players = {"player_1": player}
3299 controller._controls = controls or {}
3300 mock_mass.players = controller
3301 mock_mass.player_queues.get = MagicMock(return_value=None)
3302 player.set_initialized()
3303 player.update_state(signal_event=False)
3304 return controller, player
3305
3306 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3307 """A player without volume control still mutes through an external PlayerControl."""
3308 mute_set = AsyncMock()
3309 control = PlayerControl(
3310 id="ext_mute",
3311 provider="test",
3312 name="External Mute",
3313 supports_mute=True,
3314 mute_set=mute_set,
3315 )
3316 controller, player = self._make_player(
3317 mock_mass,
3318 mute_control="ext_mute",
3319 volume_control=PLAYER_CONTROL_NONE,
3320 controls={"ext_mute": control},
3321 )
3322 assert player.mute_control == "ext_mute"
3323
3324 await controller.cmd_volume_mute("player_1", True)
3325 mute_set.assert_awaited_once_with(True)
3326
3327 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
3328 """A player without volume control still mutes natively."""
3329 controller, player = self._make_player(
3330 mock_mass,
3331 mute_control=PLAYER_CONTROL_NATIVE,
3332 volume_control=PLAYER_CONTROL_NONE,
3333 # native mute is only honored while the player advertises the feature
3334 features={PlayerFeature.VOLUME_MUTE},
3335 )
3336 volume_mute = AsyncMock()
3337 player.volume_mute = volume_mute # type: ignore[method-assign]
3338
3339 await controller.cmd_volume_mute("player_1", True)
3340 volume_mute.assert_awaited_once_with(True)
3341
3342 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
3343 """A player with volume control but no mute control rejects the command."""
3344 controller, player = self._make_player(
3345 mock_mass,
3346 mute_control=PLAYER_CONTROL_NONE,
3347 volume_control=PLAYER_CONTROL_NATIVE,
3348 )
3349 volume_mute = AsyncMock()
3350 player.volume_mute = volume_mute # type: ignore[method-assign]
3351
3352 with pytest.raises(UnsupportedFeaturedException):
3353 await controller.cmd_volume_mute("player_1", True)
3354 volume_mute.assert_not_awaited()
3355
3356 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
3357 """Fake mute needs a volume control to drive, so it rejects the command outright."""
3358 controller, player = self._make_player(
3359 mock_mass,
3360 mute_control=PLAYER_CONTROL_FAKE,
3361 volume_control=PLAYER_CONTROL_NONE,
3362 )
3363 player._attr_volume_level = 40
3364
3365 with pytest.raises(UnsupportedFeaturedException):
3366 await controller.cmd_volume_mute("player_1", True)
3367 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
3368 assert ATTR_FAKE_MUTE not in player.extra_data
3369
3370 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
3371 """A mute control that disappeared after being resolved is reported, not ignored."""
3372 control = PlayerControl(
3373 id="ext_mute",
3374 provider="test",
3375 name="External Mute",
3376 supports_mute=True,
3377 mute_set=AsyncMock(),
3378 )
3379 controller, player = self._make_player(
3380 mock_mass,
3381 mute_control="ext_mute",
3382 volume_control=PLAYER_CONTROL_NONE,
3383 controls={"ext_mute": control},
3384 )
3385 # the resolved control is cached on the player, so removing it here leaves
3386 # the player pointing at a control that no longer exists
3387 assert player.mute_control == "ext_mute"
3388 controller._controls = {}
3389
3390 with pytest.raises(UnsupportedFeaturedException):
3391 await controller.cmd_volume_mute("player_1", True)
3392
3393 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
3394 """Unmuting clears a mute lock left behind by a since-removed mute control."""
3395 controller, player = self._make_player(
3396 mock_mass,
3397 mute_control=PLAYER_CONTROL_NONE,
3398 volume_control=PLAYER_CONTROL_NATIVE,
3399 )
3400 player.extra_data[ATTR_MUTE_LOCK] = True
3401
3402 with pytest.raises(UnsupportedFeaturedException):
3403 await controller.cmd_volume_mute("player_1", False)
3404 assert ATTR_MUTE_LOCK not in player.extra_data
3405
3406 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
3407 """A grouped player whose mute command failed is not left holding a mute lock."""
3408 control = PlayerControl(
3409 id="ext_mute",
3410 provider="test",
3411 name="External Mute",
3412 supports_mute=False,
3413 )
3414 controller, player = self._make_player(
3415 mock_mass,
3416 mute_control="ext_mute",
3417 volume_control=PLAYER_CONTROL_NONE,
3418 controls={"ext_mute": control},
3419 )
3420 player.state.synced_to = "leader"
3421
3422 with pytest.raises(UnsupportedFeaturedException):
3423 await controller.cmd_volume_mute("player_1", True)
3424 assert ATTR_MUTE_LOCK not in player.extra_data
3425
3426 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
3427 """A failed mute leaves the lock of an earlier successful mute in place."""
3428 control = PlayerControl(
3429 id="ext_mute",
3430 provider="test",
3431 name="External Mute",
3432 supports_mute=False,
3433 )
3434 controller, player = self._make_player(
3435 mock_mass,
3436 mute_control="ext_mute",
3437 volume_control=PLAYER_CONTROL_NONE,
3438 controls={"ext_mute": control},
3439 )
3440 player.state.synced_to = "leader"
3441 player.extra_data[ATTR_MUTE_LOCK] = True
3442
3443 with pytest.raises(UnsupportedFeaturedException):
3444 await controller.cmd_volume_mute("player_1", True)
3445 assert player.extra_data[ATTR_MUTE_LOCK] is True
3446
3447
3448class TestGroupMuteMemberFilter:
3449 """Group mute skips members that have no mute control of their own."""
3450
3451 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
3452 """A member without a mute control must not fail the whole group command."""
3453 controller = PlayerController(mock_mass)
3454 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3455 leader = MockPlayer(provider, "leader", "Leader")
3456 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3457 leader._attr_group_members = ["leader", "member"]
3458 member = MockPlayer(provider, "member", "Member")
3459 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3460 controller._players = {"leader": leader, "member": member}
3461 mock_mass.players = controller
3462 mock_mass.player_queues.get = MagicMock(return_value=None)
3463 for player in (leader, member):
3464 player.set_initialized()
3465 player.update_state(signal_event=False)
3466 leader_mute = AsyncMock()
3467 leader.volume_mute = leader_mute # type: ignore[method-assign]
3468
3469 await controller.cmd_group_volume_mute("leader", True)
3470 leader_mute.assert_awaited_once_with(True)
3471
3472
3473class TestGroupPlayerMuteRedirect:
3474 """A mute command on a group player is handled at group level."""
3475
3476 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
3477 """Build a controller with a group player holding a single mute capable member."""
3478 controller = PlayerController(mock_mass)
3479 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3480 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
3481 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3482 group._attr_group_members = ["member"]
3483 member = MockPlayer(provider, "member", "Member")
3484 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
3485 controller._players = {"group": group, "member": member}
3486 mock_mass.players = controller
3487 mock_mass.player_queues.get = MagicMock(return_value=None)
3488 for player in (group, member):
3489 player.set_initialized()
3490 player.update_state(signal_event=False)
3491 return controller, group, member
3492
3493 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
3494 """A group player has no mute of its own, so the members must be muted instead."""
3495 controller, _group, member = self._setup(mock_mass)
3496 member_mute = AsyncMock()
3497 member.volume_mute = member_mute # type: ignore[method-assign]
3498
3499 await controller.cmd_volume_mute("group", True)
3500
3501 member_mute.assert_awaited_once_with(True)
3502
3503 async def test_mute_on_group_player_without_own_mute_control(
3504 self, mock_mass: MagicMock
3505 ) -> None:
3506 """A group that has no mute control of its own must still mute its members."""
3507 controller, group, member = self._setup(mock_mass)
3508 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
3509 group._cache.clear()
3510 group.update_state(signal_event=False)
3511 assert group.mute_control == PLAYER_CONTROL_NONE
3512 member_mute = AsyncMock()
3513 member.volume_mute = member_mute # type: ignore[method-assign]
3514
3515 await controller.cmd_volume_mute("group", True)
3516
3517 member_mute.assert_awaited_once_with(True)
3518
3519 async def test_mute_on_group_player_without_mute_capable_members(
3520 self, mock_mass: MagicMock
3521 ) -> None:
3522 """A group whose members cannot mute must not raise, just like group mute itself."""
3523 controller, _group, member = self._setup(mock_mass)
3524 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
3525 member._cache.clear()
3526 member.update_state(signal_event=False)
3527
3528 await controller.cmd_volume_mute("group", True)
3529
3530
3531class TestGroupMuteOnNonGroupPlayer:
3532 """A group mute command works on any player, just like the group volume command."""
3533
3534 def _setup(
3535 self, mock_mass: MagicMock, *members: str
3536 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3537 """Build a controller with a mute capable leader synced to the given members."""
3538 controller = PlayerController(mock_mass)
3539 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3540 players: dict[str, MockPlayer] = {}
3541 for player_id in ("leader", *members):
3542 player = MockPlayer(provider, player_id, player_id.title())
3543 player._attr_supported_features = {
3544 PlayerFeature.VOLUME_SET,
3545 PlayerFeature.VOLUME_MUTE,
3546 }
3547 player._attr_volume_level = 50
3548 players[player_id] = player
3549 if members:
3550 # the leader is not listed as its own member here, so the tests also cover
3551 # that a sync leader is injected into its own final group_members
3552 players["leader"]._attr_group_members = list(members)
3553 controller._players = dict(players)
3554 mock_mass.players = controller
3555 mock_mass.player_queues.get = MagicMock(return_value=None)
3556 for player in players.values():
3557 player.set_initialized()
3558 player._cache.clear()
3559 player.update_state(signal_event=False)
3560 return controller, players
3561
3562 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
3563 """Replace the native mute command of every given player with a mock."""
3564 mutes: dict[str, AsyncMock] = {}
3565 for player_id, player in players.items():
3566 mutes[player_id] = AsyncMock()
3567 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
3568 return mutes
3569
3570 async def test_group_mute_on_synced_member_redirects_to_leader(
3571 self, mock_mass: MagicMock
3572 ) -> None:
3573 """A member of a sync group mutes the whole group through its sync leader."""
3574 controller, players = self._setup(mock_mass, "member")
3575 assert players["member"].state.synced_to == "leader"
3576 mutes = self._stub_mutes(players)
3577
3578 await controller.cmd_group_volume_mute("member", True)
3579
3580 mutes["leader"].assert_awaited_once_with(True)
3581 mutes["member"].assert_awaited_once_with(True)
3582 assert ATTR_MUTE_LOCK in players["member"].extra_data
3583
3584 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
3585 self, mock_mass: MagicMock
3586 ) -> None:
3587 """A sync leader is part of its own member list, so it must be muted only once."""
3588 controller, players = self._setup(mock_mass, "member")
3589 mutes = self._stub_mutes(players)
3590
3591 await controller.cmd_group_volume_mute("leader", True)
3592
3593 mutes["leader"].assert_awaited_once_with(True)
3594 mutes["member"].assert_awaited_once_with(True)
3595
3596 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
3597 """A player that is not grouped at all is muted as a normal player."""
3598 controller, players = self._setup(mock_mass)
3599 mutes = self._stub_mutes(players)
3600
3601 await controller.cmd_group_volume_mute("leader", True)
3602
3603 mutes["leader"].assert_awaited_once_with(True)
3604
3605 async def test_group_mute_on_plain_player_without_mute_control(
3606 self, mock_mass: MagicMock
3607 ) -> None:
3608 """A plain player that cannot mute reports that, just like a normal mute command."""
3609 controller, players = self._setup(mock_mass)
3610 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
3611 players["leader"]._cache.clear()
3612 players["leader"].update_state(signal_event=False)
3613
3614 with pytest.raises(UnsupportedFeaturedException):
3615 await controller.cmd_group_volume_mute("leader", True)
3616
3617 async def test_group_unmute_on_synced_member_redirects_to_leader(
3618 self, mock_mass: MagicMock
3619 ) -> None:
3620 """Unmuting through a member clears the mute (and mute lock) of every group member."""
3621 controller, players = self._setup(mock_mass, "member")
3622 mutes = self._stub_mutes(players)
3623 players["member"].extra_data[ATTR_MUTE_LOCK] = True
3624
3625 await controller.cmd_group_volume_mute("member", False)
3626
3627 mutes["leader"].assert_awaited_once_with(False)
3628 mutes["member"].assert_awaited_once_with(False)
3629 assert ATTR_MUTE_LOCK not in players["member"].extra_data
3630
3631 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
3632 """A sync leader is as much part of the group as its members, so it is locked too."""
3633 controller, players = self._setup(mock_mass, "member")
3634 self._stub_mutes(players)
3635
3636 await controller.cmd_group_volume_mute("leader", True)
3637
3638 assert ATTR_MUTE_LOCK in players["leader"].extra_data
3639 assert ATTR_MUTE_LOCK in players["member"].extra_data
3640
3641 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
3642 """A group volume change may not half-unmute a muted pair of directly synced players."""
3643 controller, players = self._setup(mock_mass, "member")
3644 self._stub_mutes(players)
3645 await controller.cmd_group_volume_mute("leader", True)
3646 # the mock players do not act on the mute command, so reflect it in their state
3647 for player in players.values():
3648 player._attr_volume_muted = True
3649 player.update_state(signal_event=False)
3650 player.volume_set = AsyncMock() # type: ignore[method-assign]
3651 # re-stub so only the mute commands of the group volume change are counted
3652 mutes = self._stub_mutes(players)
3653
3654 await controller.cmd_group_volume("leader", 30)
3655
3656 for mute in mutes.values():
3657 mute.assert_not_awaited()
3658
3659
3660class TestMuteLockAfterUngroup:
3661 """
3662 Mute persistence across a volume-set command.
3663
3664 A native mute is never lifted by a volume command, grouped or not. A fake mute
3665 lock is honored only while the player it belongs to is still grouped.
3666 """
3667
3668 def _make_synced_pair(
3669 self, mock_mass: MagicMock, member_mute_control: str
3670 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3671 """
3672 Build a leader with one synced member.
3673
3674 :param member_mute_control: Mute control to configure on both players.
3675 """
3676 mock_mass.config.get_raw_player_config_value = MagicMock(
3677 side_effect=_player_config_stub({CONF_MUTE_CONTROL: member_mute_control})
3678 )
3679 controller = PlayerController(mock_mass)
3680 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3681 players: dict[str, MockPlayer] = {}
3682 for player_id in ("leader", "member"):
3683 player = MockPlayer(provider, player_id, player_id.title())
3684 player._attr_supported_features = {
3685 PlayerFeature.VOLUME_SET,
3686 PlayerFeature.VOLUME_MUTE,
3687 }
3688 player._attr_volume_level = 50
3689 player.volume_set = AsyncMock( # type: ignore[method-assign]
3690 side_effect=lambda volume, _player=player: setattr(
3691 _player, "_attr_volume_level", volume
3692 )
3693 )
3694 players[player_id] = player
3695 players["leader"]._attr_group_members = ["member"]
3696 controller._players = dict(players)
3697 mock_mass.players = controller
3698 mock_mass.player_queues.get = MagicMock(return_value=None)
3699 for player in players.values():
3700 player.set_initialized()
3701 player._cache.clear()
3702 player.update_state(signal_event=False)
3703 return controller, players
3704
3705 def _dissolve_group(self, players: dict[str, MockPlayer]) -> None:
3706 """Drop the sync group, the way a provider side topology change does."""
3707 players["leader"]._attr_group_members = []
3708 for player in players.values():
3709 player.refresh_state(signal_event=False)
3710
3711 async def test_fake_muted_player_follows_volume_again(self, mock_mass: MagicMock) -> None:
3712 """A fake muted player is no longer forced silent once its group is gone."""
3713 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3714 await controller.cmd_volume_mute("member", True)
3715 self._dissolve_group(players)
3716
3717 await controller.cmd_volume_set("member", 70)
3718
3719 players["member"].update_state()
3720 assert players["member"].state.volume_level == 70
3721 assert players["member"].state.volume_muted is False
3722
3723 async def test_natively_muted_player_keeps_its_mute_after_ungroup(
3724 self, mock_mass: MagicMock
3725 ) -> None:
3726 """A natively muted player keeps its mute on a volume change, group gone or not."""
3727 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_NATIVE)
3728 mute = AsyncMock(
3729 side_effect=lambda muted: setattr(players["member"], "_attr_volume_muted", muted)
3730 )
3731 players["member"].volume_mute = mute # type: ignore[method-assign]
3732 await controller.cmd_volume_mute("member", True)
3733 self._dissolve_group(players)
3734
3735 await controller.cmd_volume_set("member", 70)
3736
3737 mute.assert_awaited_once_with(True)
3738 players["member"].update_state()
3739 assert players["member"].state.volume_level == 70
3740 assert players["member"].state.volume_muted is True
3741
3742 async def test_still_grouped_player_keeps_its_lock(self, mock_mass: MagicMock) -> None:
3743 """A muted player that is still grouped stays silent on a volume change."""
3744 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3745 await controller.cmd_volume_mute("member", True)
3746
3747 await controller.cmd_volume_set("member", 70)
3748
3749 players["member"].update_state()
3750 assert players["member"].state.volume_level == 0
3751 assert players["member"].state.volume_muted is True
3752
3753 async def test_protocol_player_follows_the_lock_of_its_parent(
3754 self, mock_mass: MagicMock
3755 ) -> None:
3756 """A protocol player inherits the lock of the parent it renders for, group and all."""
3757 controller, players = self._make_synced_pair(mock_mass, PLAYER_CONTROL_FAKE)
3758 member = players["member"]
3759 protocol_player = MockPlayer(
3760 MockProvider("sendspin", instance_id="sendspin", mass=mock_mass),
3761 "proto_member",
3762 "Member Protocol",
3763 player_type=PlayerType.PROTOCOL,
3764 )
3765 protocol_player._attr_supported_features = {
3766 PlayerFeature.VOLUME_SET,
3767 PlayerFeature.VOLUME_MUTE,
3768 }
3769 protocol_player._attr_volume_level = 50
3770 protocol_player.volume_set = AsyncMock( # type: ignore[method-assign]
3771 side_effect=lambda volume: setattr(protocol_player, "_attr_volume_level", volume)
3772 )
3773 protocol_player.set_protocol_parent_id("member")
3774 controller._players["proto_member"] = protocol_player
3775 member.set_linked_output_protocols(
3776 [
3777 LinkedOutputProtocol(
3778 output_protocol_id="proto_member",
3779 protocol_domain="sendspin",
3780 priority=40,
3781 )
3782 ]
3783 )
3784 protocol_player.set_initialized()
3785 protocol_player.update_state(signal_event=False)
3786 member.refresh_state(signal_event=False)
3787
3788 # the lock is earned by the parent while it is still grouped. The internal
3789 # handler is used to fake-mute the protocol player itself, bypassing the
3790 # public command's auto-resolve to its parent, so the fake-mute flag ends
3791 # up on the protocol player and the fake-mute volume path applies to it
3792 await controller.cmd_volume_mute("member", True)
3793 await controller._handle_cmd_volume_mute(protocol_player, PLAYER_CONTROL_FAKE, True)
3794
3795 # while the parent holds the lock, a volume command for the protocol player
3796 # is forced to 0 (stays silent) instead of releasing its fake mute
3797 await controller._handle_cmd_volume_set("proto_member", 70)
3798 protocol_player.update_state()
3799 assert protocol_player.state.volume_level == 0
3800
3801 self._dissolve_group(players)
3802 await controller._handle_cmd_volume_set("proto_member", 70)
3803
3804 protocol_player.update_state()
3805 assert protocol_player.state.volume_level == 70
3806
3807
3808class TestCurrentMediaTimeUpdates:
3809 """Playback-position anchor semantics of timing-only state updates."""
3810
3811 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
3812 """Build a controller with a single playing player with a known position anchor."""
3813 controller = PlayerController(mock_mass)
3814 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3815 player = MockPlayer(provider, "player_1", "Player 1")
3816 controller._players = {"player_1": player}
3817 mock_mass.players = controller
3818 # no queue registered: current_media resolves from the player's native media
3819 mock_mass.player_queues.get = MagicMock(return_value=None)
3820 player.set_initialized()
3821 now = time.time()
3822 player._attr_playback_state = PlaybackState.PLAYING
3823 player._attr_elapsed_time = 17
3824 player._attr_elapsed_time_last_updated = now
3825 player.set_current_media(uri="http://test/stream", title="Test")
3826 assert player._attr_current_media is not None
3827 player._attr_current_media.elapsed_time = 17
3828 player._attr_current_media.elapsed_time_last_updated = now
3829 player.update_state(signal_event=False)
3830 # isolate from the unrelated state-forwarding machinery
3831 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
3832 mock_mass.signal_event.reset_mock()
3833 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
3834 return controller, player
3835
3836 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
3837 """Return whether a PLAYER_UPDATED event was signalled."""
3838 return any(
3839 call.args and call.args[0] == EventType.PLAYER_UPDATED
3840 for call in mock_mass.signal_event.call_args_list
3841 )
3842
3843 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
3844 """A regular playback tick (position and anchor advance together) emits nothing."""
3845 _controller, player = self._make_player(mock_mass)
3846 assert player._attr_current_media is not None
3847 assert player._attr_elapsed_time_last_updated is not None
3848
3849 player._attr_elapsed_time = 18
3850 player._attr_elapsed_time_last_updated += 1
3851 player._attr_current_media.elapsed_time = 18
3852 assert player._attr_current_media.elapsed_time_last_updated is not None
3853 player._attr_current_media.elapsed_time_last_updated += 1
3854 player.update_state()
3855
3856 assert not self._player_updated_signalled(mock_mass)
3857 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3858 # the previous anchor was preserved: steady playback changes nothing
3859 assert player.state.elapsed_time == 17
3860
3861 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
3862 """An anchor-only change (no significant corrected position change) emits nothing."""
3863 _controller, player = self._make_player(mock_mass)
3864 assert player._attr_current_media is not None
3865 assert player._attr_elapsed_time_last_updated is not None
3866
3867 player._attr_elapsed_time_last_updated += 0.5
3868 assert player._attr_current_media.elapsed_time_last_updated is not None
3869 player._attr_current_media.elapsed_time_last_updated += 0.5
3870 player.update_state()
3871
3872 assert not self._player_updated_signalled(mock_mass)
3873 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
3874
3875 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
3876 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
3877 _controller, player = self._make_player(mock_mass)
3878 assert player._attr_current_media is not None
3879
3880 player._attr_current_media.elapsed_time = 61
3881 player._attr_current_media.elapsed_time_last_updated = time.time()
3882 player.update_state()
3883
3884 assert self._player_updated_signalled(mock_mass)
3885 # the adopted anchor is visible to consumers
3886 assert player.state.current_media is not None
3887 assert player.state.current_media.elapsed_time == 61
3888
3889 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
3890 """A player-level corrected-position jump re-bases the queue timing."""
3891 controller, player = self._make_player(mock_mass)
3892
3893 player._attr_elapsed_time = 61
3894 player._attr_elapsed_time_last_updated = time.time()
3895 player.update_state()
3896
3897 # the queue is corrected and a follow-up player update is scheduled
3898 # (which re-anchors current_media onto the corrected queue time),
3899 # but no full player update is emitted for the jump itself
3900 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3901 assert not self._player_updated_signalled(mock_mass)
3902 cast("MagicMock", controller._forward_state_update).assert_called_once()
3903 assert player.state.elapsed_time == 61
3904
3905 def test_simultaneous_player_and_media_jump_emits_immediately(
3906 self, mock_mass: MagicMock
3907 ) -> None:
3908 """A jump reaching player and current_media in one pass corrects the queue and emits."""
3909 _controller, player = self._make_player(mock_mass)
3910 assert player._attr_current_media is not None
3911 now = time.time()
3912
3913 player._attr_elapsed_time = 61
3914 player._attr_elapsed_time_last_updated = now
3915 player._attr_current_media.elapsed_time = 61
3916 player._attr_current_media.elapsed_time_last_updated = now
3917 player.update_state()
3918
3919 # the queue is re-based AND the full update is emitted right away
3920 # (current_media already holds the fresh position in the same pass)
3921 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
3922 assert self._player_updated_signalled(mock_mass)
3923
3924
3925class TestPlayAnnouncementCleanup:
3926 """Test announcement data cleanup after play_announcement."""
3927
3928 def _make_player(
3929 self, mock_mass: MagicMock, announcements: dict[str, object]
3930 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
3931 """Create a controller and a player with native announcement support."""
3932 controller = PlayerController(mock_mass)
3933 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
3934 player = MockPlayer(provider, "player_1", "Player 1")
3935 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
3936 player._cache.clear()
3937 controller._players = {"player_1": player}
3938 mock_mass.players = controller
3939 render = MagicMock()
3940 render.wait_ready = AsyncMock(return_value=True)
3941 render.wait_finished = AsyncMock(return_value=3.0)
3942
3943 # mimic the real renderer: it owns which announcement each player is playing
3944 def _register(player_id: str, announce_data: object) -> MagicMock:
3945 announcements[player_id] = announce_data
3946 return render
3947
3948 async def _unregister(player_id: str, _render: object) -> None:
3949 announcements.pop(player_id, None)
3950
3951 renderer = mock_mass.streams.announcement_renderer
3952 renderer.register = MagicMock(side_effect=_register)
3953 renderer.unregister = AsyncMock(side_effect=_unregister)
3954 mock_mass.streams.get_announcement_url = MagicMock(
3955 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
3956 )
3957 player.update_state(signal_event=False)
3958 return controller, player, render
3959
3960 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
3961 """The registered announcement data is released once playback finished."""
3962 announcements: dict[str, object] = {}
3963 controller, player, _render = self._make_player(mock_mass, announcements)
3964
3965 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
3966 # entry must exist while the announcement is being played/served
3967 assert "player_1" in announcements
3968
3969 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
3970
3971 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3972
3973 player.play_announcement.assert_awaited_once()
3974 assert announcements == {}
3975 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3976
3977 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
3978 """The registered announcement data is released even when playback fails."""
3979 announcements: dict[str, object] = {}
3980 controller, player, _render = self._make_player(mock_mass, announcements)
3981 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
3982
3983 with pytest.raises(PlayerCommandFailed):
3984 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3985
3986 assert announcements == {}
3987 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
3988
3989 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
3990 """A native implementation is handed the url as soon as there is audio to serve."""
3991 announcements: dict[str, object] = {}
3992 controller, player, render = self._make_player(mock_mass, announcements)
3993 player.play_announcement = AsyncMock() # type: ignore[method-assign]
3994
3995 await controller.play_announcement("player_1", "http://test/announcement.mp3")
3996
3997 player.play_announcement.assert_awaited_once()
3998 # waiting for the whole clip here would delay the player for a slow source;
3999 # the length is resolved downstream while it plays
4000 render.wait_ready.assert_awaited_once()
4001 render.wait_finished.assert_not_awaited()
4002
4003
4004class _AnnounceSetup(NamedTuple):
4005 """A player announcing through a linked protocol output, with the calls it makes mocked."""
4006
4007 controller: PlayerController
4008 parent: MockPlayer
4009 output: MockPlayer
4010 volume_set: AsyncMock
4011 play_announcement: AsyncMock
4012
4013
4014@pytest.mark.usefixtures("running_background_tasks")
4015class TestNativeAnnouncementVolumeRouting:
4016 """The announcement volume is applied through the control that owns it."""
4017
4018 ANNOUNCE_VOLUME = 45
4019
4020 def _make_setup(self, mock_mass: MagicMock, volume_control: str) -> _AnnounceSetup:
4021 """
4022 Create a player announcing through a linked protocol output.
4023
4024 The parent holds a sibling interface and a bridge riding on the announcing
4025 output, so any of them can be named as its volume control.
4026
4027 :param volume_control: Value of the parent's volume control config entry.
4028 """
4029 controller = PlayerController(mock_mass)
4030 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4031 parent = MockPlayer(provider, "parent", "Parent")
4032 output = MockPlayer(provider, "output", "Output")
4033 output._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4034 output._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4035 sibling = MockPlayer(provider, "sibling", "Sibling")
4036 sibling._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4037 sibling._attr_volume_level = 20
4038 bridge = MockPlayer(provider, "bridge", "Bridge")
4039 bridge._attr_supported_features.add(PlayerFeature.VOLUME_SET)
4040 bridge._attr_underlying_player_id = "output"
4041 bridge._attr_volume_level = 20
4042 controller._players = {
4043 "parent": parent,
4044 "output": output,
4045 "sibling": sibling,
4046 "bridge": bridge,
4047 }
4048 # an external control (e.g. a Home Assistant volume entity) is not a player
4049 controller._controls = {
4050 "ha_volume": PlayerControl(
4051 id="ha_volume",
4052 provider="hass",
4053 name="Amplifier volume",
4054 supports_volume=True,
4055 volume_level=20,
4056 )
4057 }
4058 mock_mass.players = controller
4059 mock_mass.config.get_raw_player_config_value = MagicMock(
4060 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: volume_control})
4061 )
4062 for player in controller._players.values():
4063 player._cache.clear()
4064 player.update_state(signal_event=False)
4065 play_announcement = AsyncMock()
4066 volume_set = AsyncMock()
4067 output.play_announcement = play_announcement # type: ignore[method-assign]
4068 controller._handle_cmd_volume_set = volume_set # type: ignore[method-assign]
4069 return _AnnounceSetup(controller, parent, output, volume_set, play_announcement)
4070
4071 async def _announce(self, setup: _AnnounceSetup) -> None:
4072 """Play an announcement on the parent, rendered by the linked output."""
4073 await setup.controller._play_native_announcement(
4074 setup.parent, setup.output, _announcement(), self.ANNOUNCE_VOLUME
4075 )
4076
4077 async def test_external_control_applies_the_announcement_volume(
4078 self, mock_mass: MagicMock
4079 ) -> None:
4080 """A sibling interface owning the volume gets the announcement volume, not the output."""
4081 setup = self._make_setup(mock_mass, "sibling")
4082
4083 await self._announce(setup)
4084
4085 # the output cannot attenuate what another control is already attenuating,
4086 # so the level goes through that control and the output announces at unity
4087 assert setup.volume_set.await_args_list == [
4088 call("parent", self.ANNOUNCE_VOLUME),
4089 call("parent", 20),
4090 ]
4091 setup.play_announcement.assert_awaited_once_with(ANY, None)
4092
4093 async def test_player_control_applies_the_announcement_volume(
4094 self, mock_mass: MagicMock
4095 ) -> None:
4096 """A player control owning the volume (e.g. an HA entity) gets the announcement volume."""
4097 setup = self._make_setup(mock_mass, "ha_volume")
4098
4099 await self._announce(setup)
4100
4101 assert setup.volume_set.await_args_list == [
4102 call("parent", self.ANNOUNCE_VOLUME),
4103 call("parent", 20),
4104 ]
4105 setup.play_announcement.assert_awaited_once_with(ANY, None)
4106
4107 async def test_external_control_volume_restored_when_the_announcement_fails(
4108 self, mock_mass: MagicMock
4109 ) -> None:
4110 """The temporary volume is restored even when the announcement itself fails."""
4111 setup = self._make_setup(mock_mass, "sibling")
4112 setup.play_announcement.side_effect = RuntimeError("boom")
4113
4114 with pytest.raises(RuntimeError):
4115 await self._announce(setup)
4116
4117 assert setup.volume_set.await_args_list[-1] == call("parent", 20)
4118
4119 async def test_announcing_output_keeps_the_announcement_volume(
4120 self, mock_mass: MagicMock
4121 ) -> None:
4122 """An output that owns the volume applies the announcement volume itself."""
4123 setup = self._make_setup(mock_mass, "output")
4124
4125 await self._announce(setup)
4126
4127 setup.volume_set.assert_not_awaited()
4128 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4129
4130 async def test_bridge_on_the_announcing_output_keeps_the_announcement_volume(
4131 self, mock_mass: MagicMock
4132 ) -> None:
4133 """A bridge riding on the announcing output forwards the volume to it."""
4134 setup = self._make_setup(mock_mass, "bridge")
4135
4136 await self._announce(setup)
4137
4138 setup.volume_set.assert_not_awaited()
4139 setup.play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4140
4141 async def test_native_volume_is_applied_through_the_parent(self, mock_mass: MagicMock) -> None:
4142 """
4143 A native volume lives on the parent, so the parent applies and restores it.
4144
4145 The rendering output has no way to reach a native parent volume, and its own
4146 idea of the level can be stale, so routing through the parent keeps both the
4147 announcement level and the restore on the control that actually knows it.
4148 """
4149 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4150 setup.parent._attr_volume_level = 20
4151 setup.parent._cache.clear()
4152 setup.parent.update_state(signal_event=False)
4153
4154 await self._announce(setup)
4155
4156 assert setup.volume_set.await_args_list == [
4157 call("parent", self.ANNOUNCE_VOLUME),
4158 call("parent", 20),
4159 ]
4160 setup.play_announcement.assert_awaited_once_with(ANY, None)
4161
4162 async def test_native_volume_on_its_own_output_is_kept_by_the_player(
4163 self, mock_mass: MagicMock
4164 ) -> None:
4165 """A player announcing on its own output can apply its native volume itself."""
4166 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NATIVE)
4167 play_announcement = AsyncMock()
4168 setup.parent.play_announcement = play_announcement # type: ignore[method-assign]
4169
4170 await setup.controller._play_native_announcement(
4171 setup.parent, setup.parent, _announcement(), self.ANNOUNCE_VOLUME
4172 )
4173
4174 setup.volume_set.assert_not_awaited()
4175 play_announcement.assert_awaited_once_with(ANY, self.ANNOUNCE_VOLUME)
4176
4177 async def test_without_volume_control_no_volume_is_applied(self, mock_mass: MagicMock) -> None:
4178 """Nothing in the signal path can set a volume, so the announcement plays as-is."""
4179 setup = self._make_setup(mock_mass, PLAYER_CONTROL_NONE)
4180
4181 await self._announce(setup)
4182
4183 setup.volume_set.assert_not_awaited()
4184 setup.play_announcement.assert_awaited_once_with(ANY, None)
4185
4186
4187class TestPlayAnnouncementMessage:
4188 """A spoken message is rendered by a TTS engine and then announced like any other audio."""
4189
4190 ANNOUNCE_MODULE = "music_assistant.controllers.players.announcements"
4191
4192 def _make_engine(self, path: str = "http://speech/spoken.mp3") -> MagicMock:
4193 """Create a TTS engine that renders every message to the given path."""
4194 engine = MagicMock()
4195 engine.uid = "tts_plugin/voice"
4196 engine.id = "voice"
4197 engine.provider.get_tts_message = AsyncMock(return_value=SimpleNamespace(path=path))
4198 return engine
4199
4200 def _make_player(
4201 self, mock_mass: MagicMock, announcements: dict[str, object]
4202 ) -> tuple[PlayerController, AsyncMock]:
4203 """Create a controller and a player with native announcement support."""
4204 controller, player, _render = TestPlayAnnouncementCleanup()._make_player(
4205 mock_mass, announcements
4206 )
4207 announce = AsyncMock()
4208 player.play_announcement = announce # type: ignore[method-assign]
4209 return controller, announce
4210
4211 async def test_message_is_spoken_by_the_configured_engine(self, mock_mass: MagicMock) -> None:
4212 """A message is rendered by the default engine and announced as the rendered audio."""
4213 announcements: dict[str, object] = {}
4214 controller, announce = self._make_player(mock_mass, announcements)
4215 engine = self._make_engine()
4216
4217 with patch(
4218 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4219 ):
4220 await controller.play_announcement("player_1", message="dinner is ready")
4221
4222 # no language is sent, so the engine speaks in the language it is configured for
4223 engine.provider.get_tts_message.assert_awaited_once_with(
4224 "dinner is ready", language=None, engine_id="voice", options=None
4225 )
4226 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4227 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4228 announce.assert_awaited_once()
4229
4230 async def test_an_explicit_language_reaches_the_engine(self, mock_mass: MagicMock) -> None:
4231 """A message can name the language to speak it in."""
4232 announcements: dict[str, object] = {}
4233 controller, _announce = self._make_player(mock_mass, announcements)
4234 engine = self._make_engine()
4235
4236 with patch(
4237 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4238 ):
4239 await controller.play_announcement(
4240 "player_1", message="het eten is klaar", language="nl-NL"
4241 )
4242
4243 engine.provider.get_tts_message.assert_awaited_once_with(
4244 "het eten is klaar", language="nl-NL", engine_id="voice", options=None
4245 )
4246
4247 async def test_a_rejected_language_is_retried_without_it(self, mock_mass: MagicMock) -> None:
4248 """An engine that rejects the language speaks the message in its default voice."""
4249 announcements: dict[str, object] = {}
4250 controller, announce = self._make_player(mock_mass, announcements)
4251 engine = self._make_engine()
4252 engine.provider.get_tts_message = AsyncMock(
4253 side_effect=[
4254 RuntimeError("unsupported language"),
4255 SimpleNamespace(path="http://speech/spoken.mp3"),
4256 ]
4257 )
4258
4259 with patch(
4260 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4261 ):
4262 await controller.play_announcement(
4263 "player_1", message="dinner is ready", language="en-US"
4264 )
4265
4266 first_call, second_call = engine.provider.get_tts_message.await_args_list
4267 assert first_call.kwargs["language"] == "en-US"
4268 assert second_call.kwargs["language"] is None
4269 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4270 assert registered["announcement_url"] == "http://speech/spoken.mp3"
4271 announce.assert_awaited_once()
4272
4273 @pytest.mark.parametrize(
4274 "error", [TimeoutError(), MusicAssistantError("engine did not respond within 30s")]
4275 )
4276 async def test_a_failure_that_is_not_a_language_rejection_is_not_retried(
4277 self, mock_mass: MagicMock, error: Exception
4278 ) -> None:
4279 """A timeout or a structured failure is no language rejection, so it is not retried."""
4280 announcements: dict[str, object] = {}
4281 controller, announce = self._make_player(mock_mass, announcements)
4282 engine = self._make_engine()
4283 engine.provider.get_tts_message = AsyncMock(side_effect=error)
4284
4285 with (
4286 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4287 pytest.raises(MusicAssistantError),
4288 ):
4289 await controller.play_announcement("player_1", message="dinner is ready")
4290
4291 engine.provider.get_tts_message.assert_awaited_once()
4292 announce.assert_not_awaited()
4293
4294 async def test_an_explicit_engine_is_used(self, mock_mass: MagicMock) -> None:
4295 """A message names the engine to speak it, overriding the configured default."""
4296 announcements: dict[str, object] = {}
4297 controller, _announce = self._make_player(mock_mass, announcements)
4298 engine = self._make_engine()
4299
4300 with (
4301 patch(
4302 f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=engine)
4303 ) as resolve,
4304 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock()) as select,
4305 ):
4306 await controller.play_announcement(
4307 "player_1", message="hello", tts_engine="tts_plugin/voice"
4308 )
4309
4310 resolve.assert_awaited_once_with(mock_mass, "tts_plugin/voice")
4311 select.assert_not_awaited()
4312
4313 async def test_pre_announce_follows_the_player_config(self, mock_mass: MagicMock) -> None:
4314 """A spoken message uses the player's pre-announce setting without sniffing the url."""
4315 announcements: dict[str, object] = {}
4316 controller, _announce = self._make_player(mock_mass, announcements)
4317 engine = self._make_engine()
4318 mock_mass.config.get_raw_player_config_value = MagicMock(
4319 side_effect=lambda _player_id, key, default=None: (
4320 True if key == CONF_ENTRY_TTS_PRE_ANNOUNCE.key else default
4321 )
4322 )
4323
4324 with patch(
4325 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4326 ):
4327 await controller.play_announcement("player_1", message="dinner is ready")
4328
4329 registered = mock_mass.streams.announcement_renderer.register.call_args.args[1]
4330 assert registered["pre_announce"] is True
4331
4332 async def test_the_engine_gets_the_shorter_announcement_timeout(
4333 self, mock_mass: MagicMock
4334 ) -> None:
4335 """The engine is capped well below the background default, it holds the player lock."""
4336 announcements: dict[str, object] = {}
4337 controller, _announce = self._make_player(mock_mass, announcements)
4338 engine = self._make_engine()
4339 query = AsyncMock(return_value=SimpleNamespace(path="http://speech/spoken.mp3"))
4340
4341 with (
4342 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4343 patch(f"{self.ANNOUNCE_MODULE}.query_tts_engine_with_language_fallback", query),
4344 ):
4345 await controller.play_announcement("player_1", message="hello")
4346
4347 assert query.call_args.kwargs["timeout"] == ANNOUNCEMENT_TTS_TIMEOUT
4348 assert ANNOUNCEMENT_TTS_TIMEOUT < TTS_QUERY_TIMEOUT_SECONDS
4349
4350 async def test_an_engine_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4351 """Naming an engine for a url announcement is rejected instead of silently ignored."""
4352 announcements: dict[str, object] = {}
4353 controller, _announce = self._make_player(mock_mass, announcements)
4354
4355 with pytest.raises(PlayerCommandFailed, match="only be used to speak a message"):
4356 await controller.play_announcement(
4357 "player_1", url="http://test/clip.mp3", tts_engine="tts_plugin/voice"
4358 )
4359
4360 async def test_a_language_without_a_message_is_rejected(self, mock_mass: MagicMock) -> None:
4361 """Naming a language for a url announcement is rejected instead of silently ignored."""
4362 announcements: dict[str, object] = {}
4363 controller, _announce = self._make_player(mock_mass, announcements)
4364
4365 with pytest.raises(PlayerCommandFailed, match="A language can only be used"):
4366 await controller.play_announcement(
4367 "player_1", url="http://test/clip.mp3", language="nl-NL"
4368 )
4369
4370 async def test_a_failing_engine_surfaces_its_error(self, mock_mass: MagicMock) -> None:
4371 """An engine that fails to speak the message fails the announcement."""
4372 announcements: dict[str, object] = {}
4373 controller, announce = self._make_player(mock_mass, announcements)
4374 engine = self._make_engine()
4375 engine.provider.get_tts_message = AsyncMock(side_effect=RuntimeError("engine down"))
4376
4377 with (
4378 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4379 pytest.raises(PlayerCommandFailed),
4380 ):
4381 await controller.play_announcement("player_1", message="hello")
4382
4383 announce.assert_not_awaited()
4384 mock_mass.streams.announcement_renderer.register.assert_not_called()
4385
4386 async def test_a_url_or_a_message_is_required(self, mock_mass: MagicMock) -> None:
4387 """An announcement with neither a url nor a message is rejected."""
4388 announcements: dict[str, object] = {}
4389 controller, _announce = self._make_player(mock_mass, announcements)
4390
4391 with pytest.raises(PlayerCommandFailed, match="Either a url or a message"):
4392 await controller.play_announcement("player_1")
4393
4394 async def test_a_url_and_a_message_are_mutually_exclusive(self, mock_mass: MagicMock) -> None:
4395 """An announcement carrying both a url and a message is rejected."""
4396 announcements: dict[str, object] = {}
4397 controller, _announce = self._make_player(mock_mass, announcements)
4398
4399 with pytest.raises(PlayerCommandFailed, match="not both"):
4400 await controller.play_announcement(
4401 "player_1", url="http://test/clip.mp3", message="hello"
4402 )
4403
4404 async def test_unknown_engine_is_rejected(self, mock_mass: MagicMock) -> None:
4405 """A message naming an engine that does not exist fails instead of using another."""
4406 announcements: dict[str, object] = {}
4407 controller, _announce = self._make_player(mock_mass, announcements)
4408
4409 with (
4410 patch(f"{self.ANNOUNCE_MODULE}.resolve_tts_engine", AsyncMock(return_value=None)),
4411 pytest.raises(PlayerCommandFailed, match="is not available"),
4412 ):
4413 await controller.play_announcement("player_1", message="hello", tts_engine="gone")
4414
4415 async def test_no_engine_available_is_rejected(self, mock_mass: MagicMock) -> None:
4416 """A message fails clearly when no TTS engine is set up at all."""
4417 announcements: dict[str, object] = {}
4418 controller, _announce = self._make_player(mock_mass, announcements)
4419
4420 with (
4421 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=None)),
4422 pytest.raises(PlayerCommandFailed, match="No text-to-speech engine"),
4423 ):
4424 await controller.play_announcement("player_1", message="hello")
4425
4426 async def test_audio_that_can_not_be_fetched_is_rejected(self, mock_mass: MagicMock) -> None:
4427 """An engine that only rendered to disk fails, since an announcement is fetched by url."""
4428 announcements: dict[str, object] = {}
4429 controller, _announce = self._make_player(mock_mass, announcements)
4430 engine = self._make_engine(path=str(ANNOUNCE_ALERT_FILE))
4431
4432 with (
4433 patch(f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)),
4434 pytest.raises(PlayerCommandFailed, match="rendered the message to a local file"),
4435 ):
4436 await controller.play_announcement("player_1", message="hello")
4437
4438 async def test_group_members_play_the_rendered_audio(self, mock_mass: MagicMock) -> None:
4439 """The message is spoken once and every group member announces the resulting audio."""
4440 announcements: dict[str, object] = {}
4441 use_real_create_task(mock_mass)
4442 controller, _announce = self._make_player(mock_mass, announcements)
4443 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4444 group = MockPlayer(provider, "group_1", "Group 1", player_type=PlayerType.GROUP)
4445 group._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4446 group._attr_group_members = ["player_1"]
4447 group._cache.clear()
4448 controller._players["group_1"] = group
4449 group.update_state(signal_event=False)
4450 engine = self._make_engine()
4451
4452 with patch(
4453 f"{self.ANNOUNCE_MODULE}.select_core_tts_engine", AsyncMock(return_value=engine)
4454 ):
4455 await controller.play_announcement("group_1", message="dinner is ready")
4456
4457 # rendered once for the group, then handed to the member as plain audio
4458 engine.provider.get_tts_message.assert_awaited_once()
4459 member_call = next(
4460 call_args
4461 for call_args in mock_mass.streams.announcement_renderer.register.call_args_list
4462 if call_args.args[0] == "player_1"
4463 )
4464 assert member_call.args[1]["announcement_url"] == "http://speech/spoken.mp3"
4465
4466
4467class TestNativeAnnouncementRouting:
4468 """Announcement routing respects the player's own support and its active output."""
4469
4470 def _make_player_with_linked_child(
4471 self,
4472 mock_mass: MagicMock,
4473 playback_state: PlaybackState,
4474 *,
4475 parent_supports_announce: bool = False,
4476 active_protocol: str | None = None,
4477 ) -> tuple[PlayerController, MockPlayer, MockPlayer, AsyncMock, AsyncMock]:
4478 """Create a controller, a player, its linked protocol child and the two path mocks."""
4479 controller = PlayerController(mock_mass)
4480 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4481 player = MockPlayer(provider, "player_1", "Player 1")
4482 player._attr_playback_state = playback_state
4483 if parent_supports_announce:
4484 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4485 proto_provider = MockProvider("airplay", mass=mock_mass)
4486 proto = MockPlayer(
4487 proto_provider, "proto_1", "AirPlay Child", player_type=PlayerType.PROTOCOL
4488 )
4489 proto._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4490 controller._players = {"player_1": player, "proto_1": proto}
4491 mock_mass.players = controller
4492 player.set_linked_output_protocols(
4493 [
4494 LinkedOutputProtocol(
4495 output_protocol_id="proto_1",
4496 protocol_domain="airplay",
4497 priority=40,
4498 )
4499 ]
4500 )
4501 if active_protocol is not None:
4502 player.set_active_output_protocol(active_protocol)
4503 render = MagicMock()
4504 render.wait_ready = AsyncMock(return_value=True)
4505 renderer = mock_mass.streams.announcement_renderer
4506 renderer.register = MagicMock(return_value=render)
4507 renderer.unregister = AsyncMock()
4508 mock_mass.streams.get_announcement_url = MagicMock(
4509 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
4510 )
4511 proto.update_state(signal_event=False)
4512 player.update_state(signal_event=False)
4513 native_path = AsyncMock()
4514 generic_path = AsyncMock()
4515 controller._play_native_announcement = native_path # type: ignore[method-assign]
4516 controller._play_announcement = generic_path # type: ignore[method-assign]
4517 return controller, player, proto, native_path, generic_path
4518
4519 async def test_playing_player_does_not_route_to_an_idle_linked_child(
4520 self, mock_mass: MagicMock
4521 ) -> None:
4522 """
4523 A player rendering through one output must not announce through another.
4524
4525 E.g. a WiiM playing natively with an idle linked AirPlay child: routing
4526 the announcement to the child would seize the device from the native
4527 output, with nothing restoring that playback afterwards.
4528 """
4529 controller, _player, _proto, native_path, generic_path = (
4530 self._make_player_with_linked_child(
4531 mock_mass, PlaybackState.PLAYING, active_protocol="native"
4532 )
4533 )
4534
4535 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4536
4537 native_path.assert_not_awaited()
4538 generic_path.assert_awaited_once()
4539
4540 async def test_idle_player_routes_to_the_linked_child(self, mock_mass: MagicMock) -> None:
4541 """An idle player announces natively through any capable linked protocol."""
4542 controller, _player, proto, native_path, _generic_path = (
4543 self._make_player_with_linked_child(mock_mass, PlaybackState.IDLE)
4544 )
4545
4546 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4547
4548 native_path.assert_awaited_once()
4549 assert native_path.call_args.args[1] is proto
4550
4551 async def test_active_protocol_child_beats_own_native_support(
4552 self, mock_mass: MagicMock
4553 ) -> None:
4554 """
4555 The output that is actively rendering wins over the player's own support.
4556
4557 E.g. a Sonos playing through its AirPlay child: the announcement rides
4558 the same audio path as the music (mixed into the live stream, in sync
4559 with the rest of a group) instead of a second mechanism firing beside
4560 the playback.
4561 """
4562 controller, _player, proto, native_path, _generic_path = (
4563 self._make_player_with_linked_child(
4564 mock_mass,
4565 PlaybackState.PLAYING,
4566 parent_supports_announce=True,
4567 active_protocol="proto_1",
4568 )
4569 )
4570
4571 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4572
4573 native_path.assert_awaited_once()
4574 assert native_path.call_args.args[1] is proto
4575
4576 async def test_own_native_support_wins_when_playing_natively(
4577 self, mock_mass: MagicMock
4578 ) -> None:
4579 """A player rendering through its own native output announces natively."""
4580 controller, player, _proto, native_path, _generic_path = (
4581 self._make_player_with_linked_child(
4582 mock_mass,
4583 PlaybackState.PLAYING,
4584 parent_supports_announce=True,
4585 active_protocol="native",
4586 )
4587 )
4588
4589 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4590
4591 native_path.assert_awaited_once()
4592 assert native_path.call_args.args[1] is player
4593
4594 async def test_idle_player_prefers_its_own_support_over_a_linked_child(
4595 self, mock_mass: MagicMock
4596 ) -> None:
4597 """Without active playback the player's own announcement support wins."""
4598 controller, player, _proto, native_path, _generic_path = (
4599 self._make_player_with_linked_child(
4600 mock_mass,
4601 PlaybackState.IDLE,
4602 parent_supports_announce=True,
4603 )
4604 )
4605
4606 await controller.play_announcement("player_1", "http://test/announcement.mp3")
4607
4608 native_path.assert_awaited_once()
4609 assert native_path.call_args.args[1] is player
4610
4611
4612@pytest.mark.usefixtures("running_background_tasks")
4613class TestPlayAnnouncementRestore:
4614 """Test the state restore of the default (fallback) announcement implementation."""
4615
4616 def _make_player(
4617 self, mock_mass: MagicMock, prev_media: PlayerMedia
4618 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4619 """Create a controller and a playing player, returning the patched resume handler."""
4620 controller = PlayerController(mock_mass)
4621 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4622 player = MockPlayer(provider, "player_1", "Player 1")
4623 player._attr_playback_state = PlaybackState.PLAYING
4624 player._attr_current_media = prev_media
4625 player._cache.clear()
4626 controller._players = {"player_1": player}
4627 mock_mass.players = controller
4628 mock_mass.player_queues.get = MagicMock(return_value=None)
4629 resume_mock = AsyncMock()
4630 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
4631 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
4632 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
4633 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
4634 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4635 player.set_initialized()
4636 player.update_state(signal_event=False)
4637 return controller, player, resume_mock
4638
4639 @staticmethod
4640 def _add_group(
4641 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
4642 ) -> MockPlayer:
4643 """Register a powered group player that holds the given player as its member."""
4644 group = MockPlayer(
4645 cast("MockProvider", player.provider),
4646 "group_1",
4647 "Group 1",
4648 player_type=PlayerType.GROUP,
4649 )
4650 group._attr_powered = True
4651 group._attr_group_members = [player.player_id]
4652 if supports_set_members:
4653 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4654 controller._players[group.player_id] = group
4655 group.set_initialized()
4656 group._cache.clear()
4657 group.update_state(signal_event=False)
4658 # the member has no own input change to trigger a recalculation of its
4659 # (group derived) state, so force it here - just like register() does
4660 player._cache.clear()
4661 player.update_state(force_update=True, signal_event=False)
4662 assert player.state.active_group == group.player_id
4663 return group
4664
4665 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
4666 """Content that was playing before the announcement is resumed afterwards."""
4667 controller, player, resume_mock = self._make_player(
4668 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4669 )
4670
4671 await controller._play_announcement(player, _announcement())
4672
4673 resume_mock.assert_awaited_once()
4674
4675 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
4676 """A player still busy with an earlier announcement has no playback to restore."""
4677 controller, player, resume_mock = self._make_player(
4678 mock_mass,
4679 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
4680 )
4681
4682 await controller._play_announcement(player, _announcement())
4683
4684 resume_mock.assert_not_awaited()
4685
4686 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4687 """A failing announcement never leaves the player at the raised volume."""
4688 controller, player, _ = self._make_player(
4689 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4690 )
4691 player._attr_volume_level = 20
4692 player._cache.clear()
4693 player.update_state(force_update=True, signal_event=False)
4694 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4695 volume_mock = AsyncMock()
4696 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4697 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4698 side_effect=PlayerCommandFailed("player went away")
4699 )
4700
4701 with pytest.raises(PlayerCommandFailed):
4702 await controller._play_announcement(player, _announcement())
4703
4704 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
4705
4706 async def test_zero_announcement_volume_is_applied_and_restored(
4707 self, mock_mass: MagicMock
4708 ) -> None:
4709 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
4710 controller, player, _ = self._make_player(
4711 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4712 )
4713 player._attr_volume_level = 20
4714 player._cache.clear()
4715 player.update_state(force_update=True, signal_event=False)
4716 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
4717 volume_mock = AsyncMock()
4718 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
4719
4720 await controller._play_announcement(player, _announcement())
4721
4722 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
4723
4724 async def test_playback_is_restored_when_duration_is_unknown(
4725 self, mock_mass: MagicMock
4726 ) -> None:
4727 """An announcement of unknown length still hands the player back to its content."""
4728 controller, player, resume_mock = self._make_player(
4729 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4730 )
4731 announcement = _announcement()
4732 announcement.duration = None
4733
4734 # an unknown length waits for the player to report it finished instead of failing
4735 await controller._play_announcement(player, announcement)
4736
4737 resume_mock.assert_awaited_once()
4738
4739 async def test_group_membership_is_restored_when_playback_fails(
4740 self, mock_mass: MagicMock
4741 ) -> None:
4742 """A failing announcement never leaves the player out of its group player."""
4743 controller, player, _ = self._make_player(
4744 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4745 )
4746 group = self._add_group(controller, player, supports_set_members=True)
4747 group.set_members = AsyncMock() # type: ignore[method-assign]
4748 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4749 side_effect=PlayerCommandFailed("player went away")
4750 )
4751
4752 with pytest.raises(PlayerCommandFailed):
4753 await controller._play_announcement(player, _announcement())
4754
4755 assert group.set_members.await_args_list == [
4756 call(player_ids_to_remove=["player_1"]),
4757 call(player_ids_to_add=["player_1"]),
4758 ]
4759
4760 async def test_restore_failure_does_not_mask_the_announcement_error(
4761 self, mock_mass: MagicMock
4762 ) -> None:
4763 """A provider blowing up during the restore must not hide why the announcement failed."""
4764 controller, player, _ = self._make_player(
4765 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4766 )
4767 group = self._add_group(controller, player, supports_set_members=True)
4768 # set_members is a raw provider call: whatever its client library raises comes
4769 # through unwrapped, so the ungroup succeeds and the regroup times out
4770 group.set_members = AsyncMock( # type: ignore[method-assign]
4771 side_effect=[None, TimeoutError("provider timeout")]
4772 )
4773 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4774 side_effect=PlayerCommandFailed("player went away")
4775 )
4776
4777 with pytest.raises(PlayerCommandFailed, match="player went away"):
4778 await controller._play_announcement(player, _announcement())
4779
4780 async def test_group_without_set_members_is_the_one_powered_off(
4781 self, mock_mass: MagicMock
4782 ) -> None:
4783 """A group that can not release members is powered off, not the announcement target."""
4784 controller, player, _ = self._make_player(
4785 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4786 )
4787 self._add_group(controller, player, supports_set_members=False)
4788 power_mock = AsyncMock()
4789 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
4790 play_mock = AsyncMock()
4791 controller.cmd_play = play_mock # type: ignore[method-assign]
4792
4793 await controller._play_announcement(player, _announcement())
4794
4795 # the group is switched off for the announcement and restarted afterwards
4796 power_mock.assert_awaited_once_with("group_1", False)
4797 play_mock.assert_awaited_once_with("group_1")
4798
4799 async def test_idle_player_without_power_control_is_regrouped(
4800 self, mock_mass: MagicMock
4801 ) -> None:
4802 """An idle player that has no power state to restore is still put back in its group."""
4803 controller, player, _ = self._make_player(
4804 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4805 )
4806 player._attr_playback_state = PlaybackState.IDLE
4807 player._attr_powered = None
4808 group = self._add_group(controller, player, supports_set_members=True)
4809 assert player.state.power_control == PLAYER_CONTROL_NONE
4810 group.set_members = AsyncMock() # type: ignore[method-assign]
4811
4812 await controller._play_announcement(player, _announcement())
4813
4814 assert group.set_members.await_args_list == [
4815 call(player_ids_to_remove=["player_1"]),
4816 call(player_ids_to_add=["player_1"]),
4817 ]
4818
4819 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4820 """A muted player hears the announcement and is muted again afterwards."""
4821 controller, player, _ = self._make_player(
4822 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4823 )
4824 mute_mock = _mute_natively(player)
4825
4826 await controller._play_announcement(player, _announcement())
4827
4828 assert mute_mock.await_args_list == [call(False), call(True)]
4829
4830 async def test_player_without_volume_control_is_still_unmuted(
4831 self, mock_mass: MagicMock
4832 ) -> None:
4833 """A player that can only be muted is unmuted for the announcement all the same."""
4834 mock_mass.config.get_raw_player_config_value = MagicMock(
4835 side_effect=_player_config_stub({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
4836 )
4837 controller, player, _ = self._make_player(
4838 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4839 )
4840 mute_mock = _mute_natively(player)
4841 assert player.state.volume_control == PLAYER_CONTROL_NONE
4842
4843 await controller._play_announcement(player, _announcement())
4844
4845 assert mute_mock.await_args_list == [call(False), call(True)]
4846
4847 async def test_mute_is_restored_before_the_player_is_regrouped(
4848 self, mock_mass: MagicMock
4849 ) -> None:
4850 """A player is handed back to its group already muted, holding on to its mute lock."""
4851 controller, player, _ = self._make_player(
4852 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4853 )
4854 group = self._add_group(controller, player, supports_set_members=True)
4855 real_set_members = group.set_members
4856
4857 async def _set_members(**kwargs: list[str]) -> None:
4858 # let the membership really change, so the player is ungrouped while the
4859 # announcement plays - just like it is in production. neither player picks
4860 # the new membership up on its own here, so publish it on both.
4861 await real_set_members(**kwargs)
4862 group.update_state(force_update=True, signal_event=False)
4863 player._cache.clear()
4864 player.update_state(force_update=True, signal_event=False)
4865
4866 set_members = AsyncMock(side_effect=_set_members)
4867 group.set_members = set_members # type: ignore[method-assign]
4868 player.extra_data[ATTR_MUTE_LOCK] = True
4869 recorder = MagicMock()
4870 recorder.attach_mock(_mute_natively(player), "mute")
4871 recorder.attach_mock(set_members, "set_members")
4872
4873 await controller._play_announcement(player, _announcement())
4874
4875 assert recorder.mock_calls == [
4876 call.set_members(player_ids_to_remove=["player_1"]),
4877 call.mute(False),
4878 call.mute(True),
4879 call.set_members(player_ids_to_add=["player_1"]),
4880 ]
4881 # the lock survives the announcement, so the regroup does not unmute the player
4882 assert player.extra_data[ATTR_MUTE_LOCK] is True
4883
4884 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
4885 """A player that was not muted is never sent a mute command."""
4886 controller, player, _ = self._make_player(
4887 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4888 )
4889 mute_mock = _mute_natively(player)
4890 player._attr_volume_muted = False
4891 player._cache.clear()
4892 player.update_state(force_update=True, signal_event=False)
4893 mute_mock.reset_mock()
4894
4895 await controller._play_announcement(player, _announcement())
4896
4897 mute_mock.assert_not_awaited()
4898
4899 async def test_mute_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
4900 """A failing announcement never leaves the player unmuted."""
4901 controller, player, _ = self._make_player(
4902 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4903 )
4904 mute_mock = _mute_natively(player)
4905 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
4906 side_effect=PlayerCommandFailed("player went away")
4907 )
4908
4909 with pytest.raises(PlayerCommandFailed):
4910 await controller._play_announcement(player, _announcement())
4911
4912 assert mute_mock.await_args_list == [call(False), call(True)]
4913
4914 async def test_muted_sync_group_members_all_hear_the_announcement(
4915 self, mock_mass: MagicMock
4916 ) -> None:
4917 """Every member of a muted sync group is unmuted, keeping its mute lock."""
4918 controller, leader, _ = self._make_player(
4919 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4920 )
4921 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
4922 controller._players["player_2"] = member
4923 member.set_initialized()
4924 leader._attr_group_members = ["player_1", "player_2"]
4925 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
4926 # both members were muted while grouped, so both hold a mute lock
4927 for player in (leader, member):
4928 player.extra_data[ATTR_MUTE_LOCK] = True
4929
4930 await controller._play_announcement(leader, _announcement())
4931
4932 for player_id, mute_mock in mute_mocks.items():
4933 assert mute_mock.await_args_list == [call(False), call(True)], player_id
4934 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
4935
4936 async def test_fake_muted_player_announces_at_its_real_volume(
4937 self, mock_mass: MagicMock
4938 ) -> None:
4939 """A fake muted player announces at its real volume, not at the zero it is parked on."""
4940 mock_mass.config.get_raw_player_config_value = MagicMock(
4941 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
4942 )
4943 controller, player, _ = self._make_player(
4944 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
4945 )
4946
4947 def _apply_volume(volume: int) -> None:
4948 player._attr_volume_level = volume
4949 player.update_state(signal_event=False)
4950
4951 player._attr_volume_level = 40
4952 volume_set = AsyncMock(side_effect=_apply_volume)
4953 player.volume_set = volume_set # type: ignore[method-assign]
4954 player._cache.clear()
4955 player.update_state(force_update=True, signal_event=False)
4956 await controller.cmd_volume_mute("player_1", True)
4957 assert player.state.volume_muted is True
4958 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
4959
4960 await controller._play_announcement(player, _announcement())
4961
4962 # unmute to 40, announce at 80, restore 40 and park back on 0 for the fake mute
4963 assert volume_set.await_args_list == [
4964 call(0),
4965 call(40),
4966 call(80),
4967 call(40),
4968 call(0),
4969 ]
4970 assert player.state.volume_muted is True
4971 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
4972
4973
4974@pytest.mark.usefixtures("running_background_tasks")
4975class TestPlayNativeAnnouncement:
4976 """Test the mute handling around an announcement that a player plays natively."""
4977
4978 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
4979 """Create a controller and a player with native announcement support."""
4980 controller = PlayerController(mock_mass)
4981 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
4982 player = MockPlayer(provider, "player_1", "Player 1")
4983 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
4984 player._cache.clear()
4985 controller._players = {"player_1": player}
4986 mock_mass.players = controller
4987 mock_mass.player_queues.get = MagicMock(return_value=None)
4988 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
4989 announce_mock = AsyncMock()
4990 player.play_announcement = announce_mock # type: ignore[method-assign]
4991 player.set_initialized()
4992 player.update_state(signal_event=False)
4993 return controller, player, announce_mock
4994
4995 async def test_muted_player_is_unmuted_and_muted_back(self, mock_mass: MagicMock) -> None:
4996 """A muted player hears the announcement and is muted again afterwards."""
4997 controller, player, announce_mock = self._make_player(mock_mass)
4998 recorder = MagicMock()
4999 recorder.attach_mock(_mute_natively(player), "mute")
5000 recorder.attach_mock(announce_mock, "announce")
5001
5002 await controller._play_native_announcement(player, player, _announcement(), None)
5003
5004 assert recorder.mock_calls == [
5005 call.mute(False),
5006 call.announce(ANY, None),
5007 call.mute(True),
5008 ]
5009
5010 async def test_unmuted_player_is_left_alone(self, mock_mass: MagicMock) -> None:
5011 """A player that was not muted is never sent a mute command."""
5012 controller, player, _ = self._make_player(mock_mass)
5013 mute_mock = _mute_natively(player)
5014 player._attr_volume_muted = False
5015 player._cache.clear()
5016 player.update_state(force_update=True, signal_event=False)
5017 mute_mock.reset_mock()
5018
5019 await controller._play_native_announcement(player, player, _announcement(), None)
5020
5021 mute_mock.assert_not_awaited()
5022
5023 async def test_mute_is_restored_when_the_provider_fails(self, mock_mass: MagicMock) -> None:
5024 """A failing announcement never leaves the player unmuted."""
5025 controller, player, announce_mock = self._make_player(mock_mass)
5026 mute_mock = _mute_natively(player)
5027 announce_mock.side_effect = PlayerCommandFailed("player went away")
5028
5029 with pytest.raises(PlayerCommandFailed):
5030 await controller._play_native_announcement(player, player, _announcement(), None)
5031
5032 assert mute_mock.await_args_list == [call(False), call(True)]
5033
5034 async def test_muted_sync_group_members_all_hear_the_announcement(
5035 self, mock_mass: MagicMock
5036 ) -> None:
5037 """Every member of a muted sync group is unmuted, keeping its mute lock."""
5038 controller, leader, _ = self._make_player(mock_mass)
5039 member = MockPlayer(cast("MockProvider", leader.provider), "player_2", "Player 2")
5040 controller._players["player_2"] = member
5041 member.set_initialized()
5042 leader._attr_group_members = ["player_1", "player_2"]
5043 mute_mocks = {player.player_id: _mute_natively(player) for player in (leader, member)}
5044 # both members were muted while grouped, so both hold a mute lock
5045 for player in (leader, member):
5046 player.extra_data[ATTR_MUTE_LOCK] = True
5047
5048 await controller._play_native_announcement(leader, leader, _announcement(), None)
5049
5050 for player_id, mute_mock in mute_mocks.items():
5051 assert mute_mock.await_args_list == [call(False), call(True)], player_id
5052 assert controller._players[player_id].extra_data[ATTR_MUTE_LOCK] is True
5053
5054 async def test_fake_muted_player_announces_at_its_real_volume(
5055 self, mock_mass: MagicMock
5056 ) -> None:
5057 """The announcement volume is resolved after the unmute, not from the parked zero."""
5058 mock_mass.config.get_raw_player_config_value = MagicMock(
5059 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
5060 )
5061 controller, player, announce_mock = self._make_player(mock_mass)
5062
5063 def _apply_volume(volume: int) -> None:
5064 player._attr_volume_level = volume
5065 player.update_state(signal_event=False)
5066
5067 player._attr_volume_level = 40
5068 volume_set = AsyncMock(side_effect=_apply_volume)
5069 player.volume_set = volume_set # type: ignore[method-assign]
5070 player._cache.clear()
5071 player.update_state(force_update=True, signal_event=False)
5072 await controller.cmd_volume_mute("player_1", True)
5073 assert player.state.volume_muted is True
5074 # stand in for the configured strategy, which reads the volume of the player
5075 controller.get_announcement_volume = MagicMock( # type: ignore[method-assign]
5076 side_effect=lambda _player_id, _volume_level: player.state.volume_level
5077 )
5078
5079 await controller._play_native_announcement(player, player, _announcement(), None)
5080
5081 assert announce_mock.await_args == call(ANY, 40)
5082 # unmute to 40 for the announcement, park back on 0 for the fake mute
5083 assert volume_set.await_args_list == [call(0), call(40), call(0)]
5084 assert player.state.volume_muted is True
5085 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
5086
5087
5088class TestScheduleActiveOutputProtocolClear:
5089 """Test the deferred clear of a player's active output protocol."""
5090
5091 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
5092 """Scheduling defers the clear to a single, per-player, cancellable task."""
5093 controller = PlayerController(mock_mass)
5094 player = MagicMock()
5095 player.player_id = "player_1"
5096
5097 controller.schedule_active_output_protocol_clear(player)
5098
5099 mock_mass.create_task.assert_called_once()
5100 # close the coroutine passed to the mocked create_task to avoid a
5101 # "coroutine was never awaited" warning
5102 mock_mass.create_task.call_args.args[0].close()
5103 # no abort_existing: a duplicate schedule must reuse the pending clear
5104 # (deduped by task_id) instead of replacing it with an untracked task
5105 assert mock_mass.create_task.call_args.kwargs == {
5106 "task_id": "clear_active_protocol_player_1",
5107 }
5108
5109 @pytest.mark.asyncio
5110 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
5111 """The protocol is cleared after waiting for the player to reach IDLE."""
5112 controller = PlayerController(mock_mass)
5113 player = MagicMock()
5114 player.player_id = "player_1"
5115
5116 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
5117 await controller._clear_active_output_protocol_when_idle(player)
5118
5119 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
5120 player.set_active_output_protocol.assert_called_once_with(None)
5121
5122
5123@contextlib.asynccontextmanager
5124async def _skip_player_update_wait(
5125 *_args: object,
5126 **_kwargs: object,
5127) -> AsyncIterator[None]:
5128 """Skip provider-driven state propagation in command-routing tests."""
5129 yield
5130
5131
5132class TestRemovePlayerControl:
5133 """Test removing a registered player control."""
5134
5135 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
5136 """Test that only the players configured to use the removed control are refreshed."""
5137 mock_mass.loop = MagicMock()
5138 controller = PlayerController(mock_mass)
5139 using_control = MagicMock()
5140 using_control.state.power_control = "switch.amp"
5141 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
5142 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
5143 unrelated = MagicMock()
5144 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
5145 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
5146 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
5147 controller._players = {"using_control": using_control, "unrelated": unrelated}
5148 controller._controls = {
5149 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5150 }
5151
5152 controller.remove_player_control("switch.amp")
5153
5154 assert controller.player_controls() == []
5155 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
5156
5157 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
5158 """Test that a control removed and registered again re-attaches to its player."""
5159 # run the scheduled refresh straight away so each step is observable
5160 mock_mass.loop = MagicMock()
5161 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
5162 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
5163 {CONF_POWER_CONTROL: "switch.amp"}
5164 )
5165 controller = PlayerController(mock_mass)
5166 mock_mass.players = controller
5167 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5168 mock_mass.get_provider.return_value = provider
5169 player = MockPlayer(provider, "player", "Player")
5170 controller._players = {"player": player}
5171 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
5172
5173 await controller.register_or_update_player_control(control)
5174 assert player.state.power_control == "switch.amp"
5175
5176 # the Home Assistant plugin drops and re-registers its controls around a reload
5177 controller.remove_player_control(control.id)
5178 assert player.state.power_control == PLAYER_CONTROL_NONE
5179
5180 await controller.register_or_update_player_control(control)
5181 assert player.state.power_control == "switch.amp"
5182
5183 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
5184 """Test that removing a control that was never registered is a no-op."""
5185 mock_mass.loop = MagicMock()
5186 controller = PlayerController(mock_mass)
5187 player = MagicMock()
5188 player.state.power_control = PLAYER_CONTROL_NATIVE
5189 player.state.volume_control = PLAYER_CONTROL_NATIVE
5190 player.state.mute_control = PLAYER_CONTROL_NATIVE
5191 controller._players = {"player": player}
5192
5193 controller.remove_player_control("switch.gone")
5194
5195 mock_mass.loop.call_soon.assert_not_called()
5196
5197
5198class _FailingTeardownPlayer(MockPlayer):
5199 """Player whose provider fails to release it."""
5200
5201 unloaded = False
5202
5203 async def on_unload(self) -> None:
5204 """Handle logic when the player is unloaded from the Player controller."""
5205 self.unloaded = True
5206 msg = "device is gone"
5207 raise RuntimeError(msg)
5208
5209
5210class TestUnregisterTeardown:
5211 """Test that a failing player teardown stays contained."""
5212
5213 async def test_failing_on_unload_still_unregisters_the_player(
5214 self, mock_mass: MagicMock
5215 ) -> None:
5216 """Test that a provider raising while releasing its player does not break unregister."""
5217 controller = PlayerController(mock_mass)
5218 mock_mass.players = controller
5219 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
5220 player = _FailingTeardownPlayer(provider, "boom", "Boom")
5221 controller._players = {"boom": player}
5222
5223 await controller.unregister("boom")
5224
5225 assert "boom" not in controller._players
5226 assert player.unloaded
5227
5228
5229class TestDeletePlayerConfigUserFilters:
5230 """Test how a deleted player config is reflected in the user access filters."""
5231
5232 def test_removal_drops_the_player_from_the_filters(self, mock_mass: MagicMock) -> None:
5233 """A removed player is dropped from the access filter of every user."""
5234 controller = PlayerController(mock_mass)
5235 mock_mass.players = controller
5236
5237 controller.delete_player_config("sonos_1")
5238
5239 mock_mass.webserver.auth.remove_from_user_filters.assert_called_once_with(
5240 player_ids=["sonos_1"]
5241 )
5242 mock_mass.webserver.auth.replace_player_in_user_filters.assert_not_called()
5243
5244 def test_replacement_hands_the_filters_to_the_new_player(self, mock_mass: MagicMock) -> None:
5245 """A replaced player hands its access filter entries over to its replacement."""
5246 controller = PlayerController(mock_mass)
5247 mock_mass.players = controller
5248
5249 controller.delete_player_config("up_old", replacement_player_id="sonos_1")
5250
5251 mock_mass.webserver.auth.replace_player_in_user_filters.assert_called_once_with(
5252 "up_old", "sonos_1", removed_player_ids=["up_old"]
5253 )
5254 mock_mass.webserver.auth.remove_from_user_filters.assert_not_called()
5255
5256
5257class TestConfigChangeRestartsPlayback:
5258 """Test that a changed player setting which needs a reload restarts playback."""
5259
5260 @staticmethod
5261 def _config(*, requires_reload: bool) -> PlayerConfig:
5262 """Build a PlayerConfig holding a single output codec entry."""
5263 return PlayerConfig(
5264 provider="test_prov",
5265 player_id="player_1",
5266 values={
5267 CONF_OUTPUT_CODEC: ConfigEntry(
5268 key=CONF_OUTPUT_CODEC,
5269 type=ConfigEntryType.STRING,
5270 label="Output codec",
5271 value="flac",
5272 requires_reload=requires_reload,
5273 )
5274 },
5275 )
5276
5277 @staticmethod
5278 def _prepare(mock_mass: MagicMock, queue_state: PlaybackState) -> PlayerController:
5279 """Register a player whose active queue is in the given state."""
5280 controller = PlayerController(mock_mass)
5281 player = MagicMock()
5282 player.state.active_source = "player_1"
5283 player.on_config_updated = AsyncMock()
5284 controller._players = {"player_1": player}
5285 queue = MagicMock()
5286 queue.queue_id = "player_1"
5287 queue.state = queue_state
5288 mock_mass.player_queues.get = MagicMock(return_value=queue)
5289 mock_mass.player_queues.stop = AsyncMock()
5290 return controller
5291
5292 async def test_reload_setting_restarts_playback(self, mock_mass: MagicMock) -> None:
5293 """Test that changing a reload-requiring setting stops and resumes the queue."""
5294 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5295
5296 await controller.on_player_config_change(
5297 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5298 )
5299
5300 mock_mass.player_queues.stop.assert_awaited_once_with("player_1")
5301 mock_mass.call_later.assert_called_once_with(
5302 1, mock_mass.player_queues.resume, "player_1", False
5303 )
5304
5305 async def test_plain_setting_does_not_restart_playback(self, mock_mass: MagicMock) -> None:
5306 """Test that a setting which applies on the fly leaves playback alone."""
5307 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5308
5309 await controller.on_player_config_change(
5310 self._config(requires_reload=False), {f"values/{CONF_OUTPUT_CODEC}"}
5311 )
5312
5313 mock_mass.player_queues.stop.assert_not_awaited()
5314 mock_mass.call_later.assert_not_called()
5315
5316 async def test_untouched_reload_setting_does_not_restart_playback(
5317 self, mock_mass: MagicMock
5318 ) -> None:
5319 """Test that only a changed reload-requiring setting restarts playback."""
5320 controller = self._prepare(mock_mass, PlaybackState.PLAYING)
5321
5322 await controller.on_player_config_change(
5323 self._config(requires_reload=True), {f"values/{CONF_ICON}"}
5324 )
5325
5326 mock_mass.player_queues.stop.assert_not_awaited()
5327 mock_mass.call_later.assert_not_called()
5328
5329 async def test_idle_queue_is_left_alone(self, mock_mass: MagicMock) -> None:
5330 """Test that a reload-requiring change does not start playback on an idle queue."""
5331 controller = self._prepare(mock_mass, PlaybackState.IDLE)
5332
5333 await controller.on_player_config_change(
5334 self._config(requires_reload=True), {f"values/{CONF_OUTPUT_CODEC}"}
5335 )
5336
5337 mock_mass.player_queues.stop.assert_not_awaited()
5338 mock_mass.call_later.assert_not_called()
5339
5340
5341if __name__ == "__main__":
5342 pytest.main([__file__, "-v"])
5343