/
/
/
1"""Tests for the shared Sendspin bridge manager lifecycle reconciliation."""
2
3import json
4import logging
5from pathlib import Path
6from typing import Any
7from unittest.mock import AsyncMock, MagicMock, patch
8
9import pytest
10from music_assistant_models.errors import PlayerCommandFailed
11
12from music_assistant.constants import CONF_PROTOCOL_EXPERIMENTAL_NOTE, CONF_PROTOCOL_PARENT_ID
13from music_assistant.providers.chromecast import sendspin_bridge as bridge_module
14from music_assistant.providers.chromecast.constants import (
15 CONF_SENDSPIN_OPT_OUT_PENDING,
16 CONF_SENDSPIN_UNSUPPORTED,
17 SENDSPIN_CAST_EXPERIMENTAL_NOTE,
18)
19from music_assistant.providers.chromecast.sendspin_bridge import (
20 SendspinBridgeManager as CastSendspinBridgeManager,
21)
22from music_assistant.providers.sendspin import constants as sendspin_constants
23from music_assistant.providers.sendspin.bridge_manager import SendspinBridgeManagerBase
24
25
26class FakeBridge:
27 """Minimal Sendspin bridge implementation for testing."""
28
29 def __init__(self, sendspin_server: Any) -> None:
30 """Initialize the fake bridge."""
31 self.sendspin_server = sendspin_server
32 self.started = False
33 self.stopped = False
34
35 @property
36 def is_registered(self) -> bool:
37 """Return whether the bridge is registered with Sendspin."""
38 return self.started and not self.stopped
39
40 async def start(self) -> None:
41 """Register the bridge as an external Sendspin client."""
42 self.started = True
43
44 async def stop(self) -> None:
45 """Stop and unregister the bridge."""
46 self.stopped = True
47
48
49class FakeBridgeManager(SendspinBridgeManagerBase[FakeBridge]):
50 """Concrete bridge manager with controllable policy for testing."""
51
52 policy_result = True
53
54 def _bridge_client_id(self, player: Any) -> str | None:
55 """Return the Sendspin client_id used to bridge the given player."""
56 return f"spb_{player.player_id}"
57
58 def _create_bridge(self, player: Any) -> FakeBridge:
59 """Create a (not yet started) bridge instance for the given player."""
60 return FakeBridge(self.sendspin_server)
61
62 def _should_have_bridge(self, player: Any) -> bool:
63 """Return whether provider policy wants a bridge for this player."""
64 return self.policy_result
65
66
67def _make_environment() -> tuple[
68 FakeBridgeManager, MagicMock, MagicMock, dict[str, Any], dict[str, Any]
69]:
70 """
71 Build a bridge manager with a mocked MusicAssistant environment.
72
73 :return: Tuple of (manager, mass, player, registered_players, player_configs).
74 """
75 registered_players: dict[str, Any] = {}
76 player_configs: dict[str, Any] = {}
77
78 mass = MagicMock()
79 mass.subscribe = MagicMock(return_value=MagicMock())
80 sendspin_provider = MagicMock()
81 sendspin_provider.server_api = MagicMock()
82 mass.get_provider = MagicMock(
83 side_effect=lambda domain: sendspin_provider if domain == "sendspin" else None
84 )
85 mass.players.get_player = MagicMock(side_effect=registered_players.get)
86 mass.config.get = MagicMock(
87 side_effect=lambda key, default=None: player_configs.get(key, default)
88 )
89
90 async def fake_save_player_config(player_id: str, values: dict[str, Any]) -> None:
91 player_configs.setdefault(f"players/{player_id}", {}).update(values)
92
93 mass.config.save_player_config = AsyncMock(side_effect=fake_save_player_config)
94
95 provider = MagicMock()
96 provider.mass = mass
97 provider.logger = logging.getLogger("test.bridge_manager")
98
99 player = MagicMock()
100 player.player_id = "player_1"
101 player.display_name = "Test Player"
102 player.provider = provider
103 registered_players["player_1"] = player
104 provider.players = [player]
105
106 manager = FakeBridgeManager(provider)
107 return manager, mass, player, registered_players, player_configs
108
109
110class TestBridgeLifecycleReconciliation:
111 """Tests for the desired-state reconciliation of Sendspin bridges."""
112
113 @pytest.mark.asyncio
114 async def test_bridge_created_when_all_conditions_met(self) -> None:
115 """Test a bridge is created for a registered, enabled player."""
116 manager, _, player, _, _ = _make_environment()
117
118 await manager.evaluate_bridge(player)
119
120 bridge = manager.get_bridge("player_1")
121 assert bridge is not None
122 assert bridge.is_registered
123
124 @pytest.mark.asyncio
125 async def test_bridge_removed_when_base_player_disabled(self) -> None:
126 """Test the bridge is torn down when the base player gets disabled."""
127 manager, _, player, _, player_configs = _make_environment()
128 await manager.evaluate_bridge(player)
129 bridge = manager.get_bridge("player_1")
130 assert bridge is not None
131
132 player_configs["players/player_1"] = {"enabled": False}
133 await manager.evaluate_bridge(player)
134
135 assert manager.get_bridge("player_1") is None
136 assert bridge.stopped
137
138 @pytest.mark.asyncio
139 async def test_bridge_removed_when_bridge_client_disabled(self) -> None:
140 """Test the bridge is torn down when its own Sendspin client gets disabled."""
141 manager, mass, player, _, player_configs = _make_environment()
142 await manager.evaluate_bridge(player)
143 assert manager.get_bridge("player_1") is not None
144
145 # a user-made disable carries the parent link the toggle was rendered under
146 player_configs["players/player_1"] = {"enabled": True}
147 player_configs["players/spb_player_1"] = {
148 "enabled": False,
149 "values": {"protocol_parent_id": "player_1"},
150 }
151 await manager.evaluate_bridge(player)
152
153 assert manager.get_bridge("player_1") is None
154 mass.config.save_player_config.assert_not_awaited()
155
156 @pytest.mark.asyncio
157 async def test_bridge_not_created_for_unregistered_player(self) -> None:
158 """Test no bridge is created when the player is not the registered instance."""
159 manager, _, player, registered_players, _ = _make_environment()
160 del registered_players["player_1"]
161
162 await manager.evaluate_bridge(player)
163
164 assert manager.get_bridge("player_1") is None
165
166 @pytest.mark.asyncio
167 async def test_policy_denial_removes_bridge_permanently(self) -> None:
168 """Test a policy denial removes the bridge and cleans up the client config."""
169 manager, mass, player, _, player_configs = _make_environment()
170 await manager.evaluate_bridge(player)
171 assert manager.get_bridge("player_1") is not None
172
173 manager.policy_result = False
174 player_configs["players/spb_player_1"] = {"enabled": True}
175 await manager.evaluate_bridge(player)
176
177 assert manager.get_bridge("player_1") is None
178 mass.players.delete_player_config.assert_called_once_with("spb_player_1")
179
180 @pytest.mark.asyncio
181 async def test_config_event_on_bridge_client_recreates_bridge(self) -> None:
182 """Test a config event on the bridge client id re-evaluates the base player."""
183 manager, _, player, _, player_configs = _make_environment()
184 player_configs["players/player_1"] = {"enabled": True}
185 player_configs["players/spb_player_1"] = {
186 "enabled": False,
187 "values": {"protocol_parent_id": "player_1"},
188 }
189 await manager.evaluate_bridge(player)
190 assert manager.get_bridge("player_1") is None
191
192 # Re-enable the bridge client and fire the (mapped) config event
193 player_configs["players/spb_player_1"] = {"enabled": True}
194 event = MagicMock()
195 event.object_id = "spb_player_1"
196 await manager._on_player_config_updated(event)
197
198 assert manager.get_bridge("player_1") is not None
199
200 @pytest.mark.asyncio
201 async def test_stale_disabled_client_reenabled_when_parent_gone(self) -> None:
202 """Test a client disabled under a no-longer-existing parent is re-enabled."""
203 manager, mass, player, _, player_configs = _make_environment()
204 # e.g. left behind by a cascade-disable from a parent player whose
205 # config was removed (device re-setup changed its player id)
206 player_configs["players/spb_player_1"] = {
207 "enabled": False,
208 "values": {"protocol_parent_id": "cc_old_uuid"},
209 }
210
211 await manager.evaluate_bridge(player)
212
213 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
214 assert manager.get_bridge("player_1") is not None
215
216 @pytest.mark.asyncio
217 async def test_stale_disabled_client_without_parent_link_reenabled(self) -> None:
218 """Test a disabled client without any parent link is re-enabled."""
219 manager, mass, player, _, player_configs = _make_environment()
220 player_configs["players/spb_player_1"] = {"enabled": False}
221
222 await manager.evaluate_bridge(player)
223
224 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
225 assert manager.get_bridge("player_1") is not None
226
227 @pytest.mark.asyncio
228 async def test_no_heal_when_base_player_disabled(self) -> None:
229 """Test a stale client disable is left alone while the base player is disabled."""
230 manager, mass, player, _, player_configs = _make_environment()
231 player_configs["players/player_1"] = {"enabled": False}
232 player_configs["players/spb_player_1"] = {"enabled": False}
233
234 await manager.evaluate_bridge(player)
235
236 mass.config.save_player_config.assert_not_awaited()
237 assert manager.get_bridge("player_1") is None
238
239 @pytest.mark.asyncio
240 async def test_stale_bridge_rebuilt_after_sendspin_reload(self) -> None:
241 """Test a bridge bound to a replaced Sendspin server is rebuilt."""
242 manager, mass, player, _, _ = _make_environment()
243 await manager.evaluate_bridge(player)
244 old_bridge = manager.get_bridge("player_1")
245 assert old_bridge is not None
246
247 # Simulate a sendspin provider reload (new server instance)
248 new_provider = MagicMock()
249 new_provider.server_api = MagicMock()
250 mass.get_provider = MagicMock(
251 side_effect=lambda domain: new_provider if domain == "sendspin" else None
252 )
253 await manager.evaluate_bridge(player)
254
255 new_bridge = manager.get_bridge("player_1")
256 assert old_bridge.stopped
257 assert new_bridge is not None
258 assert new_bridge is not old_bridge
259 assert new_bridge.sendspin_server is new_provider.server_api
260
261
262class TestCastBridgePolicy:
263 """Tests for the Chromecast-specific bridge policy."""
264
265 @staticmethod
266 def _make_cast_environment() -> tuple[CastSendspinBridgeManager, MagicMock, MagicMock]:
267 """
268 Build a cast bridge manager with a mocked environment.
269
270 :return: Tuple of (manager, mass, cast_player).
271 """
272 mass = MagicMock()
273 mass.subscribe = MagicMock(return_value=MagicMock())
274 mass.players.subscribe_player_state_update = MagicMock(return_value=MagicMock())
275 mass.create_task = MagicMock()
276 mass.config.get_raw_player_config_value = MagicMock(return_value=None)
277 provider = MagicMock()
278 provider.mass = mass
279 provider.logger = logging.getLogger("test.cast_bridge_manager")
280
281 cast_player = MagicMock()
282 cast_player.player_id = "cc_player"
283 cast_player.display_name = "Cast Speaker"
284 cast_player.cast_info.is_audio_group = False
285 cast_player.cast_info.is_multichannel_group = False
286 cast_player.cast_info.mac_address = "AA:BB:CC:DD:EE:FF"
287 cast_player.device_info.manufacturer = "TestCo"
288 cast_player.device_info.model = "TestSpeaker"
289 cast_player.protocol_parent_id = "parent_1"
290 cast_player.provider = provider
291 provider.players = [cast_player]
292
293 manager = CastSendspinBridgeManager(provider)
294 return manager, mass, cast_player
295
296 def test_hard_deny_when_device_has_airplay(self) -> None:
297 """Test the cast bridge is denied when the device has AirPlay at all."""
298 manager, mass, cast_player = self._make_cast_environment()
299
300 parent = MagicMock()
301 airplay_protocol = MagicMock()
302 # AirPlay protocol link present but NOT available (e.g. disabled by user)
303 airplay_protocol.available = False
304 parent.get_output_protocol_by_domain = MagicMock(return_value=airplay_protocol)
305 mass.players.get_player = MagicMock(return_value=parent)
306
307 assert manager._should_have_bridge(cast_player) is False
308
309 def test_hard_deny_when_airplay_provider_not_loaded(self) -> None:
310 """Test the deny also applies while the airplay provider itself is not loaded."""
311 manager, mass, cast_player = self._make_cast_environment()
312
313 parent = MagicMock()
314 # Cached airplay protocol entry from an earlier session
315 parent.get_output_protocol_by_domain = MagicMock(return_value=MagicMock())
316 mass.get_provider = MagicMock(return_value=None)
317 mass.players.get_player = MagicMock(return_value=parent)
318
319 assert manager._should_have_bridge(cast_player) is False
320
321 def test_allowed_when_device_has_no_airplay(self) -> None:
322 """Test the cast bridge is allowed when the device has no AirPlay protocol."""
323 manager, mass, cast_player = self._make_cast_environment()
324
325 parent = MagicMock()
326 parent.get_output_protocol_by_domain = MagicMock(return_value=None)
327 mass.players.get_player = MagicMock(return_value=parent)
328
329 assert manager._should_have_bridge(cast_player) is True
330
331 def test_denied_after_the_device_reported_it_cannot_run_sendspin(self) -> None:
332 """Test a device that failed once is not offered the bridge again at all."""
333 manager, mass, cast_player = self._make_cast_environment()
334 parent = MagicMock()
335 parent.get_output_protocol_by_domain = MagicMock(return_value=None)
336 mass.players.get_player = MagicMock(return_value=parent)
337 assert manager._should_have_bridge(cast_player) is True
338
339 mass.config.get_raw_player_config_value = MagicMock(
340 side_effect=lambda _player_id, key, default=None: (
341 True if key == CONF_SENDSPIN_UNSUPPORTED else default
342 )
343 )
344
345 assert manager._should_have_bridge(cast_player) is False
346
347 def test_blocklist_message_only_repeats_after_policy_change(
348 self, caplog: pytest.LogCaptureFixture
349 ) -> None:
350 """Test repeated blocklist checks only log when the policy result changes."""
351 manager, _, cast_player = self._make_cast_environment()
352 cast_player.device_info.manufacturer = "Harman Luxury Audio"
353 cast_player.protocol_parent_id = None
354
355 with caplog.at_level(logging.DEBUG, logger=manager.logger.name):
356 assert manager._should_have_bridge(cast_player) is False
357 assert manager._should_have_bridge(cast_player) is False
358 cast_player.device_info.manufacturer = "TestCo"
359 assert manager._should_have_bridge(cast_player) is True
360 cast_player.device_info.manufacturer = "Harman Luxury Audio"
361 assert manager._should_have_bridge(cast_player) is False
362
363 blocklist_records = [
364 record for record in caplog.records if "device is blocklisted" in record.message
365 ]
366 assert len(blocklist_records) == 2
367
368 def test_irrelevant_state_update_does_not_schedule_evaluation(self) -> None:
369 """Test playback state updates do not re-evaluate bridge policy."""
370 manager, mass, cast_player = self._make_cast_environment()
371
372 manager._on_player_state_updated(cast_player, {"volume_level": (20, 30)})
373
374 mass.create_task.assert_not_called()
375
376 def test_relevant_cast_state_update_schedules_evaluation(self) -> None:
377 """Test device policy changes re-evaluate the affected Cast bridge."""
378 manager, mass, cast_player = self._make_cast_environment()
379
380 manager._on_player_state_updated(
381 cast_player, {"device_info.model": ("Old Model", "New Model")}
382 )
383
384 mass.create_task.assert_called_once_with(
385 manager._process_pending_bridge_evaluations,
386 cast_player.player_id,
387 task_id="evaluate_chromecast_sendspin_bridge_cc_player",
388 )
389
390 def test_parent_protocol_update_schedules_evaluation(self) -> None:
391 """Test protocol changes on a parent re-evaluate its Cast bridge."""
392 manager, mass, _ = self._make_cast_environment()
393 parent = MagicMock()
394 parent.player_id = "parent_1"
395 parent.protocol_parent_id = None
396
397 manager._on_player_state_updated(parent, {"output_protocols": ((), ("airplay",))})
398
399 mass.create_task.assert_called_once()
400
401 def test_unregistered_parent_schedules_evaluation(self) -> None:
402 """Test removal of a protocol parent re-evaluates its Cast bridge."""
403 manager, mass, _ = self._make_cast_environment()
404 mass.players.get_player.return_value = None
405 event = MagicMock()
406 event.object_id = "parent_1"
407
408 manager._on_player_unregistered(event)
409
410 mass.create_task.assert_called_once()
411
412 def test_registered_player_event_does_not_duplicate_evaluation(self) -> None:
413 """Test regular player events remain on the filtered state-update path."""
414 manager, mass, _ = self._make_cast_environment()
415 mass.players.get_player.return_value = MagicMock()
416 event = MagicMock()
417 event.object_id = "parent_1"
418
419 manager._on_player_unregistered(event)
420
421 mass.create_task.assert_not_called()
422
423 @pytest.mark.asyncio
424 async def test_update_during_evaluation_triggers_trailing_reconciliation(self) -> None:
425 """Test a policy update during reconciliation is processed afterward."""
426 manager, mass, cast_player = self._make_cast_environment()
427 mass.players.get_player.return_value = cast_player
428 evaluation_count = 0
429
430 async def evaluate_bridge(player: Any) -> None:
431 nonlocal evaluation_count
432 evaluation_count += 1
433 if evaluation_count == 1:
434 manager._pending_bridge_evaluations.add(player.player_id)
435
436 evaluate_mock = AsyncMock(side_effect=evaluate_bridge)
437 manager.evaluate_bridge = evaluate_mock # type: ignore[method-assign]
438 manager._pending_bridge_evaluations.add(cast_player.player_id)
439
440 await manager._process_pending_bridge_evaluations(cast_player.player_id)
441
442 assert evaluate_mock.await_count == 2
443 assert not manager._pending_bridge_evaluations
444
445
446class TestCastBridgeOptIn:
447 """Tests for the experimental opt-in of the Sendspin Cast bridge."""
448
449 @staticmethod
450 def _make_environment() -> tuple[
451 CastSendspinBridgeManager, MagicMock, MagicMock, dict[str, Any], list[Any], list[str]
452 ]:
453 """
454 Build a cast bridge manager with dict-backed player configs.
455
456 :return: Tuple of (manager, mass, cast_player, player_configs, scheduled, write_order).
457 """
458 manager, mass, cast_player = TestCastBridgePolicy._make_cast_environment()
459 # a device with AirPlay is hard-denied by policy, so give it a plain Cast parent
460 parent = MagicMock()
461 parent.get_output_protocol_by_domain = MagicMock(return_value=None)
462 mass.players.get_player = MagicMock(return_value=parent)
463 player_configs: dict[str, Any] = {}
464 scheduled: list[Any] = []
465 # both config writes land in one log, so a test can assert their relative order
466 write_order: list[str] = []
467
468 def get_raw(player_id: str, key: str, default: Any = None) -> Any:
469 values = (player_configs.get(f"players/{player_id}") or {}).get("values", {})
470 return values.get(key, default)
471
472 def set_raw(player_id: str, key: str, value: Any) -> None:
473 conf = player_configs.setdefault(f"players/{player_id}", {})
474 conf.setdefault("values", {})[key] = value
475 if key == CONF_PROTOCOL_EXPERIMENTAL_NOTE:
476 write_order.append("note")
477
478 async def save(player_id: str, values: dict[str, Any]) -> None:
479 player_configs.setdefault(f"players/{player_id}", {}).update(values)
480 write_order.append("save")
481
482 mass.config.get = MagicMock(
483 side_effect=lambda key, default=None: player_configs.get(key, default)
484 )
485 mass.config.get_raw_player_config_value = MagicMock(side_effect=get_raw)
486 mass.config.set_raw_player_config_value = MagicMock(side_effect=set_raw)
487 mass.config.save_player_config = AsyncMock(side_effect=save)
488 mass.create_task = MagicMock(
489 side_effect=lambda target, *_a, **_kw: scheduled.append(target)
490 )
491 return manager, mass, cast_player, player_configs, scheduled, write_order
492
493 @pytest.mark.asyncio
494 async def test_new_device_is_flagged_and_left_switched_off(self) -> None:
495 """Test a device that never had the bridge keeps it off for the user to opt in."""
496 manager, _, cast_player, player_configs, scheduled, order = self._make_environment()
497 client_id = manager._bridge_client_id(cast_player)
498 assert client_id is not None
499
500 player_configs[f"players/{cast_player.player_id}"] = {"enabled": True}
501
502 async def fake_super(player: Any) -> None:
503 """Register the bridge the way a real setup would, link included."""
504 player_configs[f"players/{client_id}"] = {
505 "enabled": True,
506 "values": {"protocol_parent_id": "parent_1"},
507 }
508 player_configs["players/parent_1"] = {"enabled": True}
509 manager._bridges[player.player_id] = MagicMock()
510
511 with patch.object(SendspinBridgeManagerBase, "evaluate_bridge", side_effect=fake_super):
512 await manager.evaluate_bridge(cast_player)
513 assert len(scheduled) == 1
514 await scheduled[0]
515
516 assert player_configs[f"players/{client_id}"]["enabled"] is False
517 assert (
518 player_configs[f"players/{client_id}"]["values"][CONF_PROTOCOL_EXPERIMENTAL_NOTE]
519 == SENDSPIN_CAST_EXPERIMENTAL_NOTE
520 )
521 # the note is what stops the next evaluation from retrying, so it must come last
522 assert order == ["save", "note"]
523
524 @pytest.mark.asyncio
525 async def test_device_that_already_had_the_bridge_keeps_it_on(self) -> None:
526 """Test an existing setup is flagged as experimental but not switched off."""
527 manager, mass, cast_player, player_configs, scheduled, _ = self._make_environment()
528 client_id = manager._bridge_client_id(cast_player)
529 assert client_id is not None
530 player_configs[f"players/{cast_player.player_id}"] = {"enabled": True}
531 player_configs[f"players/{client_id}"] = {
532 "enabled": True,
533 "values": {"protocol_parent_id": "parent_1"},
534 }
535 player_configs["players/parent_1"] = {"enabled": True}
536
537 async def fake_super(player: Any) -> None:
538 manager._bridges[player.player_id] = MagicMock()
539
540 with patch.object(SendspinBridgeManagerBase, "evaluate_bridge", side_effect=fake_super):
541 await manager.evaluate_bridge(cast_player)
542 assert len(scheduled) == 1
543 await scheduled[0]
544
545 assert player_configs[f"players/{client_id}"]["enabled"] is True
546 mass.config.save_player_config.assert_not_awaited()
547
548 @pytest.mark.asyncio
549 async def test_already_flagged_device_is_left_alone(self) -> None:
550 """Test a device that carries the warning already does no further work."""
551 manager, mass, cast_player, player_configs, scheduled, _ = self._make_environment()
552 client_id = manager._bridge_client_id(cast_player)
553 assert client_id is not None
554 player_configs[f"players/{client_id}"] = {
555 "enabled": False,
556 "values": {
557 "protocol_parent_id": "parent_1",
558 CONF_PROTOCOL_EXPERIMENTAL_NOTE: SENDSPIN_CAST_EXPERIMENTAL_NOTE,
559 },
560 }
561 manager._bridges[cast_player.player_id] = MagicMock()
562
563 with patch.object(SendspinBridgeManagerBase, "evaluate_bridge", new=AsyncMock()):
564 await manager.evaluate_bridge(cast_player)
565
566 assert not scheduled
567 mass.config.set_raw_player_config_value.assert_not_called()
568
569 @pytest.mark.asyncio
570 async def test_the_warning_text_is_authored(self) -> None:
571 """Test the note the output renders resolves to a string that exists."""
572 manager, _, cast_player, player_configs, _, _ = self._make_environment()
573 client_id = manager._bridge_client_id(cast_player)
574 assert client_id is not None
575 player_configs[f"players/{client_id}"] = {
576 "enabled": True,
577 "values": {"protocol_parent_id": "parent_1"},
578 }
579 player_configs["players/parent_1"] = {"enabled": True}
580 manager._bridges[cast_player.player_id] = MagicMock()
581
582 await manager._flag_bridge_experimental(cast_player.player_id, client_id, disable=False)
583
584 values = player_configs[f"players/{client_id}"]["values"]
585 assert values[CONF_PROTOCOL_EXPERIMENTAL_NOTE] == SENDSPIN_CAST_EXPERIMENTAL_NOTE
586 # the note is rendered as an alert, so guard the authored string against drift
587 strings = json.loads(
588 (Path(sendspin_constants.__file__).resolve().parent / "strings.json").read_text(
589 encoding="utf-8"
590 )
591 )
592 assert SENDSPIN_CAST_EXPERIMENTAL_NOTE in strings["config_entries"]
593
594 @pytest.mark.asyncio
595 async def test_bridge_without_a_persisted_link_is_not_switched_off(self) -> None:
596 """Test the bridge stays as-is while there is no toggle to opt back in with."""
597 manager, mass, cast_player, player_configs, _, _ = self._make_environment()
598 client_id = manager._bridge_client_id(cast_player)
599 assert client_id is not None
600 player_configs[f"players/{client_id}"] = {"enabled": True}
601 manager._bridges[cast_player.player_id] = MagicMock()
602
603 manager._bridges[cast_player.player_id] = MagicMock()
604 with (
605 patch.object(bridge_module, "SENDSPIN_LINK_WAIT_TRIES", 2),
606 patch.object(bridge_module, "SENDSPIN_LINK_WAIT_INTERVAL", 0),
607 ):
608 await manager._flag_bridge_experimental(cast_player.player_id, client_id, disable=True)
609
610 assert player_configs[f"players/{client_id}"]["enabled"] is True
611 assert CONF_PROTOCOL_EXPERIMENTAL_NOTE not in player_configs[f"players/{client_id}"].get(
612 "values", {}
613 )
614 mass.config.save_player_config.assert_not_awaited()
615
616 @pytest.mark.asyncio
617 async def test_a_missed_opt_out_is_retried_on_the_next_evaluation(self) -> None:
618 """Test a device whose link never landed still gets switched off later."""
619 manager, _, cast_player, player_configs, scheduled, _ = self._make_environment()
620 client_id = manager._bridge_client_id(cast_player)
621 assert client_id is not None
622 # what a previous run left behind: bridged and marked, but never switched off
623 # because its protocol link had not been persisted yet
624 player_configs[f"players/{cast_player.player_id}"] = {
625 "enabled": True,
626 "values": {CONF_SENDSPIN_OPT_OUT_PENDING: True},
627 }
628 player_configs[f"players/{client_id}"] = {
629 "enabled": True,
630 "values": {"protocol_parent_id": "parent_1"},
631 }
632 player_configs["players/parent_1"] = {"enabled": True}
633
634 async def fake_super(player: Any) -> None:
635 manager._bridges[player.player_id] = MagicMock()
636
637 with patch.object(SendspinBridgeManagerBase, "evaluate_bridge", side_effect=fake_super):
638 await manager.evaluate_bridge(cast_player)
639 assert len(scheduled) == 1
640 await scheduled[0]
641
642 assert player_configs[f"players/{client_id}"]["enabled"] is False
643 # settled now, so the marker is cleared and nothing retries again
644 assert not player_configs[f"players/{cast_player.player_id}"]["values"][
645 CONF_SENDSPIN_OPT_OUT_PENDING
646 ]
647
648 @pytest.mark.asyncio
649 async def test_a_client_claimed_under_the_other_mac_variant_is_left_alone(self) -> None:
650 """Test the Cast warning is not put on a client that belongs to the AirPlay bridge."""
651 manager, _, cast_player, player_configs, scheduled, _ = self._make_environment()
652 client_id = manager._bridge_client_id(cast_player)
653 assert client_id is not None
654 player_configs[f"players/{cast_player.player_id}"] = {"enabled": True}
655
656 async def fake_super(player: Any) -> None:
657 """Claim a client under the locally-administered MAC, as the real one can."""
658 manager._claimed_clients[player.player_id] = "spb_5678c9e60da0"
659
660 with patch.object(SendspinBridgeManagerBase, "evaluate_bridge", side_effect=fake_super):
661 await manager.evaluate_bridge(cast_player)
662
663 assert not scheduled
664
665 @pytest.mark.asyncio
666 async def test_the_wait_gives_up_as_soon_as_the_bridge_is_taken_away(self) -> None:
667 """Test a bridge denied mid-wait stops the poll instead of running it out."""
668 manager, mass, cast_player, player_configs, _, _ = self._make_environment()
669 client_id = manager._bridge_client_id(cast_player)
670 assert client_id is not None
671 player_configs[f"players/{client_id}"] = {"enabled": True}
672 polls = 0
673
674 def get_raw(_player_id: str, key: str, default: Any = None) -> Any:
675 nonlocal polls
676 if key == CONF_PROTOCOL_PARENT_ID:
677 polls += 1
678 # the device turns out to speak AirPlay too, so policy pulls the bridge
679 manager._bridges.pop(cast_player.player_id, None)
680 return default
681
682 manager._bridges[cast_player.player_id] = MagicMock()
683 mass.config.get_raw_player_config_value = MagicMock(side_effect=get_raw)
684
685 with patch.object(bridge_module, "SENDSPIN_LINK_WAIT_INTERVAL", 0):
686 assert await manager._wait_for_protocol_link(cast_player.player_id, client_id) is False
687
688 # one look for the link, then the next round sees the bridge is gone and stops
689 assert polls == 1
690
691 @pytest.mark.asyncio
692 async def test_bridge_gone_after_the_wait_is_not_written_to(self) -> None:
693 """Test a bridge that went away is not switched off, since its config may be gone."""
694 manager, mass, cast_player, player_configs, _, _ = self._make_environment()
695 client_id = manager._bridge_client_id(cast_player)
696 assert client_id is not None
697 player_configs[f"players/{client_id}"] = {
698 "enabled": True,
699 "values": {"protocol_parent_id": "parent_1"},
700 }
701 player_configs["players/parent_1"] = {"enabled": True}
702
703 await manager._flag_bridge_experimental(cast_player.player_id, client_id, disable=True)
704
705 mass.config.set_raw_player_config_value.assert_not_called()
706 mass.config.save_player_config.assert_not_awaited()
707
708
709class TestCastFatalAudioError:
710 """Tests for a Cast device that reports it cannot run the Sendspin receiver."""
711
712 @pytest.mark.asyncio
713 async def test_unsupported_device_is_not_offered_the_bridge_again(self) -> None:
714 """Test the device is recorded as unsupported and its bridge re-evaluated away."""
715 raw_values: dict[tuple[str, str], Any] = {}
716 # the resolve and the re-evaluation share one log: the re-evaluation tears the
717 # bridge down, which cancels the future the play command waits on
718 order: list[str] = []
719
720 mass = MagicMock()
721 mass.config.set_raw_player_config_value = MagicMock(
722 side_effect=lambda player_id, key, value: raw_values.__setitem__(
723 (player_id, key), value
724 )
725 )
726
727 async def evaluate(_player: Any) -> None:
728 order.append("evaluate")
729
730 bridge = MagicMock(spec=bridge_module.SendspinChromecastBridge)
731 bridge.mass = mass
732 bridge.logger = logging.getLogger("test.cast_bridge")
733 bridge._bridge_client_id = "spb_aabbccddeeff"
734 bridge.cast_player = MagicMock()
735 bridge.cast_player.player_id = "cc_player"
736 bridge.cast_player.display_name = "Cast Speaker"
737 bridge.provider = MagicMock()
738 bridge.provider.bridge_manager.evaluate_bridge = AsyncMock(side_effect=evaluate)
739 resolved: list[BaseException | None] = []
740
741 def resolve(error: BaseException | None) -> None:
742 resolved.append(error)
743 order.append("resolve")
744
745 bridge._resolve_cast_app_ready = MagicMock(side_effect=resolve)
746
747 await bridge_module.SendspinChromecastBridge._handle_fatal_audio_error(bridge)
748
749 # recorded on the Cast player: the bridge config goes away with the bridge
750 assert raw_values[("cc_player", CONF_SENDSPIN_UNSUPPORTED)] is True
751 assert isinstance(resolved[0], PlayerCommandFailed)
752 assert order == ["resolve", "evaluate"]
753