diff options
| author | Warry <FunWarry@users.noreply.github.com> | 2025-12-29 17:37:39 +0100 |
|---|---|---|
| committer | Warry <FunWarry@users.noreply.github.com> | 2025-12-29 17:37:39 +0100 |
| commit | 296c6c7d6b319b38958b2aacb9904f18f6f46fd1 (patch) | |
| tree | a3e72a9326defee5804cc9387ecee779b9bf02a2 /tests/test_playlist_manager.py | |
| parent | 75868b32d07aafe46b69649d2da0dc2c833f112d (diff) | |
| download | tidal-dl-ng-296c6c7d6b319b38958b2aacb9904f18f6f46fd1.tar.gz | |
feat: Add playlist management functionality and compliance checks
- Introduced a new "Playlists" column in results view with visual indicators for membership.
- Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks).
- Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching.
- Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards.
- Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow.
- Updated GUI logic and QStandardItemModel setup to include playlist data.
- Included unit tests for playlist management components and API.
Diffstat (limited to 'tests/test_playlist_manager.py')
| -rw-r--r-- | tests/test_playlist_manager.py | 543 |
1 files changed, 543 insertions, 0 deletions
diff --git a/tests/test_playlist_manager.py b/tests/test_playlist_manager.py new file mode 100644 index 0000000..e434dc0 --- /dev/null +++ b/tests/test_playlist_manager.py @@ -0,0 +1,543 @@ +"""Tests for Playlist Membership Manager. + +Comprehensive test suite for the playlist management system including: +- Cache thread-safety and operations +- Worker thread loading and API integration +- Dialog UI state management +- Transaction rollback on errors +""" + +import threading +import unittest +from unittest.mock import Mock, patch + +from PySide6 import QtCore, QtWidgets +from tidalapi import Session, Track + +from tidal_dl_ng.gui.playlist_membership import ( + PlaylistCellState, + PlaylistColumnDelegate, + PlaylistContextLoader, + ThreadSafePlaylistCache, +) +from tidal_dl_ng.ui.dialog_playlist_manager import PlaylistManagerDialog + + +class TestThreadSafePlaylistCache(unittest.TestCase): + """Tests for ThreadSafePlaylistCache. + + Verifies thread-safety, O(1) performance, and data integrity. + """ + + def setUp(self) -> None: + """Set up test fixtures.""" + self.cache = ThreadSafePlaylistCache() + + def test_add_track_to_playlist(self) -> None: + """Test adding a track to a playlist.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + playlists = self.cache.get_playlists_for_track("track_1") + self.assertIn("playlist_1", playlists) + + def test_remove_track_from_playlist(self) -> None: + """Test removing a track from a playlist.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + self.cache.remove_track_from_playlist("track_1", "playlist_1") + playlists = self.cache.get_playlists_for_track("track_1") + self.assertNotIn("playlist_1", playlists) + + def test_is_track_in_playlist(self) -> None: + """Test checking if track is in playlist.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + self.assertTrue(self.cache.is_track_in_playlist("track_1", "playlist_1")) + self.assertFalse(self.cache.is_track_in_playlist("track_1", "playlist_2")) + + def test_get_nonexistent_track(self) -> None: + """Test getting playlists for nonexistent track returns empty set.""" + playlists = self.cache.get_playlists_for_track("nonexistent") + self.assertEqual(playlists, set()) + + def test_multiple_playlists_per_track(self) -> None: + """Test track can be in multiple playlists.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + self.cache.add_track_to_playlist("track_1", "playlist_2") + self.cache.add_track_to_playlist("track_1", "playlist_3") + + playlists = self.cache.get_playlists_for_track("track_1") + self.assertEqual(len(playlists), 3) + self.assertIn("playlist_1", playlists) + self.assertIn("playlist_2", playlists) + self.assertIn("playlist_3", playlists) + + def test_concurrent_reads_no_deadlock(self) -> None: + """Test concurrent reads don't cause deadlock.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + + results = [] + + def read_cache() -> None: + for _ in range(100): + result = self.cache.get_playlists_for_track("track_1") + results.append(result) + + threads = [threading.Thread(target=read_cache) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All reads should succeed + self.assertEqual(len(results), 500) + + def test_concurrent_writes_thread_safe(self) -> None: + """Test concurrent writes maintain consistency.""" + + def write_cache(track_id: str, playlist_ids: list[str]) -> None: + for pid in playlist_ids: + self.cache.add_track_to_playlist(track_id, pid) + + threads = [] + for i in range(10): + t = threading.Thread(target=write_cache, args=(f"track_{i}", [f"p_{i}_{j}" for j in range(5)])) + threads.append(t) + + for t in threads: + t.start() + for t in threads: + t.join() + + # Verify all writes succeeded + total_tracks = sum(len(self.cache.get_playlists_for_track(f"track_{i}")) for i in range(10)) + self.assertEqual(total_tracks, 50) + + def test_update_from_dict(self) -> None: + """Test batch update from dictionary.""" + data = { + "track_1": {"playlist_1", "playlist_2"}, + "track_2": {"playlist_2", "playlist_3"}, + } + self.cache.update_from_dict(data) + + self.assertEqual(self.cache.get_playlists_for_track("track_1"), {"playlist_1", "playlist_2"}) + self.assertEqual(self.cache.get_playlists_for_track("track_2"), {"playlist_2", "playlist_3"}) + + def test_clear(self) -> None: + """Test clearing the cache.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + self.cache.clear() + self.assertEqual(self.cache.get_playlists_for_track("track_1"), set()) + + def test_set_and_get_playlist_metadata(self) -> None: + """Test storing and retrieving playlist metadata.""" + self.cache.set_playlist_metadata("playlist_1", "My Favorites", 42) + metadata = self.cache.get_playlist_metadata("playlist_1") + + self.assertIsNotNone(metadata) + self.assertEqual(metadata["name"], "My Favorites") + self.assertEqual(metadata["item_count"], 42) + + def test_get_all_playlists(self) -> None: + """Test getting all playlist IDs.""" + self.cache.add_track_to_playlist("track_1", "playlist_1") + self.cache.add_track_to_playlist("track_2", "playlist_2") + self.cache.add_track_to_playlist("track_3", "playlist_1") + + all_playlists = self.cache.get_all_playlists() + self.assertEqual(all_playlists, {"playlist_1", "playlist_2"}) + + +class TestPlaylistContextLoader(unittest.TestCase): + """Tests for PlaylistContextLoader worker. + + Verifies API calls, pagination, and cache building. + """ + + def setUp(self) -> None: + """Set up test fixtures.""" + self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + @patch("tidal_dl_ng.gui.playlist_membership.Session") + def test_fetch_user_playlists_pagination(self, mock_session_class: Mock) -> None: + """Test handling of playlist pagination.""" + mock_session = Mock() + + # Mock paginated responses + mock_session.request.side_effect = [ + Mock( + json=lambda: { + "items": [ + {"uuid": f"playlist_{i}", "title": f"Playlist {i}", "numberOfItems": 10} for i in range(50) + ], + "totalNumberOfItems": 75, + } + ), + Mock( + json=lambda: { + "items": [ + {"uuid": f"playlist_{i}", "title": f"Playlist {i}", "numberOfItems": 10} for i in range(50, 75) + ], + "totalNumberOfItems": 75, + } + ), + ] + + loader = PlaylistContextLoader(mock_session, user_id="user_123") + playlists = loader._fetch_user_playlists() + + # Should have fetched all playlists across two pages + self.assertEqual(len(playlists), 75) + + @patch("tidal_dl_ng.gui.playlist_membership.Session") + def test_fetch_playlist_items_pagination(self, mock_session_class: Mock) -> None: + """Test handling of playlist items pagination.""" + mock_session = Mock() + + # Mock paginated item responses + mock_session.request.side_effect = [ + Mock( + json=lambda: { + "items": [{"item": {"id": f"track_{i}"}} for i in range(300)], + "totalNumberOfItems": 450, + } + ), + Mock( + json=lambda: { + "items": [{"item": {"id": f"track_{i}"}} for i in range(300, 450)], + "totalNumberOfItems": 450, + } + ), + ] + + loader = PlaylistContextLoader(mock_session, user_id="user_123") + track_ids = loader._fetch_playlist_items("playlist_1") + + self.assertEqual(len(track_ids), 450) + + @patch("tidal_dl_ng.gui.playlist_membership.Session") + def test_build_cache_structure(self, mock_session_class: Mock) -> None: + """Test correct cache structure building.""" + mock_session = Mock() + + # Mock responses + playlists_response = Mock( + json=lambda: { + "items": [ + {"uuid": "playlist_1", "title": "Favorites", "numberOfItems": 2}, + {"uuid": "playlist_2", "title": "Workout", "numberOfItems": 1}, + ], + "totalNumberOfItems": 2, + } + ) + + items_responses = [ + Mock( + json=lambda: { + "items": [{"item": {"id": "track_1"}}, {"item": {"id": "track_2"}}], + "totalNumberOfItems": 2, + } + ), + Mock( + json=lambda: { + "items": [{"item": {"id": "track_2"}}], + "totalNumberOfItems": 1, + } + ), + ] + + mock_session.request.side_effect = [playlists_response, *items_responses] + + loader = PlaylistContextLoader(mock_session, user_id="user_123") + + # Manually test cache building (since run() needs event loop) + playlists = loader._fetch_user_playlists() + cache = loader._fetch_all_playlist_contents(playlists) + + # Verify cache structure + self.assertIn("track_1", cache) + self.assertIn("track_2", cache) + self.assertEqual(cache["track_1"], {"playlist_1"}) + self.assertEqual(cache["track_2"], {"playlist_1", "playlist_2"}) + + @patch("tidal_dl_ng.gui.playlist_membership.Session") + def test_abort_request(self, mock_session_class: Mock) -> None: + """Test aborting the loader.""" + mock_session = Mock() + loader = PlaylistContextLoader(mock_session, user_id="user_123") + + # Request abort + loader.request_abort() + + # Verify abort flag is set + self.assertTrue(loader._abort_requested.is_set()) + + def test_max_workers_clamped(self) -> None: + """Test max_workers is clamped to valid range.""" + mock_session = Mock() + + loader1 = PlaylistContextLoader(mock_session, user_id="user_123", max_workers=10) + self.assertLessEqual(loader1.max_workers, 5) + + loader2 = PlaylistContextLoader(mock_session, user_id="user_123", max_workers=0) + self.assertEqual(loader2.max_workers, 1) + + +class TestPlaylistColumnDelegate(unittest.TestCase): + """Tests for PlaylistColumnDelegate. + + Verifies state rendering and transitions. + """ + + def setUp(self) -> None: + """Set up test fixtures.""" + self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + self.delegate = PlaylistColumnDelegate() + + def test_initial_state_pending(self) -> None: + """Test delegate starts in PENDING state.""" + # Cell should render as PENDING by default + self.assertFalse(self.delegate._cache_ready) + + def test_set_cache_ready_transitions_states(self) -> None: + """Test state transitions when cache becomes ready.""" + self.delegate.set_cell_state(0, PlaylistCellState.PENDING) + self.delegate.set_cell_state(1, PlaylistCellState.PENDING) + + # Mark cache as ready + self.delegate.set_cache_ready(True) + + # States should transition to READY + self.assertTrue(self.delegate._cache_ready) + + def test_set_cell_state(self) -> None: + """Test setting cell state.""" + self.delegate.set_cell_state(5, PlaylistCellState.READY) + self.assertEqual(self.delegate._cell_states.get("5"), PlaylistCellState.READY) + + self.delegate.set_cell_state(5, PlaylistCellState.ERROR) + self.assertEqual(self.delegate._cell_states.get("5"), PlaylistCellState.ERROR) + + def test_state_rendering_pending(self) -> None: + """Test rendering of PENDING state (spinner).""" + # Create mock painter and option + painter = Mock() + painter.fillRect = Mock() + painter.setPen = Mock() + painter.drawText = Mock() + + option = Mock() + option.rect = Mock(adjusted=Mock(return_value=Mock(center=Mock(return_value=QtCore.QPoint(50, 50))))) + option.state = 0 + + index = Mock() + index.row = Mock(return_value=0) + + # Set state to PENDING + self.delegate.set_cell_state(0, PlaylistCellState.PENDING) + + # Paint should be called without errors + # (Actual painting verification would require QPixmap/screen rendering) + + def test_button_click_event(self) -> None: + """Test that READY state cells emit button_clicked signal.""" + self.delegate.set_cell_state(0, PlaylistCellState.READY) + + # Create mock event and model + event = Mock() + event.type = Mock(return_value=QtCore.QEvent.Type.MouseButtonRelease) + + model = Mock() + option = Mock() + index = Mock() + index.row = Mock(return_value=0) + + # Track signal emissions + signal_emitted = [] + + def on_button_clicked(idx) -> None: + signal_emitted.append(idx) + + self.delegate.button_clicked.connect(on_button_clicked) + + # Simulate click event + handled = self.delegate.editorEvent(event, model, option, index) + + # Should handle the event and emit signal + self.assertTrue(handled) + + +class TestPlaylistManagerDialog(unittest.TestCase): + """Tests for PlaylistManagerDialog. + + Verifies UI state, transaction logic, and error handling. + """ + + def setUp(self) -> None: + """Set up test fixtures.""" + self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + self.threadpool = QtCore.QThreadPool() + + # Create mock objects + self.mock_track = Mock(spec=Track) + self.mock_track.id = "track_uuid_1" + self.mock_track.name = "Test Track" + + self.cache = ThreadSafePlaylistCache() + + # Add test data to cache + self.cache.set_playlist_metadata("playlist_1", "Favorites", 42) + self.cache.set_playlist_metadata("playlist_2", "Workout Mix", 15) + self.cache.add_track_to_playlist("track_uuid_1", "playlist_1") + + self.mock_session = Mock(spec=Session) + + def test_dialog_initialization(self) -> None: + """Test dialog initializes correctly.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + self.assertIsNotNone(dialog) + self.assertEqual(dialog.track.name, "Test Track") + + def test_playlists_populated_from_cache(self) -> None: + """Test dialog populates playlists from cache.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Count checkbox widgets (should be 2 playlists) + checkboxes = dialog.container_layout.count() + self.assertGreaterEqual(checkboxes, 2) + + def test_checkbox_initial_state_from_cache(self) -> None: + """Test checkboxes reflect initial cache state.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Track is in playlist_1, so that checkbox should be checked + # Track is NOT in playlist_2, so that checkbox should be unchecked + original_states = dialog._original_states + + self.assertTrue(original_states.get("playlist_1")) + self.assertFalse(original_states.get("playlist_2")) + + @patch("tidal_dl_ng.gui.dialog_playlist_manager.Worker") + def test_checkbox_change_triggers_worker(self, mock_worker_class: Mock) -> None: + """Test checkbox change triggers worker thread.""" + PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Mock worker + mock_worker_class.return_value = Mock() + + # Simulate checkbox change would trigger worker + # (Actual checkbox interaction would require Qt event simulation) + + def test_api_add_track_success(self) -> None: + """Test successful track addition.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Mock successful API response + mock_response = Mock() + mock_response.raise_for_status = Mock() + self.mock_session.request.return_value = mock_response + + # Create checkbox mock + mock_checkbox = Mock() + + # Simulate add operation + dialog._api_add_track_to_playlist("track_uuid_1", "playlist_2", mock_checkbox, False) + + # Cache should be updated + self.assertTrue(self.cache.is_track_in_playlist("track_uuid_1", "playlist_2")) + + # Checkbox should be re-enabled + mock_checkbox.setEnabled.assert_called_with(True) + + def test_api_add_track_error_rollback(self) -> None: + """Test rollback on add failure.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Mock failed API response + mock_response = Mock() + mock_response.raise_for_status = Mock(side_effect=Exception("API Error")) + self.mock_session.request.return_value = mock_response + + # Create checkbox mock + mock_checkbox = Mock() + + # Original state: not checked + original_state = False + + # Simulate failed add operation + dialog._api_add_track_to_playlist("track_uuid_1", "playlist_2", mock_checkbox, original_state) + + # Checkbox should be restored to original state + mock_checkbox.setChecked.assert_called() + + # Checkbox should be re-enabled + mock_checkbox.setEnabled.assert_called_with(True) + + def test_api_remove_track_success(self) -> None: + """Test successful track removal.""" + dialog = PlaylistManagerDialog( + track=self.mock_track, + cache=self.cache, + session=self.mock_session, + threadpool=self.threadpool, + ) + + # Mock successful API responses + mock_items_response = Mock() + mock_items_response.json.return_value = { + "items": [{"id": "item_uuid_1", "item": {"id": "track_uuid_1"}}], + "totalNumberOfItems": 1, + } + + mock_delete_response = Mock() + mock_delete_response.raise_for_status = Mock() + + self.mock_session.request.side_effect = [mock_items_response, mock_delete_response] + + # Create checkbox mock + mock_checkbox = Mock() + + # Verify track is in cache initially + self.assertTrue(self.cache.is_track_in_playlist("track_uuid_1", "playlist_1")) + + # Simulate remove operation + dialog._api_remove_track_from_playlist("track_uuid_1", "playlist_1", mock_checkbox, True) + + # Cache should be updated (track removed) + self.assertFalse(self.cache.is_track_in_playlist("track_uuid_1", "playlist_1")) + + # Checkbox should be re-enabled + mock_checkbox.setEnabled.assert_called_with(True) + + +if __name__ == "__main__": + unittest.main() |
