/
/
/
1"""Tests for Infinite Mix dynamic built-in playlists."""
2
3from typing import cast
4from unittest.mock import AsyncMock, MagicMock
5
6import pytest
7from music_assistant_models.media_items import ProviderMapping, Track
8
9from music_assistant.helpers.track_filter import track_filter
10from music_assistant.providers.builtin import BuiltinProvider
11from music_assistant.providers.builtin.constants import (
12 ALL_FAVORITE_TRACKS,
13 BUILTIN_PLAYLISTS,
14 DYNAMIC_BUILTIN_PLAYLISTS,
15 INFINITE_MIX,
16 INFINITE_MIX_FAVORITES,
17 RANDOM_TRACKS,
18)
19
20# ---------------------------------------------------------------------------
21# Constants
22# ---------------------------------------------------------------------------
23
24
25def test_infinite_mix_ids_in_builtin_playlists() -> None:
26 """Both Infinite Mix IDs must be registered in BUILTIN_PLAYLISTS."""
27 assert INFINITE_MIX in BUILTIN_PLAYLISTS
28 assert INFINITE_MIX_FAVORITES in BUILTIN_PLAYLISTS
29
30
31def test_dynamic_builtin_playlists_membership() -> None:
32 """DYNAMIC_BUILTIN_PLAYLISTS contains exactly the two Infinite Mix variants."""
33 assert INFINITE_MIX in DYNAMIC_BUILTIN_PLAYLISTS
34 assert INFINITE_MIX_FAVORITES in DYNAMIC_BUILTIN_PLAYLISTS
35 # Static playlists must NOT be in the dynamic set.
36 assert RANDOM_TRACKS not in DYNAMIC_BUILTIN_PLAYLISTS
37 assert ALL_FAVORITE_TRACKS not in DYNAMIC_BUILTIN_PLAYLISTS
38
39
40# ---------------------------------------------------------------------------
41# get_playlist() â is_dynamic flag
42# ---------------------------------------------------------------------------
43
44
45def _make_provider() -> BuiltinProvider:
46 """Return a BuiltinProvider instance with a minimal mock mass."""
47 provider = BuiltinProvider.__new__(BuiltinProvider)
48 provider.mass = MagicMock()
49 # instance_id and domain are read-only properties backed by config/manifest
50 provider.config = MagicMock()
51 provider.config.instance_id = "builtin"
52 provider.manifest = MagicMock()
53 provider.manifest.domain = "builtin"
54 return provider
55
56
57@pytest.mark.asyncio
58@pytest.mark.parametrize("playlist_id", [INFINITE_MIX, INFINITE_MIX_FAVORITES])
59async def test_get_playlist_is_dynamic_true_for_infinite_mix(playlist_id: str) -> None:
60 """get_playlist() must set is_dynamic=True for Infinite Mix playlists."""
61 provider = _make_provider()
62 playlist = await provider.get_playlist(playlist_id)
63 assert playlist.is_dynamic is True
64
65
66@pytest.mark.asyncio
67@pytest.mark.parametrize("playlist_id", [ALL_FAVORITE_TRACKS, RANDOM_TRACKS])
68async def test_get_playlist_is_dynamic_false_for_static_playlists(playlist_id: str) -> None:
69 """get_playlist() must set is_dynamic=False for non-dynamic built-in playlists."""
70 provider = _make_provider()
71 playlist = await provider.get_playlist(playlist_id)
72 assert playlist.is_dynamic is False
73
74
75# ---------------------------------------------------------------------------
76# _get_builtin_playlist_infinite_mix â track fetching
77# ---------------------------------------------------------------------------
78
79
80def _make_tracks(n: int) -> list[Track]:
81 """Return n minimal Track objects."""
82 return cast("list[Track]", [MagicMock(spec=Track) for _ in range(n)])
83
84
85@pytest.mark.asyncio
86async def test_infinite_mix_returns_25_tracks_with_positions() -> None:
87 """_get_builtin_playlist_infinite_mix returns 25 tracks with 1-based positions."""
88 provider = _make_provider()
89 fake_tracks = _make_tracks(25)
90 provider.mass.music.tracks.library_items = AsyncMock(return_value=fake_tracks) # type: ignore[method-assign]
91
92 result = await provider._get_builtin_playlist_infinite_mix()
93
94 provider.mass.music.tracks.library_items.assert_called_once_with(
95 favorite=None, limit=25, order_by="random", summary=False
96 )
97 assert len(result) == 25
98 for expected_pos, track in enumerate(result, 1):
99 assert track.position == expected_pos
100
101
102@pytest.mark.asyncio
103async def test_infinite_mix_favorites_passes_favorite_filter() -> None:
104 """_get_builtin_playlist_infinite_mix_favorites passes favorite=True to library_items."""
105 provider = _make_provider()
106 fake_tracks = _make_tracks(25)
107 provider.mass.music.tracks.library_items = AsyncMock(return_value=fake_tracks) # type: ignore[method-assign]
108
109 result = await provider._get_builtin_playlist_infinite_mix_favorites()
110
111 provider.mass.music.tracks.library_items.assert_called_once_with(
112 favorite=True, limit=25, order_by="random", summary=False
113 )
114 assert len(result) == 25
115 for expected_pos, track in enumerate(result, 1):
116 assert track.position == expected_pos
117
118
119@pytest.mark.asyncio
120async def test_infinite_mix_empty_library_returns_empty_list() -> None:
121 """_get_builtin_playlist_infinite_mix handles an empty library gracefully."""
122 provider = _make_provider()
123 provider.mass.music.tracks.library_items = AsyncMock(return_value=[]) # type: ignore[method-assign]
124
125 result = await provider._get_builtin_playlist_infinite_mix()
126
127 assert result == []
128
129
130# ---------------------------------------------------------------------------
131# _infinite_mix_tracks â recency track filter
132# ---------------------------------------------------------------------------
133
134
135def _real_tracks(item_ids: list[str]) -> list[Track]:
136 """Return real Track objects so a filter predicate can read item_id."""
137 return [
138 Track(
139 item_id=item_id,
140 provider="test",
141 name=item_id,
142 provider_mappings={
143 ProviderMapping(item_id=item_id, provider_domain="test", provider_instance="test")
144 },
145 )
146 for item_id in item_ids
147 ]
148
149
150@pytest.mark.asyncio
151async def test_infinite_mix_over_fetches_and_drops_filtered_tracks() -> None:
152 """With a recency filter active, the mix over-fetches (3x) and drops rejected tracks."""
153 provider = _make_provider()
154 pool = _real_tracks([str(i) for i in range(75)])
155 provider.mass.music.tracks.library_items = AsyncMock(return_value=pool) # type: ignore[method-assign]
156
157 # reject odd-numbered tracks; the even ones remain and are trimmed back to the mix size
158 with track_filter(lambda track: int(track.item_id) % 2 == 0):
159 result = await provider._get_builtin_playlist_infinite_mix()
160
161 provider.mass.music.tracks.library_items.assert_called_once_with(
162 favorite=None, limit=75, order_by="random", summary=False
163 )
164 assert len(result) == 25
165 assert all(int(track.item_id) % 2 == 0 for track in result)
166 for expected_pos, track in enumerate(result, 1):
167 assert track.position == expected_pos
168
169
170@pytest.mark.asyncio
171async def test_infinite_mix_filter_rejecting_all_falls_back_to_pool() -> None:
172 """If the filter rejects every track, the mix still returns a full batch (never empty)."""
173 provider = _make_provider()
174 pool = _real_tracks([str(i) for i in range(75)])
175 provider.mass.music.tracks.library_items = AsyncMock(return_value=pool) # type: ignore[method-assign]
176
177 with track_filter(lambda _track: False):
178 result = await provider._get_builtin_playlist_infinite_mix()
179
180 assert len(result) == 25
181