/
/
/
1"""Tests for the line-in autostart config entries on a Sendspin player."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import TYPE_CHECKING, cast
7
8from music_assistant_models.enums import PlayerType
9
10from music_assistant.providers.sendspin.constants import (
11 CONF_SOURCE_AUTOSTART_TARGET,
12 SOURCE_AUTOSTART_OFF,
13)
14from music_assistant.providers.sendspin.player import SendspinBasePlayer
15
16if TYPE_CHECKING:
17 from aiosendspin.server.client import SendspinClient
18 from music_assistant_models.config_entries import ConfigEntry
19
20if TYPE_CHECKING:
21 from music_assistant.mass import MusicAssistant
22
23
24def _player(
25 *,
26 negotiated_role_ids: list[str],
27 line_sense: bool,
28 known_players: list[str],
29 unavailable_players: list[str] | None = None,
30 non_audio_players: dict[str, PlayerType] | None = None,
31 protocol_parent_id: str | None = None,
32) -> SendspinBasePlayer:
33 player = SendspinBasePlayer.__new__(SendspinBasePlayer)
34 player._player_id = "turntable"
35 player.api = cast(
36 "SendspinClient",
37 SimpleNamespace(
38 negotiated_role_ids=negotiated_role_ids,
39 info_or_none=SimpleNamespace(
40 source_support=SimpleNamespace(features=SimpleNamespace(line_sense=line_sense))
41 ),
42 ),
43 )
44 player.mass = cast(
45 "MusicAssistant",
46 SimpleNamespace(
47 players=SimpleNamespace(
48 all_players=lambda return_unavailable=True, *_args: [
49 SimpleNamespace(player_id=pid, display_name=pid, type=player_type)
50 for pid, player_type in [
51 *((pid, PlayerType.PLAYER) for pid in known_players),
52 *(
53 (pid, PlayerType.PLAYER)
54 for pid in (unavailable_players or [])
55 if return_unavailable
56 ),
57 *(non_audio_players or {}).items(),
58 ]
59 ],
60 trigger_player_update=lambda _player_id: None,
61 ),
62 ),
63 )
64 player.set_protocol_parent_id(protocol_parent_id)
65 return player
66
67
68def _target_entry(player: SendspinBasePlayer) -> ConfigEntry | None:
69 entries = player._get_source_autostart_config_entries()
70 return next((e for e in entries if e.key == CONF_SOURCE_AUTOSTART_TARGET), None)
71
72
73def test_no_autostart_entries_without_line_sense() -> None:
74 """A source that cannot report signal presence has no trigger, so it gets no setting."""
75 player = _player(negotiated_role_ids=["source@v1"], line_sense=False, known_players=["speaker"])
76 assert player._get_source_autostart_config_entries() == []
77
78
79def test_no_autostart_entries_for_a_client_without_a_source_role() -> None:
80 """A plain speaker never shows line-in settings."""
81 player = _player(negotiated_role_ids=["player@v1"], line_sense=True, known_players=["speaker"])
82 assert player._get_source_autostart_config_entries() == []
83
84
85def test_capture_only_source_defaults_to_off() -> None:
86 """With no player of its own there is no sensible target, so autostart stays off."""
87 player = _player(negotiated_role_ids=["source@v1"], line_sense=True, known_players=["speaker"])
88 entry = _target_entry(player)
89 assert entry is not None
90 assert entry.default_value == SOURCE_AUTOSTART_OFF
91
92
93def test_a_source_that_is_also_a_player_defaults_to_itself() -> None:
94 """A turntable that can play defaults to playing its own line-in, with nothing saved."""
95 player = _player(
96 negotiated_role_ids=["source@v1", "player@v1"],
97 line_sense=True,
98 known_players=["turntable", "speaker"],
99 )
100 entry = _target_entry(player)
101 assert entry is not None
102 assert entry.default_value == "turntable"
103
104
105def test_an_unavailable_player_is_not_selectable() -> None:
106 """Unavailable players are not offered as autostart targets."""
107 player = _player(
108 negotiated_role_ids=["source@v1"],
109 line_sense=True,
110 known_players=["other-speaker"],
111 unavailable_players=["sleeping-speaker"],
112 )
113 entry = _target_entry(player)
114 assert entry is not None
115 assert "sleeping-speaker" not in {option.value for option in entry.options or []}
116
117
118def test_players_that_cannot_render_audio_are_not_offered() -> None:
119 """Lights, displays and other capture-only clients are nowhere to play a line-in."""
120 player = _player(
121 negotiated_role_ids=["source@v1"],
122 line_sense=True,
123 known_players=["speaker"],
124 non_audio_players={
125 "lamp": PlayerType.LIGHT,
126 "screen": PlayerType.DISPLAY,
127 "other-turntable": PlayerType.UNKNOWN,
128 },
129 )
130 entry = _target_entry(player)
131 assert entry is not None
132 assert {option.value for option in entry.options or []} == {SOURCE_AUTOSTART_OFF, "speaker"}
133
134
135def test_the_devices_own_player_leads_the_target_list() -> None:
136 """Off stays first, then the default, so it is not buried among every other player."""
137 player = _player(
138 negotiated_role_ids=["source@v1", "player@v1"],
139 line_sense=True,
140 known_players=["amp", "turntable", "zone"],
141 )
142 entry = _target_entry(player)
143 assert entry is not None
144 assert [option.value for option in entry.options or []] == [
145 SOURCE_AUTOSTART_OFF,
146 "turntable",
147 "amp",
148 "zone",
149 ]
150 assert (entry.options or [])[1].translation_key == "this_device"
151
152
153def test_a_protocol_parent_is_the_default_target() -> None:
154 """A linked source defaults to its visible parent rather than its hidden protocol player."""
155 player = _player(
156 negotiated_role_ids=["source@v1", "player@v1"],
157 line_sense=True,
158 known_players=["amp", "visible-player"],
159 protocol_parent_id="visible-player",
160 )
161 entry = _target_entry(player)
162 assert entry is not None
163 assert entry.default_value == "visible-player"
164 assert [option.value for option in entry.options or []][:2] == [
165 SOURCE_AUTOSTART_OFF,
166 "visible-player",
167 ]
168 assert (entry.options or [])[1].translation_key == "this_device"
169
170
171def test_groups_and_stereo_pairs_are_offered() -> None:
172 """A line-in must be routable to a group, not just to a single speaker."""
173 player = _player(
174 negotiated_role_ids=["source@v1"],
175 line_sense=True,
176 known_players=["speaker"],
177 non_audio_players={"kitchen-group": PlayerType.GROUP, "pair": PlayerType.STEREO_PAIR},
178 )
179 entry = _target_entry(player)
180 assert entry is not None
181 assert {"kitchen-group", "pair"} <= {option.value for option in entry.options or []}
182