music-assistant-server

7.2 KBPY
test_plugin_provider_entries.py
7.2 KB159 lines • python
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