/
/
/
1"""Tests for receiver-style plugin setup flows."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any
8from unittest import mock
9
10import pytest
11from music_assistant_models.enums import FlowStepType, PlayerType
12
13from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT
14from music_assistant.models.setup_flow import AbortFlow, SetupFlowContext, SetupSession
15from music_assistant.providers.ariacast_receiver import (
16 CONF_MASS_PLAYER_ID as ARIACAST_PLAYER_ID,
17)
18from music_assistant.providers.ariacast_receiver import setup_flow as ariacast_flow
19from music_assistant.providers.vban_receiver import setup_flow as vban_flow
20from music_assistant.providers.vban_receiver.constants import (
21 CONF_AUDIO_CHANNELS,
22 CONF_PCM_AUDIO_FORMAT,
23 CONF_PCM_SAMPLE_RATE,
24 CONF_SENDER_HOST,
25 CONF_VBAN_STREAM_NAME,
26)
27
28if TYPE_CHECKING:
29 from music_assistant_models.config_entries import ConfigValueType
30
31
32def _player(
33 player_id: str, display_name: str, player_type: PlayerType = PlayerType.PLAYER
34) -> mock.Mock:
35 """Return a minimal player for setup-flow option generation."""
36 player = mock.Mock()
37 player.player_id = player_id
38 player.display_name = display_name
39 player.type = player_type
40 return player
41
42
43def _make_session(
44 domain: str,
45 *,
46 setup_data: dict[str, Any] | None = None,
47 values: dict[str, Any] | None = None,
48) -> tuple[SetupSession, dict[str, Any]]:
49 """Return a real setup session and the values collected by its finish handler."""
50 mass = mock.Mock()
51 # The source player must never appear in the player selector: capture-only
52 # clients cannot be a playback target.
53 mass.players.all_players.return_value = [
54 _player("living-room", "Living Room"),
55 _player("kitchen", "Kitchen"),
56 _player("turntable", "Turntable", PlayerType.SOURCE),
57 ]
58 collected: dict[str, Any] = {}
59
60 async def finish(_session: SetupSession, submitted: dict[str, Any]) -> dict[str, str]:
61 collected.update(submitted)
62 return {"instance_id": f"{domain}--test"}
63
64 context = SetupFlowContext(
65 kind="setup",
66 reason="user",
67 domain=domain,
68 setup_data=setup_data or {},
69 values=values or {},
70 )
71 return SetupSession(mass, "flow-test", context, finish), collected
72
73
74async def _start_form(session: SetupSession, flow_module: Any) -> tuple[asyncio.Task[None], Any]:
75 """Start a setup flow and wait for its form step."""
76 task = asyncio.create_task(flow_module.run_setup(session))
77 deadline = time.monotonic() + 5
78 while time.monotonic() < deadline:
79 if session.current_step and session.current_step.type == FlowStepType.FORM:
80 return task, session.current_step
81 await asyncio.sleep(0.01)
82 raise AssertionError("form step not published")
83
84
85async def _wait_finished(session: SetupSession) -> None:
86 """Wait for a setup session to finish."""
87 deadline = time.monotonic() + 5
88 while time.monotonic() < deadline:
89 if session.finished:
90 return
91 await asyncio.sleep(0.01)
92 raise AssertionError("setup flow did not finish")
93
94
95async def test_ariacast_flow_collects_mandatory_player() -> None:
96 """The AriaCast flow persists the mandatory target player (no automatic option)."""
97 session, collected = _make_session("ariacast_receiver")
98 task, step = await _start_form(session, ariacast_flow)
99 player_entry = next(entry for entry in step.entries if entry.key == ARIACAST_PLAYER_ID)
100 assert [option.value for option in player_entry.options] == ["kitchen", "living-room"]
101
102 session.handle_submit({ARIACAST_PLAYER_ID: "kitchen"})
103 await _wait_finished(session)
104 await task
105
106 assert collected == {ARIACAST_PLAYER_ID: "kitchen"}
107
108
109async def test_ariacast_flow_requires_a_player_selection() -> None:
110 """Submitting without a selection re-renders the form with a required error."""
111 session, collected = _make_session("ariacast_receiver")
112 task, _step = await _start_form(session, ariacast_flow)
113
114 step = session.handle_submit({})
115 assert step is not None
116 assert step.errors == {ARIACAST_PLAYER_ID: "required"}
117 assert collected == {}
118
119 task.cancel()
120 with pytest.raises(asyncio.CancelledError):
121 await task
122
123
124async def test_ariacast_flow_vanished_stored_player_renders_unselected() -> None:
125 """A stored player that no longer exists is not preselected (nor silently replaced)."""
126 session, _collected = _make_session(
127 "ariacast_receiver", setup_data={ARIACAST_PLAYER_ID: "gone-player"}
128 )
129 task, step = await _start_form(session, ariacast_flow)
130 player_entry = next(entry for entry in step.entries if entry.key == ARIACAST_PLAYER_ID)
131 assert player_entry.value is None
132
133 task.cancel()
134 with pytest.raises(asyncio.CancelledError):
135 await task
136
137
138async def test_ariacast_flow_aborts_without_players() -> None:
139 """With no players registered the flow aborts with the no_players reason."""
140 session, _collected = _make_session("ariacast_receiver")
141 session.mass.players.all_players.return_value = [] # type: ignore[attr-defined]
142
143 with pytest.raises(AbortFlow) as excinfo:
144 await ariacast_flow.run_setup(session)
145 assert excinfo.value.reason == "no_players"
146
147
148async def test_vban_flow_collects_receiver_endpoint_and_stream_format() -> None:
149 """The VBAN flow collects all values needed before starting its UDP receiver."""
150 session, collected = _make_session("vban_receiver")
151 submitted: dict[str, ConfigValueType] = {
152 CONF_BIND_PORT: 6981,
153 CONF_VBAN_STREAM_NAME: "Studio",
154 CONF_SENDER_HOST: "192.0.2.10",
155 CONF_PCM_AUDIO_FORMAT: "S16LE",
156 CONF_PCM_SAMPLE_RATE: 48000,
157 CONF_AUDIO_CHANNELS: 2,
158 CONF_BIND_IP: "0.0.0.0",
159 }
160 with mock.patch.object(
161 vban_flow, "get_ip_addresses", mock.AsyncMock(return_value=["192.0.2.2"])
162 ):
163 task, step = await _start_form(session, vban_flow)
164 assert {entry.key for entry in step.entries} >= set(submitted)
165 session.handle_submit(submitted)
166 await _wait_finished(session)
167 await task
168
169 assert collected == submitted
170