/
/
/
1"""Unit tests for the Sonos S1 provider discovery scheduling."""
2
3from __future__ import annotations
4
5import asyncio
6import threading
7from dataclasses import dataclass
8from typing import TYPE_CHECKING, Any
9from unittest.mock import MagicMock, patch
10
11import pytest
12from music_assistant_models.enums import CoreState
13
14from music_assistant.mass import MusicAssistant
15from music_assistant.providers.sonos_s1.constants import DISCOVERY_INTERVAL
16from music_assistant.providers.sonos_s1.provider import SonosPlayerProvider
17
18if TYPE_CHECKING:
19 from collections.abc import AsyncGenerator, Callable
20
21
22@dataclass
23class DiscoveryHarness:
24 """A bare MusicAssistant together with the timers armed on its event loop."""
25
26 mass: MusicAssistant
27 scheduled: list[asyncio.TimerHandle]
28
29 @property
30 def armed_reschedules(self) -> list[asyncio.TimerHandle]:
31 """
32 Return the discovery reschedules still armed, however they were scheduled.
33
34 The reschedule is the only timer these tests arm for the long haul, so the
35 delay tells it apart from the short-lived handles asyncio arms internally.
36 """
37 now = self.mass.loop.time()
38 return [
39 handle
40 for handle in self.scheduled
41 if not handle.cancelled() and handle.when() - now > DISCOVERY_INTERVAL / 2
42 ]
43
44 def make_provider(self) -> SonosPlayerProvider:
45 """Create a SonosPlayerProvider bound to this MusicAssistant."""
46 provider = object.__new__(SonosPlayerProvider)
47 provider.mass = self.mass
48 provider.logger = MagicMock()
49 provider.config = MagicMock()
50 provider.config.instance_id = "sonos_s1--abc"
51 provider.config.get_value.side_effect = lambda key, *_args, **_kwargs: (
52 [] if "ip" in key else False
53 )
54 provider._discovery_task_id = "sonos_s1_discovery_test"
55 provider._unloaded = False
56 return provider
57
58
59@pytest.fixture
60async def harness() -> AsyncGenerator[DiscoveryHarness]:
61 """Create a bare MusicAssistant exposing the real task/timer machinery."""
62 mass = object.__new__(MusicAssistant)
63 loop = asyncio.get_running_loop()
64 mass.loop = loop
65 mass.loop_thread_id = threading.get_ident()
66 mass._tracked_timers = {}
67 mass._tracked_tasks = {}
68 mass._state = CoreState.RUNNING
69 scheduled: list[asyncio.TimerHandle] = []
70 original_call_later = loop.call_later
71
72 # record every handle armed on the loop, so a reschedule that bypasses the
73 # tracked timers is still visible to the assertions
74 def _recording_call_later(
75 delay: float, callback: Callable[..., object], *args: Any, **kwargs: Any
76 ) -> asyncio.TimerHandle:
77 handle = original_call_later(delay, callback, *args, **kwargs)
78 scheduled.append(handle)
79 return handle
80
81 loop.call_later = _recording_call_later # type: ignore[assignment,method-assign]
82 harness = DiscoveryHarness(mass, scheduled)
83 yield harness
84 loop.call_later = original_call_later # type: ignore[method-assign]
85 # only the reschedules are ours to disarm: the rest of the recorded handles
86 # belong to asyncio itself
87 for handle in harness.armed_reschedules:
88 handle.cancel()
89 for task in mass._tracked_tasks.values():
90 task.cancel()
91
92
93async def test_repeated_discovery_arms_a_single_reschedule(harness: DiscoveryHarness) -> None:
94 """Every load runs discovery twice, which must not leave a second reschedule armed."""
95 provider = harness.make_provider()
96
97 with patch("music_assistant.providers.sonos_s1.provider.discover", return_value=set()):
98 await provider.discover_players()
99 await provider.discover_players()
100
101 assert len(harness.armed_reschedules) == 1
102
103
104async def test_unload_disarms_the_reschedule(harness: DiscoveryHarness) -> None:
105 """An unloaded provider must not keep a discovery reschedule armed."""
106 provider = harness.make_provider()
107
108 with patch("music_assistant.providers.sonos_s1.provider.discover", return_value=set()):
109 await provider.discover_players()
110 await provider.discover_players()
111 with patch("music_assistant.providers.sonos_s1.provider.events_asyncio") as events:
112 events.event_listener = None
113 await provider.unload()
114
115 assert harness.armed_reschedules == []
116
117
118async def test_unload_aborts_a_reschedule_that_already_fired(harness: DiscoveryHarness) -> None:
119 """A rescheduled discovery that already started must be cancelled by the unload."""
120 provider = harness.make_provider()
121 loop = asyncio.get_running_loop()
122 scanning = asyncio.Event()
123 unload_reached = threading.Event()
124
125 def _blocking_discover(*_args: object, **_kwargs: object) -> set[object]:
126 loop.call_soon_threadsafe(scanning.set)
127 # hold the worker thread so the scan is unmistakably in flight during the unload
128 assert unload_reached.wait(timeout=5)
129 return set()
130
131 with patch("music_assistant.providers.sonos_s1.provider.discover", _blocking_discover):
132 harness.mass.call_later(0, provider.discover_players, task_id=provider._discovery_task_id)
133 await scanning.wait()
134 task = harness.mass._tracked_tasks[provider._discovery_task_id]
135
136 with patch("music_assistant.providers.sonos_s1.provider.events_asyncio") as events:
137 events.event_listener = None
138 unload_task = asyncio.create_task(provider.unload())
139 # the unload waits out the scan already running in its worker thread
140 await asyncio.sleep(0)
141 assert not unload_task.done()
142 unload_reached.set()
143 async with asyncio.timeout(5):
144 await unload_task
145
146 assert task.cancelled()
147 assert harness.armed_reschedules == []
148
149
150async def test_unload_stops_an_untracked_discovery_from_rescheduling(
151 harness: DiscoveryHarness,
152) -> None:
153 """A discovery the provider does not own must not reschedule itself after the unload."""
154 provider = harness.make_provider()
155 loop = asyncio.get_running_loop()
156 scanning = asyncio.Event()
157 unload_reached = threading.Event()
158
159 def _blocking_discover(*_args: object, **_kwargs: object) -> set[object]:
160 loop.call_soon_threadsafe(scanning.set)
161 assert unload_reached.wait(timeout=5)
162 return set()
163
164 with patch("music_assistant.providers.sonos_s1.provider.discover", _blocking_discover):
165 # the discovery run after a provider load is awaited directly, so it is not
166 # tracked under the provider's task id and the unload cannot cancel it
167 post_load = asyncio.create_task(provider.discover_players())
168 await scanning.wait()
169
170 with patch("music_assistant.providers.sonos_s1.provider.events_asyncio") as events:
171 events.event_listener = None
172 unload_task = asyncio.create_task(provider.unload())
173 await asyncio.sleep(0)
174 unload_reached.set()
175 async with asyncio.timeout(5):
176 await unload_task
177 async with asyncio.timeout(5):
178 await post_load
179
180 assert harness.armed_reschedules == []
181