/
/
/
1"""Tests for the Sendspin player needs_setup state (pairing/consent/audio input)."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import TYPE_CHECKING, Any, cast
7from unittest import mock
8
9from music_assistant.providers.sendspin.constants import CONF_SOURCE_APPROVAL_DISMISSED
10from music_assistant.providers.sendspin.player import SendspinBasePlayer, SendspinSourcePlayer
11
12if TYPE_CHECKING:
13 from aiosendspin.server.client import SendspinClient
14
15 from music_assistant.providers.sendspin.provider import SendspinProvider
16
17
18def _make_api(
19 *,
20 active_roles: tuple[str, ...] = (),
21 negotiated_role_ids: tuple[str, ...] = (),
22 unpaired_access: bool = False,
23 encrypted: bool = True,
24) -> Any:
25 return SimpleNamespace(
26 info_or_none=SimpleNamespace(unpaired_access=SimpleNamespace(enabled=unpaired_access)),
27 connection_security=SimpleNamespace() if encrypted else None,
28 active_roles=active_roles,
29 negotiated_role_ids=list(negotiated_role_ids),
30 roles_by_family=lambda family: [
31 role for role in active_roles if role.startswith(f"{family}@")
32 ],
33 )
34
35
36def _make_player(
37 api: Any, *, cls: type[SendspinBasePlayer] = SendspinBasePlayer, dismissed: bool = False
38) -> SendspinBasePlayer:
39 player = cls.__new__(cls)
40 player._player_id = "client-1"
41 player._provider = cast(
42 "SendspinProvider", SimpleNamespace(pairing_config_snapshot=lambda _client_id: None)
43 )
44 player.api = cast("SendspinClient", api)
45 player.mass = mock.MagicMock()
46 player.mass.config.get_raw_player_config_value = mock.Mock(return_value=dismissed)
47 return player
48
49
50def test_unpaired_client_needs_setup() -> None:
51 """An unpaired encrypted client reports pairing_required, guest-capable or not."""
52 for unpaired_access in (False, True):
53 player = _make_player(_make_api(unpaired_access=unpaired_access))
54 assert player.needs_setup is True
55 assert player.setup_reason == "pairing_required"
56
57
58def test_client_with_active_roles_needs_no_setup() -> None:
59 """An allowed (or paired) client without a pending input carries no setup state."""
60 player = _make_player(_make_api(active_roles=("player@v1",), unpaired_access=True))
61 assert player.needs_setup is False
62 assert player.setup_reason is None
63
64
65def test_legacy_unencrypted_client_needs_no_setup() -> None:
66 """A legacy unencrypted client plays as-is and never prompts."""
67 player = _make_player(_make_api(encrypted=False, unpaired_access=True))
68 assert player.needs_setup is False
69
70
71def test_undecided_audio_input_keeps_the_device_usable() -> None:
72 """
73 A combo with an active player role stays available while its input is undecided.
74
75 Devices granted unpaired access before the input decision existed must not
76 become unavailable on upgrade; the pending input only drives the setup flow.
77 """
78 api = _make_api(
79 active_roles=("player@v1",),
80 negotiated_role_ids=("player@v1", "source@v1"),
81 unpaired_access=True,
82 )
83 player = _make_player(api)
84 assert player.needs_setup is False
85 assert player._source_input_pending is True
86
87
88def test_declined_audio_input_clears_the_pending_state() -> None:
89 """The persisted don't-use-audio-input choice resolves the pending input."""
90 api = _make_api(
91 active_roles=("player@v1",),
92 negotiated_role_ids=("player@v1", "source@v1"),
93 unpaired_access=True,
94 )
95 player = _make_player(api, dismissed=True)
96 assert player._source_input_pending is False
97 config_get = cast("mock.Mock", player.mass.config.get_raw_player_config_value)
98 config_get.assert_called_once_with("client-1", CONF_SOURCE_APPROVAL_DISMISSED, False)
99
100
101def test_active_source_role_clears_the_pending_state() -> None:
102 """Once the source role runs (paired client), nothing is pending."""
103 api = _make_api(
104 active_roles=("player@v1", "source@v1"),
105 negotiated_role_ids=("player@v1", "source@v1"),
106 unpaired_access=True,
107 )
108 player = _make_player(api)
109 assert player.needs_setup is False
110 assert player._source_input_pending is False
111
112
113def test_source_only_client_gets_no_consent_shortcut() -> None:
114 """A capture-only client pairs instead: unpaired consent would grant it nothing."""
115 api = _make_api(negotiated_role_ids=("source@v1",), unpaired_access=True)
116 player = _make_player(api, cls=SendspinSourcePlayer)
117 assert player.needs_setup is True
118 assert player._offers_unpaired_consent is False
119