/
/
/
1"""Unit tests for the Sonos S1 helpers."""
2
3from __future__ import annotations
4
5import inspect
6import re
7from typing import Any
8from unittest.mock import MagicMock
9
10import pytest
11from soco.exceptions import SoCoException, SoCoUPnPException
12
13from music_assistant.providers.sonos_s1.helpers import SonosUpdateError, soco_error
14from music_assistant.providers.sonos_s1.player import SonosPlayer
15
16IGNORED_ERROR_CODE = "501"
17OTHER_ERROR_CODE = "701"
18SPEAKER_IP = "192.168.1.20"
19
20SOCO_ERRORS = [
21 pytest.param(SoCoException("boom"), id="soco"),
22 pytest.param(SoCoUPnPException("boom", OTHER_ERROR_CODE, b""), id="upnp"),
23 pytest.param(OSError("boom"), id="oserror"),
24 pytest.param(TimeoutError("boom"), id="timeout"),
25]
26
27SYNC_AND_ASYNC = ["probe", "regroup"]
28SYNC_AND_ASYNC_FILTERED = ["probe_filtered", "regroup_filtered"]
29
30
31class _Speaker(SonosPlayer):
32 """Minimal speaker exposing a decorated service call in a sync and an async flavour."""
33
34 def __init__(self, error: Exception | None = None, player_name: str | None = "Kitchen") -> None:
35 soco = MagicMock()
36 soco._player_name = player_name
37 soco.ip_address = SPEAKER_IP
38 self.soco = soco
39 self._error = error
40
41 @soco_error()
42 def probe(self) -> str:
43 """Sync service call."""
44 return self._service_call()
45
46 @soco_error()
47 async def regroup(self) -> str:
48 """Async service call."""
49 return self._service_call()
50
51 @soco_error([IGNORED_ERROR_CODE])
52 def probe_filtered(self) -> str:
53 """Sync service call that tolerates one error code."""
54 return self._service_call()
55
56 @soco_error([IGNORED_ERROR_CODE])
57 async def regroup_filtered(self) -> str:
58 """Async service call that tolerates one error code."""
59 return self._service_call()
60
61 def _service_call(self) -> str:
62 if self._error:
63 raise self._error
64 return "ok"
65
66
67async def _invoke(speaker: _Speaker, method: str) -> Any:
68 """Call the named decorated method, awaiting it for the async flavour."""
69 result = getattr(speaker, method)()
70 return await result if inspect.isawaitable(result) else result
71
72
73@pytest.mark.parametrize("method", SYNC_AND_ASYNC)
74async def test_result_is_returned_untouched(method: str) -> None:
75 """A call that succeeds returns its own result."""
76 assert await _invoke(_Speaker(), method) == "ok"
77
78
79@pytest.mark.parametrize("error", SOCO_ERRORS)
80@pytest.mark.parametrize("method", SYNC_AND_ASYNC)
81async def test_soco_errors_become_a_sonos_update_error(method: str, error: Exception) -> None:
82 """Every kind of soco failure is reported as a SonosUpdateError naming the speaker."""
83 with pytest.raises(SonosUpdateError, match="Kitchen") as exc_info:
84 await _invoke(_Speaker(error), method)
85
86 assert exc_info.value.__cause__ is error
87
88
89def test_speaker_without_a_cached_name_is_reported_by_ip() -> None:
90 """A speaker whose name is not cached yet is named by its IP address."""
91 with pytest.raises(SonosUpdateError, match=re.escape(SPEAKER_IP)):
92 _Speaker(SoCoException("boom"), player_name=None).probe()
93
94
95@pytest.mark.parametrize("method", SYNC_AND_ASYNC_FILTERED)
96async def test_ignored_error_code_is_swallowed(method: str) -> None:
97 """An error code the caller opted to ignore yields None instead of an exception."""
98 error = SoCoUPnPException("boom", IGNORED_ERROR_CODE, b"")
99
100 assert await _invoke(_Speaker(error), method) is None
101
102
103@pytest.mark.parametrize("method", SYNC_AND_ASYNC_FILTERED)
104async def test_other_error_codes_are_still_raised(method: str) -> None:
105 """Error codes outside the ignore list are reported as usual."""
106 error = SoCoUPnPException("boom", OTHER_ERROR_CODE, b"")
107
108 with pytest.raises(SonosUpdateError, match="Kitchen"):
109 await _invoke(_Speaker(error), method)
110
111
112def test_async_method_stays_a_coroutine_function() -> None:
113 """Decorated coroutine methods remain awaitable for their callers."""
114 assert inspect.iscoroutinefunction(_Speaker.regroup)
115 assert not inspect.iscoroutinefunction(_Speaker.probe)
116