/
/
/
1"""Tests for the Bose SoundTouch provider preset config entries."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any, cast
6
7from music_assistant_models.config_entries import ProviderConfig
8from music_assistant_models.enums import ConfigEntryType, MediaType
9from music_assistant_models.media_items import Playlist, SearchResults
10
11from music_assistant.providers.bose_soundtouch.config import (
12 ACTION_ASSIGN,
13 ACTION_SEARCH,
14 CONF_SEARCH_MEDIA_TYPE,
15 CONF_SEARCH_QUERY,
16 CONF_SEARCH_RESULT,
17 CONF_SEARCH_TARGET,
18 build_preset_config_entries,
19 preset_media_key,
20)
21from music_assistant.providers.bose_soundtouch.provider import (
22 BoseSoundTouchProvider,
23 _search_values_to_reset,
24)
25
26if TYPE_CHECKING:
27 from music_assistant_models.config_entries import ConfigEntry
28
29
30class _MusicStub:
31 """Record preset media searches and return a single result."""
32
33 def __init__(self) -> None:
34 self.searches: list[tuple[str, list[MediaType]]] = []
35
36 async def search(
37 self,
38 search_query: str,
39 media_types: list[MediaType],
40 limit: int,
41 library_only: bool,
42 ) -> SearchResults:
43 """Return a single playlist result for any query."""
44 self.searches.append((search_query, media_types))
45 return SearchResults(
46 playlists=[
47 Playlist(
48 item_id="42",
49 provider="demo",
50 name="Morning Mix",
51 uri="demo://playlist/42",
52 provider_mappings=set(),
53 )
54 ]
55 )
56
57
58class _ProviderStub:
59 """Provide stored config values and the music search controller."""
60
61 def __init__(self, values: dict[str, Any] | None = None) -> None:
62 self._values = values or {}
63 self.music = _MusicStub()
64 self.mass = self
65 self.instance_id = "bose_soundtouch"
66 self.config_updates: list[tuple[str, Any, bool]] = []
67
68 def get_config_value(self, key: str, default: Any = None) -> Any:
69 """Return a stored value."""
70 return self._values.get(key, default)
71
72 def _update_config_value(self, key: str, value: Any, immediate: bool = False) -> None:
73 """Record and apply a provider-owned config update."""
74 self.config_updates.append((key, value, immediate))
75 self._values[key] = value
76
77 def _reset_search_values(self, *keys: str) -> None:
78 """Reset persisted search fields."""
79 for key in keys:
80 self._update_config_value(key, "", immediate=True)
81
82
83def _as_provider(stub: _ProviderStub) -> BoseSoundTouchProvider:
84 """Present the stub as a provider for the entry builder."""
85 return cast("BoseSoundTouchProvider", stub)
86
87
88def _entries_by_key(entries: list[ConfigEntry]) -> dict[str, ConfigEntry]:
89 """Index config entries by key."""
90 return {entry.key: entry for entry in entries}
91
92
93async def test_plain_render_has_one_search_engine_and_six_presets() -> None:
94 """A plain render exposes one search flow and all six preset mappings."""
95 stub = _ProviderStub()
96 entries = _entries_by_key(await build_preset_config_entries(_as_provider(stub)))
97
98 assert entries[CONF_SEARCH_MEDIA_TYPE].default_value == MediaType.PLAYLIST.value
99 assert entries[CONF_SEARCH_MEDIA_TYPE].immediate_apply
100 assert entries[CONF_SEARCH_QUERY].immediate_apply
101 assert entries["preset_do_search"].action == ACTION_SEARCH
102 assert entries[CONF_SEARCH_RESULT].hidden
103 assert entries[CONF_SEARCH_RESULT].immediate_apply
104 assert entries[CONF_SEARCH_TARGET].hidden
105 assert entries[CONF_SEARCH_TARGET].immediate_apply
106 assert entries[CONF_SEARCH_TARGET].default_value == ""
107 assert entries["preset_do_assign"].hidden
108 for preset_id in range(1, 7):
109 preset_entry = entries[preset_media_key(preset_id)]
110 assert preset_entry.type == ConfigEntryType.STRING
111 assert preset_entry.category == "presets"
112 assert stub.music.searches == []
113
114
115async def test_hidden_search_fields_accept_immediate_config_save() -> None:
116 """The server accepts search selections when rebuilding plain config entries."""
117 stub = _ProviderStub()
118 entries = await build_preset_config_entries(_as_provider(stub))
119 config = cast(
120 "ProviderConfig",
121 ProviderConfig.parse(
122 entries,
123 {
124 "type": "player",
125 "domain": "bose_soundtouch",
126 "instance_id": "bose_soundtouch",
127 "values": {},
128 },
129 ),
130 )
131 selected_uri = "radiobrowser://radio/france-inter"
132
133 changed_keys = config.update({CONF_SEARCH_RESULT: selected_uri, CONF_SEARCH_TARGET: "4"})
134
135 assert changed_keys == {
136 f"values/{CONF_SEARCH_RESULT}",
137 f"values/{CONF_SEARCH_TARGET}",
138 }
139 assert config.values[CONF_SEARCH_RESULT].value == selected_uri
140 assert config.values[CONF_SEARCH_TARGET].value == "4"
141
142
143async def test_search_refreshes_shared_results_once() -> None:
144 """The shared search action performs a single media search."""
145 stub = _ProviderStub(
146 {
147 CONF_SEARCH_QUERY: " morning ",
148 CONF_SEARCH_MEDIA_TYPE: MediaType.PLAYLIST.value,
149 }
150 )
151 entries = _entries_by_key(
152 await build_preset_config_entries(_as_provider(stub), refresh_results=True)
153 )
154
155 assert stub.music.searches == [("morning", [MediaType.PLAYLIST])]
156 selection = entries[CONF_SEARCH_RESULT]
157 assert selection.options is not None
158 assert [option.value for option in selection.options] == ["demo://playlist/42"]
159 assert not selection.hidden
160 target = entries[CONF_SEARCH_TARGET]
161 assert not target.hidden
162 assert target.options is not None
163 assert [option.value for option in target.options] == [
164 "",
165 *[str(value) for value in range(1, 7)],
166 ]
167 assert target.options[0].disabled
168 assert target.options[0].translation_key == "select"
169 assert all(option.title is None for option in target.options)
170 assert target.value == ""
171 assert entries["preset_do_assign"].hidden
172
173
174async def test_assign_action_appears_after_result_and_target_selection() -> None:
175 """Assignment is unavailable until both the result and target preset are selected."""
176 stub = _ProviderStub(
177 {
178 CONF_SEARCH_RESULT: "demo://playlist/42",
179 CONF_SEARCH_TARGET: "4",
180 }
181 )
182 entries = _entries_by_key(await build_preset_config_entries(_as_provider(stub)))
183
184 assign_action = entries["preset_do_assign"]
185 assert assign_action.action == ACTION_ASSIGN
186 assert assign_action.immediate_apply
187 assert assign_action.translation_params == ["4"]
188 assert not assign_action.hidden
189
190
191async def test_plain_render_keeps_current_search_selection() -> None:
192 """A stored search result stays selectable without repeating the search."""
193 stub = _ProviderStub({CONF_SEARCH_RESULT: "demo://playlist/7"})
194 entries = _entries_by_key(await build_preset_config_entries(_as_provider(stub)))
195
196 selection = entries[CONF_SEARCH_RESULT]
197 assert selection.options is not None
198 assert [option.value for option in selection.options] == ["demo://playlist/7"]
199 assert not selection.hidden
200 assert stub.music.searches == []
201
202
203async def test_search_falls_back_to_default_media_type() -> None:
204 """An unsupported media type falls back to playlist search."""
205 stub = _ProviderStub(
206 {CONF_SEARCH_MEDIA_TYPE: MediaType.FOLDER.value, CONF_SEARCH_QUERY: "jazz"}
207 )
208
209 await build_preset_config_entries(_as_provider(stub), refresh_results=True)
210
211 assert stub.music.searches == [("jazz", [MediaType.PLAYLIST])]
212
213
214async def test_search_action_resets_previous_result_and_target() -> None:
215 """A new search clears all values produced by the previous search."""
216 stub = _ProviderStub(
217 {
218 CONF_SEARCH_MEDIA_TYPE: MediaType.PLAYLIST.value,
219 CONF_SEARCH_QUERY: "morning",
220 CONF_SEARCH_RESULT: "demo://playlist/old",
221 CONF_SEARCH_TARGET: "5",
222 }
223 )
224 entry = await BoseSoundTouchProvider.handle_config_action(_as_provider(stub), ACTION_SEARCH)
225 assert isinstance(entry, tuple)
226 entries = _entries_by_key(list(entry))
227
228 assert stub.config_updates == [
229 (CONF_SEARCH_RESULT, "", True),
230 (CONF_SEARCH_TARGET, "", True),
231 ]
232 assert entries[CONF_SEARCH_RESULT].value == ""
233 assert entries[CONF_SEARCH_TARGET].value == ""
234 assert entries["preset_do_assign"].hidden
235 assert stub.music.searches == [("morning", [MediaType.PLAYLIST])]
236
237
238def test_search_changes_reset_only_downstream_steps() -> None:
239 """Each search step invalidates only the values that follow it."""
240 assert _search_values_to_reset({f"values/{CONF_SEARCH_MEDIA_TYPE}"}) == (
241 CONF_SEARCH_QUERY,
242 CONF_SEARCH_RESULT,
243 CONF_SEARCH_TARGET,
244 )
245 assert _search_values_to_reset({f"values/{CONF_SEARCH_QUERY}"}) == (
246 CONF_SEARCH_RESULT,
247 CONF_SEARCH_TARGET,
248 )
249 assert _search_values_to_reset({f"values/{CONF_SEARCH_RESULT}"}) == (CONF_SEARCH_TARGET,)
250 assert _search_values_to_reset({f"values/{CONF_SEARCH_TARGET}"}) == ()
251
252
253async def test_assign_action_copies_result_to_selected_preset() -> None:
254 """The assignment action copies the selected URI to the chosen preset."""
255 selected_uri = "radiobrowser://radio/france-inter"
256 stub = _ProviderStub(
257 {
258 CONF_SEARCH_MEDIA_TYPE: MediaType.RADIO.value,
259 CONF_SEARCH_QUERY: "france inter",
260 CONF_SEARCH_RESULT: selected_uri,
261 CONF_SEARCH_TARGET: "4",
262 }
263 )
264
265 entry = await BoseSoundTouchProvider.handle_config_action(_as_provider(stub), ACTION_ASSIGN)
266 assert isinstance(entry, tuple)
267 entries = _entries_by_key(list(entry))
268
269 assert stub._values[preset_media_key(4)] == selected_uri
270 assert stub.config_updates == [
271 (preset_media_key(4), selected_uri, True),
272 (CONF_SEARCH_MEDIA_TYPE, "", True),
273 (CONF_SEARCH_QUERY, "", True),
274 (CONF_SEARCH_RESULT, "", True),
275 (CONF_SEARCH_TARGET, "", True),
276 ]
277 assert entries[preset_media_key(4)].value == selected_uri
278 assert entries[CONF_SEARCH_MEDIA_TYPE].value == MediaType.PLAYLIST.value
279 assert entries[CONF_SEARCH_QUERY].value == ""
280 assert entries[CONF_SEARCH_RESULT].value == ""
281 assert entries[CONF_SEARCH_TARGET].value == ""
282 assert entries["preset_do_assign"].hidden
283 assert stub.music.searches == []
284