/
/
/
1"""
2Tests for Player model final volume/mute state fallback logic.
3
4Covers the fallback-to-native behavior in __final_volume_level and
5__final_volume_muted_state when a configured volume/mute control is
6unavailable or returns None.
7"""
8
9from __future__ import annotations
10
11from unittest.mock import MagicMock
12
13import pytest
14from music_assistant_models.enums import PlayerFeature
15
16from music_assistant.constants import CONF_MUTE_CONTROL, CONF_VOLUME_CONTROL
17from music_assistant.models.player import LinkedOutputProtocol
18from tests.common import MockPlayer, MockProvider
19
20
21@pytest.fixture
22def mock_mass() -> MagicMock:
23 """Create a mock MusicAssistant instance."""
24 mass = MagicMock()
25 mass.closing = False
26 mass.loop = None
27 mass.config.get = MagicMock(return_value=[])
28 mass.config.get_raw_player_config_value = MagicMock(return_value=None)
29 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
30 mass.config.set = MagicMock()
31 mass.signal_event = MagicMock()
32 mass.get_providers = MagicMock(return_value=[])
33 return mass
34
35
36class TestFinalVolumeLevel:
37 """Test __final_volume_level fallback behaviour."""
38
39 def test_uses_control_player_when_available(self, mock_mass: MagicMock) -> None:
40 """Final volume uses the control player's volume when it is present."""
41 mock_mass.config.get_raw_player_config_value = MagicMock(
42 side_effect=lambda _pid, key, *_a: (
43 "control_player" if key == CONF_VOLUME_CONTROL else None
44 )
45 )
46 provider = MockProvider("test", mass=mock_mass)
47 player = MockPlayer(provider, "main_player", "Main")
48 player._attr_volume_level = 30
49
50 control = MagicMock()
51 control.volume_level = 80
52 mock_mass.players.get_player = MagicMock(return_value=control)
53 mock_mass.players.get_player_control = MagicMock(return_value=None)
54 # With default min=0, max=100, scaling is identity
55 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
56
57 player.update_state(signal_event=False)
58 assert player.state.volume_level == 80
59
60 def test_falls_back_to_native_when_control_player_missing(self, mock_mass: MagicMock) -> None:
61 """Final volume falls back to native when the control player doesn't exist."""
62 mock_mass.config.get_raw_player_config_value = MagicMock(
63 side_effect=lambda _pid, key, *_a: (
64 "missing_player" if key == CONF_VOLUME_CONTROL else None
65 )
66 )
67 provider = MockProvider("test", mass=mock_mass)
68 player = MockPlayer(provider, "main_player", "Main")
69 player._attr_volume_level = 55
70
71 mock_mass.players.get_player = MagicMock(return_value=None)
72 mock_mass.players.get_player_control = MagicMock(return_value=None)
73 # With default min=0, max=100, scaling is identity
74 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
75
76 player.update_state(signal_event=False)
77 assert player.state.volume_level == 55
78
79 def test_falls_back_to_native_when_control_returns_none(self, mock_mass: MagicMock) -> None:
80 """Final volume falls back to native when the control player's volume is None."""
81 mock_mass.config.get_raw_player_config_value = MagicMock(
82 side_effect=lambda _pid, key, *_a: (
83 "control_player" if key == CONF_VOLUME_CONTROL else None
84 )
85 )
86 provider = MockProvider("test", mass=mock_mass)
87 player = MockPlayer(provider, "main_player", "Main")
88 player._attr_volume_level = 42
89
90 control = MagicMock()
91 control.volume_level = None
92 mock_mass.players.get_player = MagicMock(return_value=control)
93 mock_mass.players.get_player_control = MagicMock(return_value=None)
94 # With default min=0, max=100, scaling is identity
95 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
96
97 player.update_state(signal_event=False)
98 assert player.state.volume_level == 42
99
100 def test_ignores_zero_from_idle_linked_protocol(self, mock_mass: MagicMock) -> None:
101 """Volume 0 from a linked protocol that is not the active output is not authoritative."""
102 mock_mass.config.get_raw_player_config_value = MagicMock(
103 side_effect=lambda _pid, key, *_a: "cast_child" if key == CONF_VOLUME_CONTROL else None
104 )
105 provider = MockProvider("test", mass=mock_mass)
106 player = MockPlayer(provider, "main_player", "Main")
107 player._attr_volume_level = 55
108 player.set_linked_output_protocols(
109 [
110 LinkedOutputProtocol(
111 output_protocol_id="cast_child",
112 protocol_domain="chromecast",
113 )
114 ]
115 )
116
117 control = MagicMock()
118 control.player_id = "cast_child"
119 control.volume_level = 0
120 mock_mass.players.get_player = MagicMock(return_value=control)
121 mock_mass.players.get_player_control = MagicMock(return_value=None)
122 # With default min=0, max=100, scaling is identity
123 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
124
125 player.update_state(signal_event=False)
126 assert player.state.volume_level == 55
127
128 def test_uses_zero_from_active_linked_protocol(self, mock_mass: MagicMock) -> None:
129 """Volume 0 from the linked protocol that is actively rendering is trusted."""
130 mock_mass.config.get_raw_player_config_value = MagicMock(
131 side_effect=lambda _pid, key, *_a: "cast_child" if key == CONF_VOLUME_CONTROL else None
132 )
133 provider = MockProvider("test", mass=mock_mass)
134 player = MockPlayer(provider, "main_player", "Main")
135 player._attr_volume_level = 55
136 player.set_linked_output_protocols(
137 [
138 LinkedOutputProtocol(
139 output_protocol_id="cast_child",
140 protocol_domain="chromecast",
141 )
142 ]
143 )
144
145 control = MagicMock()
146 control.player_id = "cast_child"
147 control.volume_level = 0
148 mock_mass.players.get_player = MagicMock(return_value=control)
149 mock_mass.players.get_player_control = MagicMock(return_value=None)
150 # With default min=0, max=100, scaling is identity
151 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
152
153 player.set_active_output_protocol("cast_child")
154 player.update_state(signal_event=False)
155 assert player.state.volume_level == 0
156
157 def test_uses_zero_from_external_control_player(self, mock_mass: MagicMock) -> None:
158 """Volume 0 from an external (non-linked) control player is trusted as genuine."""
159 mock_mass.config.get_raw_player_config_value = MagicMock(
160 side_effect=lambda _pid, key, *_a: "amplifier" if key == CONF_VOLUME_CONTROL else None
161 )
162 provider = MockProvider("test", mass=mock_mass)
163 player = MockPlayer(provider, "main_player", "Main")
164 player._attr_volume_level = 55
165
166 control = MagicMock()
167 control.player_id = "amplifier"
168 control.volume_level = 0
169 mock_mass.players.get_player = MagicMock(return_value=control)
170 mock_mass.players.get_player_control = MagicMock(return_value=None)
171 # With default min=0, max=100, scaling is identity
172 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
173
174 player.update_state(signal_event=False)
175 assert player.state.volume_level == 0
176
177
178class TestFinalVolumeMutedState:
179 """Test __final_volume_muted_state fallback behaviour."""
180
181 def test_uses_control_player_when_available(self, mock_mass: MagicMock) -> None:
182 """Final mute state uses the control player's mute state when present."""
183 mock_mass.config.get_raw_player_config_value = MagicMock(
184 side_effect=lambda _pid, key, default=None: (
185 "control_player"
186 if key == CONF_MUTE_CONTROL
187 else 0
188 if key == "min_volume"
189 else 100
190 if key == "max_volume"
191 else default
192 ),
193 )
194 provider = MockProvider("test", mass=mock_mass)
195 player = MockPlayer(provider, "main_player", "Main")
196 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
197 player._attr_volume_muted = False
198
199 control = MagicMock()
200 control.volume_muted = True
201 mock_mass.players.get_player = MagicMock(return_value=control)
202 mock_mass.players.get_player_control = MagicMock(return_value=None)
203
204 player.update_state(signal_event=False)
205 assert player.state.volume_muted is True
206
207 def test_falls_back_to_native_when_control_player_missing(self, mock_mass: MagicMock) -> None:
208 """Final mute state falls back to native when the control player doesn't exist."""
209 mock_mass.config.get_raw_player_config_value = MagicMock(
210 side_effect=lambda _pid, key, default=None: (
211 "missing_player"
212 if key == CONF_MUTE_CONTROL
213 else 0
214 if key == "min_volume"
215 else 100
216 if key == "max_volume"
217 else default
218 ),
219 )
220 provider = MockProvider("test", mass=mock_mass)
221 player = MockPlayer(provider, "main_player", "Main")
222 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
223 player._attr_volume_muted = True
224
225 mock_mass.players.get_player = MagicMock(return_value=None)
226 mock_mass.players.get_player_control = MagicMock(return_value=None)
227
228 player.update_state(signal_event=False)
229 assert player.state.volume_muted is True
230
231 def test_falls_back_to_native_when_control_returns_none(self, mock_mass: MagicMock) -> None:
232 """Final mute state falls back to native when the control's mute is None."""
233 mock_mass.config.get_raw_player_config_value = MagicMock(
234 side_effect=lambda _pid, key, default=None: (
235 "control_player"
236 if key == CONF_MUTE_CONTROL
237 else 0
238 if key == "min_volume"
239 else 100
240 if key == "max_volume"
241 else default
242 ),
243 )
244 provider = MockProvider("test", mass=mock_mass)
245 player = MockPlayer(provider, "main_player", "Main")
246 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
247 player._attr_volume_muted = False
248
249 control = MagicMock()
250 control.volume_muted = None
251 mock_mass.players.get_player = MagicMock(return_value=control)
252 mock_mass.players.get_player_control = MagicMock(return_value=None)
253
254 player.update_state(signal_event=False)
255 assert player.state.volume_muted is False
256