/
/
/
1"""Test Yandex Music Recommendations."""
2
3from __future__ import annotations
4
5import json
6import pathlib
7from datetime import UTC, datetime
8from typing import Any
9from unittest.mock import AsyncMock, Mock, 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 ItemMapping,
18 Playlist,
19 RecommendationFolder,
20 Track,
21 UniqueList,
22)
23from yandex_music import Track as YandexTrack
24
25from music_assistant.providers.yandex_music.constants import (
26 MY_WAVE_PLAYLIST_ID,
27 RADIO_TRACK_ID_SEP,
28 ROTOR_STATION_MY_WAVE,
29)
30from music_assistant.providers.yandex_music.provider import YandexMusicProvider, _WaveState
31from tests.common import use_real_create_task
32
33from .conftest import DE_JSON_CLIENT, provider_dir
34
35_RECOMMENDATION_STRINGS = json.loads((provider_dir() / "strings.json").read_text(encoding="utf-8"))[
36 "media"
37]["recommendations"]
38
39
40def _media_item_mock(spec: type) -> Mock:
41 """
42 Return a media item stand-in that a copy hands back unchanged.
43
44 A cached result is copied per caller, which real media items survive because they
45 compare by value. A mock compares by identity, so keep it out of the copy.
46
47 :param spec: The media item class the mock stands in for.
48 """
49 item = Mock(spec=spec)
50 item.__deepcopy__ = lambda _memo: item
51 return item
52
53
54@pytest.fixture
55def provider_mock() -> Mock:
56 """Return a mock Yandex Music provider."""
57 provider = Mock(spec=YandexMusicProvider)
58 provider.domain = "yandex_music"
59 provider.instance_id = "yandex_music_instance"
60 provider.logger = Mock()
61
62 # Mock client
63 provider.client = AsyncMock()
64 provider.client.user_id = 12345
65
66 # Mock config
67 provider.config = Mock()
68 provider.config.get_value = Mock(side_effect=lambda key: 150 if "max_tracks" in key else None)
69
70 # Mock mass with cache
71 provider.mass = Mock()
72 provider.mass.metadata = Mock()
73 provider.mass.metadata.locale = "en_US"
74 provider.mass.cache = AsyncMock()
75 provider.mass.cache.get = AsyncMock(return_value=None) # Cache always misses
76 provider.mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
77 provider.mass.cache.set = AsyncMock()
78 use_real_create_task(provider.mass)
79
80 # Resolve media labels through the real helper; unauthored keys fall back.
81 provider.mass.translations.get_translation = Mock(return_value=None)
82 provider._media_label = YandexMusicProvider._media_label.__get__(provider, YandexMusicProvider)
83
84 return provider
85
86
87def _install_wave_state(provider_mock: Mock) -> _WaveState:
88 """Stub _get_wave_state to return a fresh in-memory _WaveState per provider_mock."""
89 wave = _WaveState()
90 provider_mock._get_wave_state = Mock(return_value=wave)
91 return wave
92
93
94@pytest.mark.asyncio
95async def test_get_my_wave_recommendations_success(provider_mock: Mock) -> None:
96 """Test _get_my_wave_recommendations returns data when session API provides tracks."""
97 _install_wave_state(provider_mock)
98 mock_track = Mock()
99 mock_track.id = "12345"
100 mock_track.track_id = "12345"
101
102 # Mock the session-API helper; return the same track every time â matches
103 # the old single-track-per-batch test intent where the fake rotor returns
104 # the same shape across repeated batch calls.
105 provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([mock_track], "batch_a"))
106
107 mock_parsed_track = _media_item_mock(Track)
108 mock_parsed_track.item_id = f"12345{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
109 mock_parsed_track.name = "Test Track"
110 mock_parsed_track.provider_mappings = []
111 provider_mock._parse_my_wave_track = Mock(return_value=mock_parsed_track)
112
113 result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock)
114
115 assert result is not None
116 assert isinstance(result, RecommendationFolder)
117 assert result.item_id == MY_WAVE_PLAYLIST_ID
118 assert result.provider == provider_mock.instance_id
119 assert result.name == _RECOMMENDATION_STRINGS[MY_WAVE_PLAYLIST_ID]["name"]
120 assert result.translation_key == MY_WAVE_PLAYLIST_ID
121 assert result.icon == "mdi-waveform"
122 assert len(result.items) > 0
123
124
125@pytest.mark.asyncio
126async def test_get_my_wave_recommendations_empty(provider_mock: Mock) -> None:
127 """Test _get_my_wave_recommendations returns None when session API yields no tracks."""
128 _install_wave_state(provider_mock)
129 provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([], None))
130
131 result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock)
132
133 assert result is None
134
135
136@pytest.mark.asyncio
137async def test_get_my_wave_recommendations_duplicate_filtering(provider_mock: Mock) -> None:
138 """Test _get_my_wave_recommendations filters duplicate tracks across batches."""
139 _install_wave_state(provider_mock)
140 mock_track1 = Mock()
141 mock_track1.id = "12345"
142 mock_track1.track_id = "12345"
143
144 mock_track2 = Mock()
145 mock_track2.id = "12345" # Same ID
146 mock_track2.track_id = "12345"
147
148 # First batch returns track1, second batch returns track2 (duplicate)
149 provider_mock._fetch_rotor_session_batch = AsyncMock(
150 side_effect=[
151 ([mock_track1], "batch_a"),
152 ([mock_track2], "batch_b"),
153 ]
154 )
155
156 mock_parsed_track = _media_item_mock(Track)
157 mock_parsed_track.item_id = f"12345{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
158 mock_parsed_track.name = "Test Track"
159 mock_parsed_track.provider_mappings = []
160
161 # _parse_my_wave_track returns track on first call, None on duplicate
162 provider_mock._parse_my_wave_track = Mock(side_effect=[mock_parsed_track, None])
163
164 result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock)
165
166 assert result is not None
167 # Should only have 1 track despite 2 API calls (duplicate filtered)
168 assert len(result.items) == 1
169
170
171@pytest.mark.asyncio
172async def test_get_my_wave_recommendations_invalid_data_error(provider_mock: Mock) -> None:
173 """Test _get_my_wave_recommendations handles parse failures gracefully."""
174 _install_wave_state(provider_mock)
175 mock_track = Mock()
176 mock_track.id = "12345"
177 mock_track.track_id = "12345"
178
179 provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([mock_track], "batch_a"))
180
181 # _parse_my_wave_track returns None (simulates parse error handled internally)
182 provider_mock._parse_my_wave_track = Mock(return_value=None)
183
184 result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock)
185
186 # Should return None as no valid tracks were parsed
187 assert result is None
188
189
190@pytest.mark.asyncio
191async def test_get_feed_recommendations_success(provider_mock: Mock) -> None:
192 """Test _get_feed_recommendations returns data when API provides feed."""
193 # Mock feed with generated playlists
194 mock_gen_playlist = Mock()
195 mock_gen_playlist.ready = True
196 mock_gen_playlist.data = Mock() # Playlist data
197
198 mock_feed = Mock()
199 mock_feed.generated_playlists = [mock_gen_playlist]
200
201 provider_mock.client.get_feed = AsyncMock(return_value=mock_feed)
202
203 # Mock parse_playlist
204 mock_parsed_playlist = Mock(spec=Playlist)
205 mock_parsed_playlist.item_id = "playlist_1"
206 mock_parsed_playlist.name = "Playlist of the Day"
207
208 with patch(
209 "music_assistant.providers.yandex_music.provider.parse_playlist",
210 return_value=mock_parsed_playlist,
211 ):
212 result = await YandexMusicProvider._get_feed_recommendations(provider_mock)
213
214 assert result is not None
215 assert isinstance(result, RecommendationFolder)
216 assert result.item_id == "feed"
217 assert result.provider == provider_mock.instance_id
218 assert result.name == _RECOMMENDATION_STRINGS["feed"]["name"]
219 assert result.translation_key == "feed"
220 assert result.icon == "mdi-account-music"
221 assert len(result.items) > 0
222
223
224@pytest.mark.asyncio
225async def test_get_feed_recommendations_empty(provider_mock: Mock) -> None:
226 """Test _get_feed_recommendations returns None when feed is empty."""
227 provider_mock.client.get_feed = AsyncMock(return_value=None)
228
229 result = await YandexMusicProvider._get_feed_recommendations(provider_mock)
230
231 assert result is None
232
233
234@pytest.mark.asyncio
235async def test_get_feed_recommendations_no_generated_playlists(provider_mock: Mock) -> None:
236 """Test _get_feed_recommendations returns None when no generated playlists."""
237 mock_feed = Mock()
238 mock_feed.generated_playlists = []
239
240 provider_mock.client.get_feed = AsyncMock(return_value=mock_feed)
241
242 result = await YandexMusicProvider._get_feed_recommendations(provider_mock)
243
244 assert result is None
245
246
247@pytest.mark.asyncio
248async def test_get_feed_recommendations_invalid_data_error(provider_mock: Mock) -> None:
249 """Test _get_feed_recommendations handles InvalidDataError gracefully."""
250 mock_gen_playlist = Mock()
251 mock_gen_playlist.ready = True
252 mock_gen_playlist.data = Mock()
253
254 mock_feed = Mock()
255 mock_feed.generated_playlists = [mock_gen_playlist]
256
257 provider_mock.client.get_feed = AsyncMock(return_value=mock_feed)
258
259 with patch(
260 "music_assistant.providers.yandex_music.provider.parse_playlist",
261 side_effect=InvalidDataError("Parse error"),
262 ):
263 result = await YandexMusicProvider._get_feed_recommendations(provider_mock)
264
265 assert result is None
266 provider_mock.logger.debug.assert_called()
267
268
269@pytest.mark.asyncio
270async def test_get_chart_recommendations_success(provider_mock: Mock) -> None:
271 """Test _get_chart_recommendations returns data when API provides chart."""
272 # Mock TrackShort with .track attribute
273 mock_track_short = Mock()
274 mock_track_obj = Mock() # The actual Track object
275 mock_track_short.track = mock_track_obj
276
277 mock_chart = Mock()
278 mock_chart.tracks = [mock_track_short]
279
280 mock_chart_info = Mock()
281 mock_chart_info.chart = mock_chart
282
283 provider_mock.client.get_chart = AsyncMock(return_value=mock_chart_info)
284
285 # Mock parse_track
286 mock_parsed_track = _media_item_mock(Track)
287 mock_parsed_track.item_id = "track_1"
288 mock_parsed_track.name = "Chart Track 1"
289
290 with patch(
291 "music_assistant.providers.yandex_music.provider.parse_track",
292 return_value=mock_parsed_track,
293 ):
294 result = await YandexMusicProvider._get_chart_recommendations(provider_mock)
295
296 assert result is not None
297 assert isinstance(result, RecommendationFolder)
298 assert result.item_id == "chart"
299 assert result.provider == provider_mock.instance_id
300 assert result.name == _RECOMMENDATION_STRINGS["chart"]["name"]
301 assert result.translation_key == "chart"
302 assert result.icon == "mdi-chart-line"
303 assert len(result.items) > 0
304
305
306@pytest.mark.asyncio
307async def test_get_chart_recommendations_empty(provider_mock: Mock) -> None:
308 """Test _get_chart_recommendations returns None when chart is empty."""
309 provider_mock.client.get_chart = AsyncMock(return_value=None)
310
311 result = await YandexMusicProvider._get_chart_recommendations(provider_mock)
312
313 assert result is None
314
315
316@pytest.mark.asyncio
317async def test_get_chart_recommendations_no_tracks(provider_mock: Mock) -> None:
318 """Test _get_chart_recommendations returns None when chart has no tracks."""
319 mock_chart = Mock()
320 mock_chart.tracks = []
321
322 mock_chart_info = Mock()
323 mock_chart_info.chart = mock_chart
324
325 provider_mock.client.get_chart = AsyncMock(return_value=mock_chart_info)
326
327 result = await YandexMusicProvider._get_chart_recommendations(provider_mock)
328
329 assert result is None
330
331
332@pytest.mark.asyncio
333async def test_get_chart_recommendations_invalid_data_error(provider_mock: Mock) -> None:
334 """Test _get_chart_recommendations handles InvalidDataError gracefully."""
335 mock_track_short = Mock()
336 mock_track_obj = Mock()
337 mock_track_short.track = mock_track_obj
338
339 mock_chart = Mock()
340 mock_chart.tracks = [mock_track_short]
341
342 mock_chart_info = Mock()
343 mock_chart_info.chart = mock_chart
344
345 provider_mock.client.get_chart = AsyncMock(return_value=mock_chart_info)
346
347 with patch(
348 "music_assistant.providers.yandex_music.provider.parse_track",
349 side_effect=InvalidDataError("Parse error"),
350 ):
351 result = await YandexMusicProvider._get_chart_recommendations(provider_mock)
352
353 assert result is None
354 provider_mock.logger.debug.assert_called()
355
356
357@pytest.mark.asyncio
358async def test_get_new_releases_recommendations_success(provider_mock: Mock) -> None:
359 """Test _get_new_releases_recommendations returns data when API provides releases."""
360 # Mock releases with album IDs
361 mock_releases = Mock()
362 mock_releases.new_releases = [123, 456, 789]
363
364 provider_mock.client.get_new_releases = AsyncMock(return_value=mock_releases)
365
366 # Mock get_albums to return album objects
367 mock_album = Mock()
368 provider_mock.client.get_albums = AsyncMock(return_value=[mock_album])
369
370 # Mock parse_album
371 mock_parsed_album = Mock(spec=Album)
372 mock_parsed_album.item_id = "album_1"
373 mock_parsed_album.name = "New Album"
374
375 with patch(
376 "music_assistant.providers.yandex_music.provider.parse_album",
377 return_value=mock_parsed_album,
378 ):
379 result = await YandexMusicProvider._get_new_releases_recommendations(provider_mock)
380
381 assert result is not None
382 assert isinstance(result, RecommendationFolder)
383 assert result.item_id == "new_releases"
384 assert result.provider == provider_mock.instance_id
385 assert result.name == _RECOMMENDATION_STRINGS["new_releases"]["name"]
386 assert result.translation_key == "new_releases"
387 assert result.icon == "mdi-new-box"
388 assert len(result.items) > 0
389
390
391@pytest.mark.asyncio
392async def test_get_new_releases_recommendations_empty(provider_mock: Mock) -> None:
393 """Test _get_new_releases_recommendations returns None when releases are empty."""
394 provider_mock.client.get_new_releases = AsyncMock(return_value=None)
395
396 result = await YandexMusicProvider._get_new_releases_recommendations(provider_mock)
397
398 assert result is None
399
400
401@pytest.mark.asyncio
402async def test_get_new_releases_recommendations_no_releases(provider_mock: Mock) -> None:
403 """Test _get_new_releases_recommendations returns None when no releases."""
404 mock_releases = Mock()
405 mock_releases.new_releases = []
406
407 provider_mock.client.get_new_releases = AsyncMock(return_value=mock_releases)
408
409 result = await YandexMusicProvider._get_new_releases_recommendations(provider_mock)
410
411 assert result is None
412
413
414@pytest.mark.asyncio
415async def test_get_new_releases_recommendations_invalid_data_error(provider_mock: Mock) -> None:
416 """Test _get_new_releases_recommendations handles InvalidDataError gracefully."""
417 mock_releases = Mock()
418 mock_releases.new_releases = [123]
419
420 provider_mock.client.get_new_releases = AsyncMock(return_value=mock_releases)
421 provider_mock.client.get_albums = AsyncMock(return_value=[Mock()])
422
423 with patch(
424 "music_assistant.providers.yandex_music.provider.parse_album",
425 side_effect=InvalidDataError("Parse error"),
426 ):
427 result = await YandexMusicProvider._get_new_releases_recommendations(provider_mock)
428
429 assert result is None
430 provider_mock.logger.debug.assert_called()
431
432
433@pytest.mark.asyncio
434async def test_get_new_playlists_recommendations_success(provider_mock: Mock) -> None:
435 """Test _get_new_playlists_recommendations returns data when API provides playlists."""
436 # Mock playlist ID object
437 mock_playlist_id = Mock()
438 mock_playlist_id.uid = "user123"
439 mock_playlist_id.kind = "456"
440
441 mock_result = Mock()
442 mock_result.new_playlists = [mock_playlist_id]
443
444 provider_mock.client.get_new_playlists = AsyncMock(return_value=mock_result)
445
446 # Mock get_playlists to return playlist objects
447 mock_playlist = Mock()
448 provider_mock.client.get_playlists = AsyncMock(return_value=[mock_playlist])
449
450 # Mock parse_playlist
451 mock_parsed_playlist = Mock(spec=Playlist)
452 mock_parsed_playlist.item_id = "playlist_1"
453 mock_parsed_playlist.name = "New Playlist"
454
455 with patch(
456 "music_assistant.providers.yandex_music.provider.parse_playlist",
457 return_value=mock_parsed_playlist,
458 ):
459 result = await YandexMusicProvider._get_new_playlists_recommendations(provider_mock)
460
461 assert result is not None
462 assert isinstance(result, RecommendationFolder)
463 assert result.item_id == "new_playlists"
464 assert result.provider == provider_mock.instance_id
465 assert result.name == _RECOMMENDATION_STRINGS["new_playlists"]["name"]
466 assert result.translation_key == "new_playlists"
467 assert result.icon == "mdi-playlist-star"
468 assert len(result.items) > 0
469
470
471@pytest.mark.asyncio
472async def test_get_new_playlists_recommendations_empty(provider_mock: Mock) -> None:
473 """Test _get_new_playlists_recommendations returns None when result is empty."""
474 provider_mock.client.get_new_playlists = AsyncMock(return_value=None)
475
476 result = await YandexMusicProvider._get_new_playlists_recommendations(provider_mock)
477
478 assert result is None
479
480
481@pytest.mark.asyncio
482async def test_get_new_playlists_recommendations_no_playlists(provider_mock: Mock) -> None:
483 """Test _get_new_playlists_recommendations returns None when no playlists."""
484 mock_result = Mock()
485 mock_result.new_playlists = []
486
487 provider_mock.client.get_new_playlists = AsyncMock(return_value=mock_result)
488
489 result = await YandexMusicProvider._get_new_playlists_recommendations(provider_mock)
490
491 assert result is None
492
493
494@pytest.mark.asyncio
495async def test_get_new_playlists_recommendations_invalid_data_error(provider_mock: Mock) -> None:
496 """Test _get_new_playlists_recommendations handles InvalidDataError gracefully."""
497 mock_playlist_id = Mock()
498 mock_playlist_id.uid = "user123"
499 mock_playlist_id.kind = "456"
500
501 mock_result = Mock()
502 mock_result.new_playlists = [mock_playlist_id]
503
504 provider_mock.client.get_new_playlists = AsyncMock(return_value=mock_result)
505 provider_mock.client.get_playlists = AsyncMock(return_value=[Mock()])
506
507 with patch(
508 "music_assistant.providers.yandex_music.provider.parse_playlist",
509 side_effect=InvalidDataError("Parse error"),
510 ):
511 result = await YandexMusicProvider._get_new_playlists_recommendations(provider_mock)
512
513 assert result is None
514 provider_mock.logger.debug.assert_called()
515
516
517@pytest.mark.asyncio
518async def test_get_top_picks_recommendations_success(provider_mock: Mock) -> None:
519 """Test _get_top_picks_recommendations returns data when API provides playlists."""
520 mock_playlist = Mock()
521 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[mock_playlist])
522
523 # Mock parse_playlist
524 mock_parsed_playlist = Mock(spec=Playlist)
525 mock_parsed_playlist.item_id = "playlist_1"
526 mock_parsed_playlist.name = "Top Pick"
527
528 with patch(
529 "music_assistant.providers.yandex_music.provider.parse_playlist",
530 return_value=mock_parsed_playlist,
531 ):
532 result = await YandexMusicProvider._get_top_picks_recommendations(provider_mock)
533
534 assert result is not None
535 assert isinstance(result, RecommendationFolder)
536 assert result.item_id == "top_picks"
537 assert result.provider == provider_mock.instance_id
538 assert result.name == _RECOMMENDATION_STRINGS["top_picks"]["name"]
539 assert result.translation_key == "top_picks"
540 assert result.icon == "mdi-star"
541 assert len(result.items) > 0
542 # Verify it called with "top" tag
543 provider_mock.client.get_tag_playlists.assert_called_once_with("top")
544
545
546@pytest.mark.asyncio
547async def test_get_top_picks_recommendations_empty(provider_mock: Mock) -> None:
548 """Test _get_top_picks_recommendations returns None when API returns empty."""
549 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[])
550
551 result = await YandexMusicProvider._get_top_picks_recommendations(provider_mock)
552
553 assert result is None
554
555
556@pytest.mark.asyncio
557async def test_get_top_picks_recommendations_invalid_data_error(provider_mock: Mock) -> None:
558 """Test _get_top_picks_recommendations handles InvalidDataError gracefully."""
559 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[Mock()])
560
561 with patch(
562 "music_assistant.providers.yandex_music.provider.parse_playlist",
563 side_effect=InvalidDataError("Parse error"),
564 ):
565 result = await YandexMusicProvider._get_top_picks_recommendations(provider_mock)
566
567 assert result is None
568 provider_mock.logger.debug.assert_called()
569
570
571@pytest.mark.asyncio
572async def test_get_mood_mix_recommendations_success(provider_mock: Mock) -> None:
573 """Test _get_mood_mix_recommendations returns data with deterministic random choice."""
574 mock_playlist = Mock()
575 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[mock_playlist])
576
577 # Mock parse_playlist
578 mock_parsed_playlist = Mock(spec=Playlist)
579 mock_parsed_playlist.item_id = "playlist_1"
580 mock_parsed_playlist.name = "Chill Playlist"
581
582 # No need to patch random.choice - tag is now passed as argument
583 with patch(
584 "music_assistant.providers.yandex_music.provider.parse_playlist",
585 return_value=mock_parsed_playlist,
586 ):
587 result = await YandexMusicProvider._get_mood_mix_recommendations(provider_mock, "chill")
588
589 assert result is not None
590 assert isinstance(result, RecommendationFolder)
591 assert result.item_id == "mood_mix"
592 assert result.provider == provider_mock.instance_id
593 # Name should include the mood tag
594 assert "Chill" in result.name or "chill" in result.name.lower()
595 assert result.icon == "mdi-emoticon-outline"
596 assert len(result.items) > 0
597 # Verify it called with mood tag
598 provider_mock.client.get_tag_playlists.assert_called_once_with("chill")
599
600
601@pytest.mark.asyncio
602async def test_get_mood_mix_recommendations_empty(provider_mock: Mock) -> None:
603 """Test _get_mood_mix_recommendations returns None when API returns empty."""
604 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[])
605
606 result = await YandexMusicProvider._get_mood_mix_recommendations(provider_mock, "sad")
607
608 assert result is None
609
610
611@pytest.mark.asyncio
612async def test_get_mood_mix_recommendations_invalid_data_error(provider_mock: Mock) -> None:
613 """Test _get_mood_mix_recommendations handles InvalidDataError gracefully."""
614 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[Mock()])
615
616 with patch(
617 "music_assistant.providers.yandex_music.provider.parse_playlist",
618 side_effect=InvalidDataError("Parse error"),
619 ):
620 result = await YandexMusicProvider._get_mood_mix_recommendations(provider_mock, "romantic")
621
622 assert result is None
623 provider_mock.logger.debug.assert_called()
624
625
626@pytest.mark.asyncio
627async def test_get_activity_mix_recommendations_success(provider_mock: Mock) -> None:
628 """Test _get_activity_mix_recommendations returns data with deterministic random choice."""
629 mock_playlist = Mock()
630 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[mock_playlist])
631
632 # Mock parse_playlist
633 mock_parsed_playlist = Mock(spec=Playlist)
634 mock_parsed_playlist.item_id = "playlist_1"
635 mock_parsed_playlist.name = "Workout Playlist"
636
637 # No need to patch random.choice - tag is now passed as argument
638 with patch(
639 "music_assistant.providers.yandex_music.provider.parse_playlist",
640 return_value=mock_parsed_playlist,
641 ):
642 result = await YandexMusicProvider._get_activity_mix_recommendations(
643 provider_mock, "workout"
644 )
645
646 assert result is not None
647 assert isinstance(result, RecommendationFolder)
648 assert result.item_id == "activity_mix"
649 assert result.provider == provider_mock.instance_id
650 # Name should include the activity tag
651 assert "Workout" in result.name or "workout" in result.name.lower()
652 assert result.icon == "mdi-run"
653 assert len(result.items) > 0
654 # Verify it called with activity tag
655 provider_mock.client.get_tag_playlists.assert_called_once_with("workout")
656
657
658@pytest.mark.asyncio
659async def test_get_activity_mix_recommendations_empty(provider_mock: Mock) -> None:
660 """Test _get_activity_mix_recommendations returns None when API returns empty."""
661 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[])
662
663 result = await YandexMusicProvider._get_activity_mix_recommendations(provider_mock, "focus")
664
665 assert result is None
666
667
668@pytest.mark.asyncio
669async def test_get_activity_mix_recommendations_invalid_data_error(provider_mock: Mock) -> None:
670 """Test _get_activity_mix_recommendations handles InvalidDataError gracefully."""
671 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[Mock()])
672
673 with patch(
674 "music_assistant.providers.yandex_music.provider.parse_playlist",
675 side_effect=InvalidDataError("Parse error"),
676 ):
677 result = await YandexMusicProvider._get_activity_mix_recommendations(
678 provider_mock, "morning"
679 )
680
681 assert result is None
682 provider_mock.logger.debug.assert_called()
683
684
685@pytest.mark.asyncio
686async def test_get_seasonal_mix_recommendations_winter(provider_mock: Mock) -> None:
687 """Test _get_seasonal_mix_recommendations returns winter playlists in January."""
688 mock_playlist = Mock()
689 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[mock_playlist])
690
691 # Mock parse_playlist
692 mock_parsed_playlist = Mock(spec=Playlist)
693 mock_parsed_playlist.item_id = "playlist_1"
694 mock_parsed_playlist.name = "Winter Playlist"
695
696 # Patch datetime to return January (month 1)
697 mock_utc = Mock()
698 mock_utc.return_value.month = 1
699
700 with (
701 patch("music_assistant.providers.yandex_music.provider.utc", mock_utc),
702 patch(
703 "music_assistant.providers.yandex_music.provider.parse_playlist",
704 return_value=mock_parsed_playlist,
705 ),
706 ):
707 result = await YandexMusicProvider._get_seasonal_mix_recommendations(provider_mock)
708
709 assert result is not None
710 assert isinstance(result, RecommendationFolder)
711 assert result.item_id == "seasonal_mix"
712 assert result.provider == provider_mock.instance_id
713 # Name should include winter
714 assert "Winter" in result.name or "winter" in result.name.lower()
715 assert result.icon == "mdi-weather-sunny"
716 assert len(result.items) > 0
717 # Verify it called with winter tag
718 provider_mock.client.get_tag_playlists.assert_called_once_with("winter")
719
720
721@pytest.mark.asyncio
722async def test_get_seasonal_mix_recommendations_summer(provider_mock: Mock) -> None:
723 """Test _get_seasonal_mix_recommendations returns summer playlists in July."""
724 mock_playlist = Mock()
725 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[mock_playlist])
726
727 mock_parsed_playlist = Mock(spec=Playlist)
728 mock_parsed_playlist.item_id = "playlist_1"
729 mock_parsed_playlist.name = "Summer Playlist"
730
731 # Patch datetime to return July (month 7)
732 mock_utc = Mock()
733 mock_utc.return_value.month = 7
734
735 with (
736 patch("music_assistant.providers.yandex_music.provider.utc", mock_utc),
737 patch(
738 "music_assistant.providers.yandex_music.provider.parse_playlist",
739 return_value=mock_parsed_playlist,
740 ),
741 ):
742 result = await YandexMusicProvider._get_seasonal_mix_recommendations(provider_mock)
743
744 assert result is not None
745 # Verify it called with summer tag
746 provider_mock.client.get_tag_playlists.assert_called_once_with("summer")
747
748
749@pytest.mark.asyncio
750async def test_get_seasonal_mix_recommendations_spring_fallback(provider_mock: Mock) -> None:
751 """Spring with no playlists falls back to autumn (Yandex coverage gap)."""
752 mock_playlist = Mock()
753 # First call (spring) â empty; second call (autumn) â has playlists
754 provider_mock.client.get_tag_playlists = AsyncMock(
755 side_effect=[[], [mock_playlist]],
756 )
757
758 mock_parsed_playlist = Mock(spec=Playlist)
759 mock_parsed_playlist.item_id = "playlist_1"
760 mock_parsed_playlist.name = "Autumn Playlist"
761
762 # Patch datetime to return March (month 3 - spring)
763 mock_utc = Mock()
764 mock_utc.return_value.month = 3
765
766 with (
767 patch("music_assistant.providers.yandex_music.provider.utc", mock_utc),
768 patch(
769 "music_assistant.providers.yandex_music.provider.parse_playlist",
770 return_value=mock_parsed_playlist,
771 ),
772 ):
773 result = await YandexMusicProvider._get_seasonal_mix_recommendations(provider_mock)
774
775 assert result is not None
776 # Verify call sequence: spring first, autumn fallback after empty result
777 assert provider_mock.client.get_tag_playlists.await_args_list[0].args == ("spring",)
778 assert provider_mock.client.get_tag_playlists.await_args_list[1].args == ("autumn",)
779
780
781@pytest.mark.asyncio
782async def test_get_seasonal_mix_recommendations_empty(provider_mock: Mock) -> None:
783 """Test _get_seasonal_mix_recommendations returns None when API returns empty."""
784 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[])
785
786 mock_utc = Mock()
787 mock_utc.return_value.month = 6
788
789 with patch("music_assistant.providers.yandex_music.provider.utc", mock_utc):
790 result = await YandexMusicProvider._get_seasonal_mix_recommendations(provider_mock)
791
792 assert result is None
793
794
795@pytest.mark.asyncio
796async def test_get_seasonal_mix_recommendations_invalid_data_error(provider_mock: Mock) -> None:
797 """Test _get_seasonal_mix_recommendations handles InvalidDataError gracefully."""
798 provider_mock.client.get_tag_playlists = AsyncMock(return_value=[Mock()])
799
800 mock_utc = Mock()
801 mock_utc.return_value.month = 9
802
803 with (
804 patch("music_assistant.providers.yandex_music.provider.utc", mock_utc),
805 patch(
806 "music_assistant.providers.yandex_music.provider.parse_playlist",
807 side_effect=InvalidDataError("Parse error"),
808 ),
809 ):
810 result = await YandexMusicProvider._get_seasonal_mix_recommendations(provider_mock)
811
812 assert result is None
813 provider_mock.logger.debug.assert_called()
814
815
816# Expected row ids in the order get_recommendations() returns them.
817ROW_IDS = [
818 MY_WAVE_PLAYLIST_ID,
819 "feed",
820 "chart",
821 "new_releases",
822 "new_playlists",
823 "top_picks",
824 "mood_mix",
825 "activity_mix",
826 "seasonal_mix",
827]
828
829
830def _install_row_helper_mocks(provider_mock: Mock) -> dict[str, AsyncMock]:
831 """Stub each row-building helper to return a folder holding one sentinel item."""
832 helpers = {
833 MY_WAVE_PLAYLIST_ID: "_get_my_wave_recommendations",
834 "feed": "_get_feed_recommendations",
835 "chart": "_get_chart_recommendations",
836 "new_releases": "_get_new_releases_recommendations",
837 "new_playlists": "_get_new_playlists_recommendations",
838 "top_picks": "_get_top_picks_recommendations",
839 "mood_mix": "_get_mood_mix_recommendations",
840 "activity_mix": "_get_activity_mix_recommendations",
841 "seasonal_mix": "_get_seasonal_mix_recommendations",
842 }
843 mocks: dict[str, AsyncMock] = {}
844 for item_id, attr in helpers.items():
845 folder = Mock(spec=RecommendationFolder)
846 folder.item_id = item_id
847 folder.items = UniqueList(
848 [
849 ItemMapping(
850 media_type=MediaType.TRACK,
851 item_id=f"{item_id}_item",
852 provider=provider_mock.instance_id,
853 name=f"{item_id} item",
854 )
855 ]
856 )
857 mock = AsyncMock(return_value=folder)
858 setattr(provider_mock, attr, mock)
859 mocks[item_id] = mock
860 provider_mock._get_valid_tags_for_category = AsyncMock(return_value=["test_tag"])
861 # run the real deterministic tag resolution; with a single valid tag it always
862 # resolves to "test_tag" so the assertions below stay meaningful
863 provider_mock._rotating_row_tag = YandexMusicProvider._rotating_row_tag.__get__(
864 provider_mock, YandexMusicProvider
865 )
866 return mocks
867
868
869@pytest.mark.asyncio
870async def test_get_recommendations_returns_static_rows_without_backend_calls(
871 provider_mock: Mock,
872) -> None:
873 """get_recommendations() returns all nine row descriptors with zero backend I/O."""
874 row_mocks = _install_row_helper_mocks(provider_mock)
875
876 # Pin the month so the locally derived seasonal title is deterministic (January -> winter).
877 mock_utc = Mock()
878 mock_utc.return_value.month = 1
879 with patch("music_assistant.providers.yandex_music.provider.utc", mock_utc):
880 result = await YandexMusicProvider.get_recommendations(provider_mock)
881
882 assert [f.item_id for f in result] == ROW_IDS
883 # Rows are descriptors only: no items, no backend calls, no row-helper calls.
884 assert all(not f.items for f in result)
885 assert provider_mock.client.mock_calls == []
886 for mock in row_mocks.values():
887 mock.assert_not_awaited()
888 provider_mock._get_valid_tags_for_category.assert_not_awaited()
889
890 by_id = {f.item_id: f for f in result}
891 for item_id in ROW_IDS[:-1]:
892 assert by_id[item_id].name == _RECOMMENDATION_STRINGS[item_id]["name"]
893 assert by_id[item_id].translation_key == item_id
894 # Mood/Activity row titles are static; the rotating tag only shows as subtitle
895 # once the tag-list cache is warm (the mocked cache misses here, so no subtitle).
896 assert by_id["mood_mix"].name == "Mood Mix"
897 assert by_id["activity_mix"].name == "Activity Mix"
898 assert by_id["seasonal_mix"].name == "Seasonal: Winter"
899 assert by_id["seasonal_mix"].translation_key == "seasonal_mix"
900 assert by_id["seasonal_mix"].translation_params == ["Winter"]
901 assert [f.icon for f in result] == [
902 "mdi-waveform",
903 "mdi-account-music",
904 "mdi-chart-line",
905 "mdi-new-box",
906 "mdi-playlist-star",
907 "mdi-star",
908 "mdi-emoticon-outline",
909 "mdi-run",
910 "mdi-weather-sunny",
911 ]
912
913
914@pytest.mark.asyncio
915@pytest.mark.parametrize("item_id", ROW_IDS)
916async def test_get_recommendation_items_routes_to_single_row_helper(
917 provider_mock: Mock, item_id: str
918) -> None:
919 """get_recommendation_items(row) awaits only that row's helper and returns its items."""
920 row_mocks = _install_row_helper_mocks(provider_mock)
921
922 result = await YandexMusicProvider.get_recommendation_items(provider_mock, item_id)
923
924 row_mocks[item_id].assert_awaited_once()
925 for other_id, mock in row_mocks.items():
926 if other_id != item_id:
927 mock.assert_not_awaited()
928 assert list(result) == list(row_mocks[item_id].return_value.items)
929
930
931@pytest.mark.asyncio
932@pytest.mark.parametrize(
933 ("item_id", "category"),
934 [("mood_mix", "mood"), ("activity_mix", "activity")],
935)
936async def test_get_recommendation_items_picks_tag_outside_cached_helper(
937 provider_mock: Mock, item_id: str, category: str
938) -> None:
939 """Mood/Activity items derive their rotating tag and pass it to the cached row helper."""
940 row_mocks = _install_row_helper_mocks(provider_mock)
941
942 await YandexMusicProvider.get_recommendation_items(provider_mock, item_id)
943
944 provider_mock._get_valid_tags_for_category.assert_awaited_once_with(category)
945 row_mocks[item_id].assert_awaited_once_with("test_tag")
946
947
948@pytest.mark.asyncio
949async def test_get_recommendation_items_no_tag_returns_empty(provider_mock: Mock) -> None:
950 """No valid mood tag yields an empty result without calling the mood helper."""
951 row_mocks = _install_row_helper_mocks(provider_mock)
952 provider_mock._get_valid_tags_for_category = AsyncMock(return_value=[])
953
954 result = await YandexMusicProvider.get_recommendation_items(provider_mock, "mood_mix")
955
956 assert list(result) == []
957 row_mocks["mood_mix"].assert_not_awaited()
958
959
960@pytest.mark.asyncio
961async def test_get_recommendation_items_empty_row_returns_empty(provider_mock: Mock) -> None:
962 """A row whose helper yields no folder returns an empty list."""
963 _install_row_helper_mocks(provider_mock)
964 provider_mock._get_chart_recommendations = AsyncMock(return_value=None)
965
966 result = await YandexMusicProvider.get_recommendation_items(provider_mock, "chart")
967
968 assert list(result) == []
969
970
971@pytest.mark.asyncio
972async def test_get_recommendation_items_unknown_id_returns_empty(provider_mock: Mock) -> None:
973 """An unknown row item_id returns an empty list without awaiting any helper."""
974 row_mocks = _install_row_helper_mocks(provider_mock)
975
976 result = await YandexMusicProvider.get_recommendation_items(provider_mock, "no_such_row")
977
978 assert list(result) == []
979 for mock in row_mocks.values():
980 mock.assert_not_awaited()
981 assert provider_mock.client.mock_calls == []
982
983
984@pytest.mark.asyncio
985async def test_get_recommendation_items_chart_triggers_only_chart_fetch(
986 provider_mock: Mock,
987) -> None:
988 """items("chart") issues only the chart backend fetch â no other row's fetch fires."""
989 # Bind the real cached helper so the call goes through to the (mocked) client.
990 provider_mock._get_chart_recommendations = (
991 YandexMusicProvider._get_chart_recommendations.__get__(provider_mock)
992 )
993 mock_track_short = Mock()
994 mock_track_short.track = Mock()
995 mock_chart = Mock()
996 mock_chart.tracks = [mock_track_short]
997 mock_chart_info = Mock()
998 mock_chart_info.chart = mock_chart
999 provider_mock.client.get_chart = AsyncMock(return_value=mock_chart_info)
1000
1001 mock_parsed_track = _media_item_mock(Track)
1002 mock_parsed_track.item_id = "track_1"
1003 with patch(
1004 "music_assistant.providers.yandex_music.provider.parse_track",
1005 return_value=mock_parsed_track,
1006 ):
1007 result = await YandexMusicProvider.get_recommendation_items(provider_mock, "chart")
1008
1009 assert list(result) == [mock_parsed_track]
1010 provider_mock.client.get_chart.assert_awaited_once()
1011 provider_mock.client.get_feed.assert_not_awaited()
1012
1013
1014@pytest.mark.asyncio
1015async def test_get_similar_artists_returns_parsed(provider_mock: Mock) -> None:
1016 """get_similar_artists parses each artist from the underlying client."""
1017 yandex_artists = [Mock(), Mock(), Mock()]
1018 provider_mock.client.get_similar_artists = AsyncMock(return_value=yandex_artists)
1019
1020 parsed = [_media_item_mock(Artist) for _ in yandex_artists]
1021 with patch(
1022 "music_assistant.providers.yandex_music.provider.parse_artist",
1023 side_effect=parsed,
1024 ):
1025 result = await YandexMusicProvider.get_similar_artists(provider_mock, "42", limit=10)
1026
1027 provider_mock.client.get_similar_artists.assert_awaited_once_with("42", limit=10)
1028 assert result == parsed
1029
1030
1031@pytest.mark.asyncio
1032async def test_get_similar_artists_skips_invalid(provider_mock: Mock) -> None:
1033 """get_similar_artists skips artists that fail to parse."""
1034 yandex_artists = [Mock(), Mock()]
1035 provider_mock.client.get_similar_artists = AsyncMock(return_value=yandex_artists)
1036
1037 parsed_ok = _media_item_mock(Artist)
1038 with patch(
1039 "music_assistant.providers.yandex_music.provider.parse_artist",
1040 side_effect=[InvalidDataError("missing id"), parsed_ok],
1041 ):
1042 result = await YandexMusicProvider.get_similar_artists(provider_mock, "99")
1043
1044 assert result == [parsed_ok]
1045
1046
1047@pytest.mark.asyncio
1048async def test_get_similar_artists_empty(provider_mock: Mock) -> None:
1049 """get_similar_artists returns [] when client returns no artists."""
1050 provider_mock.client.get_similar_artists = AsyncMock(return_value=[])
1051
1052 result = await YandexMusicProvider.get_similar_artists(provider_mock, "42")
1053
1054 assert result == []
1055
1056
1057# -- M18: integration coverage with the real parser path ---------------------
1058#
1059# Most tests in this file mock ``_parse_my_wave_track`` / ``parse_playlist``
1060# directly. That keeps them fast but leaves the orchestrator-parser seam
1061# untested: a regression where the orchestrator builds malformed inputs for
1062# the parser, or where the parser returns shape-incompatible output, would
1063# slip through. The tests below exercise the real parser end-to-end with a
1064# minimal Yandex track fixture.
1065
1066
1067def _real_yandex_track() -> Any:
1068 """Return a minimally-shaped Yandex track for the real ``parse_track`` to consume."""
1069 fixture = pathlib.Path(__file__).parent / "fixtures" / "tracks" / "with_artist_and_album.json"
1070 return YandexTrack.de_json(json.loads(fixture.read_text()), DE_JSON_CLIENT)
1071
1072
1073@pytest.mark.asyncio
1074async def test_get_my_wave_recommendations_with_real_parser(provider_mock: Mock) -> None:
1075 """
1076 End-to-end: real ``_parse_my_wave_track`` against a real Yandex track fixture.
1077
1078 Mocking ``_parse_my_wave_track`` directly (as the success/duplicate/error
1079 tests above do) cannot catch a regression where ``parse_track`` returns a
1080 malformed Track, where the composite ``item_id`` is not stamped onto the
1081 provider_mappings, or where the wave-state lock is not respected. This
1082 test binds the real method and asserts on the produced ``Track``.
1083 """
1084 yt = _real_yandex_track()
1085 assert yt is not None
1086
1087 # Bind the real method so it actually calls parse_track + composite item_id logic.
1088 real_parse = YandexMusicProvider._parse_my_wave_track.__get__(provider_mock)
1089 provider_mock._parse_my_wave_track = real_parse
1090 provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([yt], "batch_x"))
1091 # Wave-state lock is held during the loop; supply a fresh state.
1092 provider_mock._get_wave_state = Mock(return_value=_WaveState())
1093 # get_item_mapping is consumed by parse_track for provider_mappings; mock the spec
1094 # method to return a minimal ItemMapping-like stub.
1095 provider_mock.get_item_mapping = Mock(
1096 side_effect=lambda mt, key, name: ItemMapping(
1097 media_type=MediaType(mt) if isinstance(mt, str) else mt,
1098 item_id=key,
1099 provider=provider_mock.instance_id,
1100 name=name,
1101 )
1102 )
1103
1104 result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock)
1105
1106 assert result is not None
1107 assert isinstance(result, RecommendationFolder)
1108 assert len(result.items) == 1
1109 track = result.items[0]
1110 assert isinstance(track, Track)
1111 # The orchestrator-parser contract: composite item_id, real Yandex track id.
1112 assert track.item_id == f"500{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
1113 assert track.name == "Track With Album"
1114 # provider_mappings carry the composite id too â not the bare track id.
1115 assert all(pm.item_id == track.item_id for pm in track.provider_mappings)
1116
1117
1118@pytest.mark.asyncio
1119async def test_rotating_row_tag_subtitle_from_warm_cache(
1120 provider_mock: Mock, monkeypatch: pytest.MonkeyPatch
1121) -> None:
1122 """A warm tag-list cache yields the deterministic tag's display label as subtitle."""
1123 # freeze the clock so the hourly tag bucket cannot flip mid-test
1124 monkeypatch.setattr(
1125 "music_assistant.providers.yandex_music.provider.utc",
1126 lambda: datetime(2026, 7, 24, 12, 30, tzinfo=UTC),
1127 )
1128 tags = ["chill", "focus"]
1129 provider_mock.mass.cache.get_with_freshness = AsyncMock(return_value=(tags, True, True))
1130 provider_mock._rotating_row_tag = YandexMusicProvider._rotating_row_tag.__get__(
1131 provider_mock, YandexMusicProvider
1132 )
1133
1134 label = await YandexMusicProvider._rotating_row_tag_subtitle(provider_mock, "mood")
1135
1136 expected = YandexMusicProvider._rotating_row_tag(provider_mock, "mood", tags)
1137 assert label == expected.title()
1138 # cache-only read: no backend client involved
1139 assert provider_mock.client.mock_calls == []
1140
1141
1142@pytest.mark.asyncio
1143async def test_rotating_row_tag_subtitle_cold_cache_returns_none(provider_mock: Mock) -> None:
1144 """A cold tag-list cache yields no subtitle."""
1145 label = await YandexMusicProvider._rotating_row_tag_subtitle(provider_mock, "mood")
1146
1147 assert label is None
1148
1149
1150@pytest.mark.asyncio
1151async def test_rotating_row_tag_is_deterministic(
1152 provider_mock: Mock, monkeypatch: pytest.MonkeyPatch
1153) -> None:
1154 """The tag pick is stable within the hour and always one of the valid tags."""
1155 # freeze the clock so the hourly tag bucket cannot flip mid-test
1156 monkeypatch.setattr(
1157 "music_assistant.providers.yandex_music.provider.utc",
1158 lambda: datetime(2026, 7, 24, 12, 30, tzinfo=UTC),
1159 )
1160 pick = YandexMusicProvider._rotating_row_tag.__get__(provider_mock, YandexMusicProvider)
1161 tags = ["chill", "focus", "sad", "romantic"]
1162
1163 first = pick("mood", tags)
1164
1165 assert first in tags
1166 assert pick("mood", tags) == first
1167