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