/
/
/
1"""Tests for ZvukMusicProvider browse and playlist management."""
2
3from __future__ import annotations
4
5from typing import Any, cast
6from unittest.mock import AsyncMock, Mock
7
8import pytest
9from music_assistant_models.enums import ProviderFeature
10from music_assistant_models.media_items import BrowseFolder, Playlist
11
12from music_assistant.providers.zvuk_music.constants import PLAYLIST_TRACK_FETCH_LIMIT
13from music_assistant.providers.zvuk_music.provider import ZvukMusicProvider
14
15# ---------------------------------------------------------------------------
16# Helpers
17# ---------------------------------------------------------------------------
18
19
20def _make_playlist(item_id: str = "1") -> Playlist:
21 """Return a minimal Playlist mock."""
22 pl = Mock(spec=Playlist)
23 pl.item_id = item_id
24 return pl
25
26
27def _make_track_mock(track_id: int) -> Mock:
28 """Return a minimal track mock with .id attribute."""
29 t = Mock()
30 t.id = track_id
31 return t
32
33
34# ---------------------------------------------------------------------------
35# Shared provider factory
36# ---------------------------------------------------------------------------
37
38
39def _make_provider() -> Any:
40 """Create a ZvukMusicProvider mock with client and helpers bound."""
41 provider = Mock(spec=ZvukMusicProvider)
42 provider.client = Mock()
43 provider.logger = Mock()
44 provider.instance_id = "zvuk_music"
45 provider.supported_features = frozenset({ProviderFeature.BROWSE})
46 # Async helpers as mocks â overridden per-test via direct assignment (provider is Any)
47 provider._get_for_you_playlists = AsyncMock(return_value=[])
48 provider._get_editorial_playlists = AsyncMock(return_value=[])
49 # Bind real implementations
50 provider.remove_playlist_tracks = ZvukMusicProvider.remove_playlist_tracks.__get__(
51 provider, ZvukMusicProvider
52 )
53 provider.browse = ZvukMusicProvider.browse.__get__(provider, ZvukMusicProvider)
54 return provider
55
56
57# ---------------------------------------------------------------------------
58# Tests for remove_playlist_tracks()
59# ---------------------------------------------------------------------------
60
61
62class TestRemovePlaylistTracks:
63 """Tests for ZvukMusicProvider.remove_playlist_tracks()."""
64
65 @pytest.mark.asyncio
66 async def test_removes_correct_positions_and_calls_update(self) -> None:
67 """Tracks at specified positions are excluded; remaining IDs are passed to update."""
68 provider = _make_provider()
69 tracks = [
70 _make_track_mock(10),
71 _make_track_mock(20),
72 _make_track_mock(30),
73 _make_track_mock(40),
74 ]
75 provider.client.get_playlist_tracks = AsyncMock(return_value=tracks)
76 provider.client.update_playlist = AsyncMock()
77
78 # Remove positions 0 and 2 â keep tracks at positions 1 (id=20) and 3 (id=40)
79 await provider.remove_playlist_tracks("playlist-1", (0, 2))
80
81 provider.client.update_playlist.assert_awaited_once_with("playlist-1", ["20", "40"])
82
83 @pytest.mark.asyncio
84 async def test_get_playlist_tracks_called_with_fetch_limit(self) -> None:
85 """get_playlist_tracks is always called with limit=PLAYLIST_TRACK_FETCH_LIMIT."""
86 provider = _make_provider()
87 provider.client.get_playlist_tracks = AsyncMock(return_value=[])
88 provider.client.update_playlist = AsyncMock()
89
90 await provider.remove_playlist_tracks("playlist-2", ())
91
92 provider.client.get_playlist_tracks.assert_awaited_once_with(
93 "playlist-2", limit=PLAYLIST_TRACK_FETCH_LIMIT
94 )
95
96 @pytest.mark.asyncio
97 async def test_no_positions_keeps_all_tracks(self) -> None:
98 """Removing no positions keeps all track IDs intact."""
99 provider = _make_provider()
100 tracks = [_make_track_mock(11), _make_track_mock(22)]
101 provider.client.get_playlist_tracks = AsyncMock(return_value=tracks)
102 provider.client.update_playlist = AsyncMock()
103
104 await provider.remove_playlist_tracks("playlist-3", ())
105
106 provider.client.update_playlist.assert_awaited_once_with("playlist-3", ["11", "22"])
107
108
109# ---------------------------------------------------------------------------
110# Tests for browse()
111# ---------------------------------------------------------------------------
112
113
114class TestBrowse:
115 """Tests for ZvukMusicProvider.browse()."""
116
117 @pytest.mark.asyncio
118 async def test_root_path_returns_two_browse_folders(self) -> None:
119 """Root path returns exactly two BrowseFolder items."""
120 provider = _make_provider()
121
122 result = await provider.browse("zvuk_music://")
123
124 assert len(result) == 2
125 assert all(isinstance(f, BrowseFolder) for f in result)
126 assert result[0].item_id == "for_you"
127 assert result[1].item_id == "editorial"
128
129 @pytest.mark.asyncio
130 async def test_for_you_subpath_returns_for_you_playlists(self) -> None:
131 """Path ending with 'for_you' returns the for-you playlist list."""
132 provider = _make_provider()
133 pl_1, pl_2 = _make_playlist("3"), _make_playlist("4")
134 for_you_mock = AsyncMock(return_value=[pl_1, pl_2])
135 editorial_mock = AsyncMock(return_value=[])
136 provider._get_for_you_playlists = for_you_mock
137 provider._get_editorial_playlists = editorial_mock
138
139 result = await provider.browse("zvuk_music://for_you")
140
141 assert result == [pl_1, pl_2]
142 editorial_mock.assert_not_called()
143
144 @pytest.mark.asyncio
145 async def test_editorial_subpath_returns_editorial_playlists(self) -> None:
146 """Path ending with 'editorial' returns the editorial playlist list."""
147 provider = _make_provider()
148 pl = _make_playlist("101")
149 for_you_mock = AsyncMock(return_value=[])
150 editorial_mock = AsyncMock(return_value=[pl])
151 provider._get_for_you_playlists = for_you_mock
152 provider._get_editorial_playlists = editorial_mock
153
154 result = await provider.browse("zvuk_music://editorial")
155
156 assert result == [pl]
157 for_you_mock.assert_not_called()
158
159 @pytest.mark.asyncio
160 async def test_browse_folder_paths_are_correct(self) -> None:
161 """Root BrowseFolders have paths pointing into the provider namespace."""
162 provider = _make_provider()
163
164 result = await provider.browse("zvuk_music://")
165
166 for_you_folder = cast("BrowseFolder", result[0])
167 editorial_folder = cast("BrowseFolder", result[1])
168 assert for_you_folder.path == "zvuk_music://for_you"
169 assert editorial_folder.path == "zvuk_music://editorial"
170