/
/
/
1"""Tests for the AmpliPi zone player."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from music_assistant_models.enums import PlaybackState, PlayerFeature
10from music_assistant_models.errors import PlayerCommandFailed
11
12from music_assistant.providers.amplipi.constants import SOURCE_DISCONNECTED, ZONE_OFF
13from music_assistant.providers.amplipi.player import AmpliPiZonePlayer
14from music_assistant.providers.amplipi.provider import AmpliPiPlayerProvider
15
16
17def _zone(
18 zone_id: int, source_id: int = SOURCE_DISCONNECTED, disabled: bool = False
19) -> SimpleNamespace:
20 """Build a lightweight stand-in for a pyamplipi Zone."""
21 return SimpleNamespace(
22 id=zone_id,
23 name=f"Zone {zone_id}",
24 source_id=source_id,
25 disabled=disabled,
26 mute=False,
27 vol=-30,
28 vol_f=0.5,
29 )
30
31
32def _source(
33 source_id: int, input_str: str | None = "", state: str | None = None
34) -> SimpleNamespace:
35 """Build a lightweight stand-in for a pyamplipi Source."""
36 info = SimpleNamespace(state=state) if state is not None else None
37 return SimpleNamespace(id=source_id, input=input_str, info=info)
38
39
40def _status(zones: list[SimpleNamespace], sources: list[SimpleNamespace]) -> SimpleNamespace:
41 """Build a lightweight stand-in for a pyamplipi Status."""
42 return SimpleNamespace(zones=zones, sources=sources)
43
44
45@pytest.fixture
46def mock_provider() -> MagicMock:
47 """Create a mock AmpliPiPlayerProvider with an async API."""
48 provider = MagicMock()
49 provider.instance_id = "amplipi_test"
50 provider.domain = "amplipi"
51 provider.manifest = MagicMock()
52 provider.manifest.domain = "amplipi"
53
54 provider.api = MagicMock()
55 provider.api.set_zone = AsyncMock()
56 provider.api.set_zones = AsyncMock()
57 provider.api.set_source = AsyncMock()
58 provider.api.play_stream = AsyncMock()
59 provider.api.pause_stream = AsyncMock()
60 provider.api.stop_stream = AsyncMock()
61 provider.api.get_source = AsyncMock()
62 # the provider creates/reuses one internetradio stream per source for MA playback
63 provider.ensure_stream = AsyncMock(return_value=42)
64 # selectable AmpliPi sources (native streams + RCA inputs); empty by default
65 provider.selectable_streams = MagicMock(return_value=[])
66
67 # default status: 4 sources (all free), zones 0..3 disconnected
68 provider.status = _status(
69 zones=[_zone(i) for i in range(4)],
70 sources=[_source(i, input_str="") for i in range(4)],
71 )
72 # map player_id -> zone id
73 provider.zone_id_for = MagicMock(
74 side_effect=lambda pid: (
75 int(pid.rsplit("_", 1)[1]) if pid.startswith("amplipi_test_zone_") else None
76 )
77 )
78
79 provider.mass = MagicMock()
80 provider.mass.players = MagicMock()
81 provider.mass.streams.resolve_stream_url = AsyncMock(return_value="http://ma/stream.flac")
82 config = MagicMock()
83 config.name = None
84 config.default_name = "Zone"
85 config.enabled = True
86 config.player_type = None
87 config.get_value = MagicMock(return_value=None)
88 provider.mass.config.get_base_player_config.return_value = config
89 return provider
90
91
92def _make_player(provider: MagicMock, zone_id: int) -> AmpliPiZonePlayer:
93 """Build a player with update_state patched out (heavy state calc)."""
94 player = AmpliPiZonePlayer(provider=provider, zone_id=zone_id)
95 player.update_state = MagicMock() # type: ignore[misc,method-assign]
96 return player
97
98
99class TestSupportedFeatures:
100 """Verify the player advertises the expected features."""
101
102 def test_required_features(self, mock_provider: MagicMock) -> None:
103 """Power, play_media, grouping and volume should be supported."""
104 player = _make_player(mock_provider, 0)
105 for feature in (
106 PlayerFeature.PLAY_MEDIA,
107 PlayerFeature.PAUSE,
108 PlayerFeature.POWER,
109 PlayerFeature.VOLUME_SET,
110 PlayerFeature.VOLUME_MUTE,
111 PlayerFeature.SET_MEMBERS,
112 ):
113 assert feature in player.supported_features
114
115 def test_player_id_includes_instance_and_zone(self, mock_provider: MagicMock) -> None:
116 """Player id should be namespaced by provider instance and zone id."""
117 player = _make_player(mock_provider, 3)
118 assert player.player_id == "amplipi_test_zone_3"
119 assert player.zone_id == 3
120
121 def test_player_name_uses_zone_name(self, mock_provider: MagicMock) -> None:
122 """The player name should be the AmpliPi zone's friendly name, not the raw id."""
123 mock_provider.status = _status(
124 zones=[SimpleNamespace(id=2, name="Living Room")],
125 sources=[],
126 )
127 player = _make_player(mock_provider, 2)
128 assert player.name == "Living Room"
129
130 def test_player_name_falls_back_when_unset(self, mock_provider: MagicMock) -> None:
131 """An unnamed AmpliPi zone gets a sensible 1-based default name."""
132 mock_provider.status = _status(
133 zones=[SimpleNamespace(id=1, name="")],
134 sources=[],
135 )
136 player = _make_player(mock_provider, 1)
137 assert player.name == "AmpliPi Zone 2"
138
139 def test_player_name_tracks_zone_rename(self, mock_provider: MagicMock) -> None:
140 """A zone renamed in AmpliPi propagates to the player name on the next poll."""
141 player = _make_player(mock_provider, 0)
142 status = _status(
143 zones=[SimpleNamespace(id=0, name="Kitchen", source_id=-1, mute=False, vol=-30)],
144 sources=[],
145 )
146 player.update_from_status(status)
147 assert player.name == "Kitchen"
148
149
150class TestStatePolling:
151 """Test that polling reconciles state without trusting AmpliPi's unreliable source state."""
152
153 def test_poll_keeps_commanded_state_while_connected(self, mock_provider: MagicMock) -> None:
154 """While the zone is connected, our commanded playback_state must be preserved."""
155 player = _make_player(mock_provider, 0)
156 player._attr_playback_state = PlaybackState.PLAYING
157 # AmpliPi reports the fileplayer as "stopped" even though audio is playing
158 status = _status(
159 zones=[_zone(0, source_id=0)],
160 sources=[_source(0, input_str="stream=42", state="stopped")],
161 )
162 player.update_from_status(status)
163 assert player.playback_state == PlaybackState.PLAYING
164
165 def test_poll_idle_when_disconnected(self, mock_provider: MagicMock) -> None:
166 """A disconnected zone reports IDLE regardless of prior state."""
167 player = _make_player(mock_provider, 0)
168 player._attr_playback_state = PlaybackState.PLAYING
169 status = _status(
170 zones=[_zone(0, source_id=SOURCE_DISCONNECTED)],
171 sources=[_source(0, input_str="")],
172 )
173 player.update_from_status(status)
174 assert player.playback_state == PlaybackState.IDLE
175 assert player.active_source is None
176
177
178class TestPower:
179 """Test the power command transitions (the bug from the issue)."""
180
181 async def test_power_on_connects_disconnected(self, mock_provider: MagicMock) -> None:
182 """Powering on should set the zone to SOURCE_DISCONNECTED (on, idle)."""
183 player = _make_player(mock_provider, 1)
184 await player.power(True)
185 mock_provider.api.set_zone.assert_awaited_once()
186 zone_id, update = mock_provider.api.set_zone.await_args.args
187 assert zone_id == 1
188 assert update.source_id == SOURCE_DISCONNECTED
189 assert player.powered is True
190
191 async def test_power_off_sets_zone_off(self, mock_provider: MagicMock) -> None:
192 """Powering off should set the zone (and members) to ZONE_OFF."""
193 player = _make_player(mock_provider, 1)
194 await player.power(False)
195 mock_provider.api.set_zones.assert_awaited_once()
196 multi = mock_provider.api.set_zones.await_args.args[0]
197 assert multi.zones == [1]
198 assert multi.update.source_id == ZONE_OFF
199 assert player.powered is False
200
201
202class TestAcquireSource:
203 """Test the source acquisition policy (4 sources for 6+ zones)."""
204
205 async def test_reuses_current_source(self, mock_provider: MagicMock) -> None:
206 """If the zone already holds a source, reuse it."""
207 player = _make_player(mock_provider, 0)
208 player._source_id = 2
209 source = await player._acquire_source()
210 assert source is not None
211 assert source.id == 2
212
213 async def test_claims_free_source(self, mock_provider: MagicMock) -> None:
214 """A free source (not bound to a zone) should be claimed."""
215 mock_provider.status = _status(
216 zones=[_zone(0, source_id=0), _zone(1)],
217 sources=[
218 _source(0, input_str="stream=10"),
219 _source(1, input_str=""),
220 _source(2, input_str="None"),
221 _source(3, input_str="stream=11"),
222 ],
223 )
224 player = _make_player(mock_provider, 1)
225 source = await player._acquire_source()
226 assert source is not None
227 assert source.id == 1
228
229 async def test_all_sources_in_use_returns_none(self, mock_provider: MagicMock) -> None:
230 """With all 4 sources bound to zones, acquisition fails."""
231 mock_provider.status = _status(
232 zones=[_zone(i, source_id=i) for i in range(4)] + [_zone(4)],
233 sources=[_source(i, input_str=f"stream={i}") for i in range(4)],
234 )
235 player = _make_player(mock_provider, 4)
236 assert await player._acquire_source() is None
237
238 async def test_falls_back_to_unbound_busy_source(self, mock_provider: MagicMock) -> None:
239 """A source with a non-free input but no zone attached is claimed as a fallback."""
240 mock_provider.status = _status(
241 zones=[_zone(0, source_id=0)],
242 sources=[_source(0, input_str="stream=1"), _source(1, input_str="stream=99")],
243 )
244 player = _make_player(mock_provider, 5)
245 player._source_id = None
246 source = await player._acquire_source()
247 assert source is not None
248 assert source.id == 1
249
250
251class TestPlayMedia:
252 """Test play_media wiring."""
253
254 async def test_play_media_acquires_and_plays(self, mock_provider: MagicMock) -> None:
255 """play_media should resolve a url, claim a source, connect the zone and play."""
256 mock_provider.status = _status(
257 zones=[_zone(0)],
258 sources=[_source(0, input_str="")],
259 )
260 player = _make_player(mock_provider, 0)
261 media = MagicMock()
262 await player.play_media(media)
263
264 mock_provider.mass.streams.resolve_stream_url.assert_awaited_once()
265 # an internetradio stream is created/reused for the source and pointed at the url
266 mock_provider.ensure_stream.assert_awaited_once_with(0, "http://ma/stream.flac")
267
268 mock_provider.api.set_zones.assert_awaited_once()
269 multi = mock_provider.api.set_zones.await_args.args[0]
270 assert multi.zones == [0]
271 assert multi.update.source_id == 0
272 # the zone must be unmuted, otherwise AmpliPi plays silently
273 assert multi.update.mute is False
274
275 assert player.active_source == player.player_id
276 assert player.playback_state == PlaybackState.PLAYING
277 assert player.powered is True
278 # AmpliPi reports no position, so play_media must seed the self-clock: without it
279 # the queue elapsed time stays pinned at the stream start and pause/seek-resume
280 # would always jump back to the start (see _get_flow_queue_stream_index).
281 assert player.elapsed_time == 0
282 assert player.elapsed_time_last_updated is not None
283
284 async def test_play_media_binds_source_to_stream_and_plays(
285 self, mock_provider: MagicMock
286 ) -> None:
287 """play_media must point the source at the MA stream and start it."""
288 mock_provider.status = _status(
289 zones=[_zone(0)],
290 sources=[_source(0, input_str="")],
291 )
292 player = _make_player(mock_provider, 0)
293 await player.play_media(MagicMock())
294
295 mock_provider.api.set_source.assert_awaited_once()
296 source_id, update = mock_provider.api.set_source.await_args.args
297 assert source_id == 0
298 assert update.input == "stream=42"
299 mock_provider.api.play_stream.assert_awaited_once_with(42)
300
301 async def test_play_media_detaches_stray_zones(self, mock_provider: MagicMock) -> None:
302 """play_media must detach zones left on the source that are not in the group."""
303 # our zone 0 owns source 0, but zones 2 and 3 are stale leftovers on it too
304 mock_provider.status = _status(
305 zones=[_zone(0, source_id=0), _zone(2, source_id=0), _zone(3, source_id=0)],
306 sources=[_source(0, input_str="stream=42")],
307 )
308 player = _make_player(mock_provider, 0)
309 player._source_id = 0
310 await player.play_media(MagicMock())
311
312 calls = mock_provider.api.set_zones.await_args_list
313 # first the strays are disconnected, then our group is attached
314 detach = calls[0].args[0]
315 assert set(detach.zones) == {2, 3}
316 assert detach.update.source_id == SOURCE_DISCONNECTED
317 attach = calls[1].args[0]
318 assert attach.zones == [0]
319 assert attach.update.source_id == 0
320 assert attach.update.mute is False
321
322 async def test_play_media_attaches_group_members(self, mock_provider: MagicMock) -> None:
323 """play_media must attach the leader's grouped members alongside its own zone."""
324 mock_provider.status = _status(
325 zones=[_zone(0), _zone(2)],
326 sources=[_source(0, input_str="")],
327 )
328 player = _make_player(mock_provider, 0)
329 player._attr_group_members = [player.player_id, "amplipi_test_zone_2"]
330 await player.play_media(MagicMock())
331 attach = mock_provider.api.set_zones.await_args.args[0]
332 assert set(attach.zones) == {0, 2}
333
334 async def test_play_media_raises_when_no_source(self, mock_provider: MagicMock) -> None:
335 """play_media should raise when no source can be acquired."""
336 mock_provider.status = _status(
337 zones=[_zone(i, source_id=i) for i in range(4)] + [_zone(4)],
338 sources=[_source(i, input_str=f"stream={i}") for i in range(4)],
339 )
340 player = _make_player(mock_provider, 4)
341 with pytest.raises(PlayerCommandFailed):
342 await player.play_media(MagicMock())
343
344
345class TestSelectableStreams:
346 """Test the provider's selectable-source filtering (verified against live hardware shape)."""
347
348 def test_excludes_ma_streams_and_fileplayer(self) -> None:
349 """Native streams + RCA inputs are selectable; MA's own streams and fileplayer are not."""
350 prov = AmpliPiPlayerProvider.__new__(AmpliPiPlayerProvider)
351 prov._streams = [
352 SimpleNamespace(id=996, name="Input 1", type="rca"),
353 SimpleNamespace(id=1000, name="Groove Salad", type="internetradio"),
354 SimpleNamespace(id=1003, name="AmpliPro 1", type="airplay"),
355 SimpleNamespace(id=1008, name="External Media", type="fileplayer"),
356 SimpleNamespace(id=1009, name="Music Assistant 3", type="internetradio"),
357 ]
358 ids = {s.id for s in AmpliPiPlayerProvider.selectable_streams(prov)}
359 assert ids == {996, 1000, 1003}
360
361
362class TestSelectSource:
363 """Test SELECT_SOURCE for AmpliPi-side sources (native streams + RCA/line inputs)."""
364
365 def test_select_source_feature_advertised(self, mock_provider: MagicMock) -> None:
366 """The player should advertise SELECT_SOURCE."""
367 player = _make_player(mock_provider, 0)
368 assert PlayerFeature.SELECT_SOURCE in player.supported_features
369
370 def test_source_list_built_from_selectable_streams(self, mock_provider: MagicMock) -> None:
371 """source_list should expose selectable AmpliPi streams as 'stream=<id>' sources."""
372 mock_provider.selectable_streams = MagicMock(
373 return_value=[
374 SimpleNamespace(id=3, name="Turntable", type="rca"),
375 SimpleNamespace(id=7, name="AirPlay", type="airplay"),
376 ]
377 )
378 mock_provider.status = _status(zones=[_zone(0, source_id=SOURCE_DISCONNECTED)], sources=[])
379 player = _make_player(mock_provider, 0)
380 player.update_from_status(mock_provider.status)
381 by_id = {s.id: s for s in player.source_list}
382 assert set(by_id) == {"stream=3", "stream=7"}
383 assert all(not s.passive for s in player.source_list)
384 # routing-only sources: no transport advertised
385 assert all(
386 not (s.can_play_pause or s.can_seek or s.can_next_previous) for s in player.source_list
387 )
388 # physical inputs keep their name; native streams get a disambiguating type suffix
389 assert by_id["stream=3"].name == "Turntable"
390 assert by_id["stream=7"].name == "AirPlay (AirPlay)"
391
392 async def test_select_source_sets_input_and_attaches(self, mock_provider: MagicMock) -> None:
393 """Selecting a source should point an AmpliPi source at the stream and attach the zone."""
394 mock_provider.status = _status(
395 zones=[_zone(0)],
396 sources=[_source(0, input_str="")],
397 )
398 player = _make_player(mock_provider, 0)
399 await player.select_source("stream=3")
400
401 source_id, update = mock_provider.api.set_source.await_args.args
402 assert source_id == 0
403 assert update.input == "stream=3"
404 attach = mock_provider.api.set_zones.await_args.args[0]
405 assert attach.zones == [0]
406 assert attach.update.source_id == 0
407 assert attach.update.mute is False
408 assert player.active_source == "stream=3"
409 assert player.playback_state == PlaybackState.PLAYING
410
411 async def test_select_source_rejects_unknown(self, mock_provider: MagicMock) -> None:
412 """An id that is not a 'stream=<id>' source should be rejected."""
413 player = _make_player(mock_provider, 0)
414 with pytest.raises(PlayerCommandFailed):
415 await player.select_source("hdmi1")
416
417 async def test_select_source_raises_when_no_source(self, mock_provider: MagicMock) -> None:
418 """Selecting a source must fail when no AmpliPi source can be acquired."""
419 mock_provider.status = _status(
420 zones=[_zone(i, source_id=i) for i in range(4)] + [_zone(4)],
421 sources=[_source(i, input_str=f"stream={i}") for i in range(4)],
422 )
423 player = _make_player(mock_provider, 4)
424 with pytest.raises(PlayerCommandFailed):
425 await player.select_source("stream=3")
426
427
428class TestVolume:
429 """Test volume handling."""
430
431 async def test_volume_set_maps_to_usable_db_window(self, mock_provider: MagicMock) -> None:
432 """Volume 0..100 maps onto a usable dB window (0 -> floor, 100 -> 0 dB)."""
433 player = _make_player(mock_provider, 0)
434 await player.volume_set(80)
435 mock_provider.api.set_zone.assert_awaited_once()
436 _zone_id, update = mock_provider.api.set_zone.await_args.args
437 # 80% across a -60..0 dB window -> -12 dB
438 assert update.vol == -12
439 assert player.volume_level == 80
440
441 async def test_volume_mute(self, mock_provider: MagicMock) -> None:
442 """Muting should write mute=True to the zone and reflect it locally."""
443 player = _make_player(mock_provider, 0)
444 await player.volume_mute(True)
445 _zone_id, update = mock_provider.api.set_zone.await_args.args
446 assert update.mute is True
447 assert player.volume_muted is True
448
449 async def test_volume_db_roundtrip(self, mock_provider: MagicMock) -> None:
450 """Mapping a volume to dB and back should return the original value."""
451 for level in (0, 25, 50, 80, 100):
452 assert AmpliPiZonePlayer._db_to_volume(AmpliPiZonePlayer._volume_to_db(level)) == level
453
454
455class TestPlayStop:
456 """Test the play/stop transport against the active stream."""
457
458 def test_requires_flow_mode(self, mock_provider: MagicMock) -> None:
459 """AmpliPi plays a single stream url, so flow mode is required."""
460 assert _make_player(mock_provider, 0).requires_flow_mode is True
461
462 async def test_play_starts_active_stream(self, mock_provider: MagicMock) -> None:
463 """play() should resume the stream currently bound to this zone's source."""
464 player = _make_player(mock_provider, 0)
465 player._source_id = 0
466 mock_provider.api.get_source = AsyncMock(return_value=SimpleNamespace(input="stream=5"))
467 await player.play()
468 mock_provider.api.play_stream.assert_awaited_once_with(5)
469 assert player.playback_state == PlaybackState.PLAYING
470
471 async def test_play_without_active_stream(self, mock_provider: MagicMock) -> None:
472 """With no source bound, play() still reports PLAYING but issues no stream command."""
473 player = _make_player(mock_provider, 0)
474 player._source_id = None
475 # an inactive queue must not divert play() into the resume path
476 mock_provider.mass.player_queues.get.return_value = SimpleNamespace(active=False)
477 await player.play()
478 mock_provider.api.play_stream.assert_not_awaited()
479 assert player.playback_state == PlaybackState.PLAYING
480
481 async def test_play_resumes_from_pause_via_queue(self, mock_provider: MagicMock) -> None:
482 """Unpausing a paused MA queue re-resolves from the saved position, not the stale stream."""
483 player = _make_player(mock_provider, 0)
484 player._source_id = 0
485 player._attr_playback_state = PlaybackState.PAUSED
486 mock_provider.mass.player_queues.get.return_value = SimpleNamespace(active=True)
487 mock_provider.mass.player_queues.resume = AsyncMock()
488 await player.play()
489 # AmpliPi cannot unpause in place, so play() delegates to the queue's resume
490 mock_provider.mass.player_queues.resume.assert_awaited_once_with(player.player_id)
491 # it must NOT replay the stale (stopped) stream
492 mock_provider.api.play_stream.assert_not_awaited()
493
494 async def test_stop_stops_active_stream(self, mock_provider: MagicMock) -> None:
495 """stop() should stop the bound stream and mark the zone idle."""
496 player = _make_player(mock_provider, 0)
497 player._source_id = 0
498 mock_provider.api.get_source = AsyncMock(return_value=SimpleNamespace(input="stream=5"))
499 await player.stop()
500 mock_provider.api.stop_stream.assert_awaited_once_with(5)
501 assert player.playback_state == PlaybackState.IDLE
502 assert player.stop_called is True
503
504 async def test_pause_stops_active_stream(self, mock_provider: MagicMock) -> None:
505 """pause() should stop the bound stream but report PAUSED (not idle)."""
506 player = _make_player(mock_provider, 0)
507 player._source_id = 0
508 mock_provider.api.get_source = AsyncMock(return_value=SimpleNamespace(input="stream=5"))
509 await player.pause()
510 mock_provider.api.stop_stream.assert_awaited_once_with(5)
511 assert player.playback_state == PlaybackState.PAUSED
512 # unlike stop(), pause() must not mark the queue as stopped (resume re-plays it)
513 assert player.stop_called is False
514
515 async def test_pause_without_active_stream(self, mock_provider: MagicMock) -> None:
516 """With no source bound, pause() still reports PAUSED but issues no stream command."""
517 player = _make_player(mock_provider, 0)
518 player._source_id = None
519 await player.pause()
520 mock_provider.api.stop_stream.assert_not_awaited()
521 assert player.playback_state == PlaybackState.PAUSED
522
523 async def test_active_stream_id_non_stream_input(self, mock_provider: MagicMock) -> None:
524 """A source input that is not 'stream=<id>' yields no active stream."""
525 player = _make_player(mock_provider, 0)
526 player._source_id = 0
527 mock_provider.api.get_source = AsyncMock(return_value=SimpleNamespace(input="rca"))
528 assert await player._active_stream_id() is None
529
530 async def test_active_stream_id_unparsable(self, mock_provider: MagicMock) -> None:
531 """A non-numeric stream id is ignored rather than raising."""
532 player = _make_player(mock_provider, 0)
533 player._source_id = 0
534 mock_provider.api.get_source = AsyncMock(return_value=SimpleNamespace(input="stream=x"))
535 assert await player._active_stream_id() is None
536
537
538class TestAvailability:
539 """Test the unavailable / status-reconciliation edge cases."""
540
541 def test_set_unavailable_is_idempotent(self, mock_provider: MagicMock) -> None:
542 """Marking an already-unavailable player must not re-emit a state update."""
543 player = _make_player(mock_provider, 0)
544 player._attr_available = True
545 player.set_unavailable()
546 assert player.available is False
547 player.update_state.reset_mock() # type: ignore[attr-defined]
548 player.set_unavailable()
549 player.update_state.assert_not_called() # type: ignore[attr-defined]
550
551 def test_update_from_status_missing_zone(self, mock_provider: MagicMock) -> None:
552 """If the zone is absent from the status the player goes unavailable."""
553 player = _make_player(mock_provider, 0)
554 player._attr_available = True
555 player.update_from_status(_status(zones=[_zone(1)], sources=[]))
556 assert player.available is False
557
558 def test_reflects_external_active_source(self, mock_provider: MagicMock) -> None:
559 """A connected user-selectable AmpliPi source is surfaced as the active source."""
560 mock_provider.selectable_streams = MagicMock(
561 return_value=[SimpleNamespace(id=7, name="AirPlay", type="airplay")]
562 )
563 mock_provider.status = _status(
564 zones=[_zone(0, source_id=0)],
565 sources=[_source(0, input_str="stream=7")],
566 )
567 player = _make_player(mock_provider, 0)
568 player.update_from_status(mock_provider.status)
569 assert player.active_source == "stream=7"
570
571 def test_connected_non_stream_source_defaults_to_ma(self, mock_provider: MagicMock) -> None:
572 """A connected source that is not a selectable external stream defaults to MA playback."""
573 mock_provider.status = _status(
574 zones=[_zone(0, source_id=0)],
575 sources=[_source(0, input_str="")],
576 )
577 player = _make_player(mock_provider, 0)
578 player.update_from_status(mock_provider.status)
579 assert player.active_source == player.player_id
580
581 def test_external_active_source_cleared_when_input_changes(
582 self, mock_provider: MagicMock
583 ) -> None:
584 """A previously-reflected external source must not stay selected after the input changes."""
585 mock_provider.selectable_streams = MagicMock(
586 return_value=[SimpleNamespace(id=7, name="AirPlay", type="airplay")]
587 )
588 player = _make_player(mock_provider, 0)
589 # external source selected and reflected
590 mock_provider.status = _status(
591 zones=[_zone(0, source_id=0)], sources=[_source(0, input_str="stream=7")]
592 )
593 player.update_from_status(mock_provider.status)
594 assert player.active_source == "stream=7"
595 # input changes back to our own (non-selectable) MA stream: must revert to MA playback
596 mock_provider.status = _status(
597 zones=[_zone(0, source_id=0)], sources=[_source(0, input_str="stream=42")]
598 )
599 player.update_from_status(mock_provider.status)
600 assert player.active_source == player.player_id
601
602
603class TestGrouping:
604 """Test set_members grouping behaviour."""
605
606 async def test_add_member_connects_to_leader_source(self, mock_provider: MagicMock) -> None:
607 """Adding a member should connect its zone to the leader's source."""
608 leader = _make_player(mock_provider, 0)
609 leader._source_id = 1
610 await leader.set_members(player_ids_to_add=["amplipi_test_zone_2"])
611 mock_provider.api.set_zones.assert_awaited()
612 multi = mock_provider.api.set_zones.await_args.args[0]
613 assert multi.zones == [2]
614 assert multi.update.source_id == 1
615 # the added zone must be unmuted, otherwise it joins the group silently
616 assert multi.update.mute is False
617 assert leader.group_members == [leader.player_id, "amplipi_test_zone_2"]
618
619 async def test_remove_member_disconnects(self, mock_provider: MagicMock) -> None:
620 """Removing the last member should dissolve the group view."""
621 leader = _make_player(mock_provider, 0)
622 leader._source_id = 1
623 leader._attr_group_members = [leader.player_id, "amplipi_test_zone_2"]
624 await leader.set_members(player_ids_to_remove=["amplipi_test_zone_2"])
625 multi = mock_provider.api.set_zones.await_args.args[0]
626 assert multi.zones == [2]
627 assert multi.update.source_id == SOURCE_DISCONNECTED
628 assert leader.group_members == []
629
630 async def test_add_member_acquires_source_for_leader(self, mock_provider: MagicMock) -> None:
631 """A sourceless leader claims a source (and connects its own zone) before grouping."""
632 mock_provider.status = _status(
633 zones=[_zone(0), _zone(2)],
634 sources=[_source(0, input_str="")],
635 )
636 leader = _make_player(mock_provider, 0)
637 # leader has no source yet (the default after construction)
638 await leader.set_members(player_ids_to_add=["amplipi_test_zone_2"])
639 assert leader._source_id == 0
640 # the leader's own zone is connected/unmuted to the acquired source
641 zone_id, update = mock_provider.api.set_zone.await_args.args
642 assert zone_id == 0
643 assert update.source_id == 0
644 assert update.mute is False
645 assert leader.group_members == [leader.player_id, "amplipi_test_zone_2"]
646
647 async def test_add_member_raises_when_no_source(self, mock_provider: MagicMock) -> None:
648 """Grouping fails if the sourceless leader cannot acquire a source."""
649 mock_provider.status = _status(
650 zones=[_zone(i, source_id=i) for i in range(4)] + [_zone(5)],
651 sources=[_source(i, input_str=f"stream={i}") for i in range(4)],
652 )
653 leader = _make_player(mock_provider, 5)
654 leader._source_id = None
655 with pytest.raises(PlayerCommandFailed):
656 await leader.set_members(player_ids_to_add=["amplipi_test_zone_2"])
657
658 async def test_add_unmapped_member_skips_api(self, mock_provider: MagicMock) -> None:
659 """Adding only unknown/unmapped player ids must not issue an empty set_zones call."""
660 leader = _make_player(mock_provider, 0)
661 leader._source_id = 1
662 await leader.set_members(player_ids_to_add=["nonexistent"])
663 mock_provider.api.set_zones.assert_not_awaited()
664
665 async def test_remove_unmapped_member_skips_api(self, mock_provider: MagicMock) -> None:
666 """Removing only unknown/unmapped player ids must not issue an empty set_zones call."""
667 leader = _make_player(mock_provider, 0)
668 leader._source_id = 1
669 leader._attr_group_members = [leader.player_id, "nonexistent"]
670 await leader.set_members(player_ids_to_remove=["nonexistent"])
671 mock_provider.api.set_zones.assert_not_awaited()
672 assert leader.group_members == []
673
674 def test_poll_prunes_disconnected_members(self, mock_provider: MagicMock) -> None:
675 """A polled member no longer on the leader's source is pruned from the group."""
676 mock_provider.status = _status(
677 zones=[_zone(0, source_id=0), _zone(2, source_id=SOURCE_DISCONNECTED)],
678 sources=[_source(0, input_str="stream=42")],
679 )
680 leader = _make_player(mock_provider, 0)
681 leader._attr_group_members = [leader.player_id, "amplipi_test_zone_2"]
682 leader.update_from_status(mock_provider.status)
683 assert leader.group_members == []
684
685 def test_poll_keeps_connected_members(self, mock_provider: MagicMock) -> None:
686 """A polled member still on the leader's source is retained in the group."""
687 mock_provider.status = _status(
688 zones=[_zone(0, source_id=0), _zone(2, source_id=0)],
689 sources=[_source(0, input_str="stream=42")],
690 )
691 leader = _make_player(mock_provider, 0)
692 leader._attr_group_members = [leader.player_id, "amplipi_test_zone_2"]
693 leader.update_from_status(mock_provider.status)
694 assert leader.group_members == [leader.player_id, "amplipi_test_zone_2"]
695
696 def test_poll_dissolves_group_when_disconnected(self, mock_provider: MagicMock) -> None:
697 """When the leader zone itself disconnects, its group is dissolved entirely."""
698 mock_provider.status = _status(
699 zones=[_zone(0, source_id=SOURCE_DISCONNECTED)],
700 sources=[],
701 )
702 leader = _make_player(mock_provider, 0)
703 leader._attr_group_members = [leader.player_id, "amplipi_test_zone_2"]
704 leader.update_from_status(mock_provider.status)
705 assert leader.group_members == []
706 mock_provider.mass.players.trigger_player_update.assert_any_call("amplipi_test_zone_2")
707