/
/
/
1"""
2Tests for the player state ChromecastPlayer derives from a media status.
3
4Every status the receiver sends is turned into the playback position, the metadata
5of what is loaded, and - for a cast group holding a stereo pair - the state of its
6members. These are what the user sees in the UI, so each is asserted on here.
7"""
8
9from __future__ import annotations
10
11import time
12from typing import Any, cast
13from unittest.mock import MagicMock
14
15from music_assistant_models.enums import MediaType, PlaybackState, PlayerType
16from music_assistant_models.player import PlayerMedia
17
18from music_assistant.providers.chromecast.constants import MASS_APP_ID
19from music_assistant.providers.chromecast.player import ChromecastPlayer
20
21
22def _fake_player(
23 *,
24 player_type: PlayerType = PlayerType.PLAYER,
25 powered: bool = False,
26 group_members: list[str] | None = None,
27) -> Any:
28 """
29 Build a Cast player on mocked collaborators, so it handles media status for real.
30
31 The playback state is seeded as if something was playing already, so the assertions
32 prove the handler wrote the new state instead of matching an untouched default.
33
34 :param player_type: Type of the player.
35 :param powered: Whether the player is powered on.
36 :param group_members: Player ids of the group's members, if it is a group.
37 """
38 # __init__ is skipped: it needs a provider, cast info and a live Chromecast connection.
39 # Typed as Any because the collaborators below are read back as the mocks they are.
40 fake = cast("Any", ChromecastPlayer.__new__(ChromecastPlayer))
41 fake.mass = MagicMock()
42 fake.logger = MagicMock()
43 fake.cc = MagicMock(app_id=MASS_APP_ID)
44 fake.update_state = MagicMock()
45 fake._flow_stream_underrun = MagicMock(return_value=False)
46 fake._media_error_reported = False
47 fake._app_quit_task_id = "cast_quit_app_test"
48 fake.active_cast_group = None
49 # display_name is a cached property, normally built from the config in __init__
50 fake._cache = {"display_name": "Test Cast"}
51 # the state below is exposed through read-only properties on the base Player,
52 # so it is seeded through the attributes backing them
53 fake._attr_type = player_type
54 fake._attr_powered = powered
55 fake._attr_group_members = group_members or []
56 fake._attr_playback_state = PlaybackState.PLAYING
57 fake._attr_current_media = PlayerMedia(uri="http://mass/previous.flac")
58 fake._attr_elapsed_time = 123.0
59 fake._attr_elapsed_time_last_updated = 0.0
60 return fake
61
62
63def _fake_group_member(*, multichannel: bool) -> Any:
64 """
65 Build a member of a cast group on mocked collaborators.
66
67 Its state is seeded as if left over from earlier playback, so the assertions
68 prove whether the group wrote to the member or left it alone.
69
70 :param multichannel: Whether the member is a multichannel group (a stereo pair).
71 """
72 # cast_info is set in __init__, so a mock specced on the class does not carry it
73 child = MagicMock(spec=ChromecastPlayer)
74 child.cast_info = MagicMock(is_multichannel_group=multichannel)
75 child._attr_playback_state = PlaybackState.IDLE
76 child._attr_current_media = None
77 child._attr_elapsed_time = 0.0
78 child._attr_elapsed_time_last_updated = 0.0
79 child._attr_active_source = "previous_source"
80 return child
81
82
83def _fake_group_with_a_stereo_pair(*, powered: bool = True) -> tuple[Any, Any, Any]:
84 """
85 Build a cast group holding a regular speaker and a stereo pair, in that order.
86
87 The regular speaker comes first because it is the one that has to be skipped: a group
88 that gave up on the first member it skips would leave the stereo pair behind.
89
90 :param powered: Whether the group is powered on.
91 :return: The group, its regular speaker and its stereo pair.
92 """
93 group = _fake_player(
94 player_type=PlayerType.GROUP,
95 powered=powered,
96 group_members=["speaker_1", "stereo_pair_1"],
97 )
98 speaker = _fake_group_member(multichannel=False)
99 stereo_pair = _fake_group_member(multichannel=True)
100 # by id, so the group is held to looking up the members it lists
101 group.mass.players.get_player.side_effect = {
102 "speaker_1": speaker,
103 "stereo_pair_1": stereo_pair,
104 }.get
105 return group, speaker, stereo_pair
106
107
108def _media_status(
109 *,
110 playing: bool = False,
111 paused: bool = False,
112 content_id: str = "http://mass/track.flac",
113 current_time: float = 0.0,
114 adjusted_current_time: float = 0.0,
115 title: str | None = None,
116 artist: str | None = None,
117 album_name: str | None = None,
118 image_url: str | None = None,
119 duration: float | None = None,
120) -> MagicMock:
121 """
122 Build a MediaStatus as the receiver reports it.
123
124 :param playing: Whether the receiver reports playback.
125 :param paused: Whether the receiver reports paused playback.
126 :param content_id: Content id the receiver has loaded, if any.
127 :param current_time: Position as of the moment the receiver sent the status.
128 :param adjusted_current_time: Position extrapolated to now.
129 :param title: Track title reported by the receiver.
130 :param artist: Track artist reported by the receiver.
131 :param album_name: Album name reported by the receiver.
132 :param image_url: Cover art url reported by the receiver.
133 :param duration: Track duration in seconds.
134 """
135 status = MagicMock()
136 status.content_id = content_id
137 status.player_state = "PLAYING" if playing else "PAUSED" if paused else "IDLE"
138 status.player_is_playing = playing
139 status.player_is_paused = paused
140 status.player_is_idle = not playing and not paused
141 status.idle_reason = None
142 status.current_time = current_time
143 status.adjusted_current_time = adjusted_current_time
144 status.title = title
145 status.artist = artist
146 status.album_name = album_name
147 status.images = [MagicMock(url=image_url)] if image_url else []
148 status.duration = duration
149 return status
150
151
152def test_the_position_while_playing_runs_on_between_updates() -> None:
153 """The receiver only sends a status now and then, so the position is extrapolated."""
154 fake = _fake_player()
155
156 fake._handle_media_status(
157 _media_status(playing=True, current_time=40.0, adjusted_current_time=42.5)
158 )
159
160 assert fake._attr_elapsed_time == 42.5
161
162
163def test_the_position_while_paused_is_the_reported_one() -> None:
164 """A paused position stands still, so extrapolating it would run it off."""
165 fake = _fake_player()
166
167 fake._handle_media_status(
168 _media_status(paused=True, current_time=12.5, adjusted_current_time=99.0)
169 )
170
171 assert fake._attr_elapsed_time == 12.5
172
173
174def test_the_position_is_stamped_with_the_moment_it_was_read() -> None:
175 """A position only means something together with when it was taken."""
176 fake = _fake_player()
177 before = time.time()
178
179 fake._handle_media_status(_media_status(playing=True, adjusted_current_time=5.0))
180
181 assert fake._attr_elapsed_time_last_updated >= before
182
183
184def test_the_metadata_of_what_is_playing_is_published() -> None:
185 """What the receiver reports about the track is what MA shows for it."""
186 fake = _fake_player()
187
188 fake._handle_media_status(
189 _media_status(
190 playing=True,
191 content_id="http://mass/flow/track.flac",
192 title="Sun Rays",
193 artist="The Testers",
194 album_name="Fixture Sessions",
195 image_url="http://mass/cover.jpg",
196 duration=241.7,
197 )
198 )
199
200 media = fake._attr_current_media
201 assert media.uri == "http://mass/flow/track.flac"
202 assert media.title == "Sun Rays"
203 assert media.artist == "The Testers"
204 assert media.album == "Fixture Sessions"
205 assert media.image_url == "http://mass/cover.jpg"
206 assert media.duration == 241
207 assert media.media_type == MediaType.TRACK
208
209
210def test_a_paused_player_keeps_showing_what_is_loaded() -> None:
211 """Pausing does not unload the track, so its metadata stays on screen."""
212 fake = _fake_player()
213
214 fake._handle_media_status(_media_status(paused=True, title="Sun Rays", artist="The Testers"))
215
216 assert fake._attr_current_media.title == "Sun Rays"
217 assert fake._attr_current_media.artist == "The Testers"
218
219
220def test_the_previous_track_metadata_does_not_linger() -> None:
221 """A track without an album must not keep showing the previous track's album."""
222 fake = _fake_player()
223 fake._handle_media_status(
224 _media_status(
225 playing=True,
226 content_id="http://mass/one.flac",
227 title="Sun Rays",
228 album_name="Fixture Sessions",
229 )
230 )
231
232 fake._handle_media_status(
233 _media_status(playing=True, content_id="http://mass/two.flac", title="Moon Rays")
234 )
235
236 assert fake._attr_current_media.title == "Moon Rays"
237 assert fake._attr_current_media.album is None
238
239
240def test_an_idle_status_does_not_put_its_content_back_on_screen() -> None:
241 """A receiver keeps naming the content it last had loaded, which is not playback."""
242 fake = _fake_player()
243
244 fake._handle_media_status(_media_status(content_id="http://mass/track.flac"))
245
246 assert fake._attr_current_media is None
247
248
249def test_a_multichannel_member_follows_its_group() -> None:
250 """A stereo pair gets no status of its own, so the group hands it its state."""
251 group, _, stereo_pair = _fake_group_with_a_stereo_pair()
252
253 group._handle_media_status(
254 _media_status(playing=True, title="Sun Rays", adjusted_current_time=30.0)
255 )
256
257 assert stereo_pair._attr_playback_state == PlaybackState.PLAYING
258 assert stereo_pair._attr_current_media is group._attr_current_media
259 assert stereo_pair._attr_elapsed_time == 30.0
260 assert stereo_pair._attr_elapsed_time_last_updated == group._attr_elapsed_time_last_updated
261 # the group plays an MA queue, which is not a source the member is on
262 assert stereo_pair._attr_active_source is None
263 stereo_pair.update_state.assert_called_once()
264
265
266def test_a_regular_group_member_is_left_alone() -> None:
267 """A regular speaker reports its own status, which the group must not overwrite."""
268 group, speaker, _ = _fake_group_with_a_stereo_pair()
269
270 group._handle_media_status(_media_status(playing=True, adjusted_current_time=30.0))
271
272 assert speaker._attr_playback_state == PlaybackState.IDLE
273 assert speaker._attr_current_media is None
274 assert speaker._attr_elapsed_time == 0.0
275 assert speaker._attr_active_source == "previous_source"
276 speaker.update_state.assert_not_called()
277
278
279def test_an_unpowered_group_does_not_push_state_to_its_members() -> None:
280 """A group that is off has no playback to hand out."""
281 group, _, stereo_pair = _fake_group_with_a_stereo_pair(powered=False)
282
283 group._handle_media_status(_media_status(playing=True, adjusted_current_time=30.0))
284
285 assert stereo_pair._attr_playback_state == PlaybackState.IDLE
286 stereo_pair.update_state.assert_not_called()
287