/
/
/
1"""Tests for player icon defaults and overrides."""
2
3from typing import cast
4from unittest.mock import AsyncMock, MagicMock
5
6import pytest
7from music_assistant_models.enums import PlayerType
8from music_assistant_models.player import DeviceInfo
9
10from music_assistant.constants import CONF_ICON, CONF_PLAYERS
11from music_assistant.helpers.player import get_default_player_icon
12from music_assistant.mass import MusicAssistant
13from tests.common import MockPlayer, MockProvider
14
15
16@pytest.mark.parametrize(
17 ("player_type", "provider_domain", "manufacturer", "model", "expected"),
18 [
19 (PlayerType.GROUP, "sonos", "Sonos", "Arc", "speakers"),
20 (PlayerType.STEREO_PAIR, "test", "Unknown", "Unknown", "speakers"),
21 (PlayerType.PLAYER, "sonos", "Unknown", "Unknown", "sonos"),
22 (PlayerType.PLAYER, "hass_players", "Sonos", "Era 100", "sonos"),
23 (PlayerType.PLAYER, "wiim", "Linkplay", "Unknown", "wiim"),
24 (PlayerType.PLAYER, "airplay", "Apple", "Apple TV 4K Gen2", "apple-tv"),
25 (PlayerType.PLAYER, "airplay", "Apple", "HomePod", "homepod-mini"),
26 (PlayerType.PLAYER, "airplay", "Apple", "Mac14,3", "mac"),
27 (
28 PlayerType.PLAYER,
29 "chromecast",
30 "Google Inc.",
31 "Google Nest Mini",
32 "google-nest",
33 ),
34 (PlayerType.PLAYER, "chromecast", "Google Inc.", "Chromecast Audio", "cast"),
35 (
36 PlayerType.PLAYER,
37 "hass_players",
38 "Nabu Casa",
39 "Home Assistant Voice Preview Edition",
40 "voice-pe",
41 ),
42 (PlayerType.PLAYER, "fully_kiosk", "Samsung", "SM-X700", "tablet"),
43 (PlayerType.PLAYER, "roku_media_assistant", "Roku", "Ultra", "tv"),
44 (PlayerType.PLAYER, "test", "Samsung", "HW-Q990 Soundbar", "soundbar"),
45 (PlayerType.PLAYER, "test", "Unknown", "Bluetooth Speaker", "bluetooth"),
46 (PlayerType.PLAYER, "test", "Monitor Audio", "Creator 90", "speaker"),
47 (PlayerType.PLAYER, "test", "Unknown", "Studio Monitor", "speaker"),
48 (PlayerType.DISPLAY, "sendspin", "Unknown", "Unknown", "monitor"),
49 (PlayerType.VISUALIZER, "sendspin", "Unknown", "Unknown", "monitor"),
50 (PlayerType.LIGHT, "hue_entertainment", "Signify", "Hue", "sun"),
51 (PlayerType.PLAYER, "test", "Unknown", "Unknown", "speaker"),
52 ],
53)
54def test_default_player_icon(
55 player_type: PlayerType,
56 provider_domain: str,
57 manufacturer: str,
58 model: str,
59 expected: str,
60) -> None:
61 """The player type, provider, and device metadata select a useful default icon."""
62 player = MockPlayer(
63 MockProvider(provider_domain),
64 "test_player",
65 "Test Player",
66 player_type=player_type,
67 )
68 player._attr_device_info = DeviceInfo(manufacturer=manufacturer, model=model)
69 cast("MagicMock", player.mass.config.get_raw_player_config_value).return_value = None
70 player._cache.clear()
71
72 assert player.icon == expected
73
74
75def test_default_player_icon_tolerates_missing_device_metadata() -> None:
76 """Missing metadata on restored players falls back safely."""
77 assert get_default_player_icon(PlayerType.PLAYER, "test", None, None) == "speaker"
78
79
80def test_explicit_player_icon_overrides_default() -> None:
81 """An explicitly configured icon always wins over the computed default."""
82 player = MockPlayer(MockProvider("airplay"), "apple_tv", "Apple TV")
83 player._attr_device_info = DeviceInfo(manufacturer="Apple", model="Apple TV 4K")
84 cast("MagicMock", player.mass.config.get_raw_player_config_value).return_value = "living-room"
85 player._cache.clear()
86
87 assert player.icon == "living-room"
88
89
90def test_automatic_player_icon_refreshes_with_device_info() -> None:
91 """An automatic icon follows device information learned after initialization."""
92 player = MockPlayer(MockProvider("test"), "late_metadata", "Late metadata")
93 cast("MagicMock", player.mass.config.get_raw_player_config_value).return_value = None
94
95 assert player.icon == "speaker"
96
97 player._attr_device_info = DeviceInfo(manufacturer="Sonos", model="Era 100")
98 player.update_state(signal_event=False)
99
100 assert player.icon == "sonos"
101
102
103async def test_unset_icon_config_uses_player_default(mass_minimal: MusicAssistant) -> None:
104 """An unset stored icon resolves to the player's computed config default."""
105 player = MagicMock()
106 player.player_id = "test_player"
107 player.state.type = PlayerType.PLAYER
108 player.default_icon = "sonos"
109 player.hidden_by_default = False
110 player.expose_to_ha_by_default = True
111 player.linked_output_protocols = []
112 player.supports_feature.return_value = False
113 mass_minimal.players = MagicMock()
114 mass_minimal.players.player_controls.return_value = []
115
116 entries = mass_minimal.config._get_default_player_config_entries(player)
117 icon_entry = next(entry for entry in entries if entry.key == CONF_ICON)
118
119 assert icon_entry.default_value == "sonos"
120 assert icon_entry.parse_value(None) == "sonos"
121
122
123async def test_unset_icon_config_exposes_automatic_state(
124 mass_minimal: MusicAssistant,
125) -> None:
126 """An absent stored icon stays None while the computed default is provided separately."""
127 _set_up_config_player(mass_minimal)
128
129 config = await mass_minimal.config.get_player_config("test_player")
130
131 assert config.values[CONF_ICON].value is None
132 assert config.values[CONF_ICON].default_value == "sonos"
133
134
135async def test_explicit_default_icon_remains_explicit(mass_minimal: MusicAssistant) -> None:
136 """A stored icon remains explicit even when it equals the computed default."""
137 mass_minimal.config.set(
138 f"{CONF_PLAYERS}/test_player",
139 {
140 "player_id": "test_player",
141 "provider": "sonos",
142 "player_type": PlayerType.PLAYER,
143 "values": {CONF_ICON: "sonos"},
144 },
145 )
146 _set_up_config_player(mass_minimal)
147
148 config = await mass_minimal.config.get_player_config("test_player")
149
150 assert config.values[CONF_ICON].value == "sonos"
151 assert config.values[CONF_ICON].default_value == "sonos"
152
153 await mass_minimal.config.save_player_config(
154 "test_player",
155 {CONF_ICON: "sonos", "hide_in_ui": True},
156 )
157 stored_values = mass_minimal.config.get(f"{CONF_PLAYERS}/test_player/values")
158 assert stored_values[CONF_ICON] == "sonos"
159
160
161async def test_automatic_icon_stays_unset_on_unrelated_save(
162 mass_minimal: MusicAssistant,
163) -> None:
164 """A stale automatic icon cannot become an explicit choice during a config save."""
165 _set_up_config_player(mass_minimal)
166
167 await mass_minimal.config.save_player_config(
168 "test_player",
169 {CONF_ICON: None, "hide_in_ui": True},
170 )
171
172 stored_values = mass_minimal.config.get(f"{CONF_PLAYERS}/test_player/values")
173 assert CONF_ICON not in stored_values
174
175
176async def test_explicit_icon_lifecycle(mass_minimal: MusicAssistant) -> None:
177 """Explicit icons survive unrelated saves, can change to the default, and can reset."""
178 mass_minimal.config.set(
179 f"{CONF_PLAYERS}/test_player",
180 {
181 "player_id": "test_player",
182 "provider": "sonos",
183 "player_type": PlayerType.PLAYER,
184 "values": {CONF_ICON: "speaker"},
185 },
186 )
187 _set_up_config_player(mass_minimal)
188
189 await mass_minimal.config.save_player_config(
190 "test_player",
191 {CONF_ICON: "speaker", "hide_in_ui": True},
192 )
193 stored_values = mass_minimal.config.get(f"{CONF_PLAYERS}/test_player/values")
194 assert stored_values[CONF_ICON] == "speaker"
195
196 await mass_minimal.config.save_player_config("test_player", {CONF_ICON: "sonos"})
197 stored_values = mass_minimal.config.get(f"{CONF_PLAYERS}/test_player/values")
198 assert stored_values[CONF_ICON] == "sonos"
199
200 await mass_minimal.config.save_player_config("test_player", {CONF_ICON: None})
201 stored_values = mass_minimal.config.get(f"{CONF_PLAYERS}/test_player/values")
202 assert CONF_ICON not in stored_values
203 callback = cast("AsyncMock", mass_minimal.players.on_player_config_change)
204 assert callback.await_args is not None
205 callback_config = callback.await_args.args[0]
206 assert callback_config.values[CONF_ICON].value is None
207
208
209def _set_up_config_player(mass: MusicAssistant) -> None:
210 """Set up a player mock that drives the real player config parsing path."""
211 if not mass.config.get(f"{CONF_PLAYERS}/test_player"):
212 mass.config.set(
213 f"{CONF_PLAYERS}/test_player",
214 {
215 "player_id": "test_player",
216 "provider": "sonos",
217 "player_type": PlayerType.PLAYER,
218 "values": {},
219 },
220 )
221 player = MagicMock()
222 player.player_id = "test_player"
223 player.state.type = PlayerType.PLAYER
224 player.state.name = "Test Player"
225 player.provider.instance_id = "sonos"
226 player.translation_owner = "provider.sonos"
227 player.default_icon = "sonos"
228 player.hidden_by_default = False
229 player.expose_to_ha_by_default = True
230 player.linked_output_protocols = []
231 player.output_protocols = []
232 player.supported_features = set()
233 player.supports_feature.return_value = False
234 player.get_config_entries = AsyncMock(return_value=[])
235 mass.players = MagicMock()
236 mass.players.get_player.return_value = player
237 mass.players.player_controls.return_value = []
238 mass.players.on_player_config_change = AsyncMock()
239