/
/
/
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
17from types import SimpleNamespace
18from typing import cast
19from unittest.mock import AsyncMock, MagicMock, call, patch
20
21import pytest
22from music_assistant_models.constants import (
23 PLAYER_CONTROL_FAKE,
24 PLAYER_CONTROL_NATIVE,
25 PLAYER_CONTROL_NONE,
26)
27from music_assistant_models.enums import (
28 EventType,
29 MediaType,
30 PlaybackState,
31 PlayerFeature,
32 PlayerType,
33)
34from music_assistant_models.errors import (
35 InvalidDataError,
36 PlayerCommandFailed,
37 UnsupportedFeaturedException,
38)
39from music_assistant_models.player import OutputProtocol, PlayerMedia, PlayerSource
40from music_assistant_models.player_control import PlayerControl
41
42from music_assistant.constants import (
43 ATTR_FAKE_MUTE,
44 ATTR_MUTE_LOCK,
45 ATTR_PREVIOUS_VOLUME,
46 CONF_AUTO_PLAY,
47 CONF_MAX_VOLUME,
48 CONF_MIN_VOLUME,
49 CONF_MUTE_CONTROL,
50 CONF_POWER_CONTROL,
51 CONF_VOLUME_CONTROL,
52)
53from music_assistant.controllers.players import PlayerController
54from tests.common import MockPlayer, MockProvider
55
56
57def _player_config_stub(
58 values: dict[str, object] | None = None,
59 *,
60 min_volume: int = 0,
61 max_volume: int = 100,
62) -> Callable[..., object]:
63 """
64 Build a ``get_raw_player_config_value`` side effect.
65
66 :param values: Extra config keys to answer, e.g. ``{CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE}``.
67 :param min_volume: Value returned for the ``min_volume`` key.
68 :param max_volume: Value returned for the ``max_volume`` key.
69 """
70 config: dict[str, object] = {
71 CONF_MIN_VOLUME: min_volume,
72 CONF_MAX_VOLUME: max_volume,
73 **(values or {}),
74 }
75
76 def _conf(_player_id: str, key: str, default: object = None) -> object:
77 if key in config:
78 return config[key]
79 return default
80
81 return _conf
82
83
84@pytest.fixture
85def mock_mass() -> MagicMock:
86 """Create a mock MusicAssistant instance."""
87 mass = MagicMock()
88 mass.closing = False
89 mass.loop = None
90 mass.config = MagicMock()
91 mass.config.get = MagicMock(return_value=[])
92 mass.config.get_raw_player_config_value = MagicMock(side_effect=_player_config_stub())
93 # Return "GLOBAL" for log level config (standard default)
94 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
95 mass.config.set = MagicMock()
96 mass.signal_event = MagicMock()
97 mass.get_providers = MagicMock(return_value=[])
98 return mass
99
100
101@pytest.fixture
102def controller(mock_mass: MagicMock) -> PlayerController:
103 """Create a PlayerController instance."""
104 return PlayerController(mock_mass)
105
106
107class TestSetMembersValidation:
108 """Test cmd_set_members validation logic."""
109
110 def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
111 """Test that set_members requires SET_MEMBERS feature."""
112 controller = PlayerController(mock_mass)
113 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
114
115 leader = MockPlayer(provider, "leader", "Leader")
116 # Note: NOT adding SET_MEMBERS feature
117
118 member = MockPlayer(provider, "member", "Member")
119
120 controller._players = {"leader": leader, "member": member}
121 mock_mass.players = controller
122
123 # Should raise exception because leader doesn't support SET_MEMBERS
124 with pytest.raises(UnsupportedFeaturedException):
125 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
126
127 def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
128 """Test that incompatible players cannot be grouped."""
129 controller = PlayerController(mock_mass)
130 provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
131 provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
132
133 player_a = MockPlayer(provider_a, "player_a", "Player A")
134 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
135 player_a._attr_can_group_with = {"provider_a"} # Only same provider
136
137 player_b = MockPlayer(provider_b, "player_b", "Player B")
138
139 controller._players = {"player_a": player_a, "player_b": player_b}
140 mock_mass.players = controller
141
142 # Should raise exception because players are incompatible
143 with pytest.raises(UnsupportedFeaturedException):
144 asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
145
146
147class TestCacheInvalidationAfterGrouping:
148 """Test that caches are invalidated after grouping operations."""
149
150 async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
151 """
152 Test that all players' caches are cleared after set_members.
153
154 Regression test for: Stale can_group_with cache after grouping changes.
155 """
156 controller = PlayerController(mock_mass)
157 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
158
159 leader = MockPlayer(provider, "leader", "Leader")
160 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
161 leader._attr_can_group_with = {"test"}
162 leader._attr_group_members = []
163
164 member = MockPlayer(provider, "member", "Member")
165
166 other = MockPlayer(provider, "other", "Other")
167 other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
168 other._attr_can_group_with = {"test"}
169
170 controller._players = {"leader": leader, "member": member, "other": other}
171 mock_mass.players = controller
172
173 # Populate caches
174 _ = leader.state.can_group_with
175 _ = other.state.can_group_with
176
177 # Simulate grouping (normally done by provider's set_members implementation)
178 leader._attr_group_members = ["leader", "member"]
179
180 # Call set_members to trigger cache invalidation
181 await controller._handle_set_members_with_protocols(
182 leader, player_ids_to_add=["member"], player_ids_to_remove=[]
183 )
184
185 # Note: The actual cache clearing happens via trigger_player_update
186 # which schedules update_state to be called later
187 # In a real scenario, this would clear all players' caches
188
189
190class TestNativeSetMembersGuard:
191 """Test the SET_MEMBERS feature guard on native set_members forwarding."""
192
193 async def test_native_set_members_skipped_without_feature(self, mock_mass: MagicMock) -> None:
194 """
195 Test that set_members is not called on a player without SET_MEMBERS support.
196
197 Regression test for: NotImplementedError raised from
198 _cleanup_player_memberships when removing a member from a native player
199 whose group membership is managed externally (e.g. a Google Cast group,
200 which never advertises SET_MEMBERS).
201 """
202 controller = PlayerController(mock_mass)
203 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
204
205 # a native group-like player WITHOUT SET_MEMBERS in supported_features,
206 # whose set_members behaves like the Player base class (raises)
207 parent = MockPlayer(provider, "cast_group", "Cast Group")
208 parent._attr_group_members = ["cast_group", "member"]
209 parent.set_members = AsyncMock( # type: ignore[method-assign]
210 side_effect=NotImplementedError(
211 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
212 )
213 )
214 member = MockPlayer(provider, "member", "Member")
215
216 controller._players = {"cast_group": parent, "member": member}
217 mock_mass.players = controller
218
219 # must complete without raising NotImplementedError
220 await controller._handle_set_members_with_protocols(
221 parent, player_ids_to_add=[], player_ids_to_remove=["member"]
222 )
223 parent.set_members.assert_not_called()
224
225
226class TestGroupUngroup:
227 """Test group and ungroup commands."""
228
229 async def test_group_command(self, mock_mass: MagicMock) -> None:
230 """Test the group command (cmd_group)."""
231 controller = PlayerController(mock_mass)
232 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
233
234 leader = MockPlayer(provider, "leader", "Leader")
235 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
236 leader._attr_can_group_with = {"member"} # Leader can group with member
237
238 member = MockPlayer(provider, "member", "Member")
239 # Make sure member is already powered on to skip power handling
240 member._attr_powered = True
241
242 controller._players = {"leader": leader, "member": member}
243 mock_mass.players = controller
244
245 # Update state after modifying attributes and registering with controller
246 leader.update_state(signal_event=False)
247 member.update_state(signal_event=False)
248
249 # Track if set_members was called
250 set_members_called = False
251 original_set_members = leader.set_members
252
253 async def mock_set_members(
254 player_ids_to_add: list[str] | None = None,
255 player_ids_to_remove: list[str] | None = None,
256 ) -> None:
257 nonlocal set_members_called
258 set_members_called = True
259 # Call the original to update group_members
260 await original_set_members(player_ids_to_add, player_ids_to_remove)
261
262 leader.set_members = mock_set_members # type: ignore[method-assign]
263
264 # Mock power handling to skip power control (focus is on grouping logic)
265 async def mock_handle_cmd_power(
266 player_id: str, powered: bool, skip_auto_play: bool = False
267 ) -> None:
268 pass
269
270 controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
271
272 # Execute group command
273 await controller.cmd_group("member", "leader")
274
275 # Verify set_members was called
276 assert set_members_called
277 # Verify member was added to leader's group
278 assert "member" in leader._attr_group_members
279
280
281class TestPlayerAvailability:
282 """Test player availability checks in grouping."""
283
284 def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
285 """Test that unavailable players are rejected when grouping."""
286 controller = PlayerController(mock_mass)
287 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
288
289 leader = MockPlayer(provider, "leader", "Leader")
290 leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
291 leader._attr_can_group_with = {"test"}
292
293 member = MockPlayer(provider, "member", "Member")
294 member._attr_available = False # Mark as unavailable
295
296 controller._players = {"leader": leader, "member": member}
297 mock_mass.players = controller
298
299 # Attempting to group with unavailable player should be handled
300 # (either silently ignored or raise exception depending on implementation)
301 # This should either skip the unavailable player or raise an exception
302 with contextlib.suppress(Exception):
303 asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
304
305
306class TestStateForwarding:
307 """Test forwarding of player state changes to related players."""
308
309 def test_sync_leader_updates_are_forwarded_to_sync_children(self, mock_mass: MagicMock) -> None:
310 """A regular sync leader must notify children via the sync-parent callback."""
311 controller = PlayerController(mock_mass)
312 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
313
314 leader = MockPlayer(provider, "leader", "Leader")
315 child = MockPlayer(provider, "child", "Child")
316
317 controller._players = {"leader": leader, "child": child}
318 mock_mass.players = controller
319
320 leader._attr_group_members = ["leader", "child"]
321 leader.update_state(signal_event=False)
322 child.update_state(signal_event=False)
323
324 with (
325 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
326 patch.object(child, "on_group_updated") as on_group_updated,
327 ):
328 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
329 controller._forward_state_update(leader, changed_values)
330
331 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
332 on_group_updated.assert_not_called()
333
334 def test_group_updates_are_forwarded_to_children_via_group_callback(
335 self, mock_mass: MagicMock
336 ) -> None:
337 """A group player must continue to notify children via the group callback."""
338 controller = PlayerController(mock_mass)
339 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
340
341 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
342 child = MockPlayer(provider, "child", "Child")
343
344 controller._players = {"group": group_player, "child": child}
345 mock_mass.players = controller
346
347 group_player._attr_group_members = ["group", "child"]
348 group_player.update_state(signal_event=False)
349 child.update_state(signal_event=False)
350
351 with (
352 patch.object(child, "on_group_updated") as on_group_updated,
353 patch.object(child, "on_sync_parent_updated") as on_sync_parent_updated,
354 ):
355 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
356 controller._forward_state_update(group_player, changed_values)
357
358 on_group_updated.assert_called_once_with(
359 group_player,
360 changed_values,
361 )
362 on_sync_parent_updated.assert_not_called()
363
364 def test_sync_leader_updates_also_reach_its_group_player(self, mock_mass: MagicMock) -> None:
365 """A sync leader must notify its group player as well as its own sync children."""
366 controller = PlayerController(mock_mass)
367 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
368
369 group_player = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
370 leader = MockPlayer(provider, "leader", "Leader")
371 follower = MockPlayer(provider, "follower", "Follower")
372
373 controller._players = {"group": group_player, "leader": leader, "follower": follower}
374 mock_mass.players = controller
375
376 group_player._attr_group_members = ["leader", "follower"]
377 leader._attr_group_members = ["leader", "follower"]
378 for player in (group_player, leader, follower):
379 # _get_player_groups only considers players the controller finished registering
380 player.initialized.set()
381 player.update_state(signal_event=False)
382
383 with (
384 patch.object(group_player, "on_group_member_updated") as on_group_member_updated,
385 patch.object(follower, "on_sync_parent_updated") as on_sync_parent_updated,
386 ):
387 changed_values = {"playback_state": (PlaybackState.IDLE, PlaybackState.PLAYING)}
388 controller._forward_state_update(leader, changed_values)
389
390 on_group_member_updated.assert_called_once_with(leader, changed_values)
391 on_sync_parent_updated.assert_called_once_with(leader, changed_values)
392
393
394class TestSleepTimer:
395 """Test native sleep timer handling."""
396
397 def test_set_and_clear_sleep_timer(self, mock_mass: MagicMock) -> None:
398 """Setting a sleep timer exposes state and schedules the stop callback."""
399 controller = PlayerController(mock_mass)
400 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
401 player = MockPlayer(provider, "player_1", "Player 1")
402 controller._players = {"player_1": player}
403 mock_mass.players = controller
404
405 expires_at = controller.set_sleep_timer("player_1", 30)
406
407 assert controller.get_sleep_timer("player_1") == expires_at
408 assert player.sleep_timer_expires_at == expires_at
409 mock_mass.call_later.assert_called_with(
410 30,
411 controller._handle_sleep_timer_expired,
412 "player_1",
413 task_id="player_sleep_timer_player_1",
414 )
415 mock_mass.signal_event.assert_any_call(
416 EventType.PLAYER_SLEEP_TIMER_UPDATED,
417 object_id="player_1",
418 data=expires_at,
419 )
420
421 controller.clear_sleep_timer("player_1")
422
423 # get_sleep_timer reads the model field directly, so this also asserts it cleared
424 assert controller.get_sleep_timer("player_1") is None
425 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
426 mock_mass.signal_event.assert_any_call(
427 EventType.PLAYER_SLEEP_TIMER_UPDATED,
428 object_id="player_1",
429 data=None,
430 )
431
432 async def test_sleep_timer_removed_when_player_unregistered(self, mock_mass: MagicMock) -> None:
433 """Unregistering a player cancels and clears its sleep timer."""
434 controller = PlayerController(mock_mass)
435 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
436 player = MockPlayer(provider, "player_1", "Player 1")
437 controller._players = {"player_1": player}
438 player.set_sleep_timer_expires_at(123.0)
439
440 await controller.unregister("player_1")
441
442 assert player.sleep_timer_expires_at is None
443 mock_mass.cancel_timer.assert_called_with("player_sleep_timer_player_1")
444
445 async def test_sleep_timer_expiry_stops_player(self, mock_mass: MagicMock) -> None:
446 """An expired sleep timer clears its state and stops playback."""
447 controller = PlayerController(mock_mass)
448 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
449 player = MockPlayer(provider, "player_1", "Player 1")
450 controller._players = {"player_1": player}
451 player.set_sleep_timer_expires_at(123.0)
452 controller.cmd_stop = AsyncMock() # type: ignore[method-assign]
453
454 await controller._handle_sleep_timer_expired("player_1")
455
456 assert controller.get_sleep_timer("player_1") is None
457 assert player.sleep_timer_expires_at is None
458 controller.cmd_stop.assert_awaited_once_with("player_1")
459 mock_mass.signal_event.assert_any_call(
460 EventType.PLAYER_SLEEP_TIMER_UPDATED,
461 object_id="player_1",
462 data=None,
463 )
464
465 def test_set_sleep_timer_rejects_invalid_duration(self, mock_mass: MagicMock) -> None:
466 """A non-positive or float-overflowing duration raises and schedules nothing."""
467 controller = PlayerController(mock_mass)
468 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
469 player = MockPlayer(provider, "player_1", "Player 1")
470 controller._players = {"player_1": player}
471
472 # 0/-30 are non-positive; 10**400 exceeds the float range for the expiry math
473 for invalid in (0, -30, 10**400):
474 with pytest.raises(InvalidDataError):
475 controller.set_sleep_timer("player_1", invalid)
476
477 assert controller.get_sleep_timer("player_1") is None
478 mock_mass.call_later.assert_not_called()
479
480
481class TestUnregisterCleanup:
482 """Test that unregister cleans up leaked internal state."""
483
484 def test_command_locks_removed(self, mock_mass: MagicMock) -> None:
485 """Unregistering a player removes its command lock entries."""
486 controller = PlayerController(mock_mass)
487 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
488 player = MockPlayer(provider, "player_1", "Player 1")
489
490 controller._players = {"player_1": player}
491 controller._player_command_locks = {
492 "playback_player_1": asyncio.Lock(),
493 "volume_player_1": asyncio.Lock(),
494 }
495
496 asyncio.run(controller.unregister("player_1"))
497
498 assert "playback_player_1" not in controller._player_command_locks
499 assert "volume_player_1" not in controller._player_command_locks
500
501 def test_other_players_state_untouched(self, mock_mass: MagicMock) -> None:
502 """Unregistering one player does not affect another player's state."""
503 controller = PlayerController(mock_mass)
504 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
505 player_a = MockPlayer(provider, "player_a", "Player A")
506 player_b = MockPlayer(provider, "player_b", "Player B")
507
508 controller._players = {"player_a": player_a, "player_b": player_b}
509 controller._player_command_locks = {
510 "playback_player_a": asyncio.Lock(),
511 "playback_player_b": asyncio.Lock(),
512 }
513
514 asyncio.run(controller.unregister("player_a"))
515
516 assert "playback_player_b" in controller._player_command_locks
517 assert "playback_player_a" not in controller._player_command_locks
518
519 def test_suffix_player_id_not_over_matched(self, mock_mass: MagicMock) -> None:
520 """Removing player 'b' must not remove locks for player 'a_b' (no suffix matching)."""
521 controller = PlayerController(mock_mass)
522 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
523 player_b = MockPlayer(provider, "b", "Player B")
524 player_a_b = MockPlayer(provider, "a_b", "Player A_B")
525
526 controller._players = {"b": player_b, "a_b": player_a_b}
527 controller._player_command_locks = {
528 "playback_b": asyncio.Lock(),
529 "playback_a_b": asyncio.Lock(),
530 }
531
532 asyncio.run(controller.unregister("b"))
533
534 assert "playback_a_b" in controller._player_command_locks
535 assert "playback_b" not in controller._player_command_locks
536
537 def test_pending_protocol_evaluation_cancelled(self, mock_mass: MagicMock) -> None:
538 """Unregistering a player cancels and removes its pending protocol evaluation."""
539 controller = PlayerController(mock_mass)
540 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
541 player = MockPlayer(provider, "player_1", "Player 1")
542
543 mock_handle = MagicMock()
544 controller._players = {"player_1": player}
545 controller._pending_protocol_evaluations = {"player_1": mock_handle}
546
547 asyncio.run(controller.unregister("player_1"))
548
549 mock_handle.cancel.assert_called_once()
550 assert "player_1" not in controller._pending_protocol_evaluations
551
552 def test_unregister_nonexistent_player_is_noop(self, mock_mass: MagicMock) -> None:
553 """Unregistering a player that doesn't exist is silently ignored."""
554 controller = PlayerController(mock_mass)
555 controller._player_command_locks = {"set_members_other": asyncio.Lock()}
556
557 asyncio.run(controller.unregister("nonexistent"))
558
559 assert "set_members_other" in controller._player_command_locks
560
561
562def _set_play_media_override(mock_mass: MagicMock, value: bool) -> None:
563 """
564 Configure get_raw_player_config_value to return ``value`` for the play-media override key.
565
566 Other keys keep the existing defaults from the shared fixture. Use this in
567 tests for ``play_media`` override behavior so the legacy/new branch is
568 selected deterministically.
569 """
570 original = mock_mass.config.get_raw_player_config_value.side_effect
571
572 def _side_effect(player_id: str, key: str, default: object = None) -> object:
573 if key == "play_media_overrides_group":
574 return value
575 if callable(original):
576 return original(player_id, key, default)
577 return default
578
579 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_side_effect)
580
581
582class TestCmdUngroupNewBranches:
583 """
584 Regression tests for the post-refactor cmd_ungroup flow.
585
586 The refactor changed two things:
587
588 - ``cmd_ungroup`` on a group player no longer calls ``cmd_set_members``
589 (which would hit the "Cannot remove static member" guard); instead it
590 stops or powers off the group entirely.
591 - ``cmd_ungroup`` on a static member of a group recurses to ungroup the
592 group, because static members cannot be released individually.
593 """
594
595 @pytest.mark.asyncio
596 async def test_ungroup_group_player_with_power_uses_power_off(
597 self, mock_mass: MagicMock
598 ) -> None:
599 """Ungroup on a group with explicit power control routes through cmd_power(False)."""
600 controller = PlayerController(mock_mass)
601 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
602 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
603 group._attr_powered = True
604 group._attr_group_members = ["member"]
605 group._attr_supported_features = {PlayerFeature.POWER}
606
607 # ensure power_control resolves to NATIVE so cmd_ungroup uses the power path
608 mock_mass.config.get_raw_player_config_value = MagicMock(
609 side_effect=_player_config_stub({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
610 )
611
612 controller._players = {"g1": group}
613 mock_mass.players = controller
614
615 # populate state.type / state.power_control / state.group_members
616 group.set_initialized()
617 group._cache.clear()
618 group.update_state(signal_event=False)
619
620 called: dict[str, bool | str] = {}
621
622 async def _power(
623 player_id: str,
624 powered: bool,
625 skip_auto_play: bool = False, # noqa: ARG001
626 ) -> None:
627 called["player_id"] = player_id
628 called["powered"] = powered
629
630 controller._handle_cmd_power = _power # type: ignore[method-assign]
631
632 await controller.cmd_ungroup("g1")
633
634 assert called == {"player_id": "g1", "powered": False}
635
636 @pytest.mark.asyncio
637 async def test_ungroup_powerless_group_calls_stop(self, mock_mass: MagicMock) -> None:
638 """Ungroup on a powerless group falls through to _handle_cmd_stop."""
639 controller = PlayerController(mock_mass)
640 provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
641 group = MockPlayer(provider, "g1", "Group", player_type=PlayerType.GROUP)
642 group._attr_powered = None # no power control
643 group._attr_group_members = ["member"]
644 # no POWER feature → power_control auto-selects to NONE
645
646 controller._players = {"g1": group}
647 mock_mass.players = controller
648
649 group.set_initialized()
650 group._cache.clear()
651 group.update_state(signal_event=False)
652
653 stop_called: list[str] = []
654
655 async def _stop(player_id: str) -> None:
656 stop_called.append(player_id)
657
658 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
659 # also stub power to make sure we did NOT go down that branch
660 power_called: list[str] = []
661
662 async def _power(
663 player_id: str,
664 powered: bool, # noqa: ARG001
665 skip_auto_play: bool = False, # noqa: ARG001
666 ) -> None:
667 power_called.append(player_id)
668
669 controller._handle_cmd_power = _power # type: ignore[method-assign]
670
671 await controller.cmd_ungroup("g1")
672
673 assert stop_called == ["g1"]
674 assert power_called == [] # powerless group → never goes through cmd_power
675
676
677class TestExternalPowerOffUnsync:
678 """
679 Tests for unsyncing a player when its power is turned off outside of MA.
680
681 When a player's (final) power state flips on->off because its linked power
682 control was switched off directly - rather than via an MA power command -
683 the player must be removed from any (sync)group it is part of.
684 """
685
686 def _make_synced_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
687 """Build a controller with a player synced to a registered leader."""
688 controller = PlayerController(mock_mass)
689 provider = MockProvider("test", instance_id="test", mass=mock_mass)
690 leader = MockPlayer(provider, "leader", "Leader")
691 leader._attr_group_members = ["leader", "p1"]
692 player = MockPlayer(provider, "p1", "Player")
693 controller._players = {"leader": leader, "p1": player}
694 mock_mass.players = controller
695 for _player in (leader, player):
696 _player.set_initialized()
697 _player._cache.clear()
698 _player.update_state(signal_event=False)
699 # isolate the unsync branch from the unrelated state-forwarding machinery
700 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
701 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
702 return controller, player
703
704 def test_power_off_unsyncs_synced_player(self, mock_mass: MagicMock) -> None:
705 """An on->off power transition ungroups a synced player."""
706 controller, player = self._make_synced_player(mock_mass)
707 assert player.state.synced_to == "leader"
708
709 controller.signal_player_state_update(player, {"powered": (True, False)})
710
711 controller.cmd_ungroup.assert_called_once_with("p1") # type: ignore[attr-defined]
712
713 def test_power_on_does_not_unsync(self, mock_mass: MagicMock) -> None:
714 """An off->on power transition leaves the player synced."""
715 controller, player = self._make_synced_player(mock_mass)
716
717 controller.signal_player_state_update(player, {"powered": (False, True)})
718
719 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
720
721 def test_no_power_control_is_ignored(self, mock_mass: MagicMock) -> None:
722 """A None->off transition (player without power control) is ignored."""
723 controller, player = self._make_synced_player(mock_mass)
724
725 controller.signal_player_state_update(player, {"powered": (None, False)})
726
727 controller.cmd_ungroup.assert_not_called() # type: ignore[attr-defined]
728
729 def test_power_off_ungrouped_player_is_noop(self, mock_mass: MagicMock) -> None:
730 """Powering off a player that is not in any group does nothing."""
731 controller = PlayerController(mock_mass)
732 provider = MockProvider("test", instance_id="test", mass=mock_mass)
733 player = MockPlayer(provider, "p1", "Player")
734 controller._players = {"p1": player}
735 mock_mass.players = controller
736 player.set_initialized()
737 player._cache.clear()
738 player.update_state(signal_event=False)
739 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
740 controller.cmd_ungroup = MagicMock(return_value="ungroup-coro") # type: ignore[method-assign]
741
742 controller.signal_player_state_update(player, {"powered": (True, False)})
743
744 controller.cmd_ungroup.assert_not_called()
745
746
747class TestPlayMediaOverride:
748 """
749 Tests for the new CONF_PLAY_MEDIA_OVERRIDES_GROUP behavior.
750
751 When a captured child player receives an explicit play_media command, the
752 default behavior is to *release* it from the active group/sync and play
753 directly on the targeted player. The legacy behavior (forward to the
754 leader) is preserved via the per-player config opt-out.
755 """
756
757 @pytest.mark.asyncio
758 async def test_override_disabled_redirects_to_group(self, mock_mass: MagicMock) -> None:
759 """With override disabled, play_media on a captured child redirects to the group."""
760 controller = PlayerController(mock_mass)
761 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
762 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
763
764 class _SessionedGroup(MockPlayer):
765 @property
766 def is_active_session(self) -> bool:
767 return True
768
769 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
770 group._attr_powered = None
771 group._attr_group_members = ["member"]
772
773 member = MockPlayer(member_provider, "member", "Member")
774
775 controller._players = {"g1": group, "member": member}
776 mock_mass.players = controller
777
778 group.set_initialized()
779 member.set_initialized()
780 group.update_state(signal_event=False)
781 member.update_state(signal_event=False)
782 # sanity: the member is captured by the group
783 assert member.state.active_group == "g1"
784
785 _set_play_media_override(mock_mass, False)
786
787 played_on: list[str] = []
788
789 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
790 played_on.append(player_id)
791
792 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
793 # the play_media wrapper acquires a playback lock; stub it out
794 controller._player_command_locks = {}
795
796 media = MagicMock(uri="x", source_id="src")
797 await controller.play_media("member", media)
798
799 # legacy behavior: redirected to the group leader
800 assert played_on == ["g1"]
801
802 @pytest.mark.asyncio
803 async def test_override_releases_dynamic_member(self, mock_mass: MagicMock) -> None:
804 """With override enabled, play_media on a dynamic group member releases it first."""
805 controller = PlayerController(mock_mass)
806 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
807 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
808
809 class _SessionedGroup(MockPlayer):
810 @property
811 def is_active_session(self) -> bool:
812 return True
813
814 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
815 group._attr_powered = None
816 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
817 group._attr_group_members = ["member"]
818 # NOT a static member ⇒ dynamic — can be removed via set_members
819 group._attr_static_group_members = []
820
821 member = MockPlayer(member_provider, "member", "Member")
822
823 controller._players = {"g1": group, "member": member}
824 mock_mass.players = controller
825
826 group.set_initialized()
827 member.set_initialized()
828 group.update_state(signal_event=False)
829 member.update_state(signal_event=False)
830 assert member.state.active_group == "g1"
831
832 # default: override enabled
833 _set_play_media_override(mock_mass, True)
834
835 set_members_calls: list[dict[str, object]] = []
836
837 async def _cmd_set_members(
838 target_player: str,
839 player_ids_to_add: list[str] | None = None, # noqa: ARG001
840 player_ids_to_remove: list[str] | None = None,
841 ) -> None:
842 set_members_calls.append(
843 {"player_id": target_player, "remove": player_ids_to_remove or []}
844 )
845
846 controller.cmd_set_members = _cmd_set_members # type: ignore[method-assign]
847
848 played_on: list[str] = []
849
850 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
851 played_on.append(player_id)
852
853 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
854 controller._player_command_locks = {}
855
856 media = MagicMock(uri="x", source_id="src")
857 with patch.object(
858 controller,
859 "wait_for_player_update",
860 _skip_player_update_wait,
861 ):
862 await controller.play_media("member", media)
863
864 # the member was removed from the group ...
865 assert set_members_calls == [{"player_id": "g1", "remove": ["member"]}]
866 # ... and then play_media was issued directly on the member, NOT on the group
867 assert played_on == ["member"]
868
869 @pytest.mark.asyncio
870 async def test_override_stops_static_group(self, mock_mass: MagicMock) -> None:
871 """With override enabled, play_media on a STATIC group member stops the group."""
872 controller = PlayerController(mock_mass)
873 group_provider = MockProvider("test_group", instance_id="test_group", mass=mock_mass)
874 member_provider = MockProvider("test", instance_id="test", mass=mock_mass)
875
876 class _SessionedGroup(MockPlayer):
877 @property
878 def is_active_session(self) -> bool:
879 return True
880
881 group = _SessionedGroup(group_provider, "g1", "Group", player_type=PlayerType.GROUP)
882 group._attr_powered = None # no power control ⇒ stop, not power-off
883 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
884 group._attr_group_members = ["member"]
885 # static member - cannot be removed individually
886 group._attr_static_group_members = ["member"]
887
888 member = MockPlayer(member_provider, "member", "Member")
889
890 controller._players = {"g1": group, "member": member}
891 mock_mass.players = controller
892
893 group.set_initialized()
894 member.set_initialized()
895 group.update_state(signal_event=False)
896 member.update_state(signal_event=False)
897 assert member.state.active_group == "g1"
898
899 _set_play_media_override(mock_mass, True)
900
901 stop_calls: list[str] = []
902 power_calls: list[tuple[str, bool]] = []
903
904 async def _stop(player_id: str) -> None:
905 stop_calls.append(player_id)
906
907 async def _power(
908 player_id: str,
909 powered: bool,
910 skip_auto_play: bool = False, # noqa: ARG001
911 ) -> None:
912 power_calls.append((player_id, powered))
913
914 controller._handle_cmd_stop = _stop # type: ignore[method-assign]
915 controller._handle_cmd_power = _power # type: ignore[method-assign]
916
917 played_on: list[str] = []
918
919 async def _handle_play_media(player_id: str, media: object) -> None: # noqa: ARG001
920 played_on.append(player_id)
921
922 controller._handle_play_media = _handle_play_media # type: ignore[method-assign]
923 controller._player_command_locks = {}
924
925 media = MagicMock(uri="x", source_id="src")
926 with patch.object(
927 controller,
928 "wait_for_player_update",
929 _skip_player_update_wait,
930 ):
931 await controller.play_media("member", media)
932
933 # powerless group + static member: we should have stopped the group ...
934 assert stop_calls == ["g1"]
935 # ... not powered it off ...
936 assert power_calls == []
937 # ... and play_media was issued directly on the member
938 assert played_on == ["member"]
939
940
941class TestExternalSourcePlayPause:
942 """Pause/play handling for externally-initiated sources (no active output protocol)."""
943
944 @staticmethod
945 def _make_external_source_player(
946 provider: MockProvider,
947 controller: PlayerController,
948 mock_mass: MagicMock,
949 *,
950 playback_state: PlaybackState,
951 can_play_pause: bool = True,
952 supports_pause: bool = True,
953 ) -> MockPlayer:
954 """Build a player playing a passive external source, with no active output protocol."""
955 player = MockPlayer(provider, "player_1", "Test Player")
956 player._attr_supported_features = {PlayerFeature.PAUSE} if supports_pause else set()
957 player._attr_source_list = [
958 PlayerSource(
959 id="spotify",
960 name="Spotify",
961 passive=True,
962 can_play_pause=can_play_pause,
963 can_next_previous=True,
964 can_seek=True,
965 )
966 ]
967 player._attr_active_source = "spotify"
968 player._attr_playback_state = playback_state
969 player._cache.clear()
970 controller._players = {"player_1": player}
971 mock_mass.players = controller
972 mock_mass.player_queues = MagicMock()
973 mock_mass.player_queues.get = MagicMock(return_value=None)
974 player.update_state(signal_event=False)
975 return player
976
977 def test_pause_external_source_forwards_to_player(
978 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
979 ) -> None:
980 """Pausing a pausable external source forwards to the player, not STOP."""
981 player = self._make_external_source_player(
982 provider, controller, mock_mass, playback_state=PlaybackState.PLAYING
983 )
984 player.pause = AsyncMock() # type: ignore[method-assign]
985 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
986
987 asyncio.run(controller._handle_cmd_pause("player_1"))
988
989 player.pause.assert_awaited_once()
990 controller._handle_cmd_stop.assert_not_called()
991
992 def test_play_external_source_unpauses_player(
993 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
994 ) -> None:
995 """Unpausing a paused external source forwards to the player, not a restart."""
996 player = self._make_external_source_player(
997 provider, controller, mock_mass, playback_state=PlaybackState.PAUSED
998 )
999 player.play = AsyncMock() # type: ignore[method-assign]
1000 player.play_media = AsyncMock() # type: ignore[method-assign]
1001 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1002
1003 asyncio.run(controller._handle_cmd_play("player_1"))
1004
1005 player.play.assert_awaited_once()
1006 player.play_media.assert_not_called()
1007 controller._handle_select_source.assert_not_called()
1008
1009 def test_pause_falls_back_to_stop_without_pause_support(
1010 self, mock_mass: MagicMock, controller: PlayerController, provider: MockProvider
1011 ) -> None:
1012 """A player that cannot pause natively still falls back to STOP."""
1013 player = self._make_external_source_player(
1014 provider,
1015 controller,
1016 mock_mass,
1017 playback_state=PlaybackState.PLAYING,
1018 supports_pause=False,
1019 )
1020 player.pause = AsyncMock() # type: ignore[method-assign]
1021 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1022
1023 asyncio.run(controller._handle_cmd_pause("player_1"))
1024
1025 controller._handle_cmd_stop.assert_awaited_once()
1026 player.pause.assert_not_called()
1027
1028
1029class TestProtocolOutputPlayPause:
1030 """Play/pause on a player rendering through a linked output protocol."""
1031
1032 @staticmethod
1033 def _make_player_on_protocol(
1034 mock_mass: MagicMock,
1035 controller: PlayerController,
1036 *,
1037 playback_state: PlaybackState,
1038 ) -> MockPlayer:
1039 """Build a player playing the MA queue through a protocol that cannot pause."""
1040 native_provider = MockProvider("chromecast", mass=mock_mass)
1041 player = MockPlayer(native_provider, "player_1", "Test Player")
1042 player._attr_supported_features.add(PlayerFeature.PAUSE)
1043 player._attr_playback_state = playback_state
1044
1045 protocol_provider = MockProvider("sendspin", mass=mock_mass)
1046 protocol_player = MockPlayer(
1047 protocol_provider, "proto_1", "Test Protocol", player_type=PlayerType.PROTOCOL
1048 )
1049 protocol_player._attr_playback_state = playback_state
1050
1051 controller._players = {"player_1": player, "proto_1": protocol_player}
1052 mock_mass.players = controller
1053 mock_mass.player_queues = MagicMock()
1054 # a non-empty queue, so the MA queue source advertises play/pause support
1055 queue = MagicMock()
1056 queue.items = [MagicMock()]
1057 mock_mass.player_queues.get = MagicMock(return_value=queue)
1058 player.set_linked_output_protocols(
1059 [
1060 OutputProtocol(
1061 output_protocol_id="proto_1",
1062 name="Sendspin",
1063 protocol_domain="sendspin",
1064 priority=40,
1065 )
1066 ]
1067 )
1068 player.set_active_output_protocol("proto_1")
1069 player.set_active_mass_source("player_1")
1070 protocol_player.update_state(signal_event=False)
1071 player.refresh_state(signal_event=False)
1072 return player
1073
1074 async def test_pause_on_protocol_without_pause_falls_back_to_stop(
1075 self, mock_mass: MagicMock, controller: PlayerController
1076 ) -> None:
1077 """The native transport has no session to pause while a protocol renders the audio."""
1078 player = self._make_player_on_protocol(
1079 mock_mass, controller, playback_state=PlaybackState.PLAYING
1080 )
1081 player.pause = AsyncMock() # type: ignore[method-assign]
1082 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
1083
1084 await controller._handle_cmd_pause("player_1")
1085
1086 player.pause.assert_not_called()
1087 # STOP goes to the visible player, not the protocol player
1088 controller._handle_cmd_stop.assert_awaited_once_with("player_1")
1089
1090 async def test_play_on_protocol_without_pause_does_not_unpause_natively(
1091 self, mock_mass: MagicMock, controller: PlayerController
1092 ) -> None:
1093 """Unpausing must not hit the native transport either; the source is restarted."""
1094 player = self._make_player_on_protocol(
1095 mock_mass, controller, playback_state=PlaybackState.PAUSED
1096 )
1097 player.play = AsyncMock() # type: ignore[method-assign]
1098 controller._handle_select_source = AsyncMock() # type: ignore[method-assign]
1099
1100 await controller._handle_cmd_play("player_1")
1101
1102 player.play.assert_not_called()
1103 # the MA queue source is restarted, not some other source
1104 controller._handle_select_source.assert_awaited_once_with("player_1", "player_1")
1105
1106
1107class TestMirrorsParentMedia:
1108 """Tests for _mirrors_parent_media (palette-fetch gating for grouped players)."""
1109
1110 @staticmethod
1111 def _fake_player(
1112 *,
1113 player_id: str = "p1",
1114 active_group: str | None = None,
1115 synced_to: str | None = None,
1116 player_type: PlayerType = PlayerType.PLAYER,
1117 protocol_parent_id: str | None = None,
1118 ) -> SimpleNamespace:
1119 return SimpleNamespace(
1120 player_id=player_id,
1121 state=SimpleNamespace(active_group=active_group, synced_to=synced_to, type=player_type),
1122 protocol_parent_id=protocol_parent_id,
1123 )
1124
1125 def test_standalone_player_owns_media(self, controller: PlayerController) -> None:
1126 """A standalone player resolves its own media (and palette)."""
1127 assert controller._mirrors_parent_media(self._fake_player()) is False # type: ignore[arg-type]
1128
1129 def test_group_member_mirrors(self, controller: PlayerController) -> None:
1130 """A group member borrows its parent's media."""
1131 assert controller._mirrors_parent_media(self._fake_player(active_group="g1")) is True # type: ignore[arg-type]
1132
1133 def test_synced_member_mirrors(self, controller: PlayerController) -> None:
1134 """A synced member borrows its leader's media."""
1135 assert controller._mirrors_parent_media(self._fake_player(synced_to="leader")) is True # type: ignore[arg-type]
1136
1137 def test_protocol_child_mirrors(self, controller: PlayerController) -> None:
1138 """A protocol child borrows its parent's media."""
1139 player = self._fake_player(player_type=PlayerType.PROTOCOL, protocol_parent_id="parent")
1140 assert controller._mirrors_parent_media(player) is True # type: ignore[arg-type]
1141
1142 def test_protocol_player_without_parent_owns_media(self, controller: PlayerController) -> None:
1143 """A protocol player with no parent resolves its own media."""
1144 player = self._fake_player(player_type=PlayerType.PROTOCOL)
1145 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1146
1147 def test_self_referential_parent_owns_media(self, controller: PlayerController) -> None:
1148 """A self-referential active_group/synced_to is not a real parent, so resolve locally."""
1149 player = self._fake_player(player_id="p1", synced_to="p1", active_group="p1")
1150 assert controller._mirrors_parent_media(player) is False # type: ignore[arg-type]
1151
1152
1153class TestVolumeScalingOnRedirect:
1154 """min/max volume scaling must survive a redirect to a protocol player or external control."""
1155
1156 @staticmethod
1157 def _volume_player(
1158 player_id: str,
1159 volume_control: str,
1160 volume_set: AsyncMock | None = None,
1161 ) -> SimpleNamespace:
1162 return SimpleNamespace(
1163 player_id=player_id,
1164 type=PlayerType.PLAYER,
1165 protocol_parent_id=None,
1166 extra_data={},
1167 volume_control=volume_control,
1168 mute_control=PLAYER_CONTROL_NONE,
1169 volume_set=volume_set or AsyncMock(),
1170 update_state=MagicMock(),
1171 provider=MagicMock(),
1172 state=SimpleNamespace(
1173 name=player_id,
1174 volume_control=volume_control,
1175 volume_muted=False,
1176 mute_control=PLAYER_CONTROL_NONE,
1177 ),
1178 )
1179
1180 @pytest.mark.asyncio
1181 async def test_protocol_redirect_forwards_scaled_volume(
1182 self, controller: PlayerController, mock_mass: MagicMock
1183 ) -> None:
1184 """A volume command redirected to a protocol player honors the user-facing max_volume."""
1185
1186 def _conf(player_id: str, key: str, default: object = None) -> object:
1187 if key == "min_volume":
1188 return 0
1189 if key == "max_volume":
1190 # user-facing player caps at 50, the protocol player has no limits of its own
1191 return 50 if player_id == "user_player" else 100
1192 return default
1193
1194 mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_conf)
1195
1196 protocol = self._volume_player("protocol_player", PLAYER_CONTROL_NATIVE)
1197 user = self._volume_player("user_player", "protocol_player")
1198 players = {"user_player": user, "protocol_player": protocol}
1199
1200 with (
1201 patch.object(controller, "get_player", side_effect=players.get),
1202 patch.object(controller, "_get_active_audio_source", return_value=None),
1203 ):
1204 controller._controls = {}
1205 await controller._handle_cmd_volume_set("user_player", 100)
1206
1207 # logical 100 with a max_volume of 50 must reach the protocol player as 50, not the raw 100
1208 protocol.volume_set.assert_awaited_once_with(50)
1209
1210 @pytest.mark.asyncio
1211 async def test_external_control_redirect_forwards_scaled_volume(
1212 self, controller: PlayerController, mock_mass: MagicMock
1213 ) -> None:
1214 """A volume command redirected to an external control honors the user-facing max_volume."""
1215 mock_mass.config.get_raw_player_config_value = MagicMock(
1216 side_effect=_player_config_stub(max_volume=50)
1217 )
1218
1219 volume_set = AsyncMock()
1220 control = PlayerControl(
1221 id="ext_control",
1222 provider="test",
1223 name="External Amp",
1224 supports_volume=True,
1225 volume_set=volume_set,
1226 )
1227 user = self._volume_player("user_player", "ext_control")
1228 players = {"user_player": user}
1229
1230 with (
1231 patch.object(controller, "get_player", side_effect=players.get),
1232 patch.object(controller, "_get_active_audio_source", return_value=None),
1233 ):
1234 controller._controls = {"ext_control": control}
1235 await controller._handle_cmd_volume_set("user_player", 100)
1236
1237 volume_set.assert_awaited_once_with(50)
1238
1239 @pytest.mark.asyncio
1240 async def test_external_control_without_volume_support_raises(
1241 self, controller: PlayerController, mock_mass: MagicMock
1242 ) -> None:
1243 """A volume command redirected to a control lacking volume support is rejected."""
1244 mock_mass.config.get_raw_player_config_value = MagicMock(
1245 side_effect=_player_config_stub(max_volume=50)
1246 )
1247
1248 volume_set = AsyncMock()
1249 control = PlayerControl(
1250 id="ext_control",
1251 provider="test",
1252 name="External Amp",
1253 supports_volume=False,
1254 volume_set=volume_set,
1255 )
1256 user = self._volume_player("user_player", "ext_control")
1257 players = {"user_player": user}
1258
1259 with (
1260 patch.object(controller, "get_player", side_effect=players.get),
1261 patch.object(controller, "_get_active_audio_source", return_value=None),
1262 ):
1263 controller._controls = {"ext_control": control}
1264 with pytest.raises(UnsupportedFeaturedException):
1265 await controller._handle_cmd_volume_set("user_player", 100)
1266
1267 volume_set.assert_not_awaited()
1268
1269
1270class TestExternalPowerControl:
1271 """Power commands redirected to an external PlayerControl must forward and gate correctly."""
1272
1273 def _make_player(
1274 self, mock_mass: MagicMock, control: PlayerControl
1275 ) -> tuple[PlayerController, MockPlayer]:
1276 """Build a controller with a single player whose power control is the given control."""
1277 mock_mass.config.get_raw_player_config_value = MagicMock(
1278 side_effect=_player_config_stub({CONF_POWER_CONTROL: control.id})
1279 )
1280 controller = PlayerController(mock_mass)
1281 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1282 player = MockPlayer(provider, "player_1", "Player 1")
1283 controller._controls = {control.id: control}
1284 controller._players = {"player_1": player}
1285 mock_mass.players = controller
1286 # auto-play would otherwise resume the (mocked) player queue on power on
1287 config_get_value = player.config.get_value
1288 player.config.get_value = MagicMock( # type: ignore[method-assign]
1289 side_effect=lambda key, *args, **kwargs: (
1290 False if key == CONF_AUTO_PLAY else config_get_value(key, *args, **kwargs)
1291 )
1292 )
1293 player.set_initialized()
1294 player.update_state(signal_event=False)
1295 return controller, player
1296
1297 async def test_power_on_forwards_to_control(self, mock_mass: MagicMock) -> None:
1298 """Powering on a player redirects to its external control's power_on callback."""
1299 power_on = AsyncMock()
1300 power_off = AsyncMock()
1301 control = PlayerControl(
1302 id="ext_power",
1303 provider="test",
1304 name="External Power",
1305 supports_power=True,
1306 power_on=power_on,
1307 power_off=power_off,
1308 )
1309
1310 def _report_powered_on() -> None:
1311 control.power_state = True
1312
1313 # the control only reports on once switched on, which releases wait_for_power_on
1314 power_on.side_effect = _report_powered_on
1315 controller, player = self._make_player(mock_mass, control)
1316 assert player.state.powered is False
1317
1318 await controller._handle_cmd_power("player_1", True)
1319
1320 power_on.assert_awaited_once()
1321 power_off.assert_not_awaited()
1322
1323 async def test_power_on_waits_on_the_control(self, mock_mass: MagicMock) -> None:
1324 """Powering on waits for the control to report on, not for the player itself."""
1325 control = PlayerControl(
1326 id="ext_power",
1327 provider="test",
1328 name="External Power",
1329 supports_power=True,
1330 power_on=AsyncMock(),
1331 power_off=AsyncMock(),
1332 )
1333 controller, player = self._make_player(mock_mass, control)
1334 assert player.state.powered is False
1335
1336 with patch(
1337 "music_assistant.controllers.players.controller.wait_for_power_on", AsyncMock()
1338 ) as wait_for_power_on:
1339 await controller._handle_cmd_power("player_1", True)
1340
1341 wait_for_power_on.assert_awaited_once()
1342 assert wait_for_power_on.await_args is not None
1343 assert wait_for_power_on.await_args.args[2] is control
1344
1345 async def test_power_off_forwards_to_control(self, mock_mass: MagicMock) -> None:
1346 """Powering off a player redirects to its external control's power_off callback."""
1347 power_on = AsyncMock()
1348 power_off = AsyncMock()
1349 control = PlayerControl(
1350 id="ext_power",
1351 provider="test",
1352 name="External Power",
1353 supports_power=True,
1354 power_state=True,
1355 power_on=power_on,
1356 power_off=power_off,
1357 )
1358 controller, player = self._make_player(mock_mass, control)
1359 assert player.state.powered is True
1360
1361 await controller._handle_cmd_power("player_1", False)
1362
1363 power_off.assert_awaited_once()
1364 power_on.assert_not_awaited()
1365
1366 async def test_control_without_power_support_raises(self, mock_mass: MagicMock) -> None:
1367 """A power command redirected to a control lacking power support is rejected."""
1368 power_on = AsyncMock()
1369 power_off = AsyncMock()
1370 control = PlayerControl(
1371 id="ext_power",
1372 provider="test",
1373 name="External Power",
1374 supports_power=False,
1375 power_on=power_on,
1376 power_off=power_off,
1377 )
1378 controller, player = self._make_player(mock_mass, control)
1379 assert player.state.powered is False
1380
1381 with pytest.raises(UnsupportedFeaturedException):
1382 await controller._handle_cmd_power("player_1", True)
1383
1384 power_on.assert_not_awaited()
1385 power_off.assert_not_awaited()
1386
1387
1388class TestEnforceVolumeLimits:
1389 """External volume changes outside the min/max range must be corrected."""
1390
1391 @staticmethod
1392 def _set_limits(mock_mass: MagicMock, min_volume: int, max_volume: int) -> None:
1393 mock_mass.config.get_raw_player_config_value = MagicMock(
1394 side_effect=_player_config_stub(min_volume=min_volume, max_volume=max_volume)
1395 )
1396
1397 @staticmethod
1398 def _player(logical_volume: int | None) -> SimpleNamespace:
1399 return SimpleNamespace(
1400 player_id="user_player",
1401 state=SimpleNamespace(volume_level=logical_volume),
1402 )
1403
1404 def test_out_of_range_volume_is_corrected(
1405 self, controller: PlayerController, mock_mass: MagicMock
1406 ) -> None:
1407 """A device volume above max_volume (logical > 100) is clamped back to logical 100."""
1408 self._set_limits(mock_mass, 0, 80)
1409 # device volume 100 with max 80 resolves to logical 125
1410 player = self._player(125)
1411 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1412 controller._enforce_volume_limits(cast("MockPlayer", player))
1413 cmd.assert_called_once_with("user_player", 100)
1414 mock_mass.create_task.assert_called_once()
1415
1416 def test_below_min_volume_is_corrected(
1417 self, controller: PlayerController, mock_mass: MagicMock
1418 ) -> None:
1419 """A device volume below min_volume (logical < 0) is clamped back to logical 0."""
1420 self._set_limits(mock_mass, 20, 100)
1421 # device volume 10 with min 20 resolves to a negative logical volume
1422 player = self._player(-13)
1423 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1424 controller._enforce_volume_limits(cast("MockPlayer", player))
1425 cmd.assert_called_once_with("user_player", 0)
1426
1427 def test_in_range_volume_is_untouched(
1428 self, controller: PlayerController, mock_mass: MagicMock
1429 ) -> None:
1430 """A logical volume within 0-100 needs no correction."""
1431 self._set_limits(mock_mass, 0, 80)
1432 player = self._player(100)
1433 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1434 controller._enforce_volume_limits(cast("MockPlayer", player))
1435 cmd.assert_not_called()
1436
1437 def test_no_limits_configured_is_noop(
1438 self, controller: PlayerController, mock_mass: MagicMock
1439 ) -> None:
1440 """Default 0-100 limits skip enforcement entirely."""
1441 self._set_limits(mock_mass, 0, 100)
1442 player = self._player(100)
1443 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1444 controller._enforce_volume_limits(cast("MockPlayer", player))
1445 cmd.assert_not_called()
1446
1447 def test_unknown_volume_is_noop(
1448 self, controller: PlayerController, mock_mass: MagicMock
1449 ) -> None:
1450 """A player without a resolved volume level is left alone."""
1451 self._set_limits(mock_mass, 0, 80)
1452 player = self._player(None)
1453 with patch.object(controller, "_handle_cmd_volume_set", MagicMock()) as cmd:
1454 controller._enforce_volume_limits(cast("MockPlayer", player))
1455 cmd.assert_not_called()
1456
1457
1458class TestFakeMuteControl:
1459 """Fake mute must report the muted state and restore the volume on unmute."""
1460
1461 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, AsyncMock]:
1462 """Build a controller with a single player using fake mute control."""
1463 mock_mass.config.get_raw_player_config_value = MagicMock(
1464 side_effect=_player_config_stub({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
1465 )
1466 controller = PlayerController(mock_mass)
1467 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1468 player = MockPlayer(provider, "player_1", "Player 1")
1469 controller._players = {"player_1": player}
1470 mock_mass.players = controller
1471 mock_mass.player_queues.get = MagicMock(return_value=None)
1472 player.set_initialized()
1473 player._attr_volume_level = 40
1474 # let the mocked native volume control behave like a real device
1475 volume_set = AsyncMock(
1476 side_effect=lambda volume: setattr(player, "_attr_volume_level", volume)
1477 )
1478 player.volume_set = volume_set # type: ignore[method-assign]
1479 player.update_state(signal_event=False)
1480 return controller, player, volume_set
1481
1482 async def test_mute_then_unmute_restores_volume(self, mock_mass: MagicMock) -> None:
1483 """Muting reports volume_muted=True and unmuting restores the previous volume."""
1484 controller, player, volume_set = self._make_player(mock_mass)
1485
1486 await controller.cmd_volume_mute("player_1", True)
1487 muted_state = player.state
1488 assert muted_state.volume_muted is True
1489 assert muted_state.volume_level == 0
1490 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1491
1492 await controller.cmd_volume_mute("player_1", False)
1493 volume_set.assert_awaited_with(40)
1494 # simulate the device reporting back its state after the volume command
1495 player.update_state()
1496 unmuted_state = player.state
1497 assert unmuted_state.volume_muted is False
1498 assert unmuted_state.volume_level == 40
1499
1500 async def test_repeated_mute_keeps_previous_volume(self, mock_mass: MagicMock) -> None:
1501 """A repeated mute command must not overwrite the stored volume with 0."""
1502 controller, player, volume_set = self._make_player(mock_mass)
1503
1504 await controller.cmd_volume_mute("player_1", True)
1505 await controller.cmd_volume_mute("player_1", True)
1506 assert player.extra_data[ATTR_PREVIOUS_VOLUME] == 40
1507 assert player.state.volume_muted is True
1508
1509 await controller.cmd_volume_mute("player_1", False)
1510 volume_set.assert_awaited_with(40)
1511
1512 async def test_volume_set_clears_fake_mute(self, mock_mass: MagicMock) -> None:
1513 """A regular volume change while fake muted implies an unmute."""
1514 controller, player, _volume_set = self._make_player(mock_mass)
1515
1516 await controller.cmd_volume_mute("player_1", True)
1517 muted_state = player.state
1518 assert muted_state.volume_muted is True
1519
1520 await controller.cmd_volume_set("player_1", 25)
1521 # simulate the device reporting back its state after the volume command
1522 player.update_state()
1523 unmuted_state = player.state
1524 assert unmuted_state.volume_muted is False
1525 assert unmuted_state.volume_level == 25
1526
1527
1528class TestMuteControlGuard:
1529 """Muting is gated on the mute control, independently of the volume control."""
1530
1531 def _make_player(
1532 self,
1533 mock_mass: MagicMock,
1534 mute_control: str,
1535 volume_control: str,
1536 controls: dict[str, PlayerControl] | None = None,
1537 ) -> tuple[PlayerController, MockPlayer]:
1538 """Build a controller with a single player using the given control config."""
1539 mock_mass.config.get_raw_player_config_value = MagicMock(
1540 side_effect=_player_config_stub(
1541 {CONF_MUTE_CONTROL: mute_control, CONF_VOLUME_CONTROL: volume_control}
1542 )
1543 )
1544 controller = PlayerController(mock_mass)
1545 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1546 player = MockPlayer(provider, "player_1", "Player 1")
1547 controller._players = {"player_1": player}
1548 controller._controls = controls or {}
1549 mock_mass.players = controller
1550 mock_mass.player_queues.get = MagicMock(return_value=None)
1551 player.set_initialized()
1552 player.update_state(signal_event=False)
1553 return controller, player
1554
1555 async def test_external_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
1556 """A player without volume control still mutes through an external PlayerControl."""
1557 mute_set = AsyncMock()
1558 control = PlayerControl(
1559 id="ext_mute",
1560 provider="test",
1561 name="External Mute",
1562 supports_mute=True,
1563 mute_set=mute_set,
1564 )
1565 controller, player = self._make_player(
1566 mock_mass,
1567 mute_control="ext_mute",
1568 volume_control=PLAYER_CONTROL_NONE,
1569 controls={"ext_mute": control},
1570 )
1571 assert player.mute_control == "ext_mute"
1572
1573 await controller.cmd_volume_mute("player_1", True)
1574 mute_set.assert_awaited_once_with(True)
1575
1576 async def test_native_mute_control_without_volume_control(self, mock_mass: MagicMock) -> None:
1577 """A player without volume control still mutes natively."""
1578 controller, player = self._make_player(
1579 mock_mass,
1580 mute_control=PLAYER_CONTROL_NATIVE,
1581 volume_control=PLAYER_CONTROL_NONE,
1582 )
1583 volume_mute = AsyncMock()
1584 player.volume_mute = volume_mute # type: ignore[method-assign]
1585
1586 await controller.cmd_volume_mute("player_1", True)
1587 volume_mute.assert_awaited_once_with(True)
1588
1589 async def test_mute_control_none_raises(self, mock_mass: MagicMock) -> None:
1590 """A player with volume control but no mute control rejects the command."""
1591 controller, player = self._make_player(
1592 mock_mass,
1593 mute_control=PLAYER_CONTROL_NONE,
1594 volume_control=PLAYER_CONTROL_NATIVE,
1595 )
1596 volume_mute = AsyncMock()
1597 player.volume_mute = volume_mute # type: ignore[method-assign]
1598
1599 with pytest.raises(UnsupportedFeaturedException):
1600 await controller.cmd_volume_mute("player_1", True)
1601 volume_mute.assert_not_awaited()
1602
1603 async def test_fake_mute_without_volume_control_raises(self, mock_mass: MagicMock) -> None:
1604 """Fake mute needs a volume control to drive, so it rejects the command outright."""
1605 controller, player = self._make_player(
1606 mock_mass,
1607 mute_control=PLAYER_CONTROL_FAKE,
1608 volume_control=PLAYER_CONTROL_NONE,
1609 )
1610 player._attr_volume_level = 40
1611
1612 with pytest.raises(UnsupportedFeaturedException):
1613 await controller.cmd_volume_mute("player_1", True)
1614 assert ATTR_PREVIOUS_VOLUME not in player.extra_data
1615 assert ATTR_FAKE_MUTE not in player.extra_data
1616
1617 async def test_vanished_mute_control_raises(self, mock_mass: MagicMock) -> None:
1618 """A mute control that disappeared after being resolved is reported, not ignored."""
1619 control = PlayerControl(
1620 id="ext_mute",
1621 provider="test",
1622 name="External Mute",
1623 supports_mute=True,
1624 mute_set=AsyncMock(),
1625 )
1626 controller, player = self._make_player(
1627 mock_mass,
1628 mute_control="ext_mute",
1629 volume_control=PLAYER_CONTROL_NONE,
1630 controls={"ext_mute": control},
1631 )
1632 # the resolved control is cached on the player, so removing it here leaves
1633 # the player pointing at a control that no longer exists
1634 assert player.mute_control == "ext_mute"
1635 controller._controls = {}
1636
1637 with pytest.raises(UnsupportedFeaturedException):
1638 await controller.cmd_volume_mute("player_1", True)
1639
1640 async def test_unmute_clears_mute_lock_without_mute_control(self, mock_mass: MagicMock) -> None:
1641 """Unmuting clears a mute lock left behind by a since-removed mute control."""
1642 controller, player = self._make_player(
1643 mock_mass,
1644 mute_control=PLAYER_CONTROL_NONE,
1645 volume_control=PLAYER_CONTROL_NATIVE,
1646 )
1647 player.extra_data[ATTR_MUTE_LOCK] = True
1648
1649 with pytest.raises(UnsupportedFeaturedException):
1650 await controller.cmd_volume_mute("player_1", False)
1651 assert ATTR_MUTE_LOCK not in player.extra_data
1652
1653 async def test_failed_mute_sets_no_mute_lock(self, mock_mass: MagicMock) -> None:
1654 """A grouped player whose mute command failed is not left holding a mute lock."""
1655 control = PlayerControl(
1656 id="ext_mute",
1657 provider="test",
1658 name="External Mute",
1659 supports_mute=False,
1660 )
1661 controller, player = self._make_player(
1662 mock_mass,
1663 mute_control="ext_mute",
1664 volume_control=PLAYER_CONTROL_NONE,
1665 controls={"ext_mute": control},
1666 )
1667 player.state.synced_to = "leader"
1668
1669 with pytest.raises(UnsupportedFeaturedException):
1670 await controller.cmd_volume_mute("player_1", True)
1671 assert ATTR_MUTE_LOCK not in player.extra_data
1672
1673 async def test_failed_mute_keeps_existing_mute_lock(self, mock_mass: MagicMock) -> None:
1674 """A failed mute leaves the lock of an earlier successful mute in place."""
1675 control = PlayerControl(
1676 id="ext_mute",
1677 provider="test",
1678 name="External Mute",
1679 supports_mute=False,
1680 )
1681 controller, player = self._make_player(
1682 mock_mass,
1683 mute_control="ext_mute",
1684 volume_control=PLAYER_CONTROL_NONE,
1685 controls={"ext_mute": control},
1686 )
1687 player.state.synced_to = "leader"
1688 player.extra_data[ATTR_MUTE_LOCK] = True
1689
1690 with pytest.raises(UnsupportedFeaturedException):
1691 await controller.cmd_volume_mute("player_1", True)
1692 assert player.extra_data[ATTR_MUTE_LOCK] is True
1693
1694
1695class TestGroupMuteMemberFilter:
1696 """Group mute skips members that have no mute control of their own."""
1697
1698 async def test_member_without_mute_control_is_skipped(self, mock_mass: MagicMock) -> None:
1699 """A member without a mute control must not fail the whole group command."""
1700 controller = PlayerController(mock_mass)
1701 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1702 leader = MockPlayer(provider, "leader", "Leader")
1703 leader._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
1704 leader._attr_group_members = ["leader", "member"]
1705 member = MockPlayer(provider, "member", "Member")
1706 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
1707 controller._players = {"leader": leader, "member": member}
1708 mock_mass.players = controller
1709 mock_mass.player_queues.get = MagicMock(return_value=None)
1710 for player in (leader, member):
1711 player.set_initialized()
1712 player.update_state(signal_event=False)
1713 leader_mute = AsyncMock()
1714 leader.volume_mute = leader_mute # type: ignore[method-assign]
1715
1716 await controller.cmd_group_volume_mute("leader", True)
1717 leader_mute.assert_awaited_once_with(True)
1718
1719
1720class TestGroupPlayerMuteRedirect:
1721 """A mute command on a group player is handled at group level."""
1722
1723 def _setup(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer, MockPlayer]:
1724 """Build a controller with a group player holding a single mute capable member."""
1725 controller = PlayerController(mock_mass)
1726 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1727 group = MockPlayer(provider, "group", "Group", player_type=PlayerType.GROUP)
1728 group._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
1729 group._attr_group_members = ["member"]
1730 member = MockPlayer(provider, "member", "Member")
1731 member._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE}
1732 controller._players = {"group": group, "member": member}
1733 mock_mass.players = controller
1734 mock_mass.player_queues.get = MagicMock(return_value=None)
1735 for player in (group, member):
1736 player.set_initialized()
1737 player.update_state(signal_event=False)
1738 return controller, group, member
1739
1740 async def test_mute_on_group_player_is_forwarded_to_members(self, mock_mass: MagicMock) -> None:
1741 """A group player has no mute of its own, so the members must be muted instead."""
1742 controller, _group, member = self._setup(mock_mass)
1743 member_mute = AsyncMock()
1744 member.volume_mute = member_mute # type: ignore[method-assign]
1745
1746 await controller.cmd_volume_mute("group", True)
1747
1748 member_mute.assert_awaited_once_with(True)
1749
1750 async def test_mute_on_group_player_without_own_mute_control(
1751 self, mock_mass: MagicMock
1752 ) -> None:
1753 """A group that has no mute control of its own must still mute its members."""
1754 controller, group, member = self._setup(mock_mass)
1755 group._attr_supported_features = {PlayerFeature.VOLUME_SET}
1756 group._cache.clear()
1757 group.update_state(signal_event=False)
1758 assert group.mute_control == PLAYER_CONTROL_NONE
1759 member_mute = AsyncMock()
1760 member.volume_mute = member_mute # type: ignore[method-assign]
1761
1762 await controller.cmd_volume_mute("group", True)
1763
1764 member_mute.assert_awaited_once_with(True)
1765
1766 async def test_mute_on_group_player_without_mute_capable_members(
1767 self, mock_mass: MagicMock
1768 ) -> None:
1769 """A group whose members cannot mute must not raise, just like group mute itself."""
1770 controller, _group, member = self._setup(mock_mass)
1771 member._attr_supported_features = {PlayerFeature.VOLUME_SET}
1772 member._cache.clear()
1773 member.update_state(signal_event=False)
1774
1775 await controller.cmd_volume_mute("group", True)
1776
1777
1778class TestGroupMuteOnNonGroupPlayer:
1779 """A group mute command works on any player, just like the group volume command."""
1780
1781 def _setup(
1782 self, mock_mass: MagicMock, *members: str
1783 ) -> tuple[PlayerController, dict[str, MockPlayer]]:
1784 """Build a controller with a mute capable leader synced to the given members."""
1785 controller = PlayerController(mock_mass)
1786 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1787 players: dict[str, MockPlayer] = {}
1788 for player_id in ("leader", *members):
1789 player = MockPlayer(provider, player_id, player_id.title())
1790 player._attr_supported_features = {
1791 PlayerFeature.VOLUME_SET,
1792 PlayerFeature.VOLUME_MUTE,
1793 }
1794 player._attr_volume_level = 50
1795 players[player_id] = player
1796 if members:
1797 # the leader is not listed as its own member here, so the tests also cover
1798 # that a sync leader is injected into its own final group_members
1799 players["leader"]._attr_group_members = list(members)
1800 controller._players = dict(players)
1801 mock_mass.players = controller
1802 mock_mass.player_queues.get = MagicMock(return_value=None)
1803 for player in players.values():
1804 player.set_initialized()
1805 player._cache.clear()
1806 player.update_state(signal_event=False)
1807 return controller, players
1808
1809 def _stub_mutes(self, players: dict[str, MockPlayer]) -> dict[str, AsyncMock]:
1810 """Replace the native mute command of every given player with a mock."""
1811 mutes: dict[str, AsyncMock] = {}
1812 for player_id, player in players.items():
1813 mutes[player_id] = AsyncMock()
1814 player.volume_mute = mutes[player_id] # type: ignore[method-assign]
1815 return mutes
1816
1817 async def test_group_mute_on_synced_member_redirects_to_leader(
1818 self, mock_mass: MagicMock
1819 ) -> None:
1820 """A member of a sync group mutes the whole group through its sync leader."""
1821 controller, players = self._setup(mock_mass, "member")
1822 assert players["member"].state.synced_to == "leader"
1823 mutes = self._stub_mutes(players)
1824
1825 await controller.cmd_group_volume_mute("member", True)
1826
1827 mutes["leader"].assert_awaited_once_with(True)
1828 mutes["member"].assert_awaited_once_with(True)
1829 assert ATTR_MUTE_LOCK in players["member"].extra_data
1830
1831 async def test_group_mute_on_sync_leader_mutes_the_leader_once(
1832 self, mock_mass: MagicMock
1833 ) -> None:
1834 """A sync leader is part of its own member list, so it must be muted only once."""
1835 controller, players = self._setup(mock_mass, "member")
1836 mutes = self._stub_mutes(players)
1837
1838 await controller.cmd_group_volume_mute("leader", True)
1839
1840 mutes["leader"].assert_awaited_once_with(True)
1841 mutes["member"].assert_awaited_once_with(True)
1842
1843 async def test_group_mute_on_plain_player_mutes_that_player(self, mock_mass: MagicMock) -> None:
1844 """A player that is not grouped at all is muted as a normal player."""
1845 controller, players = self._setup(mock_mass)
1846 mutes = self._stub_mutes(players)
1847
1848 await controller.cmd_group_volume_mute("leader", True)
1849
1850 mutes["leader"].assert_awaited_once_with(True)
1851
1852 async def test_group_mute_on_plain_player_without_mute_control(
1853 self, mock_mass: MagicMock
1854 ) -> None:
1855 """A plain player that cannot mute reports that, just like a normal mute command."""
1856 controller, players = self._setup(mock_mass)
1857 players["leader"]._attr_supported_features = {PlayerFeature.VOLUME_SET}
1858 players["leader"]._cache.clear()
1859 players["leader"].update_state(signal_event=False)
1860
1861 with pytest.raises(UnsupportedFeaturedException):
1862 await controller.cmd_group_volume_mute("leader", True)
1863
1864 async def test_group_unmute_on_synced_member_redirects_to_leader(
1865 self, mock_mass: MagicMock
1866 ) -> None:
1867 """Unmuting through a member clears the mute (and mute lock) of every group member."""
1868 controller, players = self._setup(mock_mass, "member")
1869 mutes = self._stub_mutes(players)
1870 players["member"].extra_data[ATTR_MUTE_LOCK] = True
1871
1872 await controller.cmd_group_volume_mute("member", False)
1873
1874 mutes["leader"].assert_awaited_once_with(False)
1875 mutes["member"].assert_awaited_once_with(False)
1876 assert ATTR_MUTE_LOCK not in players["member"].extra_data
1877
1878 async def test_group_mute_locks_the_sync_leader_too(self, mock_mass: MagicMock) -> None:
1879 """A sync leader is as much part of the group as its members, so it is locked too."""
1880 controller, players = self._setup(mock_mass, "member")
1881 self._stub_mutes(players)
1882
1883 await controller.cmd_group_volume_mute("leader", True)
1884
1885 assert ATTR_MUTE_LOCK in players["leader"].extra_data
1886 assert ATTR_MUTE_LOCK in players["member"].extra_data
1887
1888 async def test_group_volume_keeps_a_muted_sync_pair_muted(self, mock_mass: MagicMock) -> None:
1889 """A group volume change may not half-unmute a muted pair of directly synced players."""
1890 controller, players = self._setup(mock_mass, "member")
1891 self._stub_mutes(players)
1892 await controller.cmd_group_volume_mute("leader", True)
1893 # the mock players do not act on the mute command, so reflect it in their state
1894 for player in players.values():
1895 player._attr_volume_muted = True
1896 player.update_state(signal_event=False)
1897 player.volume_set = AsyncMock() # type: ignore[method-assign]
1898 # re-stub so only the mute commands of the group volume change are counted
1899 mutes = self._stub_mutes(players)
1900
1901 await controller.cmd_group_volume("leader", 30)
1902
1903 for mute in mutes.values():
1904 mute.assert_not_awaited()
1905
1906
1907class TestCurrentMediaTimeUpdates:
1908 """Playback-position anchor semantics of timing-only state updates."""
1909
1910 def _make_player(self, mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
1911 """Build a controller with a single playing player with a known position anchor."""
1912 controller = PlayerController(mock_mass)
1913 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
1914 player = MockPlayer(provider, "player_1", "Player 1")
1915 controller._players = {"player_1": player}
1916 mock_mass.players = controller
1917 # no queue registered: current_media resolves from the player's native media
1918 mock_mass.player_queues.get = MagicMock(return_value=None)
1919 player.set_initialized()
1920 now = time.time()
1921 player._attr_playback_state = PlaybackState.PLAYING
1922 player._attr_elapsed_time = 17
1923 player._attr_elapsed_time_last_updated = now
1924 player.set_current_media(uri="http://test/stream", title="Test")
1925 assert player._attr_current_media is not None
1926 player._attr_current_media.elapsed_time = 17
1927 player._attr_current_media.elapsed_time_last_updated = now
1928 player.update_state(signal_event=False)
1929 # isolate from the unrelated state-forwarding machinery
1930 controller._forward_state_update = MagicMock() # type: ignore[method-assign]
1931 mock_mass.signal_event.reset_mock()
1932 mock_mass.player_queues.on_player_elapsed_time_corrected.reset_mock()
1933 return controller, player
1934
1935 def _player_updated_signalled(self, mock_mass: MagicMock) -> bool:
1936 """Return whether a PLAYER_UPDATED event was signalled."""
1937 return any(
1938 call.args and call.args[0] == EventType.PLAYER_UPDATED
1939 for call in mock_mass.signal_event.call_args_list
1940 )
1941
1942 def test_regular_tick_is_suppressed(self, mock_mass: MagicMock) -> None:
1943 """A regular playback tick (position and anchor advance together) emits nothing."""
1944 _controller, player = self._make_player(mock_mass)
1945 assert player._attr_current_media is not None
1946 assert player._attr_elapsed_time_last_updated is not None
1947
1948 player._attr_elapsed_time = 18
1949 player._attr_elapsed_time_last_updated += 1
1950 player._attr_current_media.elapsed_time = 18
1951 assert player._attr_current_media.elapsed_time_last_updated is not None
1952 player._attr_current_media.elapsed_time_last_updated += 1
1953 player.update_state()
1954
1955 assert not self._player_updated_signalled(mock_mass)
1956 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
1957 # the previous anchor was preserved: steady playback changes nothing
1958 assert player.state.elapsed_time == 17
1959
1960 def test_anchor_only_change_is_suppressed(self, mock_mass: MagicMock) -> None:
1961 """An anchor-only change (no significant corrected position change) emits nothing."""
1962 _controller, player = self._make_player(mock_mass)
1963 assert player._attr_current_media is not None
1964 assert player._attr_elapsed_time_last_updated is not None
1965
1966 player._attr_elapsed_time_last_updated += 0.5
1967 assert player._attr_current_media.elapsed_time_last_updated is not None
1968 player._attr_current_media.elapsed_time_last_updated += 0.5
1969 player.update_state()
1970
1971 assert not self._player_updated_signalled(mock_mass)
1972 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_not_called()
1973
1974 def test_corrected_position_jump_emits_player_updated(self, mock_mass: MagicMock) -> None:
1975 """A corrected-position jump of the current media (e.g. seek) emits a player update."""
1976 _controller, player = self._make_player(mock_mass)
1977 assert player._attr_current_media is not None
1978
1979 player._attr_current_media.elapsed_time = 61
1980 player._attr_current_media.elapsed_time_last_updated = time.time()
1981 player.update_state()
1982
1983 assert self._player_updated_signalled(mock_mass)
1984 # the adopted anchor is visible to consumers
1985 assert player.state.current_media is not None
1986 assert player.state.current_media.elapsed_time == 61
1987
1988 def test_player_position_jump_corrects_queue(self, mock_mass: MagicMock) -> None:
1989 """A player-level corrected-position jump re-bases the queue timing."""
1990 controller, player = self._make_player(mock_mass)
1991
1992 player._attr_elapsed_time = 61
1993 player._attr_elapsed_time_last_updated = time.time()
1994 player.update_state()
1995
1996 # the queue is corrected and a follow-up player update is scheduled
1997 # (which re-anchors current_media onto the corrected queue time),
1998 # but no full player update is emitted for the jump itself
1999 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2000 assert not self._player_updated_signalled(mock_mass)
2001 cast("MagicMock", controller._forward_state_update).assert_called_once()
2002 assert player.state.elapsed_time == 61
2003
2004 def test_simultaneous_player_and_media_jump_emits_immediately(
2005 self, mock_mass: MagicMock
2006 ) -> None:
2007 """A jump reaching player and current_media in one pass corrects the queue and emits."""
2008 _controller, player = self._make_player(mock_mass)
2009 assert player._attr_current_media is not None
2010 now = time.time()
2011
2012 player._attr_elapsed_time = 61
2013 player._attr_elapsed_time_last_updated = now
2014 player._attr_current_media.elapsed_time = 61
2015 player._attr_current_media.elapsed_time_last_updated = now
2016 player.update_state()
2017
2018 # the queue is re-based AND the full update is emitted right away
2019 # (current_media already holds the fresh position in the same pass)
2020 mock_mass.player_queues.on_player_elapsed_time_corrected.assert_called_once_with(player)
2021 assert self._player_updated_signalled(mock_mass)
2022
2023
2024class TestPlayAnnouncementCleanup:
2025 """Test announcement data cleanup after play_announcement."""
2026
2027 def _make_player(
2028 self, mock_mass: MagicMock, announcements: dict[str, object]
2029 ) -> tuple[PlayerController, MockPlayer, MagicMock]:
2030 """Create a controller and a player with native announcement support."""
2031 controller = PlayerController(mock_mass)
2032 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2033 player = MockPlayer(provider, "player_1", "Player 1")
2034 player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
2035 player._cache.clear()
2036 controller._players = {"player_1": player}
2037 mock_mass.players = controller
2038 render = MagicMock()
2039 render.wait_ready = AsyncMock(return_value=True)
2040 render.wait_finished = AsyncMock(return_value=3.0)
2041
2042 # mimic the real renderer: it owns which announcement each player is playing
2043 def _register(player_id: str, announce_data: object) -> MagicMock:
2044 announcements[player_id] = announce_data
2045 return render
2046
2047 async def _unregister(player_id: str, _render: object) -> None:
2048 announcements.pop(player_id, None)
2049
2050 renderer = mock_mass.streams.announcement_renderer
2051 renderer.register = MagicMock(side_effect=_register)
2052 renderer.unregister = AsyncMock(side_effect=_unregister)
2053 mock_mass.streams.get_announcement_url = MagicMock(
2054 side_effect=lambda player_id, **_kwargs: f"http://ma/announcement/{player_id}.mp3"
2055 )
2056 player.update_state(signal_event=False)
2057 return controller, player, render
2058
2059 async def test_announcement_data_removed_after_playback(self, mock_mass: MagicMock) -> None:
2060 """The registered announcement data is released once playback finished."""
2061 announcements: dict[str, object] = {}
2062 controller, player, _render = self._make_player(mock_mass, announcements)
2063
2064 async def _play_announcement(*_args: object, **_kwargs: object) -> None:
2065 # entry must exist while the announcement is being played/served
2066 assert "player_1" in announcements
2067
2068 player.play_announcement = AsyncMock(side_effect=_play_announcement) # type: ignore[method-assign]
2069
2070 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2071
2072 player.play_announcement.assert_awaited_once()
2073 assert announcements == {}
2074 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2075
2076 async def test_announcement_data_removed_on_error(self, mock_mass: MagicMock) -> None:
2077 """The registered announcement data is released even when playback fails."""
2078 announcements: dict[str, object] = {}
2079 controller, player, _render = self._make_player(mock_mass, announcements)
2080 player.play_announcement = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
2081
2082 with pytest.raises(PlayerCommandFailed):
2083 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2084
2085 assert announcements == {}
2086 mock_mass.streams.announcement_renderer.unregister.assert_awaited_once()
2087
2088 async def test_native_announcement_starts_on_first_audio(self, mock_mass: MagicMock) -> None:
2089 """A native implementation is handed the url as soon as there is audio to serve."""
2090 announcements: dict[str, object] = {}
2091 controller, player, render = self._make_player(mock_mass, announcements)
2092 player.play_announcement = AsyncMock() # type: ignore[method-assign]
2093
2094 await controller.play_announcement("player_1", "http://test/announcement.mp3")
2095
2096 player.play_announcement.assert_awaited_once()
2097 # waiting for the whole clip here would delay the player for a slow source;
2098 # the length is resolved downstream while it plays
2099 render.wait_ready.assert_awaited_once()
2100 render.wait_finished.assert_not_awaited()
2101
2102
2103class TestPlayAnnouncementRestore:
2104 """Test the state restore of the default (fallback) announcement implementation."""
2105
2106 def _make_player(
2107 self, mock_mass: MagicMock, prev_media: PlayerMedia
2108 ) -> tuple[PlayerController, MockPlayer, AsyncMock]:
2109 """Create a controller and a playing player, returning the patched resume handler."""
2110 controller = PlayerController(mock_mass)
2111 provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
2112 player = MockPlayer(provider, "player_1", "Player 1")
2113 player._attr_playback_state = PlaybackState.PLAYING
2114 player._attr_current_media = prev_media
2115 player._cache.clear()
2116 controller._players = {"player_1": player}
2117 mock_mass.players = controller
2118 # the (temporary and restored) volume commands are dispatched through the
2119 # TaskManager, so background tasks must actually run in these tests
2120 mock_mass.create_task = MagicMock(
2121 side_effect=lambda coro, **_kwargs: asyncio.ensure_future(coro)
2122 )
2123 resume_mock = AsyncMock()
2124 controller._handle_cmd_resume = resume_mock # type: ignore[method-assign]
2125 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
2126 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
2127 controller._wait_for_playback_state = AsyncMock() # type: ignore[method-assign]
2128 controller.get_announcement_volume = MagicMock(return_value=None) # type: ignore[method-assign]
2129 player.set_initialized()
2130 player.update_state(signal_event=False)
2131 return controller, player, resume_mock
2132
2133 @staticmethod
2134 def _add_group(
2135 controller: PlayerController, player: MockPlayer, *, supports_set_members: bool
2136 ) -> MockPlayer:
2137 """Register a powered group player that holds the given player as its member."""
2138 group = MockPlayer(
2139 cast("MockProvider", player.provider),
2140 "group_1",
2141 "Group 1",
2142 player_type=PlayerType.GROUP,
2143 )
2144 group._attr_powered = True
2145 group._attr_group_members = [player.player_id]
2146 if supports_set_members:
2147 group._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2148 controller._players[group.player_id] = group
2149 group.set_initialized()
2150 group._cache.clear()
2151 group.update_state(signal_event=False)
2152 # the member has no own input change to trigger a recalculation of its
2153 # (group derived) state, so force it here - just like register() does
2154 player._cache.clear()
2155 player.update_state(force_update=True, signal_event=False)
2156 assert player.state.active_group == group.player_id
2157 return group
2158
2159 @staticmethod
2160 def _announcement() -> PlayerMedia:
2161 """Return the announcement to play."""
2162 return PlayerMedia(
2163 uri="http://ma/announcement/player_1.mp3",
2164 media_type=MediaType.ANNOUNCEMENT,
2165 title="Announcement",
2166 duration=3,
2167 )
2168
2169 async def test_previous_playback_is_restored(self, mock_mass: MagicMock) -> None:
2170 """Content that was playing before the announcement is resumed afterwards."""
2171 controller, player, resume_mock = self._make_player(
2172 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2173 )
2174
2175 await controller._play_announcement(player, self._announcement())
2176
2177 resume_mock.assert_awaited_once()
2178
2179 async def test_previous_announcement_is_not_restored(self, mock_mass: MagicMock) -> None:
2180 """A player still busy with an earlier announcement has no playback to restore."""
2181 controller, player, resume_mock = self._make_player(
2182 mock_mass,
2183 PlayerMedia(uri="http://ma/announcement/x.mp3", media_type=MediaType.ANNOUNCEMENT),
2184 )
2185
2186 await controller._play_announcement(player, self._announcement())
2187
2188 resume_mock.assert_not_awaited()
2189
2190 async def test_volume_is_restored_when_playback_fails(self, mock_mass: MagicMock) -> None:
2191 """A failing announcement never leaves the player at the raised volume."""
2192 controller, player, _ = self._make_player(
2193 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2194 )
2195 player._attr_volume_level = 20
2196 player._cache.clear()
2197 player.update_state(force_update=True, signal_event=False)
2198 controller.get_announcement_volume = MagicMock(return_value=80) # type: ignore[method-assign]
2199 volume_mock = AsyncMock()
2200 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2201 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2202 side_effect=PlayerCommandFailed("player went away")
2203 )
2204
2205 with pytest.raises(PlayerCommandFailed):
2206 await controller._play_announcement(player, self._announcement())
2207
2208 assert volume_mock.call_args_list == [call("player_1", 80), call("player_1", 20)]
2209
2210 async def test_zero_announcement_volume_is_applied_and_restored(
2211 self, mock_mass: MagicMock
2212 ) -> None:
2213 """An announcement volume of 0 is a real volume, not an 'unset' fallback."""
2214 controller, player, _ = self._make_player(
2215 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2216 )
2217 player._attr_volume_level = 20
2218 player._cache.clear()
2219 player.update_state(force_update=True, signal_event=False)
2220 controller.get_announcement_volume = MagicMock(return_value=0) # type: ignore[method-assign]
2221 volume_mock = AsyncMock()
2222 controller._handle_cmd_volume_set = volume_mock # type: ignore[method-assign]
2223
2224 await controller._play_announcement(player, self._announcement())
2225
2226 assert volume_mock.call_args_list == [call("player_1", 0), call("player_1", 20)]
2227
2228 async def test_playback_is_restored_when_duration_is_unknown(
2229 self, mock_mass: MagicMock
2230 ) -> None:
2231 """An announcement of unknown length still hands the player back to its content."""
2232 controller, player, resume_mock = self._make_player(
2233 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2234 )
2235 announcement = self._announcement()
2236 announcement.duration = None
2237
2238 # an unknown length waits for the player to report it finished instead of failing
2239 await controller._play_announcement(player, announcement)
2240
2241 resume_mock.assert_awaited_once()
2242
2243 async def test_group_membership_is_restored_when_playback_fails(
2244 self, mock_mass: MagicMock
2245 ) -> None:
2246 """A failing announcement never leaves the player out of its group player."""
2247 controller, player, _ = self._make_player(
2248 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2249 )
2250 group = self._add_group(controller, player, supports_set_members=True)
2251 group.set_members = AsyncMock() # type: ignore[method-assign]
2252 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2253 side_effect=PlayerCommandFailed("player went away")
2254 )
2255
2256 with pytest.raises(PlayerCommandFailed):
2257 await controller._play_announcement(player, self._announcement())
2258
2259 assert group.set_members.await_args_list == [
2260 call(player_ids_to_remove=["player_1"]),
2261 call(player_ids_to_add=["player_1"]),
2262 ]
2263
2264 async def test_restore_failure_does_not_mask_the_announcement_error(
2265 self, mock_mass: MagicMock
2266 ) -> None:
2267 """A provider blowing up during the restore must not hide why the announcement failed."""
2268 controller, player, _ = self._make_player(
2269 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2270 )
2271 group = self._add_group(controller, player, supports_set_members=True)
2272 # set_members is a raw provider call: whatever its client library raises comes
2273 # through unwrapped, so the ungroup succeeds and the regroup times out
2274 group.set_members = AsyncMock( # type: ignore[method-assign]
2275 side_effect=[None, TimeoutError("provider timeout")]
2276 )
2277 controller._handle_play_media = AsyncMock( # type: ignore[method-assign]
2278 side_effect=PlayerCommandFailed("player went away")
2279 )
2280
2281 with pytest.raises(PlayerCommandFailed, match="player went away"):
2282 await controller._play_announcement(player, self._announcement())
2283
2284 async def test_group_without_set_members_is_the_one_powered_off(
2285 self, mock_mass: MagicMock
2286 ) -> None:
2287 """A group that can not release members is powered off, not the announcement target."""
2288 controller, player, _ = self._make_player(
2289 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2290 )
2291 self._add_group(controller, player, supports_set_members=False)
2292 power_mock = AsyncMock()
2293 controller._handle_cmd_power = power_mock # type: ignore[method-assign]
2294 play_mock = AsyncMock()
2295 controller.cmd_play = play_mock # type: ignore[method-assign]
2296
2297 await controller._play_announcement(player, self._announcement())
2298
2299 # the group is switched off for the announcement and restarted afterwards
2300 power_mock.assert_awaited_once_with("group_1", False)
2301 play_mock.assert_awaited_once_with("group_1")
2302
2303 async def test_idle_player_without_power_control_is_regrouped(
2304 self, mock_mass: MagicMock
2305 ) -> None:
2306 """An idle player that has no power state to restore is still put back in its group."""
2307 controller, player, _ = self._make_player(
2308 mock_mass, PlayerMedia(uri="http://test/track.mp3", media_type=MediaType.TRACK)
2309 )
2310 player._attr_playback_state = PlaybackState.IDLE
2311 player._attr_powered = None
2312 group = self._add_group(controller, player, supports_set_members=True)
2313 assert player.state.power_control == PLAYER_CONTROL_NONE
2314 group.set_members = AsyncMock() # type: ignore[method-assign]
2315
2316 await controller._play_announcement(player, self._announcement())
2317
2318 assert group.set_members.await_args_list == [
2319 call(player_ids_to_remove=["player_1"]),
2320 call(player_ids_to_add=["player_1"]),
2321 ]
2322
2323
2324class TestScheduleActiveOutputProtocolClear:
2325 """Test the deferred clear of a player's active output protocol."""
2326
2327 def test_schedule_starts_cancellable_clear_task(self, mock_mass: MagicMock) -> None:
2328 """Scheduling defers the clear to a single, per-player, cancellable task."""
2329 controller = PlayerController(mock_mass)
2330 player = MagicMock()
2331 player.player_id = "player_1"
2332
2333 controller.schedule_active_output_protocol_clear(player)
2334
2335 mock_mass.create_task.assert_called_once()
2336 # close the coroutine passed to the mocked create_task to avoid a
2337 # "coroutine was never awaited" warning
2338 mock_mass.create_task.call_args.args[0].close()
2339 # no abort_existing: a duplicate schedule must reuse the pending clear
2340 # (deduped by task_id) instead of replacing it with an untracked task
2341 assert mock_mass.create_task.call_args.kwargs == {
2342 "task_id": "clear_active_protocol_player_1",
2343 }
2344
2345 @pytest.mark.asyncio
2346 async def test_clears_protocol_once_player_idle(self, mock_mass: MagicMock) -> None:
2347 """The protocol is cleared after waiting for the player to reach IDLE."""
2348 controller = PlayerController(mock_mass)
2349 player = MagicMock()
2350 player.player_id = "player_1"
2351
2352 with patch.object(controller, "_wait_for_playback_state", new=AsyncMock()) as wait_mock:
2353 await controller._clear_active_output_protocol_when_idle(player)
2354
2355 wait_mock.assert_awaited_once_with(player, PlaybackState.IDLE, timeout=10)
2356 player.set_active_output_protocol.assert_called_once_with(None)
2357
2358
2359@contextlib.asynccontextmanager
2360async def _skip_player_update_wait(
2361 *_args: object,
2362 **_kwargs: object,
2363) -> AsyncIterator[None]:
2364 """Skip provider-driven state propagation in command-routing tests."""
2365 yield
2366
2367
2368class TestRemovePlayerControl:
2369 """Test removing a registered player control."""
2370
2371 def test_removal_refreshes_the_players_that_used_it(self, mock_mass: MagicMock) -> None:
2372 """Test that only the players configured to use the removed control are refreshed."""
2373 mock_mass.loop = MagicMock()
2374 controller = PlayerController(mock_mass)
2375 using_control = MagicMock()
2376 using_control.state.power_control = "switch.amp"
2377 using_control.state.volume_control = PLAYER_CONTROL_NATIVE
2378 using_control.state.mute_control = PLAYER_CONTROL_NATIVE
2379 unrelated = MagicMock()
2380 unrelated.state.power_control = PLAYER_CONTROL_NATIVE
2381 unrelated.state.volume_control = PLAYER_CONTROL_NATIVE
2382 unrelated.state.mute_control = PLAYER_CONTROL_NATIVE
2383 controller._players = {"using_control": using_control, "unrelated": unrelated}
2384 controller._controls = {
2385 "switch.amp": PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
2386 }
2387
2388 controller.remove_player_control("switch.amp")
2389
2390 assert controller.player_controls() == []
2391 mock_mass.loop.call_soon.assert_called_once_with(using_control.refresh_state)
2392
2393 async def test_a_returning_control_is_picked_back_up(self, mock_mass: MagicMock) -> None:
2394 """Test that a control removed and registered again re-attaches to its player."""
2395 # run the scheduled refresh straight away so each step is observable
2396 mock_mass.loop = MagicMock()
2397 mock_mass.loop.call_soon.side_effect = lambda callback, *args: callback(*args)
2398 mock_mass.config.get_raw_player_config_value.side_effect = _player_config_stub(
2399 {CONF_POWER_CONTROL: "switch.amp"}
2400 )
2401 controller = PlayerController(mock_mass)
2402 mock_mass.players = controller
2403 provider = MockProvider("test_provider", instance_id="test_prov", mass=mock_mass)
2404 mock_mass.get_provider.return_value = provider
2405 player = MockPlayer(provider, "player", "Player")
2406 controller._players = {"player": player}
2407 control = PlayerControl(id="switch.amp", provider="test_prov", name="Amp")
2408
2409 await controller.register_or_update_player_control(control)
2410 assert player.state.power_control == "switch.amp"
2411
2412 # the Home Assistant plugin drops and re-registers its controls around a reload
2413 controller.remove_player_control(control.id)
2414 assert player.state.power_control == PLAYER_CONTROL_NONE
2415
2416 await controller.register_or_update_player_control(control)
2417 assert player.state.power_control == "switch.amp"
2418
2419 def test_removing_an_unknown_control_does_nothing(self, mock_mass: MagicMock) -> None:
2420 """Test that removing a control that was never registered is a no-op."""
2421 mock_mass.loop = MagicMock()
2422 controller = PlayerController(mock_mass)
2423 player = MagicMock()
2424 player.state.power_control = PLAYER_CONTROL_NATIVE
2425 player.state.volume_control = PLAYER_CONTROL_NATIVE
2426 player.state.mute_control = PLAYER_CONTROL_NATIVE
2427 controller._players = {"player": player}
2428
2429 controller.remove_player_control("switch.gone")
2430
2431 mock_mass.loop.call_soon.assert_not_called()
2432
2433
2434if __name__ == "__main__":
2435 pytest.main([__file__, "-v"])
2436