/
/
/
1"""Tests for Music Quiz source resolution."""
2
3from __future__ import annotations
4
5import logging
6from collections.abc import AsyncGenerator, Mapping
7from datetime import UTC, datetime
8from types import SimpleNamespace
9from unittest.mock import AsyncMock, MagicMock, call, patch
10
11import pytest
12from music_assistant_models.enums import MediaType
13from music_assistant_models.errors import InvalidDataError
14from music_assistant_models.media_items import (
15 Album,
16 Artist,
17 Genre,
18 ItemMapping,
19 Playlist,
20 ProviderMapping,
21 Track,
22)
23from music_assistant_models.unique_list import UniqueList
24
25from music_assistant.controllers.music.recency import RecencySnapshot
26from music_assistant.providers.music_quiz import MusicQuizPlugin
27from music_assistant.providers.music_quiz.models import (
28 MultipleChoiceRoundState,
29 MusicQuizConfig,
30 TimelineRoundState,
31)
32from music_assistant.providers.music_quiz.quiz_types.base import (
33 GENRE_TRACK_PAGE_SIZE,
34 MAX_GENRE_TRACK_COUNT,
35)
36from music_assistant.providers.music_quiz.quiz_types.guess_the_song import GuessTheSongQuizType
37from music_assistant.providers.music_quiz.quiz_types.music_timeline import MusicTimelineQuizType
38from music_assistant.providers.music_quiz.quiz_types.trivia import (
39 TriviaGeneration,
40 TriviaQuizType,
41)
42
43SourceItem = Album | Artist | Genre | ItemMapping | Playlist | Track
44
45
46def _track(
47 item_id: str,
48 *,
49 year: int | None = None,
50 available: bool = True,
51 is_playable: bool = True,
52) -> Track:
53 """Return a minimal playable track."""
54 provider = "provider"
55 track = Track(
56 item_id=item_id,
57 provider=provider,
58 name=f"Track {item_id}",
59 duration=180,
60 is_playable=is_playable,
61 artists=UniqueList(
62 [
63 ItemMapping(
64 media_type=MediaType.ARTIST,
65 item_id=f"artist-{item_id}",
66 provider=provider,
67 name=f"Artist {item_id}",
68 )
69 ]
70 ),
71 provider_mappings={
72 ProviderMapping(
73 item_id=item_id,
74 provider_domain=provider,
75 provider_instance=provider,
76 available=available,
77 )
78 },
79 )
80 if year is not None:
81 track.metadata.release_date = datetime(year, 1, 1, tzinfo=UTC)
82 return track
83
84
85def _uri(item: SourceItem) -> str:
86 """Return the generated URI of a test media item."""
87 assert item.uri is not None
88 return item.uri
89
90
91def _playlist(item_id: str = "playlist") -> Playlist:
92 """Return a minimal playlist source."""
93 return Playlist(
94 item_id=item_id,
95 provider="provider",
96 name="Playlist",
97 provider_mappings=set(),
98 )
99
100
101def _album(item_id: str = "album") -> Album:
102 """Return a minimal album source."""
103 return Album(
104 item_id=item_id,
105 provider="provider",
106 name="Album",
107 provider_mappings=set(),
108 )
109
110
111def _artist(item_id: str = "artist") -> Artist:
112 """Return a minimal artist source."""
113 return Artist(
114 item_id=item_id,
115 provider="provider",
116 name="Artist",
117 provider_mappings=set(),
118 )
119
120
121def _genre(item_id: str = "42") -> Genre:
122 """Return a minimal library genre source."""
123 return Genre(
124 item_id=item_id,
125 provider="library",
126 name="Genre",
127 provider_mappings=set(),
128 )
129
130
131async def _yield_tracks(tracks: list[Track]) -> AsyncGenerator[Track]:
132 """Yield the provided playlist tracks."""
133 for track in tracks:
134 yield track
135
136
137def _mass(source_items: Mapping[str, object]) -> MagicMock:
138 """Return a Music Assistant mock with empty source controllers."""
139 mass = MagicMock()
140
141 async def _get_item(
142 *,
143 media_type: MediaType,
144 item_id: str,
145 provider_instance_id_or_domain: str,
146 allow_update_metadata: bool,
147 ) -> object:
148 assert allow_update_metadata is False
149 uri = f"{provider_instance_id_or_domain}://{media_type.value}/{item_id}"
150 return source_items[uri]
151
152 mass.music.get_item = AsyncMock(side_effect=_get_item)
153 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([]))
154 mass.music.albums.tracks = AsyncMock(return_value=[])
155 mass.music.artists.tracks = AsyncMock(return_value=[])
156 mass.music.genres.tracks = AsyncMock(return_value=[])
157 mass.music.search = AsyncMock(return_value=SimpleNamespace(tracks=[]))
158 mass.music.tracks.get = AsyncMock()
159 mass.music.recency.snapshot = AsyncMock(return_value=RecencySnapshot(now=0))
160 mass.metadata.get_image_url_for_item = AsyncMock(return_value=None)
161 mass.get_providers_supporting_feature = MagicMock(return_value=[])
162 mass.get_provider = MagicMock(return_value=None)
163 mass.player_queues = MagicMock()
164 return mass
165
166
167def _guess_quiz(
168 mass: MagicMock,
169 source_uris: list[str],
170 *,
171 round_count: int = 1,
172 suggestion_count: int = 2,
173 include_similar_music: bool = False,
174) -> GuessTheSongQuizType:
175 """Return a Guess the Song quiz for source-resolution tests."""
176 return GuessTheSongQuizType(
177 mass,
178 MusicQuizConfig(
179 round_count=round_count,
180 suggestion_count=suggestion_count,
181 source_uris=source_uris,
182 include_similar_music=include_similar_music,
183 ),
184 )
185
186
187@pytest.mark.asyncio
188async def test_track_source_resolves_to_itself() -> None:
189 """Use a selected track directly without traversing a collection controller."""
190 track = _track("one")
191 track_uri = _uri(track)
192 mass = _mass({track_uri: track})
193
194 pool = await _guess_quiz(mass, [track_uri])._get_source_track_pool()
195
196 assert pool == {track_uri: track}
197 mass.music.playlists.tracks.assert_not_called()
198 mass.music.albums.tracks.assert_not_awaited()
199 mass.music.artists.tracks.assert_not_awaited()
200 mass.music.genres.tracks.assert_not_awaited()
201
202
203@pytest.mark.asyncio
204async def test_playlist_source_uses_playlist_tracks() -> None:
205 """Resolve playlist sources through the playlist track iterator."""
206 source = _playlist()
207 track = _track("one")
208 source_uri = _uri(source)
209 track_uri = _uri(track)
210 mass = _mass({source_uri: source})
211 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([track]))
212
213 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
214
215 assert pool == {track_uri: track}
216 mass.music.playlists.tracks.assert_called_once_with(
217 item_id=source.item_id,
218 provider_instance_id_or_domain=source.provider,
219 )
220
221
222@pytest.mark.asyncio
223async def test_album_source_uses_exact_album_tracks() -> None:
224 """Resolve album sources through the album controller."""
225 source = _album()
226 tracks = [_track("one"), _track("two")]
227 source_uri = _uri(source)
228 mass = _mass({source_uri: source})
229 mass.music.albums.tracks.return_value = tracks
230
231 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
232
233 assert list(pool.values()) == tracks
234 mass.music.albums.tracks.assert_awaited_once_with(
235 item_id=source.item_id,
236 provider_instance_id_or_domain=source.provider,
237 )
238
239
240@pytest.mark.asyncio
241async def test_artist_source_uses_all_artist_tracks() -> None:
242 """Resolve artist sources through the finite artist track controller."""
243 source = _artist()
244 tracks = [_track("one"), _track("two")]
245 source_uri = _uri(source)
246 mass = _mass({source_uri: source})
247 mass.music.artists.tracks.return_value = tracks
248
249 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
250
251 assert list(pool.values()) == tracks
252 mass.music.artists.tracks.assert_awaited_once_with(
253 item_id=source.item_id,
254 provider_instance_id_or_domain=source.provider,
255 )
256
257
258@pytest.mark.asyncio
259async def test_genre_source_uses_exact_genre_tracks_with_bounded_pages() -> None:
260 """Resolve exact genre membership in bounded pages."""
261 source = _genre()
262 tracks = [_track(str(index)) for index in range(GENRE_TRACK_PAGE_SIZE + 1)]
263 source_uri = _uri(source)
264 mass = _mass({source_uri: source})
265
266 async def _genre_tracks(*, item_id: str, limit: int, offset: int) -> list[Track]:
267 assert item_id == source.item_id
268 return tracks[offset : offset + limit]
269
270 mass.music.genres.tracks.side_effect = _genre_tracks
271
272 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
273
274 assert list(pool.values()) == tracks
275 assert mass.music.genres.tracks.await_args_list == [
276 call(item_id=source.item_id, limit=GENRE_TRACK_PAGE_SIZE, offset=0),
277 call(
278 item_id=source.item_id,
279 limit=GENRE_TRACK_PAGE_SIZE,
280 offset=GENRE_TRACK_PAGE_SIZE,
281 ),
282 ]
283
284
285@pytest.mark.asyncio
286async def test_genre_source_stops_at_controller_default_bound() -> None:
287 """Never load more than the established bounded genre result size."""
288 source = _genre()
289 tracks = [_track(str(index)) for index in range(MAX_GENRE_TRACK_COUNT + 1)]
290 source_uri = _uri(source)
291 mass = _mass({source_uri: source})
292
293 async def _genre_tracks(*, item_id: str, limit: int, offset: int) -> list[Track]:
294 assert item_id == source.item_id
295 return tracks[offset : offset + limit]
296
297 mass.music.genres.tracks.side_effect = _genre_tracks
298
299 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
300
301 assert len(pool) == MAX_GENRE_TRACK_COUNT
302 assert mass.music.genres.tracks.await_count == (MAX_GENRE_TRACK_COUNT // GENRE_TRACK_PAGE_SIZE)
303 mass.music.genres.tracks.assert_awaited_with(
304 item_id=source.item_id,
305 limit=GENRE_TRACK_PAGE_SIZE,
306 offset=MAX_GENRE_TRACK_COUNT - GENRE_TRACK_PAGE_SIZE,
307 )
308
309
310@pytest.mark.asyncio
311async def test_mixed_sources_deduplicate_and_skip_failure() -> None:
312 """Deduplicate track URIs while allowing another source to fail."""
313 direct = _track("shared")
314 album = _album()
315 extra = _track("extra")
316 direct_uri = _uri(direct)
317 album_uri = _uri(album)
318 extra_uri = _uri(extra)
319 mass = _mass({direct_uri: direct, album_uri: album})
320 mass.music.get_item.side_effect = [
321 RuntimeError("unavailable"),
322 direct,
323 album,
324 ]
325 mass.music.albums.tracks.return_value = [direct, extra]
326
327 pool = await _guess_quiz(
328 mass,
329 ["provider://playlist/unavailable", direct_uri, album_uri],
330 )._get_source_track_pool()
331
332 assert list(pool) == [direct_uri, extra_uri]
333
334
335@pytest.mark.asyncio
336async def test_default_source_pool_does_not_request_similar_music() -> None:
337 """Keep the complete exact playlist pool unchanged when expansion is disabled."""
338 source = _playlist()
339 tracks = [_track("one"), _track("two")]
340 source_uri = _uri(source)
341 mass = _mass({source_uri: source})
342 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks(tracks))
343 radio_provider = MagicMock()
344 radio_provider.get_dynamic_tracks = AsyncMock(return_value=[_track("similar")])
345 mass.get_provider.return_value = radio_provider
346
347 pool = await _guess_quiz(mass, [source_uri])._get_source_track_pool()
348
349 assert list(pool.values()) == tracks
350 mass.get_provider.assert_not_called()
351 radio_provider.get_dynamic_tracks.assert_not_awaited()
352
353
354@pytest.mark.parametrize(
355 ("round_count", "suggestion_count", "expected_target"),
356 [(1, 2, 25), (100, 12, 50)],
357)
358@pytest.mark.asyncio
359async def test_similar_music_uses_resolved_seeds_and_bounded_target(
360 round_count: int,
361 suggestion_count: int,
362 expected_target: int,
363) -> None:
364 """Call radio once with selected media seeds and no playback-queue state."""
365 source = _playlist()
366 exact = _track("exact")
367 expanded = _track("expanded")
368 source_uri = _uri(source)
369 mass = _mass({source_uri: source})
370 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
371 radio_provider = MagicMock()
372 radio_provider.get_dynamic_tracks = AsyncMock(return_value=[expanded])
373 mass.get_provider.return_value = radio_provider
374 quiz = _guess_quiz(
375 mass,
376 [source_uri],
377 round_count=round_count,
378 suggestion_count=suggestion_count,
379 include_similar_music=True,
380 )
381
382 pool = await quiz._get_source_track_pool()
383 cached_pool = await quiz._get_source_track_pool()
384
385 assert cached_pool is pool
386 assert list(pool.values()) == [exact, expanded]
387 radio_provider.get_dynamic_tracks.assert_awaited_once_with(
388 [source],
389 include_base_tracks=False,
390 target_size=expected_target,
391 preferred_provider_instances=["provider"],
392 )
393 assert mass.player_queues.mock_calls == []
394
395
396@pytest.mark.asyncio
397async def test_similar_music_preserves_exact_tracks_and_filters_expansion() -> None:
398 """Retain exact tracks while deduplicating and filtering radio candidates."""
399 source = _playlist()
400 exact = _track("exact")
401 duplicate = _track("exact")
402 expanded = _track("expanded")
403 unavailable = _track("unavailable", available=False)
404 unplayable = _track("unplayable", is_playable=False)
405 source_uri = _uri(source)
406 mass = _mass({source_uri: source})
407 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
408 radio_provider = MagicMock()
409 radio_provider.get_dynamic_tracks = AsyncMock(
410 return_value=[duplicate, expanded, unavailable, unplayable]
411 )
412 mass.get_provider.return_value = radio_provider
413
414 pool = await _guess_quiz(
415 mass,
416 [source_uri],
417 include_similar_music=True,
418 )._get_source_track_pool()
419
420 assert list(pool.values()) == [exact, expanded]
421 assert pool[_uri(exact)] is exact
422
423
424@pytest.mark.parametrize("radio_mode", ["missing", "empty", "filtered", "error"])
425@pytest.mark.asyncio
426async def test_similar_music_failure_falls_back_to_exact_pool(
427 radio_mode: str,
428 caplog: pytest.LogCaptureFixture,
429) -> None:
430 """Treat unavailable, empty, and failed radio expansion as optional."""
431 caplog.set_level(logging.DEBUG)
432 source = _playlist()
433 exact = _track("exact")
434 source_uri = _uri(source)
435 mass = _mass({source_uri: source})
436 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
437 if radio_mode != "missing":
438 radio_provider = MagicMock()
439 radio_provider.get_dynamic_tracks = AsyncMock(
440 return_value=(
441 [_track("unavailable", available=False)] if radio_mode == "filtered" else []
442 ),
443 side_effect=RuntimeError("radio failed") if radio_mode == "error" else None,
444 )
445 mass.get_provider.return_value = radio_provider
446
447 pool = await _guess_quiz(
448 mass,
449 [source_uri],
450 include_similar_music=True,
451 )._get_source_track_pool()
452
453 assert pool == {_uri(exact): exact}
454 assert any(
455 "similar" in record.message.lower() or "radio" in record.message.lower()
456 for record in caplog.records
457 )
458
459
460@pytest.mark.asyncio
461async def test_guess_the_song_can_select_an_expanded_track() -> None:
462 """Apply normal distractor validation when an expanded track is selected."""
463 source = _playlist()
464 exact = _track("exact")
465 expanded = _track("expanded")
466 distractor = _track("distractor")
467 source_uri = _uri(source)
468 mass = _mass({source_uri: source})
469 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
470 mass.music.search.return_value = SimpleNamespace(tracks=[distractor])
471 radio_provider = MagicMock()
472 radio_provider.get_dynamic_tracks = AsyncMock(return_value=[expanded])
473 mass.get_provider.return_value = radio_provider
474 quiz = _guess_quiz(
475 mass,
476 [source_uri],
477 include_similar_music=True,
478 )
479
480 with patch(
481 "music_assistant.providers.music_quiz.quiz_types.guess_the_song.secrets.choice",
482 return_value=expanded,
483 ):
484 game_round = await quiz.prepare_round(0, [])
485
486 assert isinstance(game_round.answer_state, MultipleChoiceRoundState)
487 assert game_round.track_uri == expanded.uri
488 assert len(game_round.answer_state.suggestions) == 2
489 assert {suggestion.uri for suggestion in game_round.answer_state.suggestions} == {
490 expanded.uri,
491 distractor.uri,
492 }
493
494
495@pytest.mark.asyncio
496async def test_music_timeline_applies_date_rules_to_expanded_tracks() -> None:
497 """Select dated expansion while excluding expanded tracks without a usable year."""
498 source = _playlist()
499 exact = _track("exact", year=1990)
500 expanded = _track("expanded", year=2000)
501 undated = _track("undated")
502 source_uri = _uri(source)
503 mass = _mass({source_uri: source})
504 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
505 mass.music.tracks.get.return_value = undated
506 radio_provider = MagicMock()
507 radio_provider.get_dynamic_tracks = AsyncMock(return_value=[expanded, undated])
508 mass.get_provider.return_value = radio_provider
509 quiz = MusicTimelineQuizType(
510 mass,
511 MusicQuizConfig(
512 round_count=1,
513 source_uris=[source_uri],
514 include_similar_music=True,
515 ),
516 )
517
518 await quiz.initialize()
519 eligible_tracks = await quiz._get_eligible_tracks()
520 game_round = await quiz.prepare_round(0, [])
521
522 assert isinstance(game_round.answer_state, TimelineRoundState)
523 assert {track.uri for track in eligible_tracks} == {exact.uri, expanded.uri}
524 assert quiz._source_track_pool is not None
525 assert undated.uri in quiz._source_track_pool
526 assert {
527 game_round.answer_state.placement_snapshot[0].track_uri,
528 game_round.answer_state.candidate.entry.track_uri,
529 } == {exact.uri, expanded.uri}
530
531
532@pytest.mark.asyncio
533async def test_trivia_applies_grounding_rules_to_expanded_tracks() -> None:
534 """Select grounded expansion while excluding candidates without factual context."""
535 source = _playlist()
536 exact = _track("exact")
537 expanded = _track("expanded")
538 ungrounded = Track(
539 item_id="ungrounded",
540 provider="provider",
541 name="Ungrounded",
542 provider_mappings={
543 ProviderMapping(
544 item_id="ungrounded",
545 provider_domain="provider",
546 provider_instance="provider",
547 )
548 },
549 )
550 source_uri = _uri(source)
551 mass = _mass({source_uri: source})
552 mass.music.playlists.tracks = MagicMock(return_value=_yield_tracks([exact]))
553 radio_provider = MagicMock()
554 radio_provider.get_dynamic_tracks = AsyncMock(return_value=[expanded, ungrounded])
555 mass.get_provider.return_value = radio_provider
556 quiz = TriviaQuizType(
557 mass,
558 MusicQuizConfig(
559 round_count=1,
560 suggestion_count=2,
561 source_uris=[source_uri],
562 include_similar_music=True,
563 ),
564 )
565
566 eligible_tracks = await quiz._get_eligible_tracks()
567 generation = TriviaGeneration(
568 question="Which artist recorded this track?",
569 wrong_answers=("Different Artist",),
570 )
571 with (
572 patch.object(quiz, "_generate_question", new=AsyncMock(return_value=generation)),
573 patch(
574 "music_assistant.providers.music_quiz.quiz_types.trivia.SYSTEM_RANDOM.choice",
575 return_value=eligible_tracks[_uri(expanded)],
576 ),
577 ):
578 game_round = await quiz.prepare_round(0, [])
579
580 assert isinstance(game_round.answer_state, MultipleChoiceRoundState)
581 assert set(eligible_tracks) == {exact.uri, expanded.uri}
582 assert game_round.track_uri == expanded.uri
583 correct = next(
584 suggestion for suggestion in game_round.answer_state.suggestions if suggestion.is_correct
585 )
586 assert correct.uri == expanded.uri
587
588
589@pytest.mark.parametrize(
590 "source_uris",
591 [
592 [],
593 [
594 "provider://radio/one",
595 "provider://podcast/two",
596 "provider://podcast_episode/three",
597 "provider://audiobook/four",
598 ],
599 ],
600)
601@pytest.mark.asyncio
602async def test_empty_or_unsupported_sources_raise_localized_error(source_uris: list[str]) -> None:
603 """Reject unsupported-only selections without traversing their controllers."""
604 source_items = {
605 source_uri: ItemMapping(
606 media_type=media_type,
607 item_id=str(index),
608 provider="provider",
609 name=media_type.value,
610 )
611 for index, (source_uri, media_type) in enumerate(
612 zip(
613 source_uris,
614 (
615 MediaType.RADIO,
616 MediaType.PODCAST,
617 MediaType.PODCAST_EPISODE,
618 MediaType.AUDIOBOOK,
619 ),
620 strict=False,
621 )
622 )
623 }
624 mass = _mass(source_items)
625
626 with pytest.raises(InvalidDataError) as err:
627 await _guess_quiz(mass, source_uris)._get_source_track_pool()
628
629 assert err.value.translation_key == "music_quiz_sources_unavailable"
630 mass.music.get_item.assert_not_awaited()
631 mass.music.playlists.tracks.assert_not_called()
632 mass.music.albums.tracks.assert_not_awaited()
633 mass.music.artists.tracks.assert_not_awaited()
634 mass.music.genres.tracks.assert_not_awaited()
635
636
637@pytest.mark.asyncio
638async def test_non_library_genre_source_raises_localized_error() -> None:
639 """Reject provider genre URIs before resolving or traversing them."""
640 source_uri = "provider://genre/42"
641 mass = _mass({})
642
643 with pytest.raises(InvalidDataError) as err:
644 await _guess_quiz(mass, [source_uri])._get_source_track_pool()
645
646 assert err.value.translation_key == "music_quiz_sources_unavailable"
647 mass.music.get_item.assert_not_awaited()
648 mass.music.genres.tracks.assert_not_awaited()
649
650
651@pytest.mark.asyncio
652async def test_host_source_metadata_ignores_unsupported_types() -> None:
653 """Expose metadata only for accepted Music Quiz source types."""
654 playlist = _playlist()
655 radio = ItemMapping(
656 media_type=MediaType.RADIO,
657 item_id="radio",
658 provider="provider",
659 name="Radio",
660 )
661 plugin = MusicQuizPlugin.__new__(MusicQuizPlugin)
662 playlist_uri = _uri(playlist)
663 radio_uri = _uri(radio)
664 plugin.mass = _mass({playlist_uri: playlist, radio_uri: radio})
665 plugin.logger = MagicMock()
666
667 sources = await plugin._resolve_sources(
668 [
669 playlist_uri,
670 radio_uri,
671 "provider://genre/42",
672 "provider://podcast/unavailable",
673 "not-a-uri",
674 ]
675 )
676
677 assert [source.to_dict() for source in sources] == [
678 {
679 "uri": playlist_uri,
680 "name": playlist.name,
681 "media_type": MediaType.PLAYLIST.value,
682 }
683 ]
684 plugin.mass.music.get_item.assert_awaited_once_with(
685 media_type=MediaType.PLAYLIST,
686 item_id=playlist.item_id,
687 provider_instance_id_or_domain=playlist.provider,
688 allow_update_metadata=False,
689 )
690
691
692@pytest.mark.asyncio
693async def test_guess_the_song_prepares_from_album_source() -> None:
694 """Prepare Guess the Song unchanged from an expanded source collection."""
695 album = _album()
696 source_track = _track("source")
697 distractor = _track("distractor")
698 album_uri = _uri(album)
699 source_track_uri = _uri(source_track)
700 mass = _mass({album_uri: album})
701 mass.music.albums.tracks.return_value = [source_track]
702 mass.music.search.return_value = SimpleNamespace(tracks=[distractor])
703 quiz = _guess_quiz(mass, [album_uri])
704
705 game_round = await quiz.prepare_round(0, [])
706
707 assert game_round.track_uri == source_track_uri
708 assert game_round.answer_label == f"{source_track.artist_str} - {source_track.name}"
709
710
711@pytest.mark.asyncio
712async def test_music_timeline_prepares_from_artist_source() -> None:
713 """Prepare Music Timeline unchanged from an expanded source collection."""
714 artist = _artist()
715 tracks = [_track("one", year=1990), _track("two", year=2000)]
716 artist_uri = _uri(artist)
717 mass = _mass({artist_uri: artist})
718 mass.music.artists.tracks.return_value = tracks
719 quiz = MusicTimelineQuizType(
720 mass,
721 MusicQuizConfig(round_count=1, source_uris=[artist_uri]),
722 )
723
724 await quiz.initialize()
725 game_round = await quiz.prepare_round(0, [])
726
727 assert game_round.track_uri in {track.uri for track in tracks}
728 mass.music.tracks.get.assert_not_awaited()
729