/
/
/
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_prompts_even_while_the_device_plays() -> None:
72 """
73 A combo with an active player role still prompts while its input is undecided.
74
75 Guest access can never carry a line-in, so the choice between keeping it and
76 pairing has to be made once. The setup state cannot be split per role, so the
77 device is held out of playback until that one choice is answered.
78 """
79 api = _make_api(
80 active_roles=("player@v1",),
81 negotiated_role_ids=("player@v1", "source@v1"),
82 unpaired_access=True,
83 )
84 player = _make_player(api)
85 assert player.needs_setup is True
86 assert player._source_input_pending is True
87
88
89def test_declined_audio_input_clears_the_pending_state() -> None:
90 """The persisted don't-use-audio-input choice resolves the pending input."""
91 api = _make_api(
92 active_roles=("player@v1",),
93 negotiated_role_ids=("player@v1", "source@v1"),
94 unpaired_access=True,
95 )
96 player = _make_player(api, dismissed=True)
97 assert player._source_input_pending is False
98 config_get = cast("mock.Mock", player.mass.config.get_raw_player_config_value)
99 config_get.assert_called_once_with("client-1", CONF_SOURCE_APPROVAL_DISMISSED, False)
100
101
102def test_active_source_role_clears_the_pending_state() -> None:
103 """Once the source role runs (paired client), nothing is pending."""
104 api = _make_api(
105 active_roles=("player@v1", "source@v1"),
106 negotiated_role_ids=("player@v1", "source@v1"),
107 unpaired_access=True,
108 )
109 player = _make_player(api)
110 assert player.needs_setup is False
111 assert player._source_input_pending is False
112
113
114def test_source_only_client_gets_no_consent_shortcut() -> None:
115 """A capture-only client pairs instead: unpaired consent would grant it nothing."""
116 api = _make_api(negotiated_role_ids=("source@v1",), unpaired_access=True)
117 player = _make_player(api, cls=SendspinSourcePlayer)
118 assert player.needs_setup is True
119 assert player._offers_unpaired_consent is False
120