/
/
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 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_queue=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