/
/
/
1"""
2Tests for Cast-member readiness handling in SendspinPlayer.set_members.
3
4Adding a Cast-bridged member waits for its Sendspin app to report ready. The caller
5always learns why a member did not join, so a member failing after that wait gave up
6must not also surface through the loop exception handler.
7"""
8
9from __future__ import annotations
10
11import asyncio
12from unittest.mock import AsyncMock, MagicMock, patch
13
14import pytest
15from music_assistant_models.errors import PlayerCommandFailed
16
17from music_assistant.providers.sendspin import player as player_module
18from music_assistant.providers.sendspin.player import SendspinPlayer
19from tests.common import collect_loop_errors
20
21MEMBER_ID = "cast-member"
22MEMBER_NAME = "Living Room TV"
23
24
25def _make_player_mock(ready: asyncio.Future[None]) -> MagicMock:
26 """Create a mock leader whose single member to add is Cast-bridged."""
27 mock = MagicMock()
28 mock.translation_owner = "sendspin"
29 mock.api.group.has_active_stream = True
30 mock.api.group.add_client = AsyncMock()
31 mock.api.group.remove_client = AsyncMock()
32 member = MagicMock()
33 member.display_name = MEMBER_NAME
34 mock.mass.players.get_player.return_value = member
35 bridge = MagicMock()
36 bridge.reset_cast_app_ready.return_value = ready
37 mock._get_cast_bridge_manager.return_value.get_bridge_by_client_id.return_value = bridge
38 return mock
39
40
41@pytest.mark.asyncio
42async def test_member_failing_after_the_timeout_logs_no_loop_error() -> None:
43 """A member reporting its failure once the wait gave up stays out of the loop handler."""
44 ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
45 mock = _make_player_mock(ready)
46 rollback_started = asyncio.Event()
47 release = asyncio.Event()
48
49 async def _park_rollback(_api: object) -> None:
50 rollback_started.set()
51 await release.wait()
52
53 mock.api.group.remove_client = AsyncMock(side_effect=_park_rollback)
54
55 with (
56 collect_loop_errors() as reported,
57 patch.object(player_module, "CAST_APP_READY_TIMEOUT", 0.01),
58 ):
59 call = asyncio.create_task(SendspinPlayer.set_members(mock, player_ids_to_add=[MEMBER_ID]))
60 await rollback_started.wait()
61 # fail the member only once the readiness wait has demonstrably given up, so the
62 # failure reliably lands after the waiter is gone
63 ready.set_exception(PlayerCommandFailed("Cast app stopped before reporting ready."))
64 ready.exception()
65 release.set()
66 with pytest.raises(PlayerCommandFailed, match="did not report ready"):
67 await call
68 await asyncio.sleep(0)
69
70 assert reported == []
71
72
73@pytest.mark.asyncio
74async def test_member_failure_is_surfaced_over_the_timeout() -> None:
75 """A member's own failure reaches the caller instead of the readiness timeout."""
76 ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
77 mock = _make_player_mock(ready)
78 ready.set_exception(PlayerCommandFailed(f"Sendspin isn't supported on {MEMBER_NAME}."))
79 ready.exception()
80
81 with (
82 patch.object(player_module, "CAST_APP_READY_TIMEOUT", 5.0),
83 pytest.raises(PlayerCommandFailed, match="isn't supported"),
84 ):
85 await SendspinPlayer.set_members(mock, player_ids_to_add=[MEMBER_ID])
86
87 mock.api.group.remove_client.assert_awaited_once()
88
89
90@pytest.mark.asyncio
91async def test_timeout_names_the_members_that_never_reported_ready() -> None:
92 """Members still pending when the wait expires are named and then cleaned up."""
93 ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
94 mock = _make_player_mock(ready)
95
96 with (
97 patch.object(player_module, "CAST_APP_READY_TIMEOUT", 0.01),
98 pytest.raises(PlayerCommandFailed, match=MEMBER_NAME),
99 ):
100 await SendspinPlayer.set_members(mock, player_ids_to_add=[MEMBER_ID])
101
102 assert ready.cancelled()
103