/
/
/
1"""
2Tests for ChromecastPlayer receiver app launching.
3
4Regression tests for https://github.com/music-assistant/support/issues/5981, where a
5receiver refused the launch and Music Assistant carried on regardless, sending the LOAD
6to an app that was not running. Playback never started and nothing was logged.
7
8A failed launch must be reported, and the configured receiver app must not be swapped
9for the other one.
10"""
11
12from __future__ import annotations
13
14import asyncio
15from functools import partial
16from typing import Any, cast
17from unittest.mock import MagicMock
18
19import pytest
20from music_assistant_models.errors import PlayerUnavailableError
21
22from music_assistant.providers.chromecast.constants import APP_MEDIA_RECEIVER, MASS_APP_ID
23from music_assistant.providers.chromecast.player import ChromecastPlayer
24
25
26async def _launch_app(fake: MagicMock) -> None:
27 await ChromecastPlayer._launch_app(cast("ChromecastPlayer", fake))
28
29
30def _fake_cast(
31 *,
32 running_app_id: str | None = None,
33 use_mass_app: bool = True,
34 launch_results: dict[str, bool | None],
35 refusal_reason: str | None = None,
36 app_id_after_launch: str | None = "same",
37) -> MagicMock:
38 """
39 Build a MagicMock Cast whose receiver answers launches per app id.
40
41 :param running_app_id: App id the receiver is already running, if any.
42 :param use_mass_app: Value of the ``use_mass_app`` config option.
43 :param launch_results: Per app id: True to accept, False to refuse, None to leave
44 the launch unanswered.
45 :param refusal_reason: LAUNCH_ERROR reason reported by the receiver.
46 :param app_id_after_launch: App the receiver reports running after accepting a
47 launch. "same" means the app that was asked for.
48 """
49 fake = MagicMock()
50 # the launch runs in an executor and is acknowledged from that thread, so the real
51 # running loop is needed to bridge back to the waiting coroutine
52 fake.mass.loop = asyncio.get_running_loop()
53 fake.display_name = "Fake Cast"
54 fake.cc.app_id = running_app_id
55 fake.config.get_value = MagicMock(return_value=use_mass_app)
56 fake.launch_attempts = []
57 fake.cc.socket_client.receiver_controller.launch_failure.reason = refusal_reason
58
59 def launch_app(
60 app_id: str,
61 *,
62 force_launch: bool = False, # noqa: ARG001
63 callback_function: Any = None,
64 ) -> None:
65 fake.launch_attempts.append(app_id)
66 result = launch_results.get(app_id)
67 if result is None:
68 # receiver ignores the request, so the callback is never invoked
69 return
70 if result:
71 fake.cc.app_id = app_id if app_id_after_launch == "same" else app_id_after_launch
72 callback_function(result, None)
73
74 fake.cc.socket_client.receiver_controller.launch_app = launch_app
75 fake._log_launch_failure = partial(ChromecastPlayer._log_launch_failure, fake)
76 return fake
77
78
79def _warnings(fake: MagicMock) -> str:
80 return " ".join(str(call) for call in fake.logger.warning.call_args_list)
81
82
83@pytest.fixture(autouse=True)
84def _instant_launch_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
85 """Keep the unanswered-launch cases from waiting out the real timeout."""
86 monkeypatch.setattr("music_assistant.providers.chromecast.player.APP_LAUNCH_TIMEOUT", 0.01)
87
88
89async def test_refused_launch_is_not_treated_as_success() -> None:
90 """A refused launch must fail instead of letting a LOAD be sent."""
91 fake = _fake_cast(launch_results={MASS_APP_ID: False})
92
93 with pytest.raises(PlayerUnavailableError):
94 await _launch_app(fake)
95
96
97async def test_ignored_launch_fails() -> None:
98 """An unanswered launch fails once the timeout expires."""
99 fake = _fake_cast(launch_results={MASS_APP_ID: None})
100
101 with pytest.raises(PlayerUnavailableError):
102 await _launch_app(fake)
103
104
105async def test_successful_launch_returns() -> None:
106 """A confirmed launch completes without raising."""
107 fake = _fake_cast(launch_results={MASS_APP_ID: True})
108
109 await _launch_app(fake)
110
111 assert fake.launch_attempts == [MASS_APP_ID]
112
113
114async def test_configured_app_is_never_swapped() -> None:
115 """A failed launch does not silently retry the other receiver app."""
116 fake = _fake_cast(launch_results={MASS_APP_ID: False, APP_MEDIA_RECEIVER: True})
117
118 with pytest.raises(PlayerUnavailableError):
119 await _launch_app(fake)
120
121 assert fake.launch_attempts == [MASS_APP_ID]
122
123
124async def test_default_receiver_used_when_mass_app_disabled() -> None:
125 """With the option disabled the default media receiver is launched."""
126 fake = _fake_cast(use_mass_app=False, launch_results={APP_MEDIA_RECEIVER: True})
127
128 await _launch_app(fake)
129
130 assert fake.launch_attempts == [APP_MEDIA_RECEIVER]
131
132
133async def test_configured_app_already_running_is_left_alone() -> None:
134 """No LAUNCH is sent when the configured receiver app is already active."""
135 fake = _fake_cast(running_app_id=MASS_APP_ID, launch_results={MASS_APP_ID: True})
136
137 await _launch_app(fake)
138
139 assert fake.launch_attempts == []
140
141
142async def test_other_receiver_app_is_replaced_by_the_configured_one() -> None:
143 """
144 The other compatible receiver app is not accepted as-is.
145
146 Treating both receiver apps as interchangeable made the use_mass_app setting a
147 no-op for as long as the other app was running on the device.
148 """
149 fake = _fake_cast(running_app_id=APP_MEDIA_RECEIVER, launch_results={MASS_APP_ID: True})
150
151 await _launch_app(fake)
152
153 assert fake.launch_attempts == [MASS_APP_ID]
154
155
156async def test_disabled_mass_app_is_replaced_by_the_default_receiver() -> None:
157 """With the option disabled, a running MA app is replaced by the default receiver."""
158 fake = _fake_cast(
159 running_app_id=MASS_APP_ID,
160 use_mass_app=False,
161 launch_results={APP_MEDIA_RECEIVER: True},
162 )
163
164 await _launch_app(fake)
165
166 assert fake.launch_attempts == [APP_MEDIA_RECEIVER]
167
168
169async def test_refusal_reason_is_logged() -> None:
170 """The reason the receiver gave is included in the warning."""
171 fake = _fake_cast(launch_results={MASS_APP_ID: False}, refusal_reason="NOT_FOUND")
172
173 with pytest.raises(PlayerUnavailableError):
174 await _launch_app(fake)
175
176 assert "NOT_FOUND" in _warnings(fake)
177
178
179async def test_failure_suggests_disabling_the_mass_app() -> None:
180 """A failed Music Assistant app launch suggests turning the option off."""
181 fake = _fake_cast(launch_results={MASS_APP_ID: False})
182
183 with pytest.raises(PlayerUnavailableError):
184 await _launch_app(fake)
185
186 assert "disabling" in _warnings(fake)
187
188
189async def test_failure_suggests_enabling_the_mass_app() -> None:
190 """A failed default receiver launch suggests turning the option on."""
191 fake = _fake_cast(use_mass_app=False, launch_results={APP_MEDIA_RECEIVER: False})
192
193 with pytest.raises(PlayerUnavailableError):
194 await _launch_app(fake)
195
196 assert "enabling" in _warnings(fake)
197
198
199async def test_acknowledged_launch_that_did_not_start_the_app_fails() -> None:
200 """A launch the receiver accepted but did not act on must still fail."""
201 fake = _fake_cast(launch_results={MASS_APP_ID: True}, app_id_after_launch=None)
202
203 with pytest.raises(PlayerUnavailableError):
204 await _launch_app(fake)
205