/
/
/
1"""
2Tests for the shuffle state a newly started media item ends up with.
3
4Shuffle is a queue setting the user owns: it survives everything they play, except media that
5carries an order of its own. Starting an album, podcast, episode, audiobook or audio source plays
6it in that order and switches shuffle off with it, while a playlist or artist keeps whatever the
7queue is set to. An explicit ``shuffle`` argument always wins, and the options that only stage
8items leave the state alone. These drive the real ``play_media`` path against a bare controller
9instance, mirroring ``test_user_initiated_plays`` and ``test_enqueue_options``: resolution and
10playback are stubbed, but the enqueue/load path runs for real so the resulting item order is
11verified end-to-end.
12"""
13
14from __future__ import annotations
15
16from typing import Any, cast
17from unittest.mock import AsyncMock, MagicMock, Mock
18
19import pytest
20from music_assistant_models.enums import MediaType, QueueOption
21from music_assistant_models.errors import MediaNotFoundError
22from music_assistant_models.media_items import (
23 Album,
24 Audiobook,
25 AudioSource,
26 ItemMapping,
27 Playlist,
28 Podcast,
29 PodcastEpisode,
30 ProviderMapping,
31 Radio,
32 Track,
33)
34from music_assistant_models.player_queue import PlayerQueue
35from music_assistant_models.queue_item import QueueItem
36from music_assistant_models.unique_list import UniqueList
37
38from music_assistant.controllers.player_queues import PlayerQueuesController
39from music_assistant.controllers.player_queues.constants import ORDERED_MEDIA_TYPES
40from music_assistant.controllers.player_queues.state import PlayerQueueData
41
42# the album the user starts, in its own track order
43ALBUM_TRACKS = ["t1", "t2", "t3", "t4"]
44
45# the options that start the media right away, i.e. begin a new listening session
46START_OPTIONS = [QueueOption.PLAY, QueueOption.REPLACE]
47
48# the options that stage media onto the queue instead of starting it
49STAGE_OPTIONS = [QueueOption.ADD, QueueOption.NEXT, QueueOption.REPLACE_NEXT]
50
51# a queue as an earlier shuffle left it: the list order deliberately disagrees with the sort order
52SHUFFLED_QUEUE = [("e1", 0), ("e4", 3), ("e2", 1), ("e5", 4), ("e3", 2)]
53
54# the managed pool a dynamic queue is playing when other media is staged over it
55POOL_TRACKS = ["p1", "p2", "p3"]
56
57
58def _track(item_id: str) -> Track:
59 """Build a playable Track on the 'test' provider."""
60 return Track(
61 item_id=item_id,
62 provider="test",
63 name=f"Track {item_id}",
64 duration=60,
65 artists=UniqueList(
66 [ItemMapping(item_id="a", provider="test", name="A", media_type=MediaType.ARTIST)]
67 ),
68 provider_mappings={
69 ProviderMapping(item_id=item_id, provider_domain="test", provider_instance="test")
70 },
71 )
72
73
74def _album() -> Album:
75 """Build the Album the user presses play on (its configured enqueue default is 'replace')."""
76 return Album(
77 item_id="al1",
78 provider="test",
79 name="Album al1",
80 provider_mappings={
81 ProviderMapping(item_id="al1", provider_domain="test", provider_instance="test")
82 },
83 )
84
85
86def _playlist() -> Playlist:
87 """Build a plain playlist: a pool of tracks with no running order of its own to protect."""
88 return Playlist(
89 item_id="pl1",
90 provider="test",
91 name="Playlist pl1",
92 provider_mappings={
93 ProviderMapping(item_id="pl1", provider_domain="test", provider_instance="test")
94 },
95 )
96
97
98def _ordered_item(media_type: MediaType) -> Any:
99 """Build a media item of one of the types that carry an order of their own."""
100 cls = {
101 MediaType.ALBUM: Album,
102 MediaType.AUDIOBOOK: Audiobook,
103 MediaType.PODCAST: Podcast,
104 MediaType.PODCAST_EPISODE: PodcastEpisode,
105 MediaType.AUDIO_SOURCE: AudioSource,
106 MediaType.RADIO: Radio,
107 }[media_type]
108 kwargs: dict[str, Any] = {
109 "item_id": "o1",
110 "provider": "test",
111 "name": f"Ordered {media_type.value}",
112 "provider_mappings": {
113 ProviderMapping(item_id="o1", provider_domain="test", provider_instance="test")
114 },
115 }
116 if media_type == MediaType.PODCAST_EPISODE:
117 kwargs["position"] = 1
118 kwargs["podcast"] = _ordered_item(MediaType.PODCAST)
119 return cls(**kwargs)
120
121
122def _dynamic_playlist() -> Playlist:
123 """Build a dynamic playlist: a source that supplies its own tracks and is always a smart mix."""
124 playlist = Playlist(
125 item_id="dyn1",
126 provider="test",
127 name="Dynamic",
128 provider_mappings={
129 ProviderMapping(item_id="dyn1", provider_domain="test", provider_instance="test")
130 },
131 )
132 playlist.is_dynamic = True
133 return playlist
134
135
136def _controller(**queue_kwargs: Any) -> Any:
137 """
138 Build a bare controller driving ``play_media`` on a single queue "q1".
139
140 The album's tracks come from a stubbed media resolver and the shuffle is made deterministic
141 (it reverses the batch), so the resulting queue order tells shuffle-on from shuffle-off.
142
143 :param queue_kwargs: Overrides for the queue this controller is set up with.
144 """
145 ctrl = PlayerQueuesController.__new__(PlayerQueuesController)
146 ctrl.logger = Mock()
147 ctrl.mass = MagicMock()
148 ctrl.mass.players.get_player = Mock(return_value=Mock(extra_data={}))
149 lock_cm = MagicMock()
150 lock_cm.__aenter__ = AsyncMock(return_value=None)
151 lock_cm.__aexit__ = AsyncMock(return_value=None)
152 ctrl.mass.players.get_player_lock = Mock(return_value=lock_cm)
153 ctrl.signal_update = Mock() # type: ignore[method-assign]
154 ctrl.on_player_update = Mock() # type: ignore[method-assign]
155 ctrl.play_index = AsyncMock() # type: ignore[method-assign]
156 ctrl.get_next_item = Mock(return_value=None) # type: ignore[method-assign]
157 ctrl.get_config_value = Mock(return_value=QueueOption.REPLACE.value) # type: ignore[method-assign]
158 ctrl._managed_pool = Mock()
159 ctrl._managed_pool.fill = AsyncMock(
160 side_effect=lambda *_args, **_kwargs: [_track(item_id) for item_id in ALBUM_TRACKS]
161 )
162 ctrl._smart_shuffle = Mock()
163 ctrl._smart_shuffle.is_enabled = Mock(return_value=True)
164 ctrl._smart_shuffle.arrange = AsyncMock(side_effect=lambda _queue, items: list(items)[::-1])
165 ctrl._media_resolver = Mock()
166 ctrl._media_resolver._resolve_media_items = AsyncMock(
167 side_effect=lambda *_args, **_kwargs: [_track(item_id) for item_id in ALBUM_TRACKS]
168 )
169 queue = PlayerQueue(
170 queue_id="q1", active=True, display_name="Q1", available=True, items=0, **queue_kwargs
171 )
172 ctrl._queue_data = {"q1": PlayerQueueData(queue=queue)}
173 return ctrl
174
175
176def _queue(ctrl: Any) -> PlayerQueue:
177 """Return the controller's queue."""
178 return cast("PlayerQueue", ctrl._queue_data["q1"].queue)
179
180
181def _played_order(ctrl: Any) -> list[str]:
182 """Return the item ids of the tracks currently loaded in the queue, in play order."""
183 return [
184 item.media_item.item_id
185 for item in ctrl._queue_data["q1"].items
186 if item.media_item is not None
187 ]
188
189
190def _load_shuffled_queue(ctrl: Any) -> None:
191 """Fill the queue with items whose list order disagrees with their original sort order."""
192 items = []
193 for item_id, sort_index in SHUFFLED_QUEUE:
194 item = QueueItem.from_media_item("q1", _track(item_id))
195 item.sort_index = sort_index
196 items.append(item)
197 ctrl._queue_data["q1"].items = items
198 _queue(ctrl).items = len(items)
199
200
201def _load_dynamic_pool(ctrl: Any) -> None:
202 """Fill the queue with a dynamic queue's managed pool, its first item playing."""
203 ctrl._queue_data["q1"].items = [
204 QueueItem.from_media_item("q1", _track(item_id)) for item_id in POOL_TRACKS
205 ]
206 queue = _queue(ctrl)
207 queue.items = len(POOL_TRACKS)
208 queue.current_index = 0
209
210
211@pytest.mark.parametrize("option", START_OPTIONS)
212async def test_playing_a_playlist_keeps_the_queue_shuffle(option: QueueOption) -> None:
213 """A playlist is a pool of tracks, so it plays the way the queue's shuffle is set."""
214 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True)
215
216 await ctrl.play_media("q1", _playlist(), option)
217
218 assert _queue(ctrl).shuffle_enabled is True
219 assert _queue(ctrl).smart_shuffle_active is True
220 # the (reversed) shuffle order rather than the order the resolver handed them over in
221 assert _played_order(ctrl) == ALBUM_TRACKS[::-1]
222 # shuffle was settled before the items were resolved: a shuffled queue asks the resolver to
223 # keep the items preceding a chosen track, an in-order one does not
224 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
225 assert resolve_call.kwargs["keep_preceding_items"] is True
226
227
228@pytest.mark.parametrize("option", START_OPTIONS)
229async def test_the_queue_shuffle_survives_repeated_plays(option: QueueOption) -> None:
230 """
231 Shuffle belongs to the queue, so every playlist the user starts after it is shuffled too.
232
233 The shuffle toggle is a setting the user owns, not a one-shot gesture attached to a single
234 play: switching it on once has to keep shuffling whatever they pick next, however many things
235 they play, or the toggle would silently switch itself off behind their back.
236 """
237 ctrl = _controller()
238 await ctrl.set_shuffle("q1", True)
239 await ctrl.play_media("q1", _playlist(), option)
240
241 await ctrl.play_media("q1", _playlist(), option)
242
243 assert _queue(ctrl).shuffle_enabled is True
244 # still in effect when the second batch is resolved, not just left set on the queue
245 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
246 assert resolve_call.kwargs["keep_preceding_items"] is True
247 assert _played_order(ctrl) != ALBUM_TRACKS
248
249
250@pytest.mark.parametrize("media_type", ORDERED_MEDIA_TYPES)
251@pytest.mark.parametrize("option", START_OPTIONS)
252async def test_starting_ordered_media_switches_shuffle_off(
253 option: QueueOption, media_type: MediaType
254) -> None:
255 """Media sequenced by its author plays in that order, and takes the queue's shuffle with it."""
256 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True)
257
258 await ctrl.play_media("q1", _ordered_item(media_type), option)
259
260 assert _queue(ctrl).shuffle_enabled is False
261 assert _queue(ctrl).smart_shuffle_active is False
262 assert _played_order(ctrl) == ALBUM_TRACKS
263 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
264 assert resolve_call.kwargs["keep_preceding_items"] is False
265
266
267async def test_an_album_switches_shuffle_off_for_a_derived_enqueue_option() -> None:
268 """The album rule also applies when the enqueue option comes from its config default."""
269 ctrl = _controller(shuffle_enabled=True)
270
271 await ctrl.play_media("q1", _album())
272
273 # the option was derived from the album's configured default (which is 'replace')
274 assert ctrl.get_config_value.call_args.args[0] == "default_enqueue_option_album"
275 assert _queue(ctrl).shuffle_enabled is False
276 assert _played_order(ctrl) == ALBUM_TRACKS
277
278
279async def test_an_album_restores_the_order_of_the_items_that_stay_in_the_queue() -> None:
280 """
281 An album played onto a shuffled queue puts the items it keeps back in their original order.
282
283 Unlike a replace, a play keeps the tail behind the current item, so switching shuffle off has
284 to un-shuffle that tail as well: items left in shuffled order behind a queue that now reads
285 "shuffle off" would contradict its own flag.
286 """
287 ctrl = _controller(shuffle_enabled=True, current_index=0)
288 _load_shuffled_queue(ctrl)
289
290 await ctrl.play_media("q1", _album(), QueueOption.PLAY)
291
292 assert _queue(ctrl).shuffle_enabled is False
293 # the item being played, then the album, then the kept tail back in its original order
294 assert _played_order(ctrl) == ["e1", *ALBUM_TRACKS, "e2", "e3", "e4", "e5"]
295
296
297async def test_an_unshuffled_queue_stays_unshuffled_for_a_playlist() -> None:
298 """A playlist follows the queue's shuffle in both directions, so an off queue stays off."""
299 ctrl = _controller(shuffle_enabled=False)
300
301 await ctrl.play_media("q1", _playlist(), QueueOption.REPLACE)
302
303 assert _queue(ctrl).shuffle_enabled is False
304 assert _played_order(ctrl) == ALBUM_TRACKS
305
306
307@pytest.mark.parametrize(
308 ("batch", "expected"),
309 [([_album(), _playlist()], False), ([_playlist(), _album()], True)],
310 ids=["album-first", "playlist-first"],
311)
312async def test_the_first_item_of_a_batch_decides_for_the_whole_batch(
313 batch: list[Any], expected: bool
314) -> None:
315 """
316 A batch is judged by its first item: it is the only media type known this early.
317
318 The shuffle state has to be settled before any of the items are resolved, because a shuffled
319 queue resolves a chosen start item differently. Both orderings are checked: an album behind a
320 playlist must not reach back and switch the queue's shuffle off.
321 """
322 ctrl = _controller(shuffle_enabled=True)
323
324 await ctrl.play_media("q1", batch, QueueOption.REPLACE)
325
326 assert _queue(ctrl).shuffle_enabled is expected
327
328
329async def test_an_unresolvable_first_item_leaves_the_decision_to_the_next_one() -> None:
330 """A batch is judged by the first item that resolves, not by one that could not be fetched."""
331 ctrl = _controller(shuffle_enabled=True)
332 ctrl.mass.music.get_item_by_uri = AsyncMock(side_effect=[MediaNotFoundError("gone"), _album()])
333
334 await ctrl.play_media("q1", ["test://track/gone", "test://album/al1"], QueueOption.REPLACE)
335
336 assert _queue(ctrl).shuffle_enabled is False
337 assert _played_order(ctrl) == ALBUM_TRACKS
338
339
340@pytest.mark.parametrize("media_type", ORDERED_MEDIA_TYPES)
341async def test_explicit_shuffle_wins_over_the_medias_own_order(media_type: MediaType) -> None:
342 """
343 A caller asking for a shuffled play gets one, even for media with an order of its own.
344
345 This is the "play shuffled" action every client offers: it has to be honoured whatever the
346 queue's toggle says and whatever is being started, or the action would silently do nothing.
347 """
348 ctrl = _controller()
349
350 await ctrl.play_media("q1", _ordered_item(media_type), QueueOption.REPLACE, shuffle=True)
351
352 assert _queue(ctrl).shuffle_enabled is True
353 assert _played_order(ctrl) == ALBUM_TRACKS[::-1]
354
355
356@pytest.mark.parametrize("option", START_OPTIONS)
357async def test_explicit_no_shuffle_wins_over_the_queue_shuffle(option: QueueOption) -> None:
358 """An explicit "play in order" beats the shuffle the queue is set to."""
359 ctrl = _controller(shuffle_enabled=True)
360
361 await ctrl.play_media("q1", _playlist(), option, shuffle=False)
362
363 assert _queue(ctrl).shuffle_enabled is False
364 assert _played_order(ctrl) == ALBUM_TRACKS
365
366
367async def test_dynamic_source_overrides_an_explicit_play_in_order() -> None:
368 """A dynamic source is an always-on smart mix, so it outranks an explicit "play in order"."""
369 ctrl = _controller()
370
371 await ctrl.play_media("q1", _dynamic_playlist(), QueueOption.REPLACE, shuffle=False)
372
373 assert _queue(ctrl).is_dynamic is True
374 assert _queue(ctrl).shuffle_enabled is True
375 assert _queue(ctrl).smart_shuffle_active is True
376
377
378async def test_replacing_a_dynamic_queue_drops_the_smart_mix_indicator() -> None:
379 """
380 An album started over a dynamic queue is a plain queue, so it must not report a smart mix.
381
382 The dynamic source is what made smart shuffle active here (the per-queue setting is off), so
383 dropping it has to take the indicator with it.
384 """
385 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
386 ctrl._smart_shuffle.is_enabled = Mock(return_value=False)
387
388 await ctrl.play_media("q1", _album(), QueueOption.REPLACE, shuffle=True)
389
390 assert _queue(ctrl).is_dynamic is False
391 # the caller asked for a shuffled play, but a plain random shuffle is not a smart mix
392 assert _queue(ctrl).shuffle_enabled is True
393 assert _queue(ctrl).smart_shuffle_active is False
394
395
396async def test_playing_over_a_dynamic_queue_honours_an_explicit_play_in_order() -> None:
397 """
398 An album played over a dynamic queue takes over from it, so it plays in the order asked for.
399
400 Play does not clear the queue up front, so the queue is still dynamic when the shuffle state is
401 settled - and a dynamic queue's toggle is locked, so the request cannot be routed through
402 set_shuffle. The requested state still has to reach the items, which are resolved against it.
403 """
404 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
405
406 await ctrl.play_media("q1", _album(), QueueOption.PLAY, shuffle=False)
407
408 # the album took over as the queue's only source, so the queue is a plain one again
409 assert _queue(ctrl).is_dynamic is False
410 assert _queue(ctrl).shuffle_enabled is False
411 assert _queue(ctrl).smart_shuffle_active is False
412 # the flag alone proves little here: the album itself has to come out in its own order
413 assert _played_order(ctrl) == ALBUM_TRACKS
414
415
416@pytest.mark.parametrize("option", START_OPTIONS)
417async def test_taking_over_a_dynamic_queue_drops_the_imposed_shuffle(option: QueueOption) -> None:
418 """
419 Media started over a dynamic queue does not inherit the shuffle that queue was forced into.
420
421 A dynamic queue is an always-on smart mix, so its shuffle is imposed by the source rather than
422 chosen by the user - its toggle is locked. Now that shuffle survives from one play to the next,
423 a shuffle left latched on here would keep reordering everything the user plays afterwards.
424 """
425 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
426 _load_dynamic_pool(ctrl)
427
428 await ctrl.play_media("q1", _playlist(), option)
429
430 assert _queue(ctrl).is_dynamic is False
431 assert _queue(ctrl).shuffle_enabled is False
432 assert _queue(ctrl).smart_shuffle_active is False
433
434
435async def test_a_dynamic_queues_shuffle_is_dropped_even_if_nothing_resolves() -> None:
436 """
437 The source is replaced whether or not the media resolves, so its shuffle goes either way.
438
439 The media type is only known once an item resolves, so the shuffle state is settled inside the
440 resolve loop - which a batch that yields nothing never reaches.
441 """
442 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
443 _load_dynamic_pool(ctrl)
444 ctrl.mass.music.get_item_by_uri = AsyncMock(side_effect=MediaNotFoundError("gone"))
445
446 with pytest.raises(MediaNotFoundError):
447 await ctrl.play_media("q1", "test://track/gone", QueueOption.REPLACE_NEXT)
448
449 assert _queue(ctrl).is_dynamic is False
450 assert _queue(ctrl).shuffle_enabled is False
451 assert _queue(ctrl).smart_shuffle_active is False
452
453
454async def test_replace_next_over_a_dynamic_queue_drops_the_imposed_shuffle() -> None:
455 """
456 An album staged over a dynamic queue takes its source away, so the smart mix's shuffle goes too.
457
458 Staging normally leaves the shuffle state alone, but the shuffle a dynamic queue runs on is not
459 the user's own choice - its toggle is locked - so it must not silently reorder the album that
460 replaces it. Replace next is the only staging option that replaces the queue's sources.
461 """
462 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
463 _load_dynamic_pool(ctrl)
464
465 await ctrl.play_media("q1", _album(), QueueOption.REPLACE_NEXT)
466
467 assert _queue(ctrl).is_dynamic is False
468 assert _queue(ctrl).shuffle_enabled is False
469 assert _queue(ctrl).smart_shuffle_active is False
470 # the item playing, then the album in its own track order rather than the (reversed) shuffle
471 assert _played_order(ctrl) == ["p1", *ALBUM_TRACKS]
472 # settled before the items were resolved: a shuffled queue asks the resolver to keep the items
473 # preceding a chosen track, an in-order one does not
474 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
475 assert resolve_call.kwargs["keep_preceding_items"] is False
476
477
478async def test_replace_next_that_leaves_the_queue_dynamic_keeps_the_smart_mix() -> None:
479 """Staging another dynamic source keeps the queue an always-on smart mix, shuffle included."""
480 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
481 _load_dynamic_pool(ctrl)
482
483 await ctrl.play_media("q1", _dynamic_playlist(), QueueOption.REPLACE_NEXT)
484
485 assert _queue(ctrl).is_dynamic is True
486 assert _queue(ctrl).shuffle_enabled is True
487 assert _queue(ctrl).smart_shuffle_active is True
488
489
490async def test_playing_an_album_on_an_ended_queue_switches_shuffle_off() -> None:
491 """A finished queue is started over by a play, and an album starts it over in its own order."""
492 ctrl = _controller(shuffle_enabled=True, ended=True)
493
494 await ctrl.play_media("q1", _album(), QueueOption.PLAY)
495
496 assert _queue(ctrl).shuffle_enabled is False
497 assert _played_order(ctrl) == ALBUM_TRACKS
498
499
500async def test_playing_a_playlist_on_an_ended_queue_keeps_shuffle() -> None:
501 """Restarting a finished queue is still not a reason to change a setting the user owns."""
502 ctrl = _controller(shuffle_enabled=True, ended=True)
503
504 await ctrl.play_media("q1", _playlist(), QueueOption.PLAY)
505
506 assert _queue(ctrl).shuffle_enabled is True
507 assert _played_order(ctrl) == ALBUM_TRACKS[::-1]
508
509
510@pytest.mark.parametrize("option", STAGE_OPTIONS)
511async def test_staging_media_onto_an_ended_queue_keeps_shuffle(option: QueueOption) -> None:
512 """
513 Staging media onto a finished queue keeps its shuffle, even though the queue restarts.
514
515 This is deliberate, not an oversight: the shuffle is only reset for the options the user reaches
516 for to start something now. Whether the queue happens to have played to its end does not change
517 what "queue this up" means, so it must not change the shuffle state either.
518 """
519 ctrl = _controller(shuffle_enabled=True, ended=True)
520
521 await ctrl.play_media("q1", _album(), option)
522
523 assert _queue(ctrl).shuffle_enabled is True
524 # the batch landed in shuffle order instead of the album's own track order
525 assert sorted(_played_order(ctrl)) == sorted(ALBUM_TRACKS)
526 assert _played_order(ctrl) != ALBUM_TRACKS
527
528
529@pytest.mark.parametrize("shuffle_enabled", [True, False])
530@pytest.mark.parametrize("option", STAGE_OPTIONS)
531async def test_enqueueing_leaves_shuffle_untouched(
532 option: QueueOption, shuffle_enabled: bool
533) -> None:
534 """
535 Staging media onto the queue is not a new listening session, so it keeps its shuffle.
536
537 These all keep (part of) the existing queue, whose items are already in shuffled order, so
538 switching shuffle off here would leave those items contradicting the queue's own flag.
539 """
540 # a running queue, as opposed to the finished one the staging options start over from
541 ctrl = _controller(shuffle_enabled=shuffle_enabled, ended=False)
542
543 await ctrl.play_media("q1", _album(), option)
544
545 assert _queue(ctrl).shuffle_enabled is shuffle_enabled
546
547
548@pytest.mark.parametrize("option", STAGE_OPTIONS)
549async def test_an_explicit_shuffle_is_ignored_by_the_staging_options(option: QueueOption) -> None:
550 """
551 Only the options that start the media right away act on an explicit shuffle request.
552
553 Staging keeps (part of) the existing queue, whose items are already in the order its current
554 shuffle put them, so honouring a request here would leave them contradicting the toggle.
555 """
556 ctrl = _controller(shuffle_enabled=False)
557
558 await ctrl.play_media("q1", _playlist(), option, shuffle=True)
559
560 assert _queue(ctrl).shuffle_enabled is False
561
562
563def test_clear_command_resets_shuffle() -> None:
564 """Clearing the queue is an explicit "start over", so the shuffle goes with the content."""
565 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True)
566 ctrl._queue_data["q1"].items = [
567 QueueItem.from_media_item("q1", _track(item_id)) for item_id in ALBUM_TRACKS
568 ]
569
570 ctrl.clear("q1")
571
572 assert _queue(ctrl).shuffle_enabled is False
573 assert _queue(ctrl).smart_shuffle_active is False
574
575
576def test_empty_queue_reaching_its_end_keeps_shuffle() -> None:
577 """An end reached with nothing to replay is still not the user clearing the queue."""
578 ctrl = _controller(shuffle_enabled=True)
579
580 ctrl.mark_ended("q1")
581
582 assert ctrl._queue_data["q1"].items == []
583 assert _queue(ctrl).shuffle_enabled is True
584