/
/
/
1"""Extended tests for Tidal Page Parser."""
2
3from typing import Any
4from unittest.mock import Mock, patch
5
6from music_assistant_models.enums import MediaType
7
8from music_assistant.providers.tidal.tidal_page_parser import TidalPageParser
9
10
11def test_parser_initialization(provider_mock: Mock) -> None:
12 """Test parser initialization."""
13 parser = TidalPageParser(provider_mock)
14
15 assert parser.provider == provider_mock
16 assert parser.logger == provider_mock.logger
17 assert "MIX" in parser._content_map
18 assert "PLAYLIST" in parser._content_map
19 assert "ALBUM" in parser._content_map
20 assert "TRACK" in parser._content_map
21 assert "ARTIST" in parser._content_map
22 assert len(parser._module_map) == 0
23 assert parser._page_path is None
24 assert parser._parsed_at == 0
25
26
27@patch("music_assistant.providers.tidal.tidal_page_parser.parse_track")
28def test_process_track_list(mock_parse_track: Mock, provider_mock: Mock) -> None:
29 """Test processing TRACK_LIST module."""
30 mock_track = Mock()
31 mock_track.name = "Test Track"
32 mock_parse_track.return_value = mock_track
33
34 page_data = {
35 "rows": [
36 {
37 "modules": [
38 {
39 "title": "Top Tracks",
40 "type": "TRACK_LIST",
41 "pagedList": {
42 "items": [
43 {"id": 1, "title": "Track 1"},
44 {"id": 2, "title": "Track 2"},
45 ]
46 },
47 }
48 ]
49 }
50 ]
51 }
52
53 parser = TidalPageParser(provider_mock)
54 parser.parse_page_structure(page_data, "pages/test")
55
56 module_info = parser._module_map[0]
57 items, content_type = parser.get_module_items(module_info)
58
59 assert content_type == MediaType.TRACK
60 assert len(items) == 2
61 assert mock_parse_track.call_count == 2
62
63
64@patch("music_assistant.providers.tidal.tidal_page_parser.parse_artist")
65def test_process_artist_list(mock_parse_artist: Mock, provider_mock: Mock) -> None:
66 """Test processing ARTIST_LIST module."""
67 mock_artist = Mock()
68 mock_artist.name = "Test Artist"
69 mock_parse_artist.return_value = mock_artist
70
71 page_data = {
72 "rows": [
73 {
74 "modules": [
75 {
76 "title": "Popular Artists",
77 "type": "ARTIST_LIST",
78 "pagedList": {
79 "items": [
80 {"id": 1, "name": "Artist 1"},
81 {"id": 2, "name": "Artist 2"},
82 {"id": 3, "name": "Artist 3"},
83 ]
84 },
85 }
86 ]
87 }
88 ]
89 }
90
91 parser = TidalPageParser(provider_mock)
92 parser.parse_page_structure(page_data, "pages/test")
93
94 module_info = parser._module_map[0]
95 items, content_type = parser.get_module_items(module_info)
96
97 assert content_type == MediaType.ARTIST
98 assert len(items) == 3
99 assert mock_parse_artist.call_count == 3
100
101
102@patch("music_assistant.providers.tidal.tidal_page_parser.parse_playlist")
103def test_process_mix_list(mock_parse_playlist: Mock, provider_mock: Mock) -> None:
104 """Test processing MIX_LIST module."""
105 mock_mix = Mock()
106 mock_mix.name = "Daily Mix"
107 mock_parse_playlist.return_value = mock_mix
108
109 page_data = {
110 "rows": [
111 {
112 "modules": [
113 {
114 "title": "Your Mixes",
115 "type": "MIX_LIST",
116 "pagedList": {
117 "items": [
118 {"id": "mix1", "title": "Mix 1"},
119 ]
120 },
121 }
122 ]
123 }
124 ]
125 }
126
127 parser = TidalPageParser(provider_mock)
128 parser.parse_page_structure(page_data, "pages/test")
129
130 module_info = parser._module_map[0]
131 items, content_type = parser.get_module_items(module_info)
132
133 assert content_type == MediaType.PLAYLIST
134 assert len(items) == 1
135 mock_parse_playlist.assert_called_with(
136 provider_mock, {"id": "mix1", "title": "Mix 1"}, is_mix=True
137 )
138
139
140@patch("music_assistant.providers.tidal.tidal_page_parser.parse_track")
141def test_process_track_list_with_error(mock_parse_track: Mock, provider_mock: Mock) -> None:
142 """Test TRACK_LIST with parsing error."""
143 mock_parse_track.side_effect = [
144 Mock(name="Track 1"),
145 KeyError("Missing field"),
146 Mock(name="Track 3"),
147 ]
148
149 page_data = {
150 "rows": [
151 {
152 "modules": [
153 {
154 "title": "Tracks",
155 "type": "TRACK_LIST",
156 "pagedList": {
157 "items": [
158 {"id": 1},
159 {"id": 2},
160 {"id": 3},
161 ]
162 },
163 }
164 ]
165 }
166 ]
167 }
168
169 parser = TidalPageParser(provider_mock)
170 parser.parse_page_structure(page_data, "pages/test")
171
172 module_info = parser._module_map[0]
173 items, _ = parser.get_module_items(module_info)
174
175 # Should have 2 items (one failed)
176 assert len(items) == 2
177 provider_mock.logger.warning.assert_called()
178
179
180def test_process_track_list_with_non_dict_items(provider_mock: Mock) -> None:
181 """Test TRACK_LIST with non-dict items (should be skipped)."""
182 page_data = {
183 "rows": [
184 {
185 "modules": [
186 {
187 "title": "Tracks",
188 "type": "TRACK_LIST",
189 "pagedList": {
190 "items": [
191 "not a dict",
192 12345,
193 None,
194 ]
195 },
196 }
197 ]
198 }
199 ]
200 }
201
202 parser = TidalPageParser(provider_mock)
203 parser.parse_page_structure(page_data, "pages/test")
204
205 module_info = parser._module_map[0]
206 items, _ = parser.get_module_items(module_info)
207
208 # All items should be skipped
209 assert len(items) == 0
210
211
212async def test_from_cache_success(provider_mock: Mock) -> None:
213 """Test loading parser from cache."""
214 cached_data = {
215 "module_map": [{"title": "Test Module"}],
216 "content_map": {"PLAYLIST": {}},
217 "parsed_at": 1234567890,
218 }
219 provider_mock.mass.cache.get.return_value = cached_data
220
221 parser = await TidalPageParser.from_cache(provider_mock, "pages/home")
222
223 assert parser is not None
224 assert len(parser._module_map) == 1
225 assert parser._parsed_at == 1234567890
226 provider_mock.mass.cache.get.assert_called_with(
227 "pages/home",
228 provider=provider_mock.instance_id,
229 category=1, # CACHE_CATEGORY_RECOMMENDATIONS
230 )
231
232
233async def test_from_cache_miss(provider_mock: Mock) -> None:
234 """Test cache miss returns None."""
235 provider_mock.mass.cache.get.return_value = None
236
237 parser = await TidalPageParser.from_cache(provider_mock, "pages/home")
238
239 assert parser is None
240
241
242async def test_from_cache_invalid_data(provider_mock: Mock) -> None:
243 """Test cache with invalid data returns None."""
244 # from_cache expects dict, won't handle invalid data gracefully
245 # The method will fail on .get() calls if data is invalid
246 provider_mock.mass.cache.get.return_value = {} # Empty dict is valid but has no data
247
248 parser = await TidalPageParser.from_cache(provider_mock, "pages/home")
249
250 # Parser should be None because empty dict evaluates to False
251 assert parser is None
252
253
254@patch("music_assistant.providers.tidal.tidal_page_parser.parse_playlist")
255def test_playlist_list_with_mix_detection(mock_parse_playlist: Mock, provider_mock: Mock) -> None:
256 """Test PLAYLIST_LIST detects mixes."""
257 mock_playlist = Mock()
258 mock_parse_playlist.return_value = mock_playlist
259
260 page_data = {
261 "rows": [
262 {
263 "modules": [
264 {
265 "title": "Playlists",
266 "type": "PLAYLIST_LIST",
267 "pagedList": {
268 "items": [
269 {"uuid": "1", "title": "Regular Playlist"},
270 {"mixId": "mix_123", "title": "Mix", "mixType": "DISCOVERY"},
271 ]
272 },
273 }
274 ]
275 }
276 ]
277 }
278
279 parser = TidalPageParser(provider_mock)
280 parser.parse_page_structure(page_data, "pages/test")
281
282 module_info = parser._module_map[0]
283 items, _ = parser.get_module_items(module_info)
284
285 assert len(items) == 2
286 # First call should be is_mix=False, second should be is_mix=True
287 assert mock_parse_playlist.call_args_list[0][1]["is_mix"] is False
288 assert mock_parse_playlist.call_args_list[1][1]["is_mix"] is True
289
290
291def test_empty_page_data(provider_mock: Mock) -> None:
292 """Test parsing empty page data."""
293 parser = TidalPageParser(provider_mock)
294 parser.parse_page_structure({}, "pages/empty")
295
296 assert len(parser._module_map) == 0
297 assert parser._page_path == "pages/empty"
298
299
300def test_page_with_no_modules(provider_mock: Mock) -> None:
301 """Test page with rows but no modules."""
302 page_data: dict[str, Any] = {
303 "rows": [
304 {},
305 {"modules": []},
306 ]
307 }
308
309 parser = TidalPageParser(provider_mock)
310 parser.parse_page_structure(page_data, "pages/test")
311
312 assert len(parser._module_map) == 0
313
314
315def test_multiple_module_types_in_one_page(provider_mock: Mock) -> None:
316 """Test page with multiple different module types."""
317 with (
318 patch("music_assistant.providers.tidal.tidal_page_parser.parse_playlist") as mock_pl,
319 patch("music_assistant.providers.tidal.tidal_page_parser.parse_album") as mock_al,
320 patch("music_assistant.providers.tidal.tidal_page_parser.parse_track") as mock_tr,
321 ):
322 mock_pl.return_value = Mock(name="Playlist")
323 mock_al.return_value = Mock(name="Album")
324 mock_tr.return_value = Mock(name="Track")
325
326 page_data = {
327 "rows": [
328 {
329 "modules": [
330 {
331 "title": "Playlists",
332 "type": "PLAYLIST_LIST",
333 "pagedList": {"items": [{"uuid": "1"}]},
334 },
335 {
336 "title": "Albums",
337 "type": "ALBUM_LIST",
338 "pagedList": {"items": [{"id": 1}]},
339 },
340 ]
341 },
342 {
343 "modules": [
344 {
345 "title": "Tracks",
346 "type": "TRACK_LIST",
347 "pagedList": {"items": [{"id": 1}]},
348 }
349 ]
350 },
351 ]
352 }
353
354 parser = TidalPageParser(provider_mock)
355 parser.parse_page_structure(page_data, "pages/test")
356
357 assert len(parser._module_map) == 3
358
359 # Verify each module
360 _, type1 = parser.get_module_items(parser._module_map[0])
361 assert type1 == MediaType.PLAYLIST
362
363 _, type2 = parser.get_module_items(parser._module_map[1])
364 assert type2 == MediaType.ALBUM
365
366 _, type3 = parser.get_module_items(parser._module_map[2])
367 assert type3 == MediaType.TRACK
368
369
370def test_module_info_structure(provider_mock: Mock) -> None:
371 """Test module_info contains correct metadata."""
372 page_data = {
373 "rows": [
374 {
375 "modules": [
376 {
377 "title": "Test Module",
378 "type": "PLAYLIST_LIST",
379 "pagedList": {"items": []},
380 }
381 ]
382 }
383 ]
384 }
385
386 parser = TidalPageParser(provider_mock)
387 parser.parse_page_structure(page_data, "pages/test")
388
389 module_info = parser._module_map[0]
390 assert module_info["title"] == "Test Module"
391 assert module_info["type"] == "PLAYLIST_LIST"
392 assert module_info["module_idx"] == 0
393 assert module_info["row_idx"] == 0
394 assert "raw_data" in module_info
395
396
397@patch("music_assistant.providers.tidal.tidal_page_parser.parse_playlist")
398def test_process_highlight_module(mock_parse_playlist: Mock, provider_mock: Mock) -> None:
399 """Test processing HIGHLIGHT_MODULE."""
400 mock_playlist = Mock()
401 mock_playlist.name = "Highlight Playlist"
402 mock_parse_playlist.return_value = mock_playlist
403
404 page_data = {
405 "rows": [
406 {
407 "modules": [
408 {
409 "title": "Highlights",
410 "type": "HIGHLIGHT_MODULE",
411 "highlight": [
412 {"type": "PLAYLIST", "item": {"uuid": "1", "title": "Highlight 1"}}
413 ],
414 }
415 ]
416 }
417 ]
418 }
419
420 parser = TidalPageParser(provider_mock)
421 parser.parse_page_structure(page_data, "pages/test")
422
423 module_info = parser._module_map[0]
424 items, content_type = parser.get_module_items(module_info)
425
426 assert content_type == MediaType.PLAYLIST
427 assert len(items) == 1
428 mock_parse_playlist.assert_called_once()
429
430
431def test_process_generic_items(provider_mock: Mock) -> None:
432 """Test processing generic items with type inference."""
433 with (
434 patch("music_assistant.providers.tidal.tidal_page_parser.parse_track") as mock_track,
435 patch("music_assistant.providers.tidal.tidal_page_parser.parse_album") as mock_album,
436 ):
437 mock_track.return_value = Mock(media_type=MediaType.TRACK)
438 mock_album.return_value = Mock(media_type=MediaType.ALBUM)
439
440 page_data = {
441 "rows": [
442 {
443 "modules": [
444 {
445 "title": "Generic",
446 "type": "UNKNOWN_LIST",
447 "pagedList": {
448 "items": [
449 {
450 "id": 1,
451 "title": "Track",
452 "duration": 100,
453 "album": {},
454 }, # Inferred TRACK
455 {
456 "id": 2,
457 "title": "Album",
458 "numberOfTracks": 10,
459 "artists": [],
460 }, # Inferred ALBUM
461 ]
462 },
463 }
464 ]
465 }
466 ]
467 }
468
469 parser = TidalPageParser(provider_mock)
470 parser.parse_page_structure(page_data, "pages/test")
471
472 module_info = parser._module_map[0]
473 items, _ = parser.get_module_items(module_info)
474
475 assert len(items) == 2
476 mock_track.assert_called_once()
477 mock_album.assert_called_once()
478
479
480def test_content_stats(provider_mock: Mock) -> None:
481 """Test content_stats property."""
482 parser = TidalPageParser(provider_mock)
483 parser._module_map = [{"title": "Test"}]
484 parser._parsed_at = 1234567890
485 parser._content_map["PLAYLIST"] = {"1": {}}
486
487 stats = parser.content_stats
488
489 assert stats["modules"] == 1
490 assert stats["playlist_count"] == 1
491 assert stats["album_count"] == 0
492 assert "cache_age_minutes" in stats
493