/
/
/
1"""
2Tests for Player control auto-select and explicit config logic.
3
4Covers the power_control, volume_control, and mute_control properties
5on the Player model, including:
6- Explicit config values (native, fake, none)
7- Explicit config pointing to a protocol player or player control
8- Auto-select fallback: native -> protocol player -> none
9- Protocol player preference for active output protocol
10"""
11
12from __future__ import annotations
13
14from typing import Any
15from unittest.mock import MagicMock
16
17import pytest
18from music_assistant_models.constants import (
19 PLAYER_CONTROL_FAKE,
20 PLAYER_CONTROL_NATIVE,
21 PLAYER_CONTROL_NONE,
22)
23from music_assistant_models.enums import PlayerFeature
24from music_assistant_models.player import OutputProtocol
25
26from music_assistant.constants import (
27 CONF_MUTE_CONTROL,
28 CONF_POWER_CONTROL,
29 CONF_PREFERRED_OUTPUT_PROTOCOL,
30 CONF_VOLUME_CONTROL,
31)
32from tests.common import MockPlayer, MockProvider
33
34_PROTO_DOMAIN = "test_protocol"
35
36
37@pytest.fixture
38def mock_mass() -> MagicMock:
39 """Create a mock MusicAssistant instance."""
40 mass = MagicMock()
41 mass.closing = False
42 mass.loop = None
43 mass.config.get = MagicMock(return_value=[])
44 mass.config.get_raw_player_config_value = MagicMock(return_value=None)
45 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
46 mass.config.set = MagicMock()
47 mass.signal_event = MagicMock()
48 mass.get_providers = MagicMock(return_value=[])
49 mass.players.get_player = MagicMock(return_value=None)
50 mass.players.get_player_control = MagicMock(return_value=None)
51 return mass
52
53
54def _make_config_side_effect(
55 overrides: dict[str, str | None],
56) -> Any:
57 """Create a side_effect for get_raw_player_config_value."""
58
59 def _side_effect(_pid: str, key: str, *_args: Any) -> str | None:
60 return overrides.get(key)
61
62 return _side_effect
63
64
65def _create_player(
66 mock_mass: MagicMock,
67 features: set[PlayerFeature] | None = None,
68) -> MockPlayer:
69 """Create a MockPlayer with the given features."""
70 provider = MockProvider("test", mass=mock_mass)
71 player = MockPlayer(provider, "main_player", "Main")
72 player._attr_supported_features = features or set()
73 player._cache.clear()
74 return player
75
76
77def _create_protocol_player(
78 mock_mass: MagicMock,
79 player_id: str,
80 features: set[PlayerFeature],
81 available: bool = True,
82) -> MockPlayer:
83 """Create a mock protocol player."""
84 provider = MockProvider("protocol", mass=mock_mass)
85 player = MockPlayer(provider, player_id, f"Protocol {player_id}")
86 player._attr_supported_features = features
87 player._attr_available = available
88 player._cache.clear()
89 return player
90
91
92def _make_output_protocol(
93 protocol_id: str,
94 name: str,
95 priority: int = 0,
96) -> OutputProtocol:
97 """Create an OutputProtocol for testing."""
98 return OutputProtocol(
99 output_protocol_id=protocol_id,
100 name=name,
101 protocol_domain=_PROTO_DOMAIN,
102 priority=priority,
103 )
104
105
106def _link_protocols(
107 player: MockPlayer,
108 protocols: list[OutputProtocol],
109 active_protocol_id: str | None = None,
110) -> None:
111 """Link output protocols to a player and optionally set active protocol."""
112 player.set_linked_output_protocols(protocols)
113 if active_protocol_id is not None:
114 player.set_active_output_protocol(active_protocol_id)
115 player._cache.clear()
116
117
118class TestPowerControlExplicitConfig:
119 """Test power_control with explicit config values."""
120
121 def test_explicit_native(self, mock_mass: MagicMock) -> None:
122 """Power control returns native when explicitly configured and feature is supported."""
123 mock_mass.config.get_raw_player_config_value = MagicMock(
124 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
125 )
126 # NATIVE control requires the player to actually advertise POWER —
127 # otherwise the getter degrades to NONE (e.g. when a group player
128 # drops POWER from its feature set on upgrade but its stale config
129 # still says NATIVE).
130 player = _create_player(mock_mass, features={PlayerFeature.POWER})
131 assert player.power_control == PLAYER_CONTROL_NATIVE
132
133 def test_explicit_native_degrades_when_feature_missing(self, mock_mass: MagicMock) -> None:
134 """Stale NATIVE config falls back to NONE when the player no longer advertises POWER."""
135 mock_mass.config.get_raw_player_config_value = MagicMock(
136 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
137 )
138 # no POWER in features → the explicit NATIVE in config is invalid
139 player = _create_player(mock_mass)
140 assert player.power_control == PLAYER_CONTROL_NONE
141
142 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
143 """Power control returns fake when explicitly configured."""
144 mock_mass.config.get_raw_player_config_value = MagicMock(
145 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_FAKE})
146 )
147 player = _create_player(mock_mass)
148 assert player.power_control == PLAYER_CONTROL_FAKE
149
150 def test_explicit_none(self, mock_mass: MagicMock) -> None:
151 """Power control returns none when explicitly configured."""
152 mock_mass.config.get_raw_player_config_value = MagicMock(
153 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NONE})
154 )
155 player = _create_player(mock_mass, features={PlayerFeature.POWER})
156 assert player.power_control == PLAYER_CONTROL_NONE
157
158 def test_explicit_protocol_player_id_ignored(self, mock_mass: MagicMock) -> None:
159 """Protocol player IDs are not valid for power control; falls through to auto-select."""
160 protocol = _create_protocol_player(
161 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=True
162 )
163 mock_mass.config.get_raw_player_config_value = MagicMock(
164 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
165 )
166 mock_mass.players.get_player = MagicMock(return_value=protocol)
167 mock_mass.players.get_player_control = MagicMock(return_value=None)
168 player = _create_player(mock_mass)
169 # protocol players handle their own power at the protocol level
170 assert player.power_control == PLAYER_CONTROL_NONE
171
172 def test_explicit_protocol_player_unavailable_falls_through(self, mock_mass: MagicMock) -> None:
173 """When explicitly set to unavailable protocol player, falls to auto-select."""
174 protocol = _create_protocol_player(
175 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=False
176 )
177 mock_mass.config.get_raw_player_config_value = MagicMock(
178 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
179 )
180 mock_mass.players.get_player = MagicMock(return_value=protocol)
181 mock_mass.players.get_player_control = MagicMock(return_value=None)
182 player = _create_player(mock_mass)
183 assert player.power_control == PLAYER_CONTROL_NONE
184
185 def test_explicit_player_control_id(self, mock_mass: MagicMock) -> None:
186 """Power control returns player control ID when set and control exists."""
187 control = MagicMock()
188 control.id = "ext_control_1"
189 mock_mass.config.get_raw_player_config_value = MagicMock(
190 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "ext_control_1"})
191 )
192 mock_mass.players.get_player = MagicMock(return_value=None)
193 mock_mass.players.get_player_control = MagicMock(return_value=control)
194 player = _create_player(mock_mass)
195 assert player.power_control == "ext_control_1"
196
197
198class TestPowerControlAutoSelect:
199 """Test power_control auto-select logic (config is None or 'auto')."""
200
201 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
202 """Auto-select returns native when player supports POWER feature."""
203 player = _create_player(mock_mass, features={PlayerFeature.POWER})
204 assert player.power_control == PLAYER_CONTROL_NATIVE
205
206 def test_auto_does_not_use_protocol_player(self, mock_mass: MagicMock) -> None:
207 """Auto-select does not use protocol players for power; they handle it themselves."""
208 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
209 mock_mass.players.get_player = MagicMock(return_value=protocol)
210 player = _create_player(mock_mass)
211 _link_protocols(
212 player,
213 [_make_output_protocol("proto_power", "Proto Power")],
214 active_protocol_id="proto_power",
215 )
216 assert player.power_control == PLAYER_CONTROL_NONE
217
218 def test_auto_returns_none_without_active_protocol(self, mock_mass: MagicMock) -> None:
219 """Auto-select returns NONE when protocol exists but is not active."""
220 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
221 mock_mass.players.get_player = MagicMock(return_value=protocol)
222 player = _create_player(mock_mass)
223 _link_protocols(player, [_make_output_protocol("proto_power", "Proto Power")])
224 assert player.power_control == PLAYER_CONTROL_NONE
225
226 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
227 """Auto-select returns NONE when no native and no protocol players."""
228 player = _create_player(mock_mass)
229 assert player.power_control == PLAYER_CONTROL_NONE
230
231 def test_auto_with_config_set_to_auto(self, mock_mass: MagicMock) -> None:
232 """Explicit 'auto' config value triggers the same auto-select."""
233 mock_mass.config.get_raw_player_config_value = MagicMock(
234 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "auto"})
235 )
236 player = _create_player(mock_mass, features={PlayerFeature.POWER})
237 assert player.power_control == PLAYER_CONTROL_NATIVE
238
239
240class TestVolumeControlExplicitConfig:
241 """Test volume_control with explicit config values."""
242
243 def test_explicit_native(self, mock_mass: MagicMock) -> None:
244 """Volume control returns native when explicitly configured."""
245 mock_mass.config.get_raw_player_config_value = MagicMock(
246 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
247 )
248 player = _create_player(mock_mass)
249 assert player.volume_control == PLAYER_CONTROL_NATIVE
250
251 def test_explicit_none(self, mock_mass: MagicMock) -> None:
252 """Volume control returns none even with native support."""
253 mock_mass.config.get_raw_player_config_value = MagicMock(
254 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
255 )
256 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
257 assert player.volume_control == PLAYER_CONTROL_NONE
258
259 def test_explicit_protocol_player_id(self, mock_mass: MagicMock) -> None:
260 """Volume control returns protocol player when set and available."""
261 protocol = _create_protocol_player(
262 mock_mass, "vol_player", {PlayerFeature.VOLUME_SET}, available=True
263 )
264 mock_mass.config.get_raw_player_config_value = MagicMock(
265 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: "vol_player"})
266 )
267 mock_mass.players.get_player = MagicMock(return_value=protocol)
268 player = _create_player(mock_mass)
269 assert player.volume_control == "vol_player"
270
271
272class TestVolumeControlAutoSelect:
273 """Test volume_control auto-select logic."""
274
275 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
276 """Auto-select returns native when player supports VOLUME_SET."""
277 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
278 assert player.volume_control == PLAYER_CONTROL_NATIVE
279
280 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
281 """Auto-select returns protocol player for volume."""
282 protocol = _create_protocol_player(mock_mass, "proto_vol", {PlayerFeature.VOLUME_SET})
283 mock_mass.players.get_player = MagicMock(return_value=protocol)
284 player = _create_player(mock_mass)
285 _link_protocols(player, [_make_output_protocol("proto_vol", "Proto Vol")])
286 assert player.volume_control == "proto_vol"
287
288 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
289 """Auto-select returns NONE when nothing supports volume."""
290 player = _create_player(mock_mass)
291 assert player.volume_control == PLAYER_CONTROL_NONE
292
293
294class TestMuteControlExplicitConfig:
295 """Test mute_control with explicit config values."""
296
297 def test_explicit_native(self, mock_mass: MagicMock) -> None:
298 """Mute control returns native when explicitly configured."""
299 mock_mass.config.get_raw_player_config_value = MagicMock(
300 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
301 )
302 player = _create_player(mock_mass)
303 assert player.mute_control == PLAYER_CONTROL_NATIVE
304
305 def test_explicit_none(self, mock_mass: MagicMock) -> None:
306 """Mute control returns none when explicitly configured."""
307 mock_mass.config.get_raw_player_config_value = MagicMock(
308 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NONE})
309 )
310 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
311 assert player.mute_control == PLAYER_CONTROL_NONE
312
313 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
314 """Mute control returns fake when explicitly configured and a volume control exists."""
315 mock_mass.config.get_raw_player_config_value = MagicMock(
316 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
317 )
318 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
319 assert player.mute_control == PLAYER_CONTROL_FAKE
320
321 def test_explicit_fake_degrades_without_volume_control(self, mock_mass: MagicMock) -> None:
322 """Fake mute drives the volume control, so it degrades to NONE without one."""
323 mock_mass.config.get_raw_player_config_value = MagicMock(
324 side_effect=_make_config_side_effect(
325 {
326 CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE,
327 CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE,
328 }
329 )
330 )
331 # a native mute capability is irrelevant here: the user explicitly picked fake
332 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
333 assert player.mute_control == PLAYER_CONTROL_NONE
334 assert PlayerFeature.VOLUME_MUTE not in player.state.supported_features
335
336
337class TestMuteControlAutoSelect:
338 """Test mute_control auto-select logic."""
339
340 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
341 """Auto-select returns native when player supports VOLUME_MUTE."""
342 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
343 assert player.mute_control == PLAYER_CONTROL_NATIVE
344
345 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
346 """Auto-select returns protocol player for mute."""
347 protocol = _create_protocol_player(mock_mass, "proto_mute", {PlayerFeature.VOLUME_MUTE})
348 mock_mass.players.get_player = MagicMock(return_value=protocol)
349 player = _create_player(mock_mass)
350 _link_protocols(player, [_make_output_protocol("proto_mute", "Proto Mute")])
351 assert player.mute_control == "proto_mute"
352
353 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
354 """Auto-select returns NONE when nothing supports mute."""
355 player = _create_player(mock_mass)
356 assert player.mute_control == PLAYER_CONTROL_NONE
357
358
359class TestProtocolPlayerPreferActive:
360 """Test that auto-select prefers the active output protocol."""
361
362 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
363 """Create a side_effect that looks up players by ID."""
364
365 def _lookup(pid: str) -> MockPlayer | None:
366 return players.get(pid)
367
368 return _lookup
369
370 def test_prefers_active_protocol(self, mock_mass: MagicMock) -> None:
371 """Auto-select prefers the active output protocol."""
372 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
373 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
374 mock_mass.players.get_player = MagicMock(
375 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
376 )
377 player = _create_player(mock_mass)
378 _link_protocols(
379 player,
380 [
381 _make_output_protocol("proto_a", "Proto A", priority=0),
382 _make_output_protocol("proto_b", "Proto B", priority=1),
383 ],
384 active_protocol_id="proto_b",
385 )
386 assert player.volume_control == "proto_b"
387
388 def test_falls_back_to_any_when_no_active(self, mock_mass: MagicMock) -> None:
389 """When no active protocol, falls back to first available."""
390 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
391 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
392 mock_mass.players.get_player = MagicMock(
393 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
394 )
395 player = _create_player(mock_mass)
396 _link_protocols(
397 player,
398 [
399 _make_output_protocol("proto_a", "Proto A", priority=0),
400 _make_output_protocol("proto_b", "Proto B", priority=1),
401 ],
402 )
403 assert player.volume_control == "proto_a"
404
405 def test_power_requires_active_protocol(self, mock_mass: MagicMock) -> None:
406 """Power auto-select requires active protocol; returns NONE without one."""
407 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
408 mock_mass.players.get_player = MagicMock(
409 side_effect=self._get_player_lookup({"proto_a": proto_a})
410 )
411 player = _create_player(mock_mass)
412 _link_protocols(
413 player,
414 [_make_output_protocol("proto_a", "Proto A", priority=0)],
415 )
416 # no active protocol set -> power returns NONE
417 assert player.power_control == PLAYER_CONTROL_NONE
418
419 def test_power_ignores_active_protocol(self, mock_mass: MagicMock) -> None:
420 """Power auto-select does not use protocol players even when active."""
421 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
422 mock_mass.players.get_player = MagicMock(
423 side_effect=self._get_player_lookup({"proto_a": proto_a})
424 )
425 player = _create_player(mock_mass)
426 _link_protocols(
427 player,
428 [_make_output_protocol("proto_a", "Proto A", priority=0)],
429 active_protocol_id="proto_a",
430 )
431 assert player.power_control == PLAYER_CONTROL_NONE
432
433 def test_volume_skips_unavailable_protocol_player(self, mock_mass: MagicMock) -> None:
434 """Volume auto-select skips unavailable protocol players."""
435 proto_a = _create_protocol_player(
436 mock_mass, "proto_a", {PlayerFeature.VOLUME_SET}, available=False
437 )
438 proto_b = _create_protocol_player(
439 mock_mass, "proto_b", {PlayerFeature.VOLUME_SET}, available=True
440 )
441 mock_mass.players.get_player = MagicMock(
442 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
443 )
444 player = _create_player(mock_mass)
445 _link_protocols(
446 player,
447 [
448 _make_output_protocol("proto_a", "Proto A", priority=0),
449 _make_output_protocol("proto_b", "Proto B", priority=1),
450 ],
451 )
452 assert player.volume_control == "proto_b"
453
454
455class TestPreferredOutputProtocolAutoValue:
456 """Test that 'auto' preferred output protocol is treated as no preference."""
457
458 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
459 """Create a side_effect that looks up players by ID."""
460
461 def _lookup(pid: str) -> MockPlayer | None:
462 return players.get(pid)
463
464 return _lookup
465
466 def test_preferred_auto_power_ignores_protocol(self, mock_mass: MagicMock) -> None:
467 """With preferred='auto', power control does not use protocol players."""
468 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
469 mock_mass.players.get_player = MagicMock(
470 side_effect=self._get_player_lookup({"proto_a": proto})
471 )
472 mock_mass.config.get_raw_player_config_value = MagicMock(
473 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
474 )
475 player = _create_player(mock_mass)
476 _link_protocols(
477 player,
478 [_make_output_protocol("proto_a", "Proto A")],
479 active_protocol_id="proto_a",
480 )
481 assert player.power_control == PLAYER_CONTROL_NONE
482
483 def test_preferred_auto_falls_back_for_volume(self, mock_mass: MagicMock) -> None:
484 """With preferred='auto', volume control falls back to any linked protocol."""
485 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
486 mock_mass.players.get_player = MagicMock(
487 side_effect=self._get_player_lookup({"proto_a": proto})
488 )
489 mock_mass.config.get_raw_player_config_value = MagicMock(
490 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
491 )
492 player = _create_player(mock_mass)
493 _link_protocols(
494 player,
495 [_make_output_protocol("proto_a", "Proto A")],
496 )
497 # volume doesn't require active, so it falls back to linked protocol
498 assert player.volume_control == "proto_a"
499
500 def test_preferred_auto_no_active_power_returns_none(self, mock_mass: MagicMock) -> None:
501 """With preferred='auto' and no active protocol, power returns NONE."""
502 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
503 mock_mass.players.get_player = MagicMock(
504 side_effect=self._get_player_lookup({"proto_a": proto})
505 )
506 mock_mass.config.get_raw_player_config_value = MagicMock(
507 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
508 )
509 player = _create_player(mock_mass)
510 _link_protocols(
511 player,
512 [_make_output_protocol("proto_a", "Proto A")],
513 )
514 assert player.power_control == PLAYER_CONTROL_NONE
515
516
517class TestActiveOutputProtocolClearCancellation:
518 """Test that setting the active output protocol cancels a pending clear."""
519
520 def test_set_cancels_pending_clear_task(self, mock_mass: MagicMock) -> None:
521 """
522 Setting a protocol cancels the scheduled clear for the same player.
523
524 This is the mechanism that stops a new session's freshly set protocol
525 from being wiped by a clear scheduled after the previous group stop.
526 """
527 player = _create_player(mock_mass)
528
529 player.set_active_output_protocol("ap_1")
530
531 mock_mass.cancel_task.assert_called_once_with("clear_active_protocol_main_player")
532