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