/
/
/
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
15from music_assistant_models.player import OutputProtocol
16
17from music_assistant.constants import CONF_MUTE_CONTROL, CONF_VOLUME_CONTROL
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 OutputProtocol(
111 output_protocol_id="cast_child",
112 name="Chromecast",
113 protocol_domain="chromecast",
114 )
115 ]
116 )
117
118 control = MagicMock()
119 control.player_id = "cast_child"
120 control.volume_level = 0
121 mock_mass.players.get_player = MagicMock(return_value=control)
122 mock_mass.players.get_player_control = MagicMock(return_value=None)
123 # With default min=0, max=100, scaling is identity
124 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
125
126 player.update_state(signal_event=False)
127 assert player.state.volume_level == 55
128
129 def test_uses_zero_from_active_linked_protocol(self, mock_mass: MagicMock) -> None:
130 """Volume 0 from the linked protocol that is actively rendering is trusted."""
131 mock_mass.config.get_raw_player_config_value = MagicMock(
132 side_effect=lambda _pid, key, *_a: "cast_child" if key == CONF_VOLUME_CONTROL else None
133 )
134 provider = MockProvider("test", mass=mock_mass)
135 player = MockPlayer(provider, "main_player", "Main")
136 player._attr_volume_level = 55
137 player.set_linked_output_protocols(
138 [
139 OutputProtocol(
140 output_protocol_id="cast_child",
141 name="Chromecast",
142 protocol_domain="chromecast",
143 )
144 ]
145 )
146
147 control = MagicMock()
148 control.player_id = "cast_child"
149 control.volume_level = 0
150 mock_mass.players.get_player = MagicMock(return_value=control)
151 mock_mass.players.get_player_control = MagicMock(return_value=None)
152 # With default min=0, max=100, scaling is identity
153 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
154
155 player.set_active_output_protocol("cast_child")
156 player.update_state(signal_event=False)
157 assert player.state.volume_level == 0
158
159 def test_uses_zero_from_external_control_player(self, mock_mass: MagicMock) -> None:
160 """Volume 0 from an external (non-linked) control player is trusted as genuine."""
161 mock_mass.config.get_raw_player_config_value = MagicMock(
162 side_effect=lambda _pid, key, *_a: "amplifier" if key == CONF_VOLUME_CONTROL else None
163 )
164 provider = MockProvider("test", mass=mock_mass)
165 player = MockPlayer(provider, "main_player", "Main")
166 player._attr_volume_level = 55
167
168 control = MagicMock()
169 control.player_id = "amplifier"
170 control.volume_level = 0
171 mock_mass.players.get_player = MagicMock(return_value=control)
172 mock_mass.players.get_player_control = MagicMock(return_value=None)
173 # With default min=0, max=100, scaling is identity
174 mock_mass.players.scale_volume_from_device = MagicMock(side_effect=lambda _pid, vol: vol)
175
176 player.update_state(signal_event=False)
177 assert player.state.volume_level == 0
178
179
180class TestFinalVolumeMutedState:
181 """Test __final_volume_muted_state fallback behaviour."""
182
183 def test_uses_control_player_when_available(self, mock_mass: MagicMock) -> None:
184 """Final mute state uses the control player's mute state when present."""
185 mock_mass.config.get_raw_player_config_value = MagicMock(
186 side_effect=lambda _pid, key, default=None: (
187 "control_player"
188 if key == CONF_MUTE_CONTROL
189 else 0
190 if key == "min_volume"
191 else 100
192 if key == "max_volume"
193 else default
194 ),
195 )
196 provider = MockProvider("test", mass=mock_mass)
197 player = MockPlayer(provider, "main_player", "Main")
198 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
199 player._attr_volume_muted = False
200
201 control = MagicMock()
202 control.volume_muted = True
203 mock_mass.players.get_player = MagicMock(return_value=control)
204 mock_mass.players.get_player_control = MagicMock(return_value=None)
205
206 player.update_state(signal_event=False)
207 assert player.state.volume_muted is True
208
209 def test_falls_back_to_native_when_control_player_missing(self, mock_mass: MagicMock) -> None:
210 """Final mute state falls back to native when the control player doesn't exist."""
211 mock_mass.config.get_raw_player_config_value = MagicMock(
212 side_effect=lambda _pid, key, default=None: (
213 "missing_player"
214 if key == CONF_MUTE_CONTROL
215 else 0
216 if key == "min_volume"
217 else 100
218 if key == "max_volume"
219 else default
220 ),
221 )
222 provider = MockProvider("test", mass=mock_mass)
223 player = MockPlayer(provider, "main_player", "Main")
224 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
225 player._attr_volume_muted = True
226
227 mock_mass.players.get_player = MagicMock(return_value=None)
228 mock_mass.players.get_player_control = MagicMock(return_value=None)
229
230 player.update_state(signal_event=False)
231 assert player.state.volume_muted is True
232
233 def test_falls_back_to_native_when_control_returns_none(self, mock_mass: MagicMock) -> None:
234 """Final mute state falls back to native when the control's mute is None."""
235 mock_mass.config.get_raw_player_config_value = MagicMock(
236 side_effect=lambda _pid, key, default=None: (
237 "control_player"
238 if key == CONF_MUTE_CONTROL
239 else 0
240 if key == "min_volume"
241 else 100
242 if key == "max_volume"
243 else default
244 ),
245 )
246 provider = MockProvider("test", mass=mock_mass)
247 player = MockPlayer(provider, "main_player", "Main")
248 player._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
249 player._attr_volume_muted = False
250
251 control = MagicMock()
252 control.volume_muted = None
253 mock_mass.players.get_player = MagicMock(return_value=control)
254 mock_mass.players.get_player_control = MagicMock(return_value=None)
255
256 player.update_state(signal_event=False)
257 assert player.state.volume_muted is False
258