/
/
/
1"""
2Unit tests for the volume and mute state held by the Sendspin bridge player role.
3
4The role's copy of the volume and mute is what the visible Sendspin player
5reports, and it moves in two directions: a command coming down from Sendspin,
6which the bridge has to forward to its player, and state observed on the player
7itself, which has to reach the Sendspin side without being pushed back at the
8player it was just read from.
9"""
10
11from unittest.mock import MagicMock
12
13from aiosendspin.server import VolumeChangedEvent
14
15from music_assistant.providers.sendspin.bridge_role import BridgePlayerRole
16
17
18def _make_role(
19 *, initial_volume: int = 40, initial_muted: bool = False
20) -> tuple[BridgePlayerRole, MagicMock, MagicMock, MagicMock]:
21 """Build a wired-up bridge role with its client mock and volume/mute callbacks."""
22 client = MagicMock()
23 role = BridgePlayerRole(client=client)
24 on_volume_change = MagicMock()
25 on_mute_change = MagicMock()
26 role.set_callbacks(
27 on_audio_chunk=MagicMock(),
28 on_volume_change=on_volume_change,
29 on_mute_change=on_mute_change,
30 on_stream_start=MagicMock(),
31 on_stream_end=MagicMock(),
32 initial_volume=initial_volume,
33 initial_muted=initial_muted,
34 )
35 client.reset_mock()
36 return role, client, on_volume_change, on_mute_change
37
38
39def _emitted_volume_events(client: MagicMock) -> list[VolumeChangedEvent]:
40 """Return the volume-changed events the role emitted on its client."""
41 return [
42 call.args[0]
43 for call in client._signal_event.call_args_list
44 if isinstance(call.args[0], VolumeChangedEvent)
45 ]
46
47
48def test_wiring_the_callbacks_seeds_the_state_the_player_is_in() -> None:
49 """A bridge registering a muted player starts out reporting it as muted."""
50 role, _, _, _ = _make_role(initial_volume=35, initial_muted=True)
51
52 assert role.get_player_volume() == 35
53 assert role.get_player_muted() is True
54
55
56def test_a_command_from_sendspin_is_forwarded_to_the_bridge() -> None:
57 """A volume or mute command is applied and handed to the bridge to carry out."""
58 role, client, on_volume_change, on_mute_change = _make_role()
59
60 role.set_player_volume(70)
61 role.set_player_mute(True)
62
63 assert role.get_player_volume() == 70
64 assert role.get_player_muted() is True
65 on_volume_change.assert_called_once_with(70)
66 on_mute_change.assert_called_once_with(True)
67 assert [(event.volume, event.muted) for event in _emitted_volume_events(client)] == [
68 (70, False),
69 (70, True),
70 ]
71
72
73def test_state_observed_on_the_player_is_not_pushed_back_at_it() -> None:
74 """
75 State read off the player reaches Sendspin without being sent back down.
76
77 Forwarding it would hand the player a value it just reported, which for a
78 device that answers every volume command with feedback never settles.
79 """
80 role, client, on_volume_change, on_mute_change = _make_role()
81
82 role.update_player_state(volume=55, muted=True)
83
84 assert role.get_player_volume() == 55
85 assert role.get_player_muted() is True
86 on_volume_change.assert_not_called()
87 on_mute_change.assert_not_called()
88 assert [(event.volume, event.muted) for event in _emitted_volume_events(client)] == [(55, True)]
89
90
91def test_only_the_state_that_is_known_is_adopted() -> None:
92 """A player that cannot report a level keeps the level the role already has."""
93 role, _, _, _ = _make_role(initial_volume=40)
94
95 role.update_player_state(volume=None, muted=True)
96
97 assert role.get_player_volume() == 40
98 assert role.get_player_muted() is True
99
100
101def test_unchanged_state_is_not_announced_again() -> None:
102 """Re-reading the same volume and mute off the player is not an update."""
103 role, client, _, _ = _make_role(initial_volume=40, initial_muted=False)
104
105 role.update_player_state(volume=40, muted=False)
106
107 assert _emitted_volume_events(client) == []
108