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