/
/
1"""Unit tests for the AirPlay Receiver provider (ports + daemon reconciliation)."""
2
3from __future__ import annotations
4
5import asyncio
6from dataclasses import dataclass
7from unittest.mock import AsyncMock, MagicMock
8
9from music_assistant_models.enums import EventType
10from music_assistant_models.event import MassEvent
11
12from music_assistant.providers.airplay_receiver import (
13 AirPlayReceiverProvider,
14 airplay_receiver_ports,
15)
16
17# --- Port derivation -----------------------------------------------------------
18
19
20def test_airplay_receiver_ports_are_deterministic() -> None:
21 """The port derivation must be stable across processes/restarts (unlike ``hash()``)."""
22 # pinned expectations guard against accidental changes to the derivation:
23 # the AirPlay provider relies on reproducing these ports from config alone
24 assert airplay_receiver_ports("airplay_receiver", ["player-a", "player-b"]) == {
25 "player-a": 7765,
26 "player-b": 7158,
27 }
28
29
30def test_airplay_receiver_ports_stay_in_expected_range_and_unique() -> None:
31 """Derived ports stay within the 7000-7999 AirPlay range and never collide."""
32 ports = airplay_receiver_ports("airplay_receiver", [f"player{index}" for index in range(50)])
33 assert all(7000 <= port <= 7999 for port in ports.values())
34 assert len(set(ports.values())) == 50
35
36
37def test_airplay_receiver_ports_deduplicates_player_ids() -> None:
38 """A duplicated player id claims a single port instead of probing extra slots."""
39 ports = airplay_receiver_ports("airplay_receiver", ["player-a", "player-a"])
40 assert ports == {"player-a": 7765}
41
42
43def test_airplay_receiver_ports_collision_probing_is_order_independent() -> None:
44 """Colliding derivations probe deterministically regardless of the input order."""
45 # player22 and player23 both derive base port 7525 for this instance id
46 colliding = ["player22", "player23"]
47 ports = airplay_receiver_ports("airplay_receiver", colliding)
48 assert ports == {"player22": 7525, "player23": 7526}
49 assert airplay_receiver_ports("airplay_receiver", list(reversed(colliding))) == ports
50
51
52# --- Daemon reconciliation -----------------------------------------------------
53
54
55@dataclass
56class _ReconcileMocks:
57 """The mocked collaborators of a reconcile-test provider."""
58
59 mass: MagicMock
60 start_receiver: MagicMock
61 stop_receiver: AsyncMock
62
63
64def _reconcile_provider(
65 assigned: tuple[str, ...],
66 registered: dict[str, str],
67) -> tuple[AirPlayReceiverProvider, _ReconcileMocks]:
68 """
69 Build a bare provider with the real reconcile logic and mocked daemon control.
70
71 :param assigned: The connected player ids the provider was loaded with.
72 :param registered: Currently registered player ids mapped to their display name.
73 """
74 prov = AirPlayReceiverProvider.__new__(AirPlayReceiverProvider)
75 prov.logger = MagicMock()
76 prov.config = MagicMock()
77 prov.mass = mass = MagicMock()
78 prov._daemons = {}
79 prov._reconcile_lock = asyncio.Lock()
80 prov._unload_called = False
81 prov._unsubscribe = None
82 prov._assigned_player_ids = assigned
83 prov.get_config_value = MagicMock(return_value="player_mass") # type: ignore[method-assign]
84
85 def get_player(player_id: str) -> MagicMock | None:
86 if player_id not in registered:
87 return None
88 player = MagicMock()
89 player.player_id = player_id
90 player.display_name = registered[player_id]
91 return player
92
93 mass.players.get_player.side_effect = get_player
94
95 def start_receiver(player: MagicMock, airplay_name: str) -> None:
96 prov._daemons[player.player_id] = MagicMock(
97 player_id=player.player_id, airplay_name=airplay_name
98 )
99
100 start_mock = MagicMock(side_effect=start_receiver)
101 stop_mock = AsyncMock()
102 prov._start_receiver = start_mock # type: ignore[method-assign]
103 prov._stop_receiver = stop_mock # type: ignore[method-assign]
104 return prov, _ReconcileMocks(mass=mass, start_receiver=start_mock, stop_receiver=stop_mock)
105
106
107async def test_reconcile_starts_daemon_when_assigned_player_registers() -> None:
108 """A daemon starts only once its connected player has actually registered."""
109 registered: dict[str, str] = {}
110 prov, mocks = _reconcile_provider(("p1",), registered)
111
112 # cold boot: the player has not registered yet, so nothing starts
113 await prov._reconcile()
114 mocks.start_receiver.assert_not_called()
115
116 registered["p1"] = "Kitchen"
117 await prov._reconcile()
118 mocks.start_receiver.assert_called_once()
119 assert mocks.start_receiver.call_args.args[1] == "Kitchen | Music Assistant"
120 assert "p1" in prov._daemons
121
122
123async def test_reconcile_restarts_daemon_on_advertised_name_drift() -> None:
124 """A renamed player gets its daemon restarted with the new advertised name."""
125 registered = {"p1": "Kitchen"}
126 prov, mocks = _reconcile_provider(("p1",), registered)
127 await prov._reconcile()
128 old_daemon = prov._daemons["p1"]
129
130 # a second pass without changes is a no-op
131 await prov._reconcile()
132 mocks.stop_receiver.assert_not_awaited()
133 assert mocks.start_receiver.call_count == 1
134
135 # a live session on the old daemon is released before the daemon is replaced
136 old_daemon.active_player_id = "consumer"
137 registered["p1"] = "Cellar"
138 await prov._reconcile()
139 mocks.stop_receiver.assert_awaited_once_with(old_daemon)
140 assert prov._daemons["p1"].airplay_name == "Cellar | Music Assistant"
141 mocks.mass.players.deselect_source.assert_called_once()
142 assert mocks.mass.players.deselect_source.call_args.args[0] == "consumer"
143
144
145async def test_reconcile_keeps_daemon_for_temporarily_unavailable_player() -> None:
146 """A temporarily unregistered player keeps its running daemon (stable identity)."""
147 registered = {"p1": "Kitchen"}
148 prov, mocks = _reconcile_provider(("p1",), registered)
149 await prov._reconcile()
150 daemon = prov._daemons["p1"]
151
152 registered.clear()
153 await prov._reconcile()
154 mocks.stop_receiver.assert_not_awaited()
155 assert prov._daemons["p1"] is daemon
156
157
158async def test_player_removed_event_stops_daemon() -> None:
159 """A permanently removed player gets its daemon stopped and dropped."""
160 registered = {"p1": "Kitchen"}
161 prov, mocks = _reconcile_provider(("p1",), registered)
162 await prov._reconcile()
163 daemon = prov._daemons["p1"]
164
165 await prov._on_player_event(MassEvent(event=EventType.PLAYER_REMOVED, object_id="p1"))
166 mocks.stop_receiver.assert_awaited_once_with(daemon)
167 assert not prov._daemons
168
169
170async def test_player_added_event_triggers_reconcile() -> None:
171 """A player registering (cold boot path) starts its daemon via the event handler."""
172 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
173
174 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
175 mocks.start_receiver.assert_called_once()
176
177
178async def test_loaded_in_mass_with_empty_connected_players_is_idle() -> None:
179 """An empty connected-players selection loads the provider fully idle."""
180 prov, mocks = _reconcile_provider((), {})
181
182 await prov.loaded_in_mass()
183 mocks.mass.subscribe.assert_not_called()
184 mocks.start_receiver.assert_not_called()
185 assert not prov._daemons
186
187
188async def test_loaded_in_mass_subscribes_to_assigned_players_only() -> None:
189 """Player events are only watched for the connected players."""
190 prov, mocks = _reconcile_provider(("p1", "p2"), {})
191
192 await prov.loaded_in_mass()
193 mocks.mass.subscribe.assert_called_once()
194 assert mocks.mass.subscribe.call_args.kwargs["id_filter"] == ("p1", "p2")
195
196
197async def test_get_player_audio_sources_scopes_to_the_daemon_player() -> None:
198 """Each receiver's source is bound to its own connected player only."""
199 prov, _mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
200 await prov._reconcile()
201 daemon = prov._daemons["p1"]
202
203 assert prov.get_player_audio_sources("p1") == [daemon.audio_source]
204 assert prov.get_player_audio_sources("p2") == []
205
206
207async def test_unload_stops_all_daemons() -> None:
208 """Unload stops every running daemon and stops watching player events."""
209 registered = {"p1": "Kitchen", "p2": "Garage"}
210 prov, mocks = _reconcile_provider(("p1", "p2"), registered)
211 await prov._reconcile()
212 unsubscribe = MagicMock()
213 prov._unsubscribe = unsubscribe
214
215 await prov.unload()
216 unsubscribe.assert_called_once()
217 assert mocks.stop_receiver.await_count == 2
218 assert not prov._daemons
219