/
/
/
1"""Unit tests for the per-player plugin toggle entries a player renders."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
8
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
10from music_assistant_models.enums import ConfigEntryType, PlayerType
11
12from music_assistant import constants as _constants
13from music_assistant.constants import CONF_ENABLED, CONF_PLUGIN_KEY_SPLITTER, CONF_PROVIDERS
14from music_assistant.helpers.config_entries import CONF_CONNECTED_PLAYERS
15from music_assistant.mass import MusicAssistant
16from music_assistant.models.plugin import PluginProvider
17
18# the common strings live next to the constants module, so this path holds from anywhere
19_STRINGS_PATH = Path(_constants.__file__).resolve().parent / "strings.json"
20
21_PLAYER_ID = "test_player"
22_PLUGIN_INSTANCE_ID = "spotify_connect--aabbcc"
23_PLUGIN_KEY = f"{_PLUGIN_INSTANCE_ID}{CONF_PLUGIN_KEY_SPLITTER}{CONF_ENABLED}"
24_CONNECTED_PLAYERS_KEY = f"{CONF_PROVIDERS}/{_PLUGIN_INSTANCE_ID}/values/{CONF_CONNECTED_PLAYERS}"
25
26
27def _make_plugin_provider(*, player_bound: bool = True) -> MagicMock:
28 """Return a plugin provider mock that (optionally) binds its sources to players."""
29 provider = MagicMock(spec=PluginProvider)
30 # instance_id and name are properties on the spec, so set them as plain attributes
31 provider.instance_id = _PLUGIN_INSTANCE_ID
32 provider.name = "Spotify Connect"
33 # a player-bound plugin returns a list for any player id, unbound returns None
34 provider.get_player_audio_sources.return_value = [] if player_bound else None
35 return provider
36
37
38def _make_player(player_type: PlayerType = PlayerType.PLAYER) -> MagicMock:
39 """Return a player mock of the given type."""
40 player = MagicMock()
41 player.player_id = _PLAYER_ID
42 player.state.type = player_type
43 return player
44
45
46def _plugin_entries(
47 mass: MusicAssistant, providers: list[MagicMock], player: MagicMock | None = None
48) -> list[ConfigEntry]:
49 """Build the plugin toggle entries for a player with the given loaded providers."""
50 # mass_minimal loads no providers, so serve the mocks through the providers property
51 with patch.object(type(mass), "providers", new_callable=PropertyMock, return_value=providers):
52 return mass.config._create_plugin_provider_config_entries(player or _make_player())
53
54
55async def test_player_bound_plugin_renders_a_toggle(mass_minimal: MusicAssistant) -> None:
56 """A plugin that binds its sources to players renders a single boolean toggle."""
57 mass_minimal.config.set(_CONNECTED_PLAYERS_KEY, [_PLAYER_ID])
58 entries = _plugin_entries(mass_minimal, [_make_plugin_provider()])
59 assert len(entries) == 1
60 entry = entries[0]
61 assert entry.key == _PLUGIN_KEY
62 assert entry.type == ConfigEntryType.BOOLEAN
63 assert entry.category == "plugins"
64 # the key is per-plugin (dynamic), so the entry pins a static catalog key
65 assert entry.translation_key == "plugin_enable"
66 assert entry.translation_params == ["Spotify Connect"]
67 assert entry.value is True
68 assert entry.default_value is False
69
70
71async def test_toggle_reads_off_for_an_unconnected_player(mass_minimal: MusicAssistant) -> None:
72 """The toggle reads off when the player is not in the plugin's connected players."""
73 mass_minimal.config.set(_CONNECTED_PLAYERS_KEY, ["other_player"])
74 entries = _plugin_entries(mass_minimal, [_make_plugin_provider()])
75 assert len(entries) == 1
76 assert entries[0].value is False
77
78
79async def test_unbound_plugin_and_non_plugin_provider_yield_no_entries(
80 mass_minimal: MusicAssistant,
81) -> None:
82 """A plugin without player-bound sources and a non-plugin provider render no toggle."""
83 unbound_plugin = _make_plugin_provider(player_bound=False)
84 music_provider = MagicMock()
85 music_provider.name = "Some Music Service"
86 entries = _plugin_entries(mass_minimal, [unbound_plugin, music_provider])
87 assert entries == []
88
89
90async def test_non_playback_player_gets_no_toggles(mass_minimal: MusicAssistant) -> None:
91 """A player that is not a playback target renders no plugin toggles."""
92 entries = _plugin_entries(
93 mass_minimal, [_make_plugin_provider()], _make_player(PlayerType.PROTOCOL)
94 )
95 assert entries == []
96
97
98def test_referenced_strings_exist() -> None:
99 """The toggle's translation key and its category are authored in the common strings."""
100 strings = json.loads(_STRINGS_PATH.read_text(encoding="utf-8"))
101 assert "plugin_enable" in strings["config_entries"]
102 assert "plugins" in strings["config_categories"]
103
104
105async def test_toggle_on_adds_the_player_to_the_plugin(mass_minimal: MusicAssistant) -> None:
106 """Enabling the toggle appends the player to the plugin's connected players."""
107 mass_minimal.config.set(_CONNECTED_PLAYERS_KEY, ["other_player"])
108 values: dict[str, ConfigValueType] = {_PLUGIN_KEY: True, "some_setting": 5}
109 with patch.object(
110 mass_minimal.config, "_update_provider_config", new_callable=AsyncMock
111 ) as update_call:
112 result = await mass_minimal.config._update_plugin_provider_config(_PLAYER_ID, values)
113 update_call.assert_awaited_once_with(
114 _PLUGIN_INSTANCE_ID, {CONF_CONNECTED_PLAYERS: ["other_player", _PLAYER_ID]}
115 )
116 # the plugin provider is the canonical store, so the toggle never reaches the player values
117 assert result == {"some_setting": 5}
118
119
120async def test_toggle_off_removes_the_player_from_the_plugin(
121 mass_minimal: MusicAssistant,
122) -> None:
123 """Disabling the toggle removes the player from the plugin's connected players."""
124 mass_minimal.config.set(_CONNECTED_PLAYERS_KEY, ["other_player", _PLAYER_ID])
125 values: dict[str, ConfigValueType] = {_PLUGIN_KEY: False}
126 with patch.object(
127 mass_minimal.config, "_update_provider_config", new_callable=AsyncMock
128 ) as update_call:
129 result = await mass_minimal.config._update_plugin_provider_config(_PLAYER_ID, values)
130 update_call.assert_awaited_once_with(
131 _PLUGIN_INSTANCE_ID, {CONF_CONNECTED_PLAYERS: ["other_player"]}
132 )
133 assert result == {}
134
135
136async def test_unchanged_toggle_makes_no_provider_call(mass_minimal: MusicAssistant) -> None:
137 """A toggle value that matches the current membership does not touch the plugin config."""
138 mass_minimal.config.set(_CONNECTED_PLAYERS_KEY, [_PLAYER_ID])
139 values: dict[str, ConfigValueType] = {_PLUGIN_KEY: True, "some_setting": 5}
140 with patch.object(
141 mass_minimal.config, "_update_provider_config", new_callable=AsyncMock
142 ) as update_call:
143 result = await mass_minimal.config._update_plugin_provider_config(_PLAYER_ID, values)
144 update_call.assert_not_awaited()
145 # the toggle key is still stripped so it can never end up in the player's stored values
146 assert result == {"some_setting": 5}
147
148
149async def test_plugin_values_are_extracted_from_the_entries(
150 mass_minimal: MusicAssistant,
151) -> None:
152 """Only the plugin toggle entries contribute to the derived config values."""
153 entries = [
154 ConfigEntry(key=_PLUGIN_KEY, type=ConfigEntryType.BOOLEAN, default_value=False, value=True),
155 ConfigEntry(key="some_setting", type=ConfigEntryType.INTEGER, default_value=5, value=3),
156 ]
157 values = mass_minimal.config._get_plugin_provider_config_values(entries)
158 assert values == {_PLUGIN_KEY: True}
159