/
/
1"""Tests for the in-place player refresh and player class selection."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import TYPE_CHECKING, cast
7from unittest.mock import Mock
8
9import pytest
10from music_assistant_models.enums import PlayerType
11
12import music_assistant.providers.sendspin.provider as provider_module
13from music_assistant.providers.sendspin.player import (
14 SendspinBasePlayer,
15 SendspinSourcePlayer,
16 SendspinVisualizerPlayer,
17)
18from music_assistant.providers.sendspin.provider import SendspinProvider
19
20if TYPE_CHECKING:
21 from aiosendspin.server.client import SendspinClient
22
23 from music_assistant.mass import MusicAssistant
24
25
26def _make_provider(player: SendspinBasePlayer | None) -> SendspinProvider:
27 provider = SendspinProvider.__new__(SendspinProvider)
28 provider.mass = cast(
29 "MusicAssistant",
30 SimpleNamespace(
31 players=SimpleNamespace(get_player=lambda _client_id: player),
32 create_task=Mock(side_effect=AssertionError("refresh must not spawn tasks")),
33 ),
34 )
35 return provider
36
37
38def _mock_player(*, initialized: bool = True) -> Mock:
39 player = Mock(spec=SendspinBasePlayer)
40 player.initialized.is_set.return_value = initialized
41 return player
42
43
44async def test_refresh_player_updates_in_place() -> None:
45 """A refresh re-applies config and state on the registered player object."""
46 player = _mock_player()
47 provider = _make_provider(player)
48 await provider._refresh_player("c")
49 player.on_config_updated.assert_awaited_once()
50 player.update_state.assert_called_once()
51
52
53async def test_refresh_player_ignores_unknown_player() -> None:
54 """A refresh for a client without a registered player is a no-op."""
55 provider = _make_provider(None)
56 await provider._refresh_player("c")
57
58
59async def test_refresh_player_ignores_uninitialized_player() -> None:
60 """A refresh during initial registration is a no-op (registration applies config)."""
61 player = _mock_player(initialized=False)
62 provider = _make_provider(player)
63 await provider._refresh_player("c")
64 player.on_config_updated.assert_not_awaited()
65 player.update_state.assert_not_called()
66
67
68class _StubPlayer:
69 def __init__(
70 self,
71 provider: SendspinProvider,
72 client_id: str,
73 initial_hello: object | None = None,
74 ) -> None:
75 self.player_id = client_id
76 self._attr_type: PlayerType | None = None
77 self._attr_underlying_player_id: str | None = None
78
79 def preserve_control_features_from(self, other: object) -> None:
80 pass
81
82
83def _class_selection_provider(monkeypatch: pytest.MonkeyPatch) -> SendspinProvider:
84 provider = SendspinProvider.__new__(SendspinProvider)
85 provider._bridge_identifiers = {}
86 provider._bridge_player_types = {}
87 provider._bridge_underlying_players = {}
88 provider._bridge_static_delay_defaults = {}
89 monkeypatch.setattr(provider_module, "SendspinPlayer", _StubPlayer)
90 monkeypatch.setattr(provider_module, "SendspinVisualizerPlayer", _StubPlayer)
91 return provider
92
93
94def _client(negotiated_role_ids: list[str]) -> SendspinClient:
95 return cast(
96 "SendspinClient",
97 SimpleNamespace(
98 negotiated_role_ids=negotiated_role_ids,
99 roles_by_family=Mock(side_effect=AssertionError("must select on negotiated roles")),
100 ),
101 )
102
103
104@pytest.mark.parametrize(
105 ("negotiated_role_ids", "expected_type"),
106 [
107 (["player@v1", "metadata@v1"], None),
108 (["metadata@v1"], PlayerType.DISPLAY),
109 (["visualizer@v1"], PlayerType.VISUALIZER),
110 ([], None),
111 ],
112)
113def test_create_player_selects_class_on_negotiated_roles(
114 monkeypatch: pytest.MonkeyPatch,
115 negotiated_role_ids: list[str],
116 expected_type: PlayerType | None,
117) -> None:
118 """The player class/type follows negotiated roles, not the (trust-gated) active roles."""
119 provider = _class_selection_provider(monkeypatch)
120 player = provider._create_player("c", _client(negotiated_role_ids), None)
121 assert cast("_StubPlayer", player)._attr_type == expected_type
122
123
124class _StubSourcePlayer(_StubPlayer):
125 pass
126
127
128def test_create_player_uses_the_capture_only_class_for_source_only_clients(
129 monkeypatch: pytest.MonkeyPatch,
130) -> None:
131 """A source-only client must not become a player that advertises playback."""
132 provider = _class_selection_provider(monkeypatch)
133 monkeypatch.setattr(provider_module, "SendspinSourcePlayer", _StubSourcePlayer)
134 source_only = provider._create_player("c", _client(["source@v1"]), None)
135 assert isinstance(cast("object", source_only), _StubSourcePlayer)
136 # A device that also plays keeps the full player class.
137 combo = provider._create_player("c", _client(["source@v1", "player@v1"]), None)
138 assert not isinstance(cast("object", combo), _StubSourcePlayer)
139
140
141def test_player_classes_declare_their_privacy() -> None:
142 """
143 Only players owned by a single device or by the server itself are private.
144
145 Visualizers and lights are hidden by default too, but clients keep offering
146 them as group members, so they must not be marked private.
147 """
148 assert SendspinVisualizerPlayer._attr_hidden_by_default is True
149 assert SendspinVisualizerPlayer._attr_private is False
150 assert SendspinSourcePlayer._attr_private is True
151