/
/
/
1"""Unit tests for the Sonos S1 player."""
2
3from __future__ import annotations
4
5import asyncio
6import threading
7from functools import partial
8from typing import TYPE_CHECKING, cast
9from unittest.mock import AsyncMock, MagicMock, patch
10
11import pytest
12from music_assistant_models.enums import IdentifierType, MediaType, PlaybackState
13from music_assistant_models.errors import PlayerCommandFailed
14from soco.exceptions import SoCoException
15
16from music_assistant.mass import MusicAssistant
17from music_assistant.models.player import PlayerMedia
18from music_assistant.providers.sonos_s1 import player as player_module
19from music_assistant.providers.sonos_s1.constants import (
20 POLL_INTERVAL,
21 SUBSCRIPTION_SERVICES,
22 TRANSITION_POLL_INTERVAL,
23)
24from music_assistant.providers.sonos_s1.player import SonosPlayer
25
26if TYPE_CHECKING:
27 from collections.abc import AsyncGenerator
28
29STREAM_URL = "http://192.168.1.2:8097/single/sessionabc/queue1/item1/player1.flac"
30
31
32def _make_soco(uid: str = "RINCON_000E58AAAAAA01400", name: str = "Test Sonos") -> MagicMock:
33 """Create a mocked soco device."""
34 soco = MagicMock()
35 soco.uid = uid
36 soco.household_id = "Sonos_household"
37 soco.player_name = name
38 soco._player_name = name
39 soco.ip_address = "127.0.0.1"
40 soco.speaker_info = {"model_name": "Sonos Play:1"}
41 return soco
42
43
44@pytest.fixture
45def sonos_player() -> SonosPlayer:
46 """Create a SonosPlayer with a mocked soco device and provider."""
47 provider = MagicMock()
48 provider.mass.streams.resolve_stream_url = AsyncMock(return_value=STREAM_URL)
49 return SonosPlayer(provider=provider, soco=_make_soco())
50
51
52@pytest.fixture
53async def timer_mass() -> AsyncGenerator[MusicAssistant]:
54 """Create a bare MusicAssistant exposing the real call_later/cancel_timer machinery."""
55 mass = object.__new__(MusicAssistant)
56 mass.loop = asyncio.get_running_loop()
57 mass.loop_thread_id = threading.get_ident()
58 mass._tracked_timers = {}
59 mass._tracked_tasks = {}
60 mass.config = MagicMock()
61 mass.players = MagicMock()
62 mass.players.all_players.return_value = []
63 yield mass
64 for handle in mass._tracked_timers.values():
65 handle.cancel()
66 for task in mass._tracked_tasks.values():
67 task.cancel()
68
69
70def _make_player(mass: MusicAssistant, uid: str, name: str) -> SonosPlayer:
71 """Create a SonosPlayer bound to the given MusicAssistant."""
72 provider = MagicMock()
73 provider.mass = mass
74 provider.topology_condition = asyncio.Condition()
75 return SonosPlayer(provider=provider, soco=_make_soco(uid, name))
76
77
78def _poll_id(player: SonosPlayer) -> str:
79 """Return the task id that debounces the follow-up poll of the given speaker."""
80 return f"sonos_poll_{player.player_id}"
81
82
83def _pending_polls(mass: MusicAssistant) -> list[str]:
84 """Return the task ids of all pending speaker polls."""
85 return sorted(task_id for task_id in mass._tracked_timers if task_id.startswith("sonos_poll_"))
86
87
88def _make_media() -> PlayerMedia:
89 """Return PlayerMedia as built by the queue controller, with an MA media uri."""
90 return PlayerMedia(
91 uri="library://track/123",
92 media_type=MediaType.TRACK,
93 title="Test Track",
94 artist="Test Artist",
95 album="Test Album",
96 duration=180,
97 source_id="queue1",
98 queue_item_id="item1",
99 )
100
101
102async def test_enqueue_next_media_builds_didl_from_stream_url(
103 sonos_player: SonosPlayer,
104) -> None:
105 """The enqueue metadata res element must contain the stream url, not the MA media uri."""
106 await sonos_player.enqueue_next_media(_make_media())
107 call_args = sonos_player.soco.avTransport.SetNextAVTransportURI.call_args
108 args = dict(call_args.args[0])
109 assert args["NextURI"] == STREAM_URL
110 assert STREAM_URL in args["NextURIMetaData"]
111 assert "library://track/123" not in args["NextURIMetaData"]
112
113
114async def test_play_media_builds_didl_from_stream_url(sonos_player: SonosPlayer) -> None:
115 """The play metadata res element must contain the stream url, not the MA media uri."""
116 await sonos_player.play_media(_make_media())
117 call_args = sonos_player.soco.play_uri.call_args
118 assert call_args.args[0] == STREAM_URL
119 assert STREAM_URL in call_args.kwargs["meta"]
120 assert "library://track/123" not in call_args.kwargs["meta"]
121
122
123class _RecordingSoco:
124 """Minimal soco stand-in that records the thread its speaker query ran on."""
125
126 def __init__(self, actions: list[str]) -> None:
127 self._actions = actions
128 self.query_threads: list[int] = []
129 self.paused = False
130
131 @property
132 def available_actions(self) -> list[str]:
133 """Return the transport actions the speaker currently offers."""
134 self.query_threads.append(threading.get_ident())
135 return self._actions
136
137 def pause(self) -> None:
138 """Pause playback on the speaker."""
139 self.paused = True
140
141
142async def test_pause_queries_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
143 """Asking a speaker whether it can pause must not stall the event loop."""
144 soco = _RecordingSoco(["Play", "Stop", "Pause"])
145 sonos_player.soco = soco
146
147 await sonos_player.pause()
148
149 assert len(soco.query_threads) == 1
150 assert soco.query_threads[0] != threading.get_ident()
151 assert soco.paused
152
153
154async def test_pause_falls_back_to_stop_when_the_speaker_cannot_pause(
155 sonos_player: SonosPlayer,
156) -> None:
157 """A speaker that offers no pause action is stopped instead."""
158 soco = _RecordingSoco(["Play", "Stop"])
159 sonos_player.soco = soco
160
161 with patch.object(sonos_player, "stop", AsyncMock()) as stop:
162 await sonos_player.pause()
163
164 stop.assert_awaited_once()
165 assert not soco.paused
166
167
168def _set_transport_state(sonos_player: SonosPlayer, state: str) -> None:
169 """Make the mocked speaker report the given transport state."""
170 sonos_player.soco.get_current_transport_info.return_value = {"current_transport_state": state}
171
172
173def test_transitional_state_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
174 """A transitional transport state keeps the last known state and is watched closely."""
175 sonos_player._attr_playback_state = PlaybackState.IDLE
176 _set_transport_state(sonos_player, "TRANSITIONING")
177
178 sonos_player.poll_media()
179
180 assert sonos_player._attr_playback_state == PlaybackState.IDLE
181 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
182
183
184def test_settled_state_restores_the_poll_interval(sonos_player: SonosPlayer) -> None:
185 """A usable transport state returns the speaker to the regular poll interval."""
186 sonos_player._attr_poll_interval = TRANSITION_POLL_INTERVAL
187
188 _set_transport_state(sonos_player, "PLAYING")
189 with (
190 patch.object(sonos_player, "_set_basic_track_info"),
191 patch.object(sonos_player, "update_player"),
192 ):
193 sonos_player.poll_media()
194
195 assert sonos_player.poll_interval == POLL_INTERVAL
196
197
198def test_transitional_event_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
199 """A transitional state delivered by subscription event is watched closely too."""
200 event = MagicMock()
201 event.variables = {"transport_state": "TRANSITIONING"}
202
203 sonos_player._handle_avtransport_event(event)
204
205 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
206
207
208async def test_repeated_commands_collapse_to_one_pending_poll(
209 timer_mass: MusicAssistant,
210) -> None:
211 """A burst of commands leaves a single pending poll instead of one poll per command."""
212 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
213
214 await player.volume_set(30)
215 first_handle = timer_mass._tracked_timers[_poll_id(player)]
216 await player.volume_set(40)
217 await player.volume_mute(True)
218 await player.play()
219
220 assert _pending_polls(timer_mass) == [_poll_id(player)]
221 assert first_handle.cancelled()
222
223
224async def test_each_speaker_keeps_its_own_pending_poll(timer_mass: MusicAssistant) -> None:
225 """Commands to different speakers are polled independently."""
226 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
227 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
228
229 await kitchen.volume_set(30)
230 await study.volume_set(40)
231
232 assert _pending_polls(timer_mass) == sorted([_poll_id(kitchen), _poll_id(study)])
233
234
235async def test_set_members_polls_the_speakers_it_regrouped(timer_mass: MusicAssistant) -> None:
236 """Grouping polls each speaker that was joined or unjoined, not the coordinator."""
237 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
238 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
239 hallway = _make_player(timer_mass, "RINCON_000E58CCCCCC01400", "Hallway")
240 cast("MagicMock", timer_mass.players).get_player.side_effect = {
241 study.player_id: study,
242 hallway.player_id: hallway,
243 }.get
244
245 await kitchen.set_members(player_ids_to_add=[study.player_id, hallway.player_id])
246
247 assert _pending_polls(timer_mass) == sorted([_poll_id(study), _poll_id(hallway)])
248
249
250async def test_join_failure_names_the_speaker_that_refused(
251 timer_mass: MusicAssistant,
252) -> None:
253 """A speaker that refuses to join is named in the failure, not the group leader."""
254 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
255 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
256 cast("MagicMock", timer_mass.players).get_player.side_effect = {study.player_id: study}.get
257 study.soco.join.side_effect = SoCoException("the speaker refused to join")
258
259 with pytest.raises(PlayerCommandFailed, match="Study") as exc_info:
260 await kitchen.set_members(player_ids_to_add=[study.player_id])
261
262 assert "Kitchen" not in str(exc_info.value)
263
264
265async def test_grouping_leaves_the_speakers_after_a_failure_untouched(
266 timer_mass: MusicAssistant,
267) -> None:
268 """Speakers joined before a failure keep their poll, the ones after it are never reached."""
269 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
270 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
271 hallway = _make_player(timer_mass, "RINCON_000E58CCCCCC01400", "Hallway")
272 attic = _make_player(timer_mass, "RINCON_000E58DDDDDD01400", "Attic")
273 cast("MagicMock", timer_mass.players).get_player.side_effect = {
274 study.player_id: study,
275 hallway.player_id: hallway,
276 attic.player_id: attic,
277 }.get
278 hallway.soco.join.side_effect = SoCoException("the speaker refused to join")
279
280 with pytest.raises(PlayerCommandFailed, match="Hallway"):
281 await kitchen.set_members(
282 player_ids_to_add=[study.player_id, hallway.player_id, attic.player_id]
283 )
284
285 assert _pending_polls(timer_mass) == [_poll_id(study)]
286 attic.soco.join.assert_not_called()
287
288
289async def test_unjoin_failure_names_the_speaker_that_refused(
290 timer_mass: MusicAssistant,
291) -> None:
292 """A speaker that refuses to leave a group is named in the failure, not the group leader."""
293 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
294 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
295 cast("MagicMock", timer_mass.players).get_player.side_effect = {study.player_id: study}.get
296 study.soco.unjoin.side_effect = SoCoException("the speaker refused to leave")
297
298 with pytest.raises(PlayerCommandFailed, match="Study") as exc_info:
299 await kitchen.set_members(player_ids_to_remove=[study.player_id])
300
301 assert "Kitchen" not in str(exc_info.value)
302
303
304async def test_unload_cancels_the_pending_poll(timer_mass: MusicAssistant) -> None:
305 """An unloaded speaker is not polled by a command that was still in flight."""
306 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
307 await player.volume_set(30)
308 handle = timer_mass._tracked_timers[_poll_id(player)]
309
310 await player.on_unload()
311
312 assert _pending_polls(timer_mass) == []
313 assert handle.cancelled()
314
315
316async def test_unload_cancels_a_poll_that_already_started(
317 timer_mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
318) -> None:
319 """A poll that started just before the speaker was unloaded is aborted."""
320 monkeypatch.setattr(player_module, "COMMAND_POLL_DELAY", 0)
321 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
322 polling = asyncio.Event()
323
324 async def _slow_poll() -> None:
325 polling.set()
326 await asyncio.sleep(5)
327
328 monkeypatch.setattr(player, "poll", _slow_poll)
329 player.schedule_poll()
330 await polling.wait()
331 task = timer_mass._tracked_tasks[_poll_id(player)]
332
333 await player.on_unload()
334
335 with pytest.raises(asyncio.CancelledError):
336 await task
337
338
339async def test_unloaded_player_ignores_results_from_a_running_poll(
340 timer_mass: MusicAssistant,
341) -> None:
342 """A poll left running in its worker thread cannot update an unloaded speaker."""
343 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
344 await player.on_unload()
345
346 with patch.object(player, "_update_attributes") as update_attributes:
347 player.update_player()
348
349 update_attributes.assert_not_called()
350
351
352async def test_unloaded_player_ignores_group_topology_updates(
353 timer_mass: MusicAssistant,
354) -> None:
355 """A group update raised by a running poll cannot update an unloaded speaker."""
356 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
357 # let the speaker report itself as coordinator of a two-speaker group, so a
358 # topology update would regroup and signal the change if it were not unloaded
359 member = MagicMock(uid="RINCON_000E58BBBBBB01400", is_visible=True)
360 player.soco.group.coordinator.uid = player.player_id
361 player.soco.group.members = [player.soco.group.coordinator, member]
362 await player.on_unload()
363
364 with patch.object(player, "update_state") as update_state:
365 await player.create_update_groups_coro()
366 await asyncio.sleep(0)
367
368 assert player.group_members == []
369 update_state.assert_not_called()
370
371
372async def test_on_unload_unsubscribes_from_soco_events(timer_mass: MusicAssistant) -> None:
373 """Unloading a speaker also tears down its soco event subscriptions."""
374 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
375 subscription = MagicMock()
376 subscription.unsubscribe = AsyncMock()
377 player._subscriptions = [subscription]
378
379 await player.on_unload()
380
381 subscription.unsubscribe.assert_awaited_once()
382 assert player._subscriptions == []
383
384
385async def test_on_unload_unsubscribes_even_when_already_unavailable(
386 timer_mass: MusicAssistant,
387) -> None:
388 """Unlike offline(), on_unload() must still tear down an unavailable speaker's events."""
389 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
390 subscription = MagicMock()
391 subscription.unsubscribe = AsyncMock()
392 player._subscriptions = [subscription]
393 player._attr_available = False
394
395 await player.on_unload()
396
397 subscription.unsubscribe.assert_awaited_once()
398 assert player._subscriptions == []
399
400
401async def test_unloaded_player_is_not_resubscribed_by_a_late_poll(
402 timer_mass: MusicAssistant,
403) -> None:
404 """A poll that reaches an unloaded speaker must not subscribe it to events again."""
405 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
406 # the speaker was marked unavailable earlier, so it carries no subscriptions and
407 # unloading it leaves that unavailable state behind for a late poll to act on
408 player._attr_available = False
409 await player.on_unload()
410
411 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
412 await player.poll()
413
414 subscribe.assert_not_called()
415 assert player._attr_available is False
416
417
418async def test_speaker_answering_a_poll_again_is_resubscribed(
419 timer_mass: MusicAssistant,
420) -> None:
421 """A speaker that answers a poll after being unavailable is subscribed to events again."""
422 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
423 player.soco.group.coordinator.uid = player.player_id
424 player.soco.group.members = [player.soco.group.coordinator]
425 player._attr_available = False
426
427 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
428 await player.poll()
429
430 subscribe.assert_called_once()
431 assert player._attr_available is True
432
433
434async def test_unloading_mid_subscribe_keeps_no_subscriptions(
435 timer_mass: MusicAssistant,
436) -> None:
437 """A speaker unloaded while subscribing must not keep the subscriptions it created."""
438 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
439 subscribing = asyncio.Event()
440 speaker_responds = asyncio.Event()
441
442 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
443 subscribing.set()
444 await speaker_responds.wait()
445 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
446
447 with patch.object(player, "_subscribe_target", _slow_subscribe_target):
448 subscribe_task = asyncio.create_task(player.subscribe())
449 await subscribing.wait()
450
451 unload_task = asyncio.create_task(player.on_unload())
452 await asyncio.sleep(0)
453 assert not unload_task.done()
454
455 speaker_responds.set()
456 async with asyncio.timeout(5):
457 await asyncio.gather(subscribe_task, unload_task)
458
459 assert player._subscriptions == []
460
461
462def _make_rediscovered_soco(ip_address: str = "127.0.0.2") -> MagicMock:
463 """Create the mocked soco device that discovery hands over for a speaker that moved."""
464 soco = _make_soco()
465 soco.ip_address = ip_address
466 return soco
467
468
469async def test_update_ip_talks_to_the_rediscovered_speaker(sonos_player: SonosPlayer) -> None:
470 """A speaker found at another address is reached through the newly discovered device."""
471 sonos_player._attr_available = False
472 new_soco = _make_rediscovered_soco()
473
474 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
475 await sonos_player.update_ip(new_soco)
476
477 assert sonos_player.soco is new_soco
478 setup.assert_awaited_once()
479 assert sonos_player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "127.0.0.2"
480
481
482async def test_update_ip_probes_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
483 """Probing the rediscovered speaker must not stall the event loop."""
484 sonos_player._attr_available = False
485 new_soco = _make_rediscovered_soco()
486 probing = threading.Event()
487 probe_may_finish = threading.Event()
488
489 def _blocking_probe(*_args: object, **_kwargs: object) -> None:
490 probing.set()
491 probe_may_finish.wait(5)
492
493 new_soco.renderingControl.GetVolume.side_effect = _blocking_probe
494
495 # a probe held on the event loop would starve this block until it hits the timeout
496 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
497 async with asyncio.timeout(2):
498 update = asyncio.create_task(sonos_player.update_ip(new_soco))
499 await asyncio.to_thread(probing.wait, 5)
500 probe_may_finish.set()
501 await update
502
503 setup.assert_awaited_once()
504
505
506async def test_update_ip_marks_the_recovered_speaker_available(sonos_player: SonosPlayer) -> None:
507 """A speaker that answers at its new address counts as reachable again."""
508 sonos_player._attr_available = False
509 new_soco = _make_rediscovered_soco()
510
511 with patch.object(sonos_player, "setup", AsyncMock()):
512 await sonos_player.update_ip(new_soco)
513
514 assert sonos_player.available
515
516
517async def test_update_ip_skips_setup_when_the_new_address_stays_silent(
518 sonos_player: SonosPlayer,
519) -> None:
520 """An unanswered probe leaves the speaker for the regular poll to pick up."""
521 sonos_player._attr_available = False
522 new_soco = _make_rediscovered_soco()
523 new_soco.renderingControl.GetVolume.side_effect = SoCoException("no answer")
524
525 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
526 await sonos_player.update_ip(new_soco)
527
528 setup.assert_not_awaited()
529 assert sonos_player.soco is new_soco
530
531
532async def test_update_ip_leaves_an_unloaded_speaker_alone(sonos_player: SonosPlayer) -> None:
533 """An unloaded speaker must not be reconnected, its subscriptions would leak."""
534 sonos_player._attr_available = False
535 sonos_player._unloaded = True
536 original_soco = sonos_player.soco
537
538 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
539 await sonos_player.update_ip(_make_rediscovered_soco())
540
541 assert sonos_player.soco is original_soco
542 setup.assert_not_awaited()
543
544
545async def test_update_ip_leaves_a_responding_speaker_alone(sonos_player: SonosPlayer) -> None:
546 """A speaker that is still reachable keeps the device it is already talking to."""
547 original_soco = sonos_player.soco
548
549 with patch.object(sonos_player, "setup", AsyncMock()) as setup:
550 await sonos_player.update_ip(_make_rediscovered_soco())
551
552 assert sonos_player.soco is original_soco
553 setup.assert_not_awaited()
554
555
556async def test_setup_reads_the_speaker_off_the_event_loop(sonos_player: SonosPlayer) -> None:
557 """Reading the initial state of a speaker must not stall the event loop."""
558 cast("MagicMock", sonos_player.mass.players).register_or_update = AsyncMock()
559 reading_threads: list[int] = []
560
561 with (
562 patch.object(
563 sonos_player,
564 "update_groups",
565 lambda: reading_threads.append(threading.get_ident()),
566 ),
567 patch.object(sonos_player, "poll_media"),
568 patch.object(sonos_player, "subscribe", AsyncMock()),
569 ):
570 await sonos_player.setup()
571
572 assert len(reading_threads) == 1
573 assert reading_threads[0] != threading.get_ident()
574
575
576async def test_unsubscribe_drops_subscriptions_even_when_cancelled() -> None:
577 """A cancelled unsubscribe must not leave stale entries that block resubscribing."""
578 provider = MagicMock()
579 player = SonosPlayer(provider=provider, soco=_make_soco())
580 subscription = MagicMock()
581 subscription.unsubscribe = AsyncMock(side_effect=partial(asyncio.sleep, 5))
582 player._subscriptions = [subscription]
583
584 task = asyncio.create_task(player.unsubscribe())
585 await asyncio.sleep(0)
586 task.cancel()
587 with pytest.raises(asyncio.CancelledError):
588 await task
589
590 assert player._subscriptions == []
591 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
592
593
594async def _subscribe_with_failing_speaker(player: SonosPlayer) -> None:
595 """Let the given player attempt to subscribe to a speaker that cannot be reached."""
596 with (
597 patch.object(player, "_subscribe_target", AsyncMock(side_effect=OSError("unreachable"))),
598 patch.object(player, "update_state"),
599 ):
600 async with asyncio.timeout(5):
601 await player.subscribe()
602
603
604async def test_failed_subscribe_marks_the_speaker_offline() -> None:
605 """A failed subscription must take the speaker offline and release the lock."""
606 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
607
608 await _subscribe_with_failing_speaker(player)
609
610 assert player.available is False
611 assert not player._subscription_lock.locked()
612
613
614async def test_speaker_can_resubscribe_after_a_failed_subscribe() -> None:
615 """A speaker that failed to subscribe must still be able to subscribe later."""
616 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
617 await _subscribe_with_failing_speaker(player)
618
619 with patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target:
620 async with asyncio.timeout(5):
621 await player.subscribe()
622
623 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
624
625
626async def test_speaker_taken_offline_mid_subscribe_keeps_no_subscriptions() -> None:
627 """A speaker that goes offline while subscribing must not keep the subscriptions it created."""
628 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
629 subscribing = asyncio.Event()
630 speaker_responds = asyncio.Event()
631
632 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
633 subscribing.set()
634 await speaker_responds.wait()
635 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
636
637 with (
638 patch.object(player, "_subscribe_target", _slow_subscribe_target),
639 patch.object(player, "update_state"),
640 ):
641 subscribe_task = asyncio.create_task(player.subscribe())
642 await subscribing.wait()
643
644 offline_task = asyncio.create_task(player.offline())
645 await asyncio.sleep(0)
646 assert not offline_task.done()
647
648 speaker_responds.set()
649 async with asyncio.timeout(5):
650 await asyncio.gather(subscribe_task, offline_task)
651
652 assert player.available is False
653 assert player._subscriptions == []
654 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
655
656
657async def test_speaker_going_offline_is_not_resubscribed_halfway() -> None:
658 """No new subscriptions may be created while a speaker is still going offline."""
659 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
660 unsubscribing = asyncio.Event()
661 speaker_responds = asyncio.Event()
662
663 async def _slow_unsubscribe() -> None:
664 unsubscribing.set()
665 await speaker_responds.wait()
666
667 player._subscriptions = [MagicMock(unsubscribe=_slow_unsubscribe)]
668
669 with (
670 patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target,
671 patch.object(player, "update_state"),
672 ):
673 offline_task = asyncio.create_task(player.offline())
674 await unsubscribing.wait()
675
676 subscribe_task = asyncio.create_task(player.subscribe())
677 await asyncio.sleep(0)
678 subscribe_target.assert_not_called()
679
680 speaker_responds.set()
681 async with asyncio.timeout(5):
682 await asyncio.gather(offline_task, subscribe_task)
683
684 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
685