/
/
/
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
24
25from music_assistant.constants import (
26 CONF_MUTE_CONTROL,
27 CONF_POWER_CONTROL,
28 CONF_PREFERRED_OUTPUT_PROTOCOL,
29 CONF_VOLUME_CONTROL,
30)
31from music_assistant.models.player import LinkedOutputProtocol
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_protocol_link(
93 protocol_id: str,
94 priority: int = 0,
95 protocol_domain: str = _PROTO_DOMAIN,
96) -> LinkedOutputProtocol:
97 """Create a protocol link for testing."""
98 return LinkedOutputProtocol(
99 output_protocol_id=protocol_id,
100 protocol_domain=protocol_domain,
101 priority=priority,
102 )
103
104
105def _link_protocols(
106 player: MockPlayer,
107 protocols: list[LinkedOutputProtocol],
108 active_protocol_id: str | None = None,
109) -> None:
110 """Link output protocols to a player and optionally set active protocol."""
111 player.set_linked_output_protocols(protocols)
112 if active_protocol_id is not None:
113 player.set_active_output_protocol(active_protocol_id)
114 player._cache.clear()
115
116
117class TestPowerControlExplicitConfig:
118 """Test power_control with explicit config values."""
119
120 def test_explicit_native(self, mock_mass: MagicMock) -> None:
121 """Power control returns native when explicitly configured and feature is supported."""
122 mock_mass.config.get_raw_player_config_value = MagicMock(
123 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
124 )
125 # NATIVE control requires the player to actually advertise POWER â
126 # otherwise the getter degrades to NONE (e.g. when a group player
127 # drops POWER from its feature set on upgrade but its stale config
128 # still says NATIVE).
129 player = _create_player(mock_mass, features={PlayerFeature.POWER})
130 assert player.power_control == PLAYER_CONTROL_NATIVE
131
132 def test_explicit_native_degrades_when_feature_missing(self, mock_mass: MagicMock) -> None:
133 """Stale NATIVE config falls back to NONE when the player no longer advertises POWER."""
134 mock_mass.config.get_raw_player_config_value = MagicMock(
135 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
136 )
137 # no POWER in features â the explicit NATIVE in config is invalid
138 player = _create_player(mock_mass)
139 assert player.power_control == PLAYER_CONTROL_NONE
140
141 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
142 """Power control returns fake when explicitly configured."""
143 mock_mass.config.get_raw_player_config_value = MagicMock(
144 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_FAKE})
145 )
146 player = _create_player(mock_mass)
147 assert player.power_control == PLAYER_CONTROL_FAKE
148
149 def test_explicit_none(self, mock_mass: MagicMock) -> None:
150 """Power control returns none when explicitly configured."""
151 mock_mass.config.get_raw_player_config_value = MagicMock(
152 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NONE})
153 )
154 player = _create_player(mock_mass, features={PlayerFeature.POWER})
155 assert player.power_control == PLAYER_CONTROL_NONE
156
157 def test_explicit_protocol_player_id_ignored(self, mock_mass: MagicMock) -> None:
158 """Protocol player IDs are not valid for power control; falls through to auto-select."""
159 protocol = _create_protocol_player(
160 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=True
161 )
162 mock_mass.config.get_raw_player_config_value = MagicMock(
163 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
164 )
165 mock_mass.players.get_player = MagicMock(return_value=protocol)
166 mock_mass.players.get_player_control = MagicMock(return_value=None)
167 player = _create_player(mock_mass)
168 # protocol players handle their own power at the protocol level
169 assert player.power_control == PLAYER_CONTROL_NONE
170
171 def test_explicit_protocol_player_unavailable_falls_through(self, mock_mass: MagicMock) -> None:
172 """When explicitly set to unavailable protocol player, falls to auto-select."""
173 protocol = _create_protocol_player(
174 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=False
175 )
176 mock_mass.config.get_raw_player_config_value = MagicMock(
177 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
178 )
179 mock_mass.players.get_player = MagicMock(return_value=protocol)
180 mock_mass.players.get_player_control = MagicMock(return_value=None)
181 player = _create_player(mock_mass)
182 assert player.power_control == PLAYER_CONTROL_NONE
183
184 def test_explicit_player_control_id(self, mock_mass: MagicMock) -> None:
185 """Power control returns player control ID when set and control exists."""
186 control = MagicMock()
187 control.id = "ext_control_1"
188 mock_mass.config.get_raw_player_config_value = MagicMock(
189 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "ext_control_1"})
190 )
191 mock_mass.players.get_player = MagicMock(return_value=None)
192 mock_mass.players.get_player_control = MagicMock(return_value=control)
193 player = _create_player(mock_mass)
194 assert player.power_control == "ext_control_1"
195
196
197class TestPowerControlAutoSelect:
198 """Test power_control auto-select logic (config is None or 'auto')."""
199
200 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
201 """Auto-select returns native when player supports POWER feature."""
202 player = _create_player(mock_mass, features={PlayerFeature.POWER})
203 assert player.power_control == PLAYER_CONTROL_NATIVE
204
205 def test_auto_does_not_use_protocol_player(self, mock_mass: MagicMock) -> None:
206 """Auto-select does not use protocol players for power; they handle it themselves."""
207 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
208 mock_mass.players.get_player = MagicMock(return_value=protocol)
209 player = _create_player(mock_mass)
210 _link_protocols(
211 player,
212 [_make_protocol_link("proto_power")],
213 active_protocol_id="proto_power",
214 )
215 assert player.power_control == PLAYER_CONTROL_NONE
216
217 def test_auto_returns_none_without_active_protocol(self, mock_mass: MagicMock) -> None:
218 """Auto-select returns NONE when protocol exists but is not active."""
219 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
220 mock_mass.players.get_player = MagicMock(return_value=protocol)
221 player = _create_player(mock_mass)
222 _link_protocols(player, [_make_protocol_link("proto_power")])
223 assert player.power_control == PLAYER_CONTROL_NONE
224
225 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
226 """Auto-select returns NONE when no native and no protocol players."""
227 player = _create_player(mock_mass)
228 assert player.power_control == PLAYER_CONTROL_NONE
229
230 def test_auto_with_config_set_to_auto(self, mock_mass: MagicMock) -> None:
231 """Explicit 'auto' config value triggers the same auto-select."""
232 mock_mass.config.get_raw_player_config_value = MagicMock(
233 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "auto"})
234 )
235 player = _create_player(mock_mass, features={PlayerFeature.POWER})
236 assert player.power_control == PLAYER_CONTROL_NATIVE
237
238
239class TestVolumeControlExplicitConfig:
240 """Test volume_control with explicit config values."""
241
242 def test_explicit_native(self, mock_mass: MagicMock) -> None:
243 """Volume control returns native when explicitly configured."""
244 mock_mass.config.get_raw_player_config_value = MagicMock(
245 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
246 )
247 # NATIVE control requires the player to actually advertise VOLUME_SET â
248 # otherwise the getter drops the stale value and falls back to auto-select.
249 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
250 assert player.volume_control == PLAYER_CONTROL_NATIVE
251
252 def test_explicit_native_degrades_when_feature_missing(self, mock_mass: MagicMock) -> None:
253 """Stale NATIVE config falls back to NONE when the player no longer advertises VOLUME_SET."""
254 mock_mass.config.get_raw_player_config_value = MagicMock(
255 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
256 )
257 player = _create_player(mock_mass)
258 assert player.volume_control == PLAYER_CONTROL_NONE
259 # the resolved control is what the final feature set is derived from, so the state has
260 # to be recalculated to prove the invalid control does not put VOLUME_SET back
261 player.set_initialized()
262 player.update_state(signal_event=False)
263 assert PlayerFeature.VOLUME_SET not in player.state.supported_features
264
265 def test_explicit_native_degrades_to_protocol_player(self, mock_mass: MagicMock) -> None:
266 """Stale NATIVE config degrades to a protocol player that can drive the volume."""
267 protocol = _create_protocol_player(mock_mass, "proto_vol", {PlayerFeature.VOLUME_SET})
268 mock_mass.config.get_raw_player_config_value = MagicMock(
269 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
270 )
271 mock_mass.players.get_player = MagicMock(return_value=protocol)
272 player = _create_player(mock_mass)
273 _link_protocols(player, [_make_protocol_link("proto_vol")])
274 assert player.volume_control == "proto_vol"
275
276 def test_explicit_none(self, mock_mass: MagicMock) -> None:
277 """Volume control returns none even with native support."""
278 mock_mass.config.get_raw_player_config_value = MagicMock(
279 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
280 )
281 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
282 assert player.volume_control == PLAYER_CONTROL_NONE
283
284 def test_explicit_protocol_player_id(self, mock_mass: MagicMock) -> None:
285 """Volume control returns protocol player when set and available."""
286 protocol = _create_protocol_player(
287 mock_mass, "vol_player", {PlayerFeature.VOLUME_SET}, available=True
288 )
289 mock_mass.config.get_raw_player_config_value = MagicMock(
290 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: "vol_player"})
291 )
292 mock_mass.players.get_player = MagicMock(return_value=protocol)
293 player = _create_player(mock_mass)
294 assert player.volume_control == "vol_player"
295
296
297class TestVolumeControlAutoSelect:
298 """Test volume_control auto-select logic."""
299
300 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
301 """Auto-select returns native when player supports VOLUME_SET."""
302 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
303 assert player.volume_control == PLAYER_CONTROL_NATIVE
304
305 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
306 """Auto-select returns protocol player for volume."""
307 protocol = _create_protocol_player(mock_mass, "proto_vol", {PlayerFeature.VOLUME_SET})
308 mock_mass.players.get_player = MagicMock(return_value=protocol)
309 player = _create_player(mock_mass)
310 _link_protocols(player, [_make_protocol_link("proto_vol")])
311 assert player.volume_control == "proto_vol"
312
313 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
314 """Auto-select returns NONE when nothing supports volume."""
315 player = _create_player(mock_mass)
316 assert player.volume_control == PLAYER_CONTROL_NONE
317
318
319class TestMuteControlExplicitConfig:
320 """Test mute_control with explicit config values."""
321
322 def test_explicit_native(self, mock_mass: MagicMock) -> None:
323 """Mute control returns native when explicitly configured."""
324 mock_mass.config.get_raw_player_config_value = MagicMock(
325 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
326 )
327 # NATIVE control requires the player to actually advertise VOLUME_MUTE â
328 # otherwise the getter drops the stale value and falls back to auto-select.
329 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
330 assert player.mute_control == PLAYER_CONTROL_NATIVE
331
332 def test_explicit_native_degrades_when_feature_missing(self, mock_mass: MagicMock) -> None:
333 """Stale NATIVE config falls back to NONE when the player no longer advertises the feature."""
334 mock_mass.config.get_raw_player_config_value = MagicMock(
335 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
336 )
337 player = _create_player(mock_mass)
338 assert player.mute_control == PLAYER_CONTROL_NONE
339 # the resolved control is what the final feature set is derived from, so the state has
340 # to be recalculated to prove the invalid control does not put VOLUME_MUTE back
341 player.set_initialized()
342 player.update_state(signal_event=False)
343 assert PlayerFeature.VOLUME_MUTE not in player.state.supported_features
344
345 def test_explicit_native_degrades_to_protocol_player(self, mock_mass: MagicMock) -> None:
346 """Stale NATIVE config degrades to a protocol player that can drive the mute."""
347 protocol = _create_protocol_player(mock_mass, "proto_mute", {PlayerFeature.VOLUME_MUTE})
348 mock_mass.config.get_raw_player_config_value = MagicMock(
349 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
350 )
351 mock_mass.players.get_player = MagicMock(return_value=protocol)
352 player = _create_player(mock_mass)
353 _link_protocols(player, [_make_protocol_link("proto_mute")])
354 assert player.mute_control == "proto_mute"
355
356 def test_explicit_none(self, mock_mass: MagicMock) -> None:
357 """Mute control returns none when explicitly configured."""
358 mock_mass.config.get_raw_player_config_value = MagicMock(
359 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NONE})
360 )
361 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
362 assert player.mute_control == PLAYER_CONTROL_NONE
363
364 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
365 """Mute control returns fake when explicitly configured and a volume control exists."""
366 mock_mass.config.get_raw_player_config_value = MagicMock(
367 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
368 )
369 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
370 assert player.mute_control == PLAYER_CONTROL_FAKE
371
372 def test_explicit_fake_degrades_without_volume_control(self, mock_mass: MagicMock) -> None:
373 """Fake mute drives the volume control, so it degrades to NONE without one."""
374 mock_mass.config.get_raw_player_config_value = MagicMock(
375 side_effect=_make_config_side_effect(
376 {
377 CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE,
378 CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE,
379 }
380 )
381 )
382 # a native mute capability is irrelevant here: the user explicitly picked fake
383 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
384 assert player.mute_control == PLAYER_CONTROL_NONE
385 assert PlayerFeature.VOLUME_MUTE not in player.state.supported_features
386
387
388class TestMuteControlAutoSelect:
389 """Test mute_control auto-select logic."""
390
391 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
392 """Auto-select returns native when player supports VOLUME_MUTE."""
393 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
394 assert player.mute_control == PLAYER_CONTROL_NATIVE
395
396 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
397 """Auto-select returns protocol player for mute."""
398 protocol = _create_protocol_player(mock_mass, "proto_mute", {PlayerFeature.VOLUME_MUTE})
399 mock_mass.players.get_player = MagicMock(return_value=protocol)
400 player = _create_player(mock_mass)
401 _link_protocols(player, [_make_protocol_link("proto_mute")])
402 assert player.mute_control == "proto_mute"
403
404 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
405 """Auto-select returns NONE when nothing supports mute."""
406 player = _create_player(mock_mass)
407 assert player.mute_control == PLAYER_CONTROL_NONE
408
409
410class TestProtocolPlayerPreferActive:
411 """Test that auto-select prefers the active output protocol."""
412
413 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
414 """Create a side_effect that looks up players by ID."""
415
416 def _lookup(pid: str) -> MockPlayer | None:
417 return players.get(pid)
418
419 return _lookup
420
421 def test_prefers_active_protocol(self, mock_mass: MagicMock) -> None:
422 """Auto-select prefers the active output protocol."""
423 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
424 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
425 mock_mass.players.get_player = MagicMock(
426 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
427 )
428 player = _create_player(mock_mass)
429 _link_protocols(
430 player,
431 [
432 _make_protocol_link("proto_a", priority=0),
433 _make_protocol_link("proto_b", priority=1),
434 ],
435 active_protocol_id="proto_b",
436 )
437 assert player.volume_control == "proto_b"
438
439 def test_falls_back_to_any_when_no_active(self, mock_mass: MagicMock) -> None:
440 """When no active protocol, falls back to first available."""
441 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
442 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
443 mock_mass.players.get_player = MagicMock(
444 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
445 )
446 player = _create_player(mock_mass)
447 _link_protocols(
448 player,
449 [
450 _make_protocol_link("proto_a", priority=0),
451 _make_protocol_link("proto_b", priority=1),
452 ],
453 )
454 assert player.volume_control == "proto_a"
455
456 def test_power_requires_active_protocol(self, mock_mass: MagicMock) -> None:
457 """Power auto-select requires active protocol; returns NONE without one."""
458 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
459 mock_mass.players.get_player = MagicMock(
460 side_effect=self._get_player_lookup({"proto_a": proto_a})
461 )
462 player = _create_player(mock_mass)
463 _link_protocols(
464 player,
465 [_make_protocol_link("proto_a", priority=0)],
466 )
467 # no active protocol set -> power returns NONE
468 assert player.power_control == PLAYER_CONTROL_NONE
469
470 def test_power_ignores_active_protocol(self, mock_mass: MagicMock) -> None:
471 """Power auto-select does not use protocol players even when active."""
472 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
473 mock_mass.players.get_player = MagicMock(
474 side_effect=self._get_player_lookup({"proto_a": proto_a})
475 )
476 player = _create_player(mock_mass)
477 _link_protocols(
478 player,
479 [_make_protocol_link("proto_a", priority=0)],
480 active_protocol_id="proto_a",
481 )
482 assert player.power_control == PLAYER_CONTROL_NONE
483
484 def test_volume_skips_unavailable_protocol_player(self, mock_mass: MagicMock) -> None:
485 """Volume auto-select skips unavailable protocol players."""
486 proto_a = _create_protocol_player(
487 mock_mass, "proto_a", {PlayerFeature.VOLUME_SET}, available=False
488 )
489 proto_b = _create_protocol_player(
490 mock_mass, "proto_b", {PlayerFeature.VOLUME_SET}, available=True
491 )
492 mock_mass.players.get_player = MagicMock(
493 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
494 )
495 player = _create_player(mock_mass)
496 _link_protocols(
497 player,
498 [
499 _make_protocol_link("proto_a", priority=0),
500 _make_protocol_link("proto_b", priority=1),
501 ],
502 )
503 assert player.volume_control == "proto_b"
504
505
506class TestControlDomainPriority:
507 """Test the protocol domain order that the ambient volume/mute fallback applies."""
508
509 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
510 """Create a side_effect that looks up players by ID."""
511
512 def _lookup(pid: str) -> MockPlayer | None:
513 return players.get(pid)
514
515 return _lookup
516
517 def test_prefers_domain_that_takes_commands_while_idle(self, mock_mass: MagicMock) -> None:
518 """A domain that accepts commands while idle owns the volume."""
519 airplay = _create_protocol_player(mock_mass, "airplay_child", {PlayerFeature.VOLUME_SET})
520 cast = _create_protocol_player(mock_mass, "cast_child", {PlayerFeature.VOLUME_SET})
521 mock_mass.players.get_player = MagicMock(
522 side_effect=self._get_player_lookup({"airplay_child": airplay, "cast_child": cast})
523 )
524 player = _create_player(mock_mass)
525 # airplay links first, but only cast can take a volume command while idle
526 _link_protocols(
527 player,
528 [
529 _make_protocol_link("airplay_child", protocol_domain="airplay"),
530 _make_protocol_link("cast_child", protocol_domain="chromecast"),
531 ],
532 )
533 assert player.volume_control == "cast_child"
534
535 def test_unknown_domain_sorts_last(self, mock_mass: MagicMock) -> None:
536 """A domain outside the priority table loses from a known one."""
537 other = _create_protocol_player(mock_mass, "other_child", {PlayerFeature.VOLUME_SET})
538 sendspin = _create_protocol_player(mock_mass, "sendspin_child", {PlayerFeature.VOLUME_SET})
539 mock_mass.players.get_player = MagicMock(
540 side_effect=self._get_player_lookup({"other_child": other, "sendspin_child": sendspin})
541 )
542 player = _create_player(mock_mass)
543 _link_protocols(
544 player,
545 [
546 _make_protocol_link("other_child", protocol_domain="something_else"),
547 _make_protocol_link("sendspin_child", protocol_domain="sendspin"),
548 ],
549 )
550 assert player.volume_control == "sendspin_child"
551
552 def test_volume_and_mute_may_land_on_different_siblings(self, mock_mass: MagicMock) -> None:
553 """
554 Volume and mute resolve independently, so they can pick different interfaces.
555
556 A DLNA renderer derives VOLUME_SET and VOLUME_MUTE from separate
557 RenderingControl actions, so it may advertise one without the other. The
558 ambient controls then split across siblings; only the output-scoped
559 variants keep a resolution inside one signal path.
560 """
561 dlna = _create_protocol_player(mock_mass, "dlna_child", {PlayerFeature.VOLUME_SET})
562 airplay = _create_protocol_player(
563 mock_mass,
564 "airplay_child",
565 {PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE},
566 )
567 mock_mass.players.get_player = MagicMock(
568 side_effect=self._get_player_lookup({"dlna_child": dlna, "airplay_child": airplay})
569 )
570 player = _create_player(mock_mass)
571 _link_protocols(
572 player,
573 [
574 _make_protocol_link("dlna_child", protocol_domain="dlna"),
575 _make_protocol_link("airplay_child", protocol_domain="airplay"),
576 ],
577 )
578 # dlna outranks airplay for volume, but has no mute to offer
579 assert player.volume_control == "dlna_child"
580 assert player.mute_control == "airplay_child"
581 # the output-scoped variants stay within the named signal path
582 assert player.volume_control_for_output("dlna_child") == "dlna_child"
583 assert player.mute_control_for_output("dlna_child") == PLAYER_CONTROL_NONE
584
585
586class TestPreferredOutputProtocolAutoValue:
587 """Test that 'auto' preferred output protocol is treated as no preference."""
588
589 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
590 """Create a side_effect that looks up players by ID."""
591
592 def _lookup(pid: str) -> MockPlayer | None:
593 return players.get(pid)
594
595 return _lookup
596
597 def test_preferred_auto_power_ignores_protocol(self, mock_mass: MagicMock) -> None:
598 """With preferred='auto', power control does not use protocol players."""
599 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
600 mock_mass.players.get_player = MagicMock(
601 side_effect=self._get_player_lookup({"proto_a": proto})
602 )
603 mock_mass.config.get_raw_player_config_value = MagicMock(
604 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
605 )
606 player = _create_player(mock_mass)
607 _link_protocols(
608 player,
609 [_make_protocol_link("proto_a")],
610 active_protocol_id="proto_a",
611 )
612 assert player.power_control == PLAYER_CONTROL_NONE
613
614 def test_preferred_auto_falls_back_for_volume(self, mock_mass: MagicMock) -> None:
615 """With preferred='auto', volume control falls back to any linked protocol."""
616 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
617 mock_mass.players.get_player = MagicMock(
618 side_effect=self._get_player_lookup({"proto_a": proto})
619 )
620 mock_mass.config.get_raw_player_config_value = MagicMock(
621 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
622 )
623 player = _create_player(mock_mass)
624 _link_protocols(
625 player,
626 [_make_protocol_link("proto_a")],
627 )
628 # volume doesn't require active, so it falls back to linked protocol
629 assert player.volume_control == "proto_a"
630
631 def test_preferred_auto_no_active_power_returns_none(self, mock_mass: MagicMock) -> None:
632 """With preferred='auto' and no active protocol, power returns NONE."""
633 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
634 mock_mass.players.get_player = MagicMock(
635 side_effect=self._get_player_lookup({"proto_a": proto})
636 )
637 mock_mass.config.get_raw_player_config_value = MagicMock(
638 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
639 )
640 player = _create_player(mock_mass)
641 _link_protocols(
642 player,
643 [_make_protocol_link("proto_a")],
644 )
645 assert player.power_control == PLAYER_CONTROL_NONE
646
647 def test_preferred_pointing_at_another_players_output_is_ignored(
648 self, mock_mass: MagicMock
649 ) -> None:
650 """A preference left behind by a relink does not steal another speaker's output."""
651 own = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
652 other = _create_protocol_player(mock_mass, "other_proto", {PlayerFeature.VOLUME_SET})
653 mock_mass.players.get_player = MagicMock(
654 side_effect=self._get_player_lookup({"proto_a": own, "other_proto": other})
655 )
656 mock_mass.config.get_raw_player_config_value = MagicMock(
657 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "other_proto"})
658 )
659 player = _create_player(mock_mass)
660 _link_protocols(player, [_make_protocol_link("proto_a")])
661 assert player.volume_control == "proto_a"
662
663
664class TestActiveOutputProtocolClearCancellation:
665 """Test that setting the active output protocol cancels a pending clear."""
666
667 def test_set_cancels_pending_clear_task(self, mock_mass: MagicMock) -> None:
668 """
669 Setting a protocol cancels the scheduled clear for the same player.
670
671 This is the mechanism that stops a new session's freshly set protocol
672 from being wiped by a clear scheduled after the previous group stop.
673 """
674 player = _create_player(mock_mass)
675
676 player.set_active_output_protocol("ap_1")
677
678 mock_mass.cancel_task.assert_called_once_with("clear_active_protocol_main_player")
679
680
681class TestControlForOutput:
682 """Test the output-scoped volume/mute control resolution."""
683
684 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
685 """Create a side_effect that looks up players by ID."""
686
687 def _lookup(pid: str) -> MockPlayer | None:
688 return players.get(pid)
689
690 return _lookup
691
692 def test_resolves_named_output_without_active_protocol(self, mock_mass: MagicMock) -> None:
693 """
694 The named output owns the volume even when no protocol is marked active.
695
696 This is the ordering trap the ambient volume_control falls into: with nothing
697 active it picks a sibling interface, which made a joining AirPlay speaker
698 decide another control owned the volume and play at unity gain.
699 """
700 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
701 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
702 mock_mass.players.get_player = MagicMock(
703 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
704 )
705 player = _create_player(mock_mass)
706 _link_protocols(
707 player,
708 [
709 _make_protocol_link("proto_a", priority=0),
710 _make_protocol_link("proto_b", priority=1),
711 ],
712 )
713 # the ambient answer picks the first linked protocol, the output-scoped one
714 # answers for the interface that is actually about to render
715 assert player.volume_control == "proto_a"
716 assert player.volume_control_for_output("proto_b") == "proto_b"
717
718 def test_ignores_active_protocol_pointing_elsewhere(self, mock_mass: MagicMock) -> None:
719 """A protocol marked active does not override the named output."""
720 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
721 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
722 mock_mass.players.get_player = MagicMock(
723 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
724 )
725 player = _create_player(mock_mass)
726 _link_protocols(
727 player,
728 [
729 _make_protocol_link("proto_a", priority=0),
730 _make_protocol_link("proto_b", priority=1),
731 ],
732 active_protocol_id="proto_a",
733 )
734 assert player.volume_control_for_output("proto_b") == "proto_b"
735
736 def test_returns_none_when_output_lacks_feature(self, mock_mass: MagicMock) -> None:
737 """No fallback to a sibling: an output without the feature does not own it."""
738 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
739 proto_b = _create_protocol_player(mock_mass, "proto_b", set())
740 mock_mass.players.get_player = MagicMock(
741 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
742 )
743 player = _create_player(mock_mass)
744 _link_protocols(
745 player,
746 [
747 _make_protocol_link("proto_a", priority=0),
748 _make_protocol_link("proto_b", priority=1),
749 ],
750 )
751 assert player.volume_control == "proto_a"
752 assert player.volume_control_for_output("proto_b") == PLAYER_CONTROL_NONE
753
754 def test_prefers_native(self, mock_mass: MagicMock) -> None:
755 """A player with native volume owns it regardless of the output."""
756 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
757 mock_mass.players.get_player = MagicMock(
758 side_effect=self._get_player_lookup({"proto_a": proto_a})
759 )
760 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
761 _link_protocols(player, [_make_protocol_link("proto_a")])
762 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_NATIVE
763
764 def test_stale_native_degrades_to_named_output(self, mock_mass: MagicMock) -> None:
765 """A stored NATIVE the player can no longer back hands the output to its own protocol."""
766 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
767 mock_mass.players.get_player = MagicMock(
768 side_effect=self._get_player_lookup({"proto_a": proto_a})
769 )
770 mock_mass.config.get_raw_player_config_value = MagicMock(
771 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
772 )
773 player = _create_player(mock_mass)
774 _link_protocols(player, [_make_protocol_link("proto_a")])
775 assert player.volume_control_for_output("proto_a") == "proto_a"
776 # the device-wide resolver must agree, otherwise the two disagree on the same config
777 assert player.volume_control == "proto_a"
778
779 def test_stale_native_mute_degrades_to_named_output(self, mock_mass: MagicMock) -> None:
780 """The mute resolver applies the same stale-NATIVE rule as the volume one."""
781 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_MUTE})
782 mock_mass.players.get_player = MagicMock(
783 side_effect=self._get_player_lookup({"proto_a": proto_a})
784 )
785 mock_mass.config.get_raw_player_config_value = MagicMock(
786 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
787 )
788 player = _create_player(mock_mass)
789 _link_protocols(player, [_make_protocol_link("proto_a")])
790 assert player.mute_control_for_output("proto_a") == "proto_a"
791
792 def test_explicit_config_wins(self, mock_mass: MagicMock) -> None:
793 """An explicitly configured control is a device-wide statement, so it still wins."""
794 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
795 mock_mass.players.get_player = MagicMock(
796 side_effect=self._get_player_lookup({"proto_a": proto_a})
797 )
798 mock_mass.config.get_raw_player_config_value = MagicMock(
799 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_FAKE})
800 )
801 player = _create_player(mock_mass)
802 _link_protocols(player, [_make_protocol_link("proto_a")])
803 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_FAKE
804
805 def test_unknown_output_returns_none(self, mock_mass: MagicMock) -> None:
806 """An output that cannot be resolved owns nothing."""
807 player = _create_player(mock_mass)
808 assert player.volume_control_for_output("nope") == PLAYER_CONTROL_NONE
809
810 def test_mute_resolves_named_output(self, mock_mass: MagicMock) -> None:
811 """Mute resolves against the named output the same way volume does."""
812 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_MUTE})
813 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_MUTE})
814 mock_mass.players.get_player = MagicMock(
815 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
816 )
817 player = _create_player(mock_mass)
818 _link_protocols(
819 player,
820 [
821 _make_protocol_link("proto_a", priority=0),
822 _make_protocol_link("proto_b", priority=1),
823 ],
824 )
825 assert player.mute_control == "proto_a"
826 assert player.mute_control_for_output("proto_b") == "proto_b"
827
828 def test_mute_returns_none_when_output_lacks_feature(self, mock_mass: MagicMock) -> None:
829 """An output with volume but no mute does not own the mute."""
830 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
831 mock_mass.players.get_player = MagicMock(
832 side_effect=self._get_player_lookup({"proto_a": proto_a})
833 )
834 player = _create_player(mock_mass)
835 _link_protocols(player, [_make_protocol_link("proto_a")])
836 assert player.mute_control_for_output("proto_a") == PLAYER_CONTROL_NONE
837
838 def test_explicit_player_id_config_beats_named_output(self, mock_mass: MagicMock) -> None:
839 """An explicitly configured control outranks the output carrying the audio."""
840 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
841 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
842 mock_mass.players.get_player = MagicMock(
843 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
844 )
845 mock_mass.config.get_raw_player_config_value = MagicMock(
846 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: "proto_a"})
847 )
848 player = _create_player(mock_mass)
849 _link_protocols(
850 player,
851 [
852 _make_protocol_link("proto_a", priority=0),
853 _make_protocol_link("proto_b", priority=1),
854 ],
855 )
856 assert player.volume_control_for_output("proto_b") == "proto_a"
857
858 def test_does_not_require_output_to_be_available(self, mock_mass: MagicMock) -> None:
859 """
860 An unavailable reading does not hand the volume to a different interface.
861
862 The caller names the output it is about to stream to, so a stale registry
863 reading is no reason to fall back to a sibling that isn't in the signal path.
864 """
865 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
866 proto_b = _create_protocol_player(
867 mock_mass, "proto_b", {PlayerFeature.VOLUME_SET}, available=False
868 )
869 mock_mass.players.get_player = MagicMock(
870 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
871 )
872 player = _create_player(mock_mass)
873 _link_protocols(
874 player,
875 [
876 _make_protocol_link("proto_a", priority=0),
877 _make_protocol_link("proto_b", priority=1),
878 ],
879 )
880 assert player.volume_control == "proto_a"
881 assert player.volume_control_for_output("proto_b") == "proto_b"
882
883 def test_fake_mute_degrades_without_volume_control_on_output(
884 self, mock_mass: MagicMock
885 ) -> None:
886 """Fake mute drives the volume to zero, so it needs a volume control to drive."""
887 proto_a = _create_protocol_player(mock_mass, "proto_a", set())
888 mock_mass.players.get_player = MagicMock(
889 side_effect=self._get_player_lookup({"proto_a": proto_a})
890 )
891 mock_mass.config.get_raw_player_config_value = MagicMock(
892 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
893 )
894 player = _create_player(mock_mass)
895 _link_protocols(player, [_make_protocol_link("proto_a")])
896 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_NONE
897 assert player.mute_control_for_output("proto_a") == PLAYER_CONTROL_NONE
898