/
/
/
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.enums import ConfigEntryType, MediaType
8from music_assistant_models.media_items import Playlist, SearchResults
9
10from music_assistant.providers.bose_soundtouch.config import (
11 build_preset_config_entries,
12 parse_preset_action,
13 preset_media_key,
14 preset_media_type_key,
15 preset_search_key,
16 preset_selected_media_key,
17)
18
19if TYPE_CHECKING:
20 from music_assistant_models.config_entries import ConfigEntry
21
22 from music_assistant.providers.bose_soundtouch.provider import BoseSoundTouchProvider
23
24
25class _MusicStub:
26 """Records the preset media searches performed while building the entries."""
27
28 def __init__(self) -> None:
29 self.searches: list[tuple[str, list[MediaType]]] = []
30
31 async def search(
32 self,
33 search_query: str,
34 media_types: list[MediaType],
35 limit: int,
36 library_only: bool,
37 ) -> SearchResults:
38 """Return a single playlist result for any query."""
39 self.searches.append((search_query, media_types))
40 return SearchResults(
41 playlists=[
42 Playlist(
43 item_id="42",
44 provider="demo",
45 name="Morning Mix",
46 uri="demo://playlist/42",
47 provider_mappings=set(),
48 )
49 ]
50 )
51
52
53class _ProviderStub:
54 """Minimal stand-in for the provider: stored config values plus a music controller."""
55
56 def __init__(self, values: dict[str, Any] | None = None) -> None:
57 self._values = values or {}
58 self.music = _MusicStub()
59 self.mass = self
60
61 def get_config_value(self, key: str, default: Any = None) -> Any:
62 """Return the stored value for the given config key."""
63 return self._values.get(key, default)
64
65
66def _as_provider(stub: _ProviderStub) -> BoseSoundTouchProvider:
67 """Present the stub as a provider for the entry builder."""
68 return cast("BoseSoundTouchProvider", stub)
69
70
71def _entries_by_key(entries: list[ConfigEntry]) -> dict[str, ConfigEntry]:
72 """Index the built entries by their config key."""
73 return {entry.key: entry for entry in entries}
74
75
76def test_parse_preset_action_search() -> None:
77 """A preset search action maps to its preset id with is_select False."""
78 assert parse_preset_action("preset_3_search_media") == (3, False)
79 assert parse_preset_action("preset_1_search_media") == (1, False)
80 assert parse_preset_action("preset_6_search_media") == (6, False)
81
82
83def test_parse_preset_action_select() -> None:
84 """A preset select action maps to its preset id with is_select True."""
85 assert parse_preset_action("preset_2_select_media") == (2, True)
86 assert parse_preset_action("preset_6_select_media") == (6, True)
87
88
89def test_parse_preset_action_unknown() -> None:
90 """Non-action ids (incl. the button entry keys and out-of-range presets) don't match."""
91 assert parse_preset_action("preset_3_do_search") == (None, False)
92 assert parse_preset_action("preset_7_search_media") == (None, False)
93 assert parse_preset_action("preset_0_select_media") == (None, False)
94 assert parse_preset_action("some_other_action") == (None, False)
95 assert parse_preset_action("") == (None, False)
96
97
98async def test_build_preset_entries_without_stored_values() -> None:
99 """A plain render exposes all six presets and runs no search."""
100 stub = _ProviderStub()
101 entries = _entries_by_key(await build_preset_config_entries(_as_provider(stub)))
102
103 for preset_id in range(1, 7):
104 assert entries[preset_media_key(preset_id)].type == ConfigEntryType.STRING
105 assert entries[preset_media_type_key(preset_id)].default_value == MediaType.PLAYLIST.value
106 assert entries[preset_search_key(preset_id)].type == ConfigEntryType.STRING
107 assert entries[f"preset_{preset_id}_do_search"].action == f"preset_{preset_id}_search_media"
108 # no search ran, so there is nothing to select yet
109 assert preset_selected_media_key(preset_id) not in entries
110 assert stub.music.searches == []
111
112
113async def test_build_preset_entries_only_searches_refreshed_preset() -> None:
114 """Only the preset whose search button was pressed queries the music controller."""
115 stub = _ProviderStub(
116 {
117 preset_search_key(2): " morning ",
118 preset_media_type_key(2): MediaType.PLAYLIST.value,
119 preset_search_key(5): "evening",
120 }
121 )
122 entries = _entries_by_key(
123 await build_preset_config_entries(_as_provider(stub), refresh_preset_id=2)
124 )
125
126 assert stub.music.searches == [("morning", [MediaType.PLAYLIST])]
127 selection = entries[preset_selected_media_key(2)]
128 assert selection.options is not None
129 assert [option.value for option in selection.options] == ["demo://playlist/42"]
130 assert entries["preset_2_do_select"].action == "preset_2_select_media"
131 # the untouched preset got no result dropdown
132 assert preset_selected_media_key(5) not in entries
133
134
135async def test_build_preset_entries_keeps_current_selection() -> None:
136 """A stored selection stays selectable when its search results are not refreshed."""
137 stub = _ProviderStub({preset_selected_media_key(4): "demo://playlist/7"})
138 entries = _entries_by_key(await build_preset_config_entries(_as_provider(stub)))
139
140 selection = entries[preset_selected_media_key(4)]
141 assert selection.options is not None
142 assert [option.value for option in selection.options] == ["demo://playlist/7"]
143 assert stub.music.searches == []
144
145
146async def test_build_preset_entries_falls_back_to_default_media_type() -> None:
147 """An unsupported stored media type falls back to the default (playlist) search."""
148 stub = _ProviderStub(
149 {preset_media_type_key(1): MediaType.FOLDER.value, preset_search_key(1): "jazz"}
150 )
151 await build_preset_config_entries(_as_provider(stub), refresh_preset_id=1)
152
153 assert stub.music.searches == [("jazz", [MediaType.PLAYLIST])]
154