/
/
/
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) -> LinkedOutputProtocol:
96 """Create a protocol link for testing."""
97 return LinkedOutputProtocol(
98 output_protocol_id=protocol_id,
99 protocol_domain=_PROTO_DOMAIN,
100 priority=priority,
101 )
102
103
104def _link_protocols(
105 player: MockPlayer,
106 protocols: list[LinkedOutputProtocol],
107 active_protocol_id: str | None = None,
108) -> None:
109 """Link output protocols to a player and optionally set active protocol."""
110 player.set_linked_output_protocols(protocols)
111 if active_protocol_id is not None:
112 player.set_active_output_protocol(active_protocol_id)
113 player._cache.clear()
114
115
116class TestPowerControlExplicitConfig:
117 """Test power_control with explicit config values."""
118
119 def test_explicit_native(self, mock_mass: MagicMock) -> None:
120 """Power control returns native when explicitly configured and feature is supported."""
121 mock_mass.config.get_raw_player_config_value = MagicMock(
122 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
123 )
124 # NATIVE control requires the player to actually advertise POWER —
125 # otherwise the getter degrades to NONE (e.g. when a group player
126 # drops POWER from its feature set on upgrade but its stale config
127 # still says NATIVE).
128 player = _create_player(mock_mass, features={PlayerFeature.POWER})
129 assert player.power_control == PLAYER_CONTROL_NATIVE
130
131 def test_explicit_native_degrades_when_feature_missing(self, mock_mass: MagicMock) -> None:
132 """Stale NATIVE config falls back to NONE when the player no longer advertises POWER."""
133 mock_mass.config.get_raw_player_config_value = MagicMock(
134 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NATIVE})
135 )
136 # no POWER in features → the explicit NATIVE in config is invalid
137 player = _create_player(mock_mass)
138 assert player.power_control == PLAYER_CONTROL_NONE
139
140 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
141 """Power control returns fake when explicitly configured."""
142 mock_mass.config.get_raw_player_config_value = MagicMock(
143 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_FAKE})
144 )
145 player = _create_player(mock_mass)
146 assert player.power_control == PLAYER_CONTROL_FAKE
147
148 def test_explicit_none(self, mock_mass: MagicMock) -> None:
149 """Power control returns none when explicitly configured."""
150 mock_mass.config.get_raw_player_config_value = MagicMock(
151 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: PLAYER_CONTROL_NONE})
152 )
153 player = _create_player(mock_mass, features={PlayerFeature.POWER})
154 assert player.power_control == PLAYER_CONTROL_NONE
155
156 def test_explicit_protocol_player_id_ignored(self, mock_mass: MagicMock) -> None:
157 """Protocol player IDs are not valid for power control; falls through to auto-select."""
158 protocol = _create_protocol_player(
159 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=True
160 )
161 mock_mass.config.get_raw_player_config_value = MagicMock(
162 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
163 )
164 mock_mass.players.get_player = MagicMock(return_value=protocol)
165 mock_mass.players.get_player_control = MagicMock(return_value=None)
166 player = _create_player(mock_mass)
167 # protocol players handle their own power at the protocol level
168 assert player.power_control == PLAYER_CONTROL_NONE
169
170 def test_explicit_protocol_player_unavailable_falls_through(self, mock_mass: MagicMock) -> None:
171 """When explicitly set to unavailable protocol player, falls to auto-select."""
172 protocol = _create_protocol_player(
173 mock_mass, "protocol_1", {PlayerFeature.POWER}, available=False
174 )
175 mock_mass.config.get_raw_player_config_value = MagicMock(
176 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "protocol_1"})
177 )
178 mock_mass.players.get_player = MagicMock(return_value=protocol)
179 mock_mass.players.get_player_control = MagicMock(return_value=None)
180 player = _create_player(mock_mass)
181 assert player.power_control == PLAYER_CONTROL_NONE
182
183 def test_explicit_player_control_id(self, mock_mass: MagicMock) -> None:
184 """Power control returns player control ID when set and control exists."""
185 control = MagicMock()
186 control.id = "ext_control_1"
187 mock_mass.config.get_raw_player_config_value = MagicMock(
188 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "ext_control_1"})
189 )
190 mock_mass.players.get_player = MagicMock(return_value=None)
191 mock_mass.players.get_player_control = MagicMock(return_value=control)
192 player = _create_player(mock_mass)
193 assert player.power_control == "ext_control_1"
194
195
196class TestPowerControlAutoSelect:
197 """Test power_control auto-select logic (config is None or 'auto')."""
198
199 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
200 """Auto-select returns native when player supports POWER feature."""
201 player = _create_player(mock_mass, features={PlayerFeature.POWER})
202 assert player.power_control == PLAYER_CONTROL_NATIVE
203
204 def test_auto_does_not_use_protocol_player(self, mock_mass: MagicMock) -> None:
205 """Auto-select does not use protocol players for power; they handle it themselves."""
206 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
207 mock_mass.players.get_player = MagicMock(return_value=protocol)
208 player = _create_player(mock_mass)
209 _link_protocols(
210 player,
211 [_make_protocol_link("proto_power")],
212 active_protocol_id="proto_power",
213 )
214 assert player.power_control == PLAYER_CONTROL_NONE
215
216 def test_auto_returns_none_without_active_protocol(self, mock_mass: MagicMock) -> None:
217 """Auto-select returns NONE when protocol exists but is not active."""
218 protocol = _create_protocol_player(mock_mass, "proto_power", {PlayerFeature.POWER})
219 mock_mass.players.get_player = MagicMock(return_value=protocol)
220 player = _create_player(mock_mass)
221 _link_protocols(player, [_make_protocol_link("proto_power")])
222 assert player.power_control == PLAYER_CONTROL_NONE
223
224 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
225 """Auto-select returns NONE when no native and no protocol players."""
226 player = _create_player(mock_mass)
227 assert player.power_control == PLAYER_CONTROL_NONE
228
229 def test_auto_with_config_set_to_auto(self, mock_mass: MagicMock) -> None:
230 """Explicit 'auto' config value triggers the same auto-select."""
231 mock_mass.config.get_raw_player_config_value = MagicMock(
232 side_effect=_make_config_side_effect({CONF_POWER_CONTROL: "auto"})
233 )
234 player = _create_player(mock_mass, features={PlayerFeature.POWER})
235 assert player.power_control == PLAYER_CONTROL_NATIVE
236
237
238class TestVolumeControlExplicitConfig:
239 """Test volume_control with explicit config values."""
240
241 def test_explicit_native(self, mock_mass: MagicMock) -> None:
242 """Volume control returns native when explicitly configured."""
243 mock_mass.config.get_raw_player_config_value = MagicMock(
244 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NATIVE})
245 )
246 player = _create_player(mock_mass)
247 assert player.volume_control == PLAYER_CONTROL_NATIVE
248
249 def test_explicit_none(self, mock_mass: MagicMock) -> None:
250 """Volume control returns none even with native support."""
251 mock_mass.config.get_raw_player_config_value = MagicMock(
252 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE})
253 )
254 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
255 assert player.volume_control == PLAYER_CONTROL_NONE
256
257 def test_explicit_protocol_player_id(self, mock_mass: MagicMock) -> None:
258 """Volume control returns protocol player when set and available."""
259 protocol = _create_protocol_player(
260 mock_mass, "vol_player", {PlayerFeature.VOLUME_SET}, available=True
261 )
262 mock_mass.config.get_raw_player_config_value = MagicMock(
263 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: "vol_player"})
264 )
265 mock_mass.players.get_player = MagicMock(return_value=protocol)
266 player = _create_player(mock_mass)
267 assert player.volume_control == "vol_player"
268
269
270class TestVolumeControlAutoSelect:
271 """Test volume_control auto-select logic."""
272
273 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
274 """Auto-select returns native when player supports VOLUME_SET."""
275 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
276 assert player.volume_control == PLAYER_CONTROL_NATIVE
277
278 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
279 """Auto-select returns protocol player for volume."""
280 protocol = _create_protocol_player(mock_mass, "proto_vol", {PlayerFeature.VOLUME_SET})
281 mock_mass.players.get_player = MagicMock(return_value=protocol)
282 player = _create_player(mock_mass)
283 _link_protocols(player, [_make_protocol_link("proto_vol")])
284 assert player.volume_control == "proto_vol"
285
286 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
287 """Auto-select returns NONE when nothing supports volume."""
288 player = _create_player(mock_mass)
289 assert player.volume_control == PLAYER_CONTROL_NONE
290
291
292class TestMuteControlExplicitConfig:
293 """Test mute_control with explicit config values."""
294
295 def test_explicit_native(self, mock_mass: MagicMock) -> None:
296 """Mute control returns native when explicitly configured."""
297 mock_mass.config.get_raw_player_config_value = MagicMock(
298 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NATIVE})
299 )
300 player = _create_player(mock_mass)
301 assert player.mute_control == PLAYER_CONTROL_NATIVE
302
303 def test_explicit_none(self, mock_mass: MagicMock) -> None:
304 """Mute control returns none when explicitly configured."""
305 mock_mass.config.get_raw_player_config_value = MagicMock(
306 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_NONE})
307 )
308 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
309 assert player.mute_control == PLAYER_CONTROL_NONE
310
311 def test_explicit_fake(self, mock_mass: MagicMock) -> None:
312 """Mute control returns fake when explicitly configured and a volume control exists."""
313 mock_mass.config.get_raw_player_config_value = MagicMock(
314 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
315 )
316 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
317 assert player.mute_control == PLAYER_CONTROL_FAKE
318
319 def test_explicit_fake_degrades_without_volume_control(self, mock_mass: MagicMock) -> None:
320 """Fake mute drives the volume control, so it degrades to NONE without one."""
321 mock_mass.config.get_raw_player_config_value = MagicMock(
322 side_effect=_make_config_side_effect(
323 {
324 CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE,
325 CONF_VOLUME_CONTROL: PLAYER_CONTROL_NONE,
326 }
327 )
328 )
329 # a native mute capability is irrelevant here: the user explicitly picked fake
330 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
331 assert player.mute_control == PLAYER_CONTROL_NONE
332 assert PlayerFeature.VOLUME_MUTE not in player.state.supported_features
333
334
335class TestMuteControlAutoSelect:
336 """Test mute_control auto-select logic."""
337
338 def test_auto_prefers_native(self, mock_mass: MagicMock) -> None:
339 """Auto-select returns native when player supports VOLUME_MUTE."""
340 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_MUTE})
341 assert player.mute_control == PLAYER_CONTROL_NATIVE
342
343 def test_auto_falls_back_to_protocol_player(self, mock_mass: MagicMock) -> None:
344 """Auto-select returns protocol player for mute."""
345 protocol = _create_protocol_player(mock_mass, "proto_mute", {PlayerFeature.VOLUME_MUTE})
346 mock_mass.players.get_player = MagicMock(return_value=protocol)
347 player = _create_player(mock_mass)
348 _link_protocols(player, [_make_protocol_link("proto_mute")])
349 assert player.mute_control == "proto_mute"
350
351 def test_auto_returns_none_when_nothing_available(self, mock_mass: MagicMock) -> None:
352 """Auto-select returns NONE when nothing supports mute."""
353 player = _create_player(mock_mass)
354 assert player.mute_control == PLAYER_CONTROL_NONE
355
356
357class TestProtocolPlayerPreferActive:
358 """Test that auto-select prefers the active output protocol."""
359
360 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
361 """Create a side_effect that looks up players by ID."""
362
363 def _lookup(pid: str) -> MockPlayer | None:
364 return players.get(pid)
365
366 return _lookup
367
368 def test_prefers_active_protocol(self, mock_mass: MagicMock) -> None:
369 """Auto-select prefers the active output protocol."""
370 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
371 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
372 mock_mass.players.get_player = MagicMock(
373 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
374 )
375 player = _create_player(mock_mass)
376 _link_protocols(
377 player,
378 [
379 _make_protocol_link("proto_a", priority=0),
380 _make_protocol_link("proto_b", priority=1),
381 ],
382 active_protocol_id="proto_b",
383 )
384 assert player.volume_control == "proto_b"
385
386 def test_falls_back_to_any_when_no_active(self, mock_mass: MagicMock) -> None:
387 """When no active protocol, falls back to first available."""
388 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
389 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
390 mock_mass.players.get_player = MagicMock(
391 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
392 )
393 player = _create_player(mock_mass)
394 _link_protocols(
395 player,
396 [
397 _make_protocol_link("proto_a", priority=0),
398 _make_protocol_link("proto_b", priority=1),
399 ],
400 )
401 assert player.volume_control == "proto_a"
402
403 def test_power_requires_active_protocol(self, mock_mass: MagicMock) -> None:
404 """Power auto-select requires active protocol; returns NONE without one."""
405 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
406 mock_mass.players.get_player = MagicMock(
407 side_effect=self._get_player_lookup({"proto_a": proto_a})
408 )
409 player = _create_player(mock_mass)
410 _link_protocols(
411 player,
412 [_make_protocol_link("proto_a", priority=0)],
413 )
414 # no active protocol set -> power returns NONE
415 assert player.power_control == PLAYER_CONTROL_NONE
416
417 def test_power_ignores_active_protocol(self, mock_mass: MagicMock) -> None:
418 """Power auto-select does not use protocol players even when active."""
419 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
420 mock_mass.players.get_player = MagicMock(
421 side_effect=self._get_player_lookup({"proto_a": proto_a})
422 )
423 player = _create_player(mock_mass)
424 _link_protocols(
425 player,
426 [_make_protocol_link("proto_a", priority=0)],
427 active_protocol_id="proto_a",
428 )
429 assert player.power_control == PLAYER_CONTROL_NONE
430
431 def test_volume_skips_unavailable_protocol_player(self, mock_mass: MagicMock) -> None:
432 """Volume auto-select skips unavailable protocol players."""
433 proto_a = _create_protocol_player(
434 mock_mass, "proto_a", {PlayerFeature.VOLUME_SET}, available=False
435 )
436 proto_b = _create_protocol_player(
437 mock_mass, "proto_b", {PlayerFeature.VOLUME_SET}, available=True
438 )
439 mock_mass.players.get_player = MagicMock(
440 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
441 )
442 player = _create_player(mock_mass)
443 _link_protocols(
444 player,
445 [
446 _make_protocol_link("proto_a", priority=0),
447 _make_protocol_link("proto_b", priority=1),
448 ],
449 )
450 assert player.volume_control == "proto_b"
451
452
453class TestPreferredOutputProtocolAutoValue:
454 """Test that 'auto' preferred output protocol is treated as no preference."""
455
456 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
457 """Create a side_effect that looks up players by ID."""
458
459 def _lookup(pid: str) -> MockPlayer | None:
460 return players.get(pid)
461
462 return _lookup
463
464 def test_preferred_auto_power_ignores_protocol(self, mock_mass: MagicMock) -> None:
465 """With preferred='auto', power control does not use protocol players."""
466 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
467 mock_mass.players.get_player = MagicMock(
468 side_effect=self._get_player_lookup({"proto_a": proto})
469 )
470 mock_mass.config.get_raw_player_config_value = MagicMock(
471 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
472 )
473 player = _create_player(mock_mass)
474 _link_protocols(
475 player,
476 [_make_protocol_link("proto_a")],
477 active_protocol_id="proto_a",
478 )
479 assert player.power_control == PLAYER_CONTROL_NONE
480
481 def test_preferred_auto_falls_back_for_volume(self, mock_mass: MagicMock) -> None:
482 """With preferred='auto', volume control falls back to any linked protocol."""
483 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
484 mock_mass.players.get_player = MagicMock(
485 side_effect=self._get_player_lookup({"proto_a": proto})
486 )
487 mock_mass.config.get_raw_player_config_value = MagicMock(
488 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
489 )
490 player = _create_player(mock_mass)
491 _link_protocols(
492 player,
493 [_make_protocol_link("proto_a")],
494 )
495 # volume doesn't require active, so it falls back to linked protocol
496 assert player.volume_control == "proto_a"
497
498 def test_preferred_auto_no_active_power_returns_none(self, mock_mass: MagicMock) -> None:
499 """With preferred='auto' and no active protocol, power returns NONE."""
500 proto = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.POWER})
501 mock_mass.players.get_player = MagicMock(
502 side_effect=self._get_player_lookup({"proto_a": proto})
503 )
504 mock_mass.config.get_raw_player_config_value = MagicMock(
505 side_effect=_make_config_side_effect({CONF_PREFERRED_OUTPUT_PROTOCOL: "auto"})
506 )
507 player = _create_player(mock_mass)
508 _link_protocols(
509 player,
510 [_make_protocol_link("proto_a")],
511 )
512 assert player.power_control == PLAYER_CONTROL_NONE
513
514
515class TestActiveOutputProtocolClearCancellation:
516 """Test that setting the active output protocol cancels a pending clear."""
517
518 def test_set_cancels_pending_clear_task(self, mock_mass: MagicMock) -> None:
519 """
520 Setting a protocol cancels the scheduled clear for the same player.
521
522 This is the mechanism that stops a new session's freshly set protocol
523 from being wiped by a clear scheduled after the previous group stop.
524 """
525 player = _create_player(mock_mass)
526
527 player.set_active_output_protocol("ap_1")
528
529 mock_mass.cancel_task.assert_called_once_with("clear_active_protocol_main_player")
530
531
532class TestControlForOutput:
533 """Test the output-scoped volume/mute control resolution."""
534
535 def _get_player_lookup(self, players: dict[str, MockPlayer]) -> Any:
536 """Create a side_effect that looks up players by ID."""
537
538 def _lookup(pid: str) -> MockPlayer | None:
539 return players.get(pid)
540
541 return _lookup
542
543 def test_resolves_named_output_without_active_protocol(self, mock_mass: MagicMock) -> None:
544 """
545 The named output owns the volume even when no protocol is marked active.
546
547 This is the ordering trap the ambient volume_control falls into: with nothing
548 active it picks a sibling interface, which made a joining AirPlay speaker
549 decide another control owned the volume and play at unity gain.
550 """
551 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
552 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
553 mock_mass.players.get_player = MagicMock(
554 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
555 )
556 player = _create_player(mock_mass)
557 _link_protocols(
558 player,
559 [
560 _make_protocol_link("proto_a", priority=0),
561 _make_protocol_link("proto_b", priority=1),
562 ],
563 )
564 # the ambient answer picks the first linked protocol, the output-scoped one
565 # answers for the interface that is actually about to render
566 assert player.volume_control == "proto_a"
567 assert player.volume_control_for_output("proto_b") == "proto_b"
568
569 def test_ignores_active_protocol_pointing_elsewhere(self, mock_mass: MagicMock) -> None:
570 """A protocol marked active does not override the named output."""
571 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
572 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
573 mock_mass.players.get_player = MagicMock(
574 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
575 )
576 player = _create_player(mock_mass)
577 _link_protocols(
578 player,
579 [
580 _make_protocol_link("proto_a", priority=0),
581 _make_protocol_link("proto_b", priority=1),
582 ],
583 active_protocol_id="proto_a",
584 )
585 assert player.volume_control_for_output("proto_b") == "proto_b"
586
587 def test_returns_none_when_output_lacks_feature(self, mock_mass: MagicMock) -> None:
588 """No fallback to a sibling: an output without the feature does not own it."""
589 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
590 proto_b = _create_protocol_player(mock_mass, "proto_b", set())
591 mock_mass.players.get_player = MagicMock(
592 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
593 )
594 player = _create_player(mock_mass)
595 _link_protocols(
596 player,
597 [
598 _make_protocol_link("proto_a", priority=0),
599 _make_protocol_link("proto_b", priority=1),
600 ],
601 )
602 assert player.volume_control == "proto_a"
603 assert player.volume_control_for_output("proto_b") == PLAYER_CONTROL_NONE
604
605 def test_prefers_native(self, mock_mass: MagicMock) -> None:
606 """A player with native volume owns it regardless of the output."""
607 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
608 mock_mass.players.get_player = MagicMock(
609 side_effect=self._get_player_lookup({"proto_a": proto_a})
610 )
611 player = _create_player(mock_mass, features={PlayerFeature.VOLUME_SET})
612 _link_protocols(player, [_make_protocol_link("proto_a")])
613 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_NATIVE
614
615 def test_explicit_config_wins(self, mock_mass: MagicMock) -> None:
616 """An explicitly configured control is a device-wide statement, so it still wins."""
617 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
618 mock_mass.players.get_player = MagicMock(
619 side_effect=self._get_player_lookup({"proto_a": proto_a})
620 )
621 mock_mass.config.get_raw_player_config_value = MagicMock(
622 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: PLAYER_CONTROL_FAKE})
623 )
624 player = _create_player(mock_mass)
625 _link_protocols(player, [_make_protocol_link("proto_a")])
626 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_FAKE
627
628 def test_unknown_output_returns_none(self, mock_mass: MagicMock) -> None:
629 """An output that cannot be resolved owns nothing."""
630 player = _create_player(mock_mass)
631 assert player.volume_control_for_output("nope") == PLAYER_CONTROL_NONE
632
633 def test_mute_resolves_named_output(self, mock_mass: MagicMock) -> None:
634 """Mute resolves against the named output the same way volume does."""
635 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_MUTE})
636 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_MUTE})
637 mock_mass.players.get_player = MagicMock(
638 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
639 )
640 player = _create_player(mock_mass)
641 _link_protocols(
642 player,
643 [
644 _make_protocol_link("proto_a", priority=0),
645 _make_protocol_link("proto_b", priority=1),
646 ],
647 )
648 assert player.mute_control == "proto_a"
649 assert player.mute_control_for_output("proto_b") == "proto_b"
650
651 def test_mute_returns_none_when_output_lacks_feature(self, mock_mass: MagicMock) -> None:
652 """An output with volume but no mute does not own the mute."""
653 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
654 mock_mass.players.get_player = MagicMock(
655 side_effect=self._get_player_lookup({"proto_a": proto_a})
656 )
657 player = _create_player(mock_mass)
658 _link_protocols(player, [_make_protocol_link("proto_a")])
659 assert player.mute_control_for_output("proto_a") == PLAYER_CONTROL_NONE
660
661 def test_explicit_player_id_config_beats_named_output(self, mock_mass: MagicMock) -> None:
662 """An explicitly configured control outranks the output carrying the audio."""
663 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
664 proto_b = _create_protocol_player(mock_mass, "proto_b", {PlayerFeature.VOLUME_SET})
665 mock_mass.players.get_player = MagicMock(
666 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
667 )
668 mock_mass.config.get_raw_player_config_value = MagicMock(
669 side_effect=_make_config_side_effect({CONF_VOLUME_CONTROL: "proto_a"})
670 )
671 player = _create_player(mock_mass)
672 _link_protocols(
673 player,
674 [
675 _make_protocol_link("proto_a", priority=0),
676 _make_protocol_link("proto_b", priority=1),
677 ],
678 )
679 assert player.volume_control_for_output("proto_b") == "proto_a"
680
681 def test_does_not_require_output_to_be_available(self, mock_mass: MagicMock) -> None:
682 """
683 An unavailable reading does not hand the volume to a different interface.
684
685 The caller names the output it is about to stream to, so a stale registry
686 reading is no reason to fall back to a sibling that isn't in the signal path.
687 """
688 proto_a = _create_protocol_player(mock_mass, "proto_a", {PlayerFeature.VOLUME_SET})
689 proto_b = _create_protocol_player(
690 mock_mass, "proto_b", {PlayerFeature.VOLUME_SET}, available=False
691 )
692 mock_mass.players.get_player = MagicMock(
693 side_effect=self._get_player_lookup({"proto_a": proto_a, "proto_b": proto_b})
694 )
695 player = _create_player(mock_mass)
696 _link_protocols(
697 player,
698 [
699 _make_protocol_link("proto_a", priority=0),
700 _make_protocol_link("proto_b", priority=1),
701 ],
702 )
703 assert player.volume_control == "proto_a"
704 assert player.volume_control_for_output("proto_b") == "proto_b"
705
706 def test_fake_mute_degrades_without_volume_control_on_output(
707 self, mock_mass: MagicMock
708 ) -> None:
709 """Fake mute drives the volume to zero, so it needs a volume control to drive."""
710 proto_a = _create_protocol_player(mock_mass, "proto_a", set())
711 mock_mass.players.get_player = MagicMock(
712 side_effect=self._get_player_lookup({"proto_a": proto_a})
713 )
714 mock_mass.config.get_raw_player_config_value = MagicMock(
715 side_effect=_make_config_side_effect({CONF_MUTE_CONTROL: PLAYER_CONTROL_FAKE})
716 )
717 player = _create_player(mock_mass)
718 _link_protocols(player, [_make_protocol_link("proto_a")])
719 assert player.volume_control_for_output("proto_a") == PLAYER_CONTROL_NONE
720 assert player.mute_control_for_output("proto_a") == PLAYER_CONTROL_NONE
721