/
/
/
1"""Tests for the inbound /control command handling of the AriaCast receiver."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import Any, cast
7from unittest.mock import AsyncMock, MagicMock
8
9from music_assistant.providers.ariacast_receiver import AUDIO_SOURCE_ID, AriaCastReceiver
10
11
12def _receiver(in_use_by_queue: str | None = "queue_1") -> SimpleNamespace:
13 """Build a bare receiver namespace for driving _handle_inbound_control."""
14 return SimpleNamespace(
15 _in_use_by_player=in_use_by_queue,
16 _forward_action=AsyncMock(),
17 _cmd_play=AsyncMock(),
18 _cmd_pause=AsyncMock(),
19 mass=MagicMock(),
20 logger=MagicMock(),
21 )
22
23
24async def _handle(receiver: SimpleNamespace, ws: Any, payload: dict[str, Any]) -> None:
25 """Run the inbound control handler on the bare receiver."""
26 await AriaCastReceiver._handle_inbound_control(cast("AriaCastReceiver", receiver), ws, payload)
27
28
29async def test_inbound_next_relays_to_the_other_senders() -> None:
30 """
31 A control client's next/previous is relayed to the other control clients.
32
33 It must never be routed through player_queues: the queue delegates transport
34 commands for an active AudioSource back to this plugin, which would echo the
35 command straight back to its originator.
36 """
37 receiver = _receiver()
38 ws = AsyncMock()
39
40 await _handle(receiver, ws, {"command": "next"})
41 await _handle(receiver, ws, {"action": "previous"})
42
43 assert receiver._forward_action.await_args_list == [
44 (("next",), {"exclude": ws}),
45 (("previous",), {"exclude": ws}),
46 ]
47 receiver.mass.player_queues.next.assert_not_called()
48 receiver.mass.player_queues.previous.assert_not_called()
49
50
51async def test_ack_shaped_payloads_are_not_dispatched_as_commands() -> None:
52 """
53 A reply/ack payload (carrying "success") is ignored, not relayed as a command.
54
55 A client acking a broadcast action with the server's own ack shape would
56 otherwise trigger a fresh relay â and loop on a client that acks the ack.
57 """
58 receiver = _receiver()
59 ws = AsyncMock()
60
61 await _handle(receiver, ws, {"action": "next", "success": True})
62 await _handle(receiver, ws, {"command": "play", "success": False})
63
64 receiver._forward_action.assert_not_awaited()
65 receiver._cmd_play.assert_not_awaited()
66 ws.send_json.assert_not_awaited()
67
68
69async def test_inbound_next_is_ignored_while_the_source_is_not_in_use() -> None:
70 """Next/previous do nothing while no MA queue is playing the source."""
71 receiver = _receiver(in_use_by_queue=None)
72 ws = AsyncMock()
73
74 await _handle(receiver, ws, {"command": "next"})
75
76 receiver._forward_action.assert_not_awaited()
77
78
79async def test_forward_action_excludes_the_originating_sender() -> None:
80 """The relayed action reaches every control client except the one that sent it."""
81 originator = AsyncMock()
82 other = AsyncMock()
83 receiver = SimpleNamespace(_control_senders={originator, other})
84
85 await AriaCastReceiver._forward_action(
86 cast("AriaCastReceiver", receiver), "next", exclude=originator
87 )
88
89 other.send_json.assert_awaited_once_with({"action": "next"})
90 originator.send_json.assert_not_awaited()
91
92
93async def test_forward_action_without_exclusion_reaches_all_senders() -> None:
94 """An MA-initiated action (no originator) is broadcast to every control client."""
95 ws1 = AsyncMock()
96 ws2 = AsyncMock()
97 receiver = SimpleNamespace(_control_senders={ws1, ws2})
98
99 await AriaCastReceiver._forward_action(cast("AriaCastReceiver", receiver), "next")
100
101 ws1.send_json.assert_awaited_once_with({"action": "next"})
102 ws2.send_json.assert_awaited_once_with({"action": "next"})
103
104
105def _playback_receiver(*, active_player_id: str | None, owner: str | None) -> SimpleNamespace:
106 """Build a bare receiver namespace for driving _handle_playback_state."""
107 mass = MagicMock()
108 mass.players.get_audio_source_session.return_value = SimpleNamespace(
109 playback_session_id="playback-session"
110 )
111 return SimpleNamespace(
112 instance_id="ariacast_receiver--test",
113 _is_playing=True,
114 _in_use_by_player=owner,
115 _active_player_id=active_player_id,
116 _get_target_player_id=MagicMock(return_value=None),
117 _safe_play_media=AsyncMock(),
118 _broadcast_meta=AsyncMock(),
119 mass=mass,
120 logger=MagicMock(),
121 )
122
123
124async def _playback(receiver: SimpleNamespace, is_playing: bool) -> None:
125 """Run the playback-state handler on the bare receiver."""
126 await AriaCastReceiver._handle_playback_state(cast("AriaCastReceiver", receiver), is_playing)
127
128
129async def test_the_sender_stopping_gives_the_source_back_to_its_owner() -> None:
130 """
131 The owner is released, not the player the audio was consumed over.
132
133 _active_player_id can be an ephemeral protocol player; the source session hangs
134 off the owner, so deselecting the bridge would leave that session alive and the
135 owner's own queue stuck inactive.
136 """
137 receiver = _playback_receiver(active_player_id="spb_bridge_1", owner="owner-player")
138
139 await _playback(receiver, False)
140
141 receiver.mass.players.deselect_source.assert_called_once_with(
142 "owner-player",
143 provider_instance_id=receiver.instance_id,
144 source_id=AUDIO_SOURCE_ID,
145 playback_session_id="playback-session",
146 )
147 assert receiver._in_use_by_player is None
148
149
150async def test_a_sender_stopping_while_not_in_use_releases_nothing() -> None:
151 """Without an owner there is no session to give back."""
152 receiver = _playback_receiver(active_player_id="some-player", owner=None)
153
154 await _playback(receiver, False)
155
156 receiver.mass.players.deselect_source.assert_not_called()
157