/
/
/
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 == {"timezone", "weather_provider", "weather_timeout_seconds"}
51
52
53def test_weather_country_is_a_dropdown_defaulting_to_the_server_region() -> None:
54 """The weather country is picked from a list and seeded from the server locale."""
55 entries = asyncio.run(get_config_entries(_make_mass("http://localhost:8095", "nl_NL")))
56
57 entry = next(entry for entry in entries if entry.key == "weather_country")
58 assert entry.default_value == "NL"
59 assert entry.options
60 assert any(option.value == "NL" for option in entry.options)
61
62
63def test_config_entries_include_weather_provider_and_timeout() -> None:
64 """Weather provider selection and its request timeout are advanced provider options."""
65 mass = _make_mass("http://localhost:8095")
66
67 entries = asyncio.run(get_config_entries(mass))
68
69 keys = {entry.key for entry in entries}
70 assert "weather_provider" in keys
71 assert "weather_timeout_seconds" in keys
72 provider_entry = next(e for e in entries if e.key == "weather_provider")
73 assert provider_entry.default_value == "open_meteo"
74 assert provider_entry.advanced is True
75 timeout_entry = next(e for e in entries if e.key == "weather_timeout_seconds")
76 assert timeout_entry.default_value == 20
77 assert timeout_entry.advanced is True
78
79
80def test_provider_instance_exposes_the_same_config_entries() -> None:
81 """
82 The options page reads the instance method, not the module-level setup hook.
83
84 Without the override the base class returns an empty tuple, so the provider's own
85 options silently vanish from the settings UI.
86 """
87 mass = _make_mass("http://localhost:8095")
88 provider = cast("Any", AIRadioProvider.__new__(AIRadioProvider))
89 provider.mass = mass
90 # instance_id is a read-only property backed by the provider config
91 provider.config = SimpleNamespace(instance_id="ai_radio")
92
93 entries = asyncio.run(provider.get_config_entries())
94
95 assert {entry.key for entry in entries} == {
96 entry.key for entry in asyncio.run(get_config_entries(mass, "ai_radio"))
97 }
98 assert "timezone" in {entry.key for entry in entries}
99
100
101def test_setup_collected_city_surfaces_via_the_config_default() -> None:
102 """A city typed into the setup flow becomes usable without a trip to settings."""
103 mass = _make_mass(
104 "http://localhost:8095",
105 setup_data={"weather_city": "Amsterdam", "weather_country": "NL"},
106 )
107
108 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
109
110 city_entry = next(e for e in entries if e.key == "weather_city")
111 country_entry = next(e for e in entries if e.key == "weather_country")
112 assert city_entry.default_value == "Amsterdam"
113 assert country_entry.default_value == "NL"
114 # no stored config value yet, so a fresh parse resolves straight to the setup answer
115 parsed = Config.parse(entries, {"values": {}})
116 assert parsed.get_value("weather_city") == "Amsterdam"
117 assert parsed.get_value("weather_country") == "NL"
118
119
120def test_stored_config_value_overrides_the_setup_answer() -> None:
121 """A later settings edit wins over whatever was collected at setup."""
122 mass = _make_mass(
123 "http://localhost:8095",
124 setup_data={"weather_city": "Amsterdam", "weather_country": "NL"},
125 )
126
127 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
128 parsed = Config.parse(entries, {"values": {"weather_city": "Rotterdam"}})
129
130 assert parsed.get_value("weather_city") == "Rotterdam"
131
132
133def test_weather_country_falls_back_to_the_locale_region_without_a_setup_answer() -> None:
134 """No setup answer for the country: it still defaults to the server's locale region."""
135 mass = _make_mass("http://localhost:8095", locale="nl_NL", setup_data={})
136
137 entries = asyncio.run(get_config_entries(mass, "ai_radio--1"))
138
139 entry = next(e for e in entries if e.key == "weather_country")
140 assert entry.default_value == "NL"
141