/
/
/
1"""Unit tests for AI Radio provider config entries."""
2
3from __future__ import annotations
4
5import asyncio
6from types import SimpleNamespace
7from typing import Any, cast
8
9from music_assistant_models.config_entries import Config
10
11from music_assistant.providers.ai_radio.config import get_config_entries
12from music_assistant.providers.ai_radio.provider import AIRadioProvider
13
14
15def _make_mass(
16 base_url: str, locale: str = "en_US", setup_data: dict[str, Any] | None = None
17) -> Any:
18 """Build a minimal mass stub exposing webserver.base_url, metadata.locale and config."""
19 config = SimpleNamespace(
20 get=lambda key: setup_data if key.endswith("/setup_data") else None,
21 # the real config controller encrypts/decrypts setup_data strings transparently;
22 # the stub's "encrypted" values are already plain, so decryption is a no-op
23 decrypt_string=lambda value: value,
24 )
25 return cast(
26 "Any",
27 SimpleNamespace(
28 webserver=SimpleNamespace(base_url=base_url),
29 metadata=SimpleNamespace(locale=locale),
30 config=config,
31 ),
32 )
33
34
35def test_optional_entries_use_the_advanced_flag_not_the_advanced_category() -> None:
36 """
37 Advanced options must set ``advanced``, not ``category="advanced"``.
38
39 The frontend drops the deprecated "advanced" category from its panel list, so entries
40 filed under it never render on the settings page.
41 """
42 mass = _make_mass("http://localhost:8095")
43
44 entries = asyncio.run(get_config_entries(mass))
45
46 assert entries, "provider must expose config entries"
47 for entry in entries:
48 assert entry.category != "advanced", f"{entry.key} uses the deprecated advanced category"
49 advanced = {entry.key for entry in entries if entry.advanced}
50 assert advanced == {
51 "timezone",
52 "weather_provider",
53 "weather_timeout_seconds",
54 "tts_loudness_boost",
55 }
56
57
58def test_weather_country_is_a_dropdown_defaulting_to_the_server_region() -> None:
59 """The weather country is picked from a list and seeded from the server locale."""
60 entries = asyncio.run(get_config_entries(_make_mass("http://localhost:8095", "nl_NL")))
61
62 entry = next(entry for entry in entries if entry.key == "weather_country")
63 assert entry.default_value == "NL"
64 assert entry.options
65 assert any(option.value == "NL" for option in entry.options)
66
67
68def test_config_entries_include_weather_provider_and_timeout() -> None:
69 """Weather provider selection and its request timeout are advanced provider options."""
70 mass = _make_mass("http://localhost:8095")
71
72 entries = asyncio.run(get_config_entries(mass))
73
74 keys = {entry.key for entry in entries}
75 assert "weather_provider" in keys
76 assert "weather_timeout_seconds" in keys
77 provider_entry = next(e for e in entries if e.key == "weather_provider")
78 assert provider_entry.default_value == "open_meteo"
79 assert provider_entry.advanced is True
80 timeout_entry = next(e for e in entries if e.key == "weather_timeout_seconds")
81 assert timeout_entry.default_value == 20
82 assert timeout_entry.advanced is True
83
84
85def test_provider_instance_exposes_the_same_config_entries() -> None:
86 """
87 The options page reads the instance method, not the module-level setup hook.
88
89 Without the override the base class returns an empty tuple, so the provider's own
90 options silently vanish from the settings UI.
91 """
92 mass = _make_mass("http://localhost:8095")
93 provider = cast("Any", AIRadioProvider.__new__(AIRadioProvider))
94 provider.mass = mass
95 # instance_id is a read-only property backed by the provider config
96 provider.config = SimpleNamespace(instance_id="ai_radio")
97
98 entries = asyncio.run(provider.get_config_entries())
99
100 assert {entry.key for entry in entries} == {
101 entry.key for entry in asyncio.run(get_config_entries(mass, "ai_radio"))
102 }
103 assert "timezone" in {entry.key for entry in entries}
104
105
106def test_setup_collected_city_surfaces_via_the_config_default() -> None:
107 """A city typed into the setup flow becomes usable without a trip to settings."""
108 mass = _make_mass(
109 "http://localhost:8095",
110 setup_data={"weather_city": "Amsterdam", "weather_country": "NL"},
111 )
112
113 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
114
115 city_entry = next(e for e in entries if e.key == "weather_city")
116 country_entry = next(e for e in entries if e.key == "weather_country")
117 assert city_entry.default_value == "Amsterdam"
118 assert country_entry.default_value == "NL"
119 # no stored config value yet, so a fresh parse resolves straight to the setup answer
120 parsed = Config.parse(entries, {"values": {}})
121 assert parsed.get_value("weather_city") == "Amsterdam"
122 assert parsed.get_value("weather_country") == "NL"
123
124
125def test_stored_config_value_overrides_the_setup_answer() -> None:
126 """A later settings edit wins over whatever was collected at setup."""
127 mass = _make_mass(
128 "http://localhost:8095",
129 setup_data={"weather_city": "Amsterdam", "weather_country": "NL"},
130 )
131
132 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
133 parsed = Config.parse(entries, {"values": {"weather_city": "Rotterdam"}})
134
135 assert parsed.get_value("weather_city") == "Rotterdam"
136
137
138def test_weather_country_falls_back_to_the_locale_region_without_a_setup_answer() -> None:
139 """No setup answer for the country: it still defaults to the server's locale region."""
140 mass = _make_mass("http://localhost:8095", locale="nl_NL", setup_data={})
141
142 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
143
144 entry = next(e for e in entries if e.key == "weather_country")
145 assert entry.default_value == "NL"
146