/
/
1"""Tests for MilkDrop visualizer target resolution."""
2
3from __future__ import annotations
4
5from unittest.mock import Mock
6
7from music_assistant_models.enums import PlaybackState
8
9from music_assistant.providers.milkdrop_visualizer.relay import MilkdropRelay
10from music_assistant.providers.sendspin.player import SendspinBasePlayer
11
12
13def _relay(players: list[Mock]) -> MilkdropRelay:
14 """Return a relay whose mass exposes the given players."""
15 provider = Mock()
16 # iterating mass.players yields the players; get_player() is set per test
17 provider.mass.players.__iter__ = lambda _self: iter(players)
18 provider.mass.players.get_player.return_value = None
19 provider.logger.getChild.return_value = Mock()
20 return MilkdropRelay(provider)
21
22
23def _player(
24 player_id: str,
25 *,
26 underlying: str | None = None,
27 playing: bool = False,
28) -> Mock:
29 """Build a SendspinBasePlayer-shaped mock the resolver will accept."""
30 player = Mock(spec=SendspinBasePlayer)
31 player.player_id = player_id
32 player.underlying_player_id = underlying
33 player.display_name = player_id
34 player.api = Mock()
35 player.playback_state = PlaybackState.PLAYING if playing else PlaybackState.IDLE
36 return player
37
38
39def test_resolves_exact_player_id() -> None:
40 """A query equal to a player id resolves to that player."""
41 target = _player("20:F8:3B:09:00:F6")
42 relay = _relay([_player("aa:bb:cc:dd:ee:ff"), target])
43 assert relay._resolve_target("20:F8:3B:09:00:F6") is target
44
45
46def test_resolves_universal_wrapper_id_by_suffix() -> None:
47 """A universal player id ('up' + normalized MAC) matches the Sendspin device it wraps."""
48 target = _player("20:F8:3B:09:00:F6")
49 relay = _relay([target])
50 assert relay._resolve_target("up20f83b0900f6") is target
51
52
53def test_unmatched_player_returns_none_rather_than_guessing() -> None:
54 """A named player with no Sendspin group resolves to None instead of an arbitrary tap."""
55 relay = _relay([_player("20:F8:3B:09:00:F6", playing=True)])
56 assert relay._resolve_target("some-chromecast-id") is None
57
58
59def test_no_query_auto_picks_the_playing_player() -> None:
60 """With no query, the currently-playing Sendspin player wins over idle ones."""
61 idle = _player("aa:bb:cc:dd:ee:ff")
62 playing = _player("20:F8:3B:09:00:F6", playing=True)
63 relay = _relay([idle, playing])
64 assert relay._resolve_target(None) is playing
65
66
67def test_no_players_returns_none() -> None:
68 """With no Sendspin players at all, resolution yields None."""
69 relay = _relay([])
70 assert relay._resolve_target(None) is None
71
72
73def test_resolves_a_player_through_its_linked_sendspin_output() -> None:
74 """A player whose Sendspin bridge shares none of its id still resolves (e.g. Sonos)."""
75 # a Sonos "RINCON_..." reaches Sendspin through a bridge riding on its
76 # AirPlay side, so no amount of id matching could connect the two
77 bridge = _player("spb_542a1b5ca840", underlying="ap542a1b5ca840")
78 relay = _relay([bridge])
79 sonos = Mock()
80 output = Mock()
81 output.output_protocol_id = "spb_542a1b5ca840"
82 sonos.get_output_protocol_by_domain.return_value = output
83 players = {"RINCON_542A1B5CA84001400": sonos, "spb_542a1b5ca840": bridge}
84 relay.mass.players.get_player = Mock(side_effect=players.get) # type: ignore[method-assign]
85
86 assert relay._resolve_target("RINCON_542A1B5CA84001400") is bridge
87 sonos.get_output_protocol_by_domain.assert_called_once_with("sendspin")
88
89
90def test_player_without_a_linked_sendspin_output_still_resolves_to_none() -> None:
91 """A player with no Sendspin output anywhere is reported as unvisualizable."""
92 relay = _relay([_player("spb_542a1b5ca840")])
93 other = Mock()
94 other.get_output_protocol_by_domain.return_value = None
95 relay.mass.players.get_player = Mock( # type: ignore[method-assign]
96 side_effect={"dlna_box": other}.get
97 )
98
99 assert relay._resolve_target("dlna_box") is None
100