/
/
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 SendspinBasePlayer
14from music_assistant.providers.sendspin.provider import SendspinProvider
15
16if TYPE_CHECKING:
17 from aiosendspin.server.client import SendspinClient
18
19 from music_assistant.mass import MusicAssistant
20
21
22def _make_provider(player: SendspinBasePlayer | None) -> SendspinProvider:
23 provider = SendspinProvider.__new__(SendspinProvider)
24 provider.mass = cast(
25 "MusicAssistant",
26 SimpleNamespace(
27 players=SimpleNamespace(get_player=lambda _client_id: player),
28 create_task=Mock(side_effect=AssertionError("refresh must not spawn tasks")),
29 ),
30 )
31 return provider
32
33
34def _mock_player(*, initialized: bool = True) -> Mock:
35 player = Mock(spec=SendspinBasePlayer)
36 player.initialized.is_set.return_value = initialized
37 return player
38
39
40async def test_refresh_player_updates_in_place() -> None:
41 """A refresh re-applies config and state on the registered player object."""
42 player = _mock_player()
43 provider = _make_provider(player)
44 await provider._refresh_player("c")
45 player.on_config_updated.assert_awaited_once()
46 player.update_state.assert_called_once()
47
48
49async def test_refresh_player_ignores_unknown_player() -> None:
50 """A refresh for a client without a registered player is a no-op."""
51 provider = _make_provider(None)
52 await provider._refresh_player("c")
53
54
55async def test_refresh_player_ignores_uninitialized_player() -> None:
56 """A refresh during initial registration is a no-op (registration applies config)."""
57 player = _mock_player(initialized=False)
58 provider = _make_provider(player)
59 await provider._refresh_player("c")
60 player.on_config_updated.assert_not_awaited()
61 player.update_state.assert_not_called()
62
63
64class _StubPlayer:
65 def __init__(
66 self,
67 provider: SendspinProvider,
68 client_id: str,
69 initial_hello: object | None = None,
70 ) -> None:
71 self.player_id = client_id
72 self._attr_type: PlayerType | None = None
73 self._attr_underlying_player_id: str | None = None
74
75 def preserve_control_features_from(self, other: object) -> None:
76 pass
77
78
79def _class_selection_provider(monkeypatch: pytest.MonkeyPatch) -> SendspinProvider:
80 provider = SendspinProvider.__new__(SendspinProvider)
81 provider._bridge_identifiers = {}
82 provider._bridge_player_types = {}
83 provider._bridge_underlying_players = {}
84 provider._bridge_static_delay_defaults = {}
85 monkeypatch.setattr(provider_module, "SendspinPlayer", _StubPlayer)
86 monkeypatch.setattr(provider_module, "SendspinVisualizerPlayer", _StubPlayer)
87 return provider
88
89
90def _client(negotiated_role_ids: list[str]) -> SendspinClient:
91 return cast(
92 "SendspinClient",
93 SimpleNamespace(
94 negotiated_role_ids=negotiated_role_ids,
95 roles_by_family=Mock(side_effect=AssertionError("must select on negotiated roles")),
96 ),
97 )
98
99
100@pytest.mark.parametrize(
101 ("negotiated_role_ids", "expected_type"),
102 [
103 (["player@v1", "metadata@v1"], None),
104 (["metadata@v1"], PlayerType.DISPLAY),
105 (["visualizer@v1"], PlayerType.VISUALIZER),
106 ([], None),
107 ],
108)
109def test_create_player_selects_class_on_negotiated_roles(
110 monkeypatch: pytest.MonkeyPatch,
111 negotiated_role_ids: list[str],
112 expected_type: PlayerType | None,
113) -> None:
114 """The player class/type follows negotiated roles, not the (trust-gated) active roles."""
115 provider = _class_selection_provider(monkeypatch)
116 player = provider._create_player("c", _client(negotiated_role_ids), None)
117 assert cast("_StubPlayer", player)._attr_type == expected_type
118
119
120class _StubSourcePlayer(_StubPlayer):
121 pass
122
123
124def test_create_player_uses_the_capture_only_class_for_source_only_clients(
125 monkeypatch: pytest.MonkeyPatch,
126) -> None:
127 """A source-only client must not become a player that advertises playback."""
128 provider = _class_selection_provider(monkeypatch)
129 monkeypatch.setattr(provider_module, "SendspinSourcePlayer", _StubSourcePlayer)
130 source_only = provider._create_player("c", _client(["source@v1"]), None)
131 assert isinstance(cast("object", source_only), _StubSourcePlayer)
132 # A device that also plays keeps the full player class.
133 combo = provider._create_player("c", _client(["source@v1", "player@v1"]), None)
134 assert not isinstance(cast("object", combo), _StubSourcePlayer)
135