summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorWarry <FunWarry@users.noreply.github.com>2025-11-29 12:10:06 +0100
committerWarry <FunWarry@users.noreply.github.com>2025-11-29 12:10:06 +0100
commitd2c3e8ab8f1dcaec2b8be7ff9d1fb6c0df2d5296 (patch)
tree2cf51e324cca81e198758e57a1c6e5780627e255 /tests
parent28a6fe5a7dbadc9532de228a539c47ec49ca4aa2 (diff)
downloadtidal-dl-ng-d2c3e8ab8f1dcaec2b8be7ff9d1fb6c0df2d5296.tar.gz
feat(hover): add track details preview on hover + fix all ruff violations
Implement comprehensive track information display on hover and resolve all code quality issues to achieve complete ruff compliance. MAJOR FEATURES: - Track details preview on hover with rich metadata display - Async BPM fetching with loading indicator - Enhanced metadata utilities with fuzzy matching - TIDAL API extras integration FILES MODIFIED: 15+ files (core, UI, tests, docs) RUFF COMPLIANCE: 0 errors (89+ violations resolved) - Fixed E999 syntax error in metadata_utils.py - Refactored 10+ complex functions (C901) - Updated error handling (S110, S112, SIM105) - Modernized type hints (UP038) - Fixed naming conflicts (A001, F811) BUG FIXES: - Added missing _on_update_cover() method (11 test failures) - Fixed BPM display and async loading - Improved thread safety TESTING: 30 passed, 1 skipped DOCUMENTATION: Windows PowerShell support, tox troubleshooting
Diffstat (limited to 'tests')
-rw-r--r--tests/conftest.py115
-rw-r--r--tests/test_hover_info.py499
-rw-r--r--tests/test_info_tab_extensions.py28
-rw-r--r--tests/test_tidal_extras.py137
4 files changed, 779 insertions, 0 deletions
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..1e6cfe3
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,115 @@
+from unittest.mock import Mock
+
+import pytest
+from PySide6 import QtCore, QtGui, QtWidgets
+from tidalapi import Album, Track, Video
+from tidalapi.artist import Artist
+
+
+@pytest.fixture
+def qt_app():
+ """Create a QApplication instance for testing Qt widgets."""
+ app = QtWidgets.QApplication.instance()
+ if app is None:
+ app = QtWidgets.QApplication([])
+ yield app
+ # Note: don't quit the app as it may be shared across tests
+
+
+@pytest.fixture
+def info_tab_widget(qt_app):
+ """Create an InfoTabWidget instance for testing."""
+ from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget
+
+ widget = InfoTabWidget()
+ yield widget
+ # InfoTabWidget inherits from QObject, not QWidget, so it doesn't have close()
+ widget.deleteLater()
+
+
+@pytest.fixture
+def mock_track():
+ """Create a mock Track object with metadata."""
+ track = Mock(spec=Track)
+ track.name = "Bohemian Rhapsody"
+ track.title = "Bohemian Rhapsody"
+ track.full_name = "Bohemian Rhapsody"
+ track.version = "2011 Remaster"
+ track.duration = 354 # 5:54
+ track.explicit = False
+ track.popularity = 95
+ track.bpm = 72
+ track.isrc = "GBUM71029604"
+ track.bit_depth = 24
+ track.sample_rate = 96000
+ track.audio_modes = []
+ track.media_metadata_tags = []
+ track.available = True
+ track.id = 12345
+
+ # Mock album
+ mock_album = Mock(spec=Album)
+ mock_album.name = "A Night at the Opera"
+ mock_album.id = 67890
+ mock_album.release_date = Mock()
+ mock_album.release_date.strftime = Mock(return_value="1975-11-21")
+ track.album = mock_album
+
+ # Mock artists
+ mock_artist = Mock(spec=Artist)
+ mock_artist.name = "Queen"
+ mock_artist.roles = [Mock(name="main")]
+ track.artists = [mock_artist]
+
+ return track
+
+
+@pytest.fixture
+def mock_video():
+ """Create a mock Video object."""
+ video = Mock(spec=Video)
+ video.name = "Thriller"
+ video.title = "Thriller"
+ video.full_name = "Thriller (Official Video)"
+ video.duration = 600
+ video.explicit = False
+ video.video_quality = "1080p"
+ video.available = True
+ video.id = 54321
+
+ mock_artist = Mock(spec=Artist)
+ mock_artist.name = "Michael Jackson"
+ video.artists = [mock_artist]
+
+ mock_album = Mock(spec=Album)
+ mock_album.name = "Thriller"
+ video.album = mock_album
+
+ return video
+
+
+@pytest.fixture
+def tree_view_setup(qt_app):
+ """Create a tree view with model for testing hover manager."""
+ tree_view = QtWidgets.QTreeView()
+ source_model = QtGui.QStandardItemModel()
+ proxy_model = QtCore.QSortFilterProxyModel()
+
+ proxy_model.setSourceModel(source_model)
+ tree_view.setModel(proxy_model)
+
+ # Add some test data
+ source_model.setColumnCount(2)
+ for i in range(5):
+ item_index = QtGui.QStandardItem(f"Item {i}")
+ item_obj = QtGui.QStandardItem()
+ mock_track = Mock(spec=Track)
+ mock_track.name = f"Track {i}"
+ mock_track.id = i
+ item_obj.setData(mock_track, QtCore.Qt.ItemDataRole.UserRole)
+ source_model.appendRow([item_index, item_obj])
+
+ yield tree_view, proxy_model, source_model
+
+ tree_view.close()
+ tree_view.deleteLater()
diff --git a/tests/test_hover_info.py b/tests/test_hover_info.py
new file mode 100644
index 0000000..38ece63
--- /dev/null
+++ b/tests/test_hover_info.py
@@ -0,0 +1,499 @@
+"""Unit tests for the hover info feature.
+
+This module tests the "Quick View on Hover" functionality, including:
+ - Debounce timer behavior
+ - Info tab widget updates
+ - Data integrity without network calls
+ - Hover state management
+
+Tests ensure that:
+ 1. Rapid hover events are properly debounced
+ 2. UI updates only occur after the debounce delay
+ 3. Pre-loaded metadata is displayed without API calls
+ 4. Hover state properly reverts when mouse leaves
+"""
+
+import time
+from unittest.mock import Mock, patch
+
+import pytest
+from PySide6 import QtCore, QtGui, QtWidgets
+from PySide6.QtCore import Qt
+from tidalapi import Album, Track, Video
+from tidalapi.artist import Artist
+
+from tidal_dl_ng.helper.hover_manager import HoverManager
+from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget, TrackInfoFormatter
+
+
+@pytest.fixture
+def qt_app():
+ """Create a QApplication instance for testing Qt widgets.
+
+ Returns:
+ QApplication: The application instance.
+ """
+ app = QtWidgets.QApplication.instance()
+ if app is None:
+ app = QtWidgets.QApplication([])
+ yield app
+ # Note: Don't quit the app as it may be shared across tests
+
+
+@pytest.fixture
+def info_tab_widget(qt_app):
+ """Create an InfoTabWidget instance for testing.
+
+ Args:
+ qt_app: The QApplication fixture.
+
+ Returns:
+ InfoTabWidget: The widget instance.
+ """
+ widget = InfoTabWidget()
+ yield widget
+ # InfoTabWidget inherits from QObject, not QWidget, so it doesn't have close()
+ widget.deleteLater()
+
+
+@pytest.fixture
+def mock_track():
+ """Create a mock Track object with metadata.
+
+ Returns:
+ Mock: A mock Track object with realistic attributes.
+ """
+ track = Mock(spec=Track)
+ track.name = "Bohemian Rhapsody"
+ track.title = "Bohemian Rhapsody"
+ track.full_name = "Bohemian Rhapsody"
+ track.version = "2011 Remaster"
+ track.duration = 354 # 5:54
+ track.explicit = False
+ track.popularity = 95
+ track.bpm = 72
+ track.isrc = "GBUM71029604"
+ track.bit_depth = 24
+ track.sample_rate = 96000
+ track.audio_modes = []
+ track.available = True
+ track.id = 12345
+
+ # Mock album
+ mock_album = Mock(spec=Album)
+ mock_album.name = "A Night at the Opera"
+ mock_album.id = 67890
+ mock_album.release_date = Mock()
+ mock_album.release_date.strftime = Mock(return_value="1975-11-21")
+ track.album = mock_album
+
+ # Mock artists
+ mock_artist = Mock(spec=Artist)
+ mock_artist.name = "Queen"
+ mock_artist.roles = [Mock(name="main")]
+ track.artists = [mock_artist]
+
+ return track
+
+
+@pytest.fixture
+def mock_video():
+ """Create a mock Video object.
+
+ Returns:
+ Mock: A mock Video object.
+ """
+ video = Mock(spec=Video)
+ video.name = "Thriller"
+ video.title = "Thriller"
+ video.full_name = "Thriller (Official Video)"
+ video.duration = 600
+ video.explicit = False
+ video.video_quality = "1080p"
+ video.available = True
+ video.id = 54321
+
+ mock_artist = Mock(spec=Artist)
+ mock_artist.name = "Michael Jackson"
+ video.artists = [mock_artist]
+
+ mock_album = Mock(spec=Album)
+ mock_album.name = "Thriller"
+ video.album = mock_album
+
+ return video
+
+
+@pytest.fixture
+def tree_view_setup(qt_app):
+ """Create a tree view with model for testing hover manager.
+
+ Args:
+ qt_app: The QApplication fixture.
+
+ Returns:
+ tuple: (tree_view, proxy_model, source_model)
+ """
+ tree_view = QtWidgets.QTreeView()
+ source_model = QtGui.QStandardItemModel()
+ proxy_model = QtCore.QSortFilterProxyModel()
+
+ proxy_model.setSourceModel(source_model)
+ tree_view.setModel(proxy_model)
+
+ # Add some test data
+ source_model.setColumnCount(2)
+ for i in range(5):
+ item_index = QtGui.QStandardItem(f"Item {i}")
+ item_obj = QtGui.QStandardItem()
+ mock_track = Mock(spec=Track)
+ mock_track.name = f"Track {i}"
+ mock_track.id = i
+ item_obj.setData(mock_track, Qt.ItemDataRole.UserRole)
+ source_model.appendRow([item_index, item_obj])
+
+ yield tree_view, proxy_model, source_model
+
+ tree_view.close()
+ tree_view.deleteLater()
+
+
+class TestTrackInfoFormatter:
+ """Test the TrackInfoFormatter utility class."""
+
+ def test_format_duration_valid(self):
+ """Test duration formatting with valid input."""
+ assert TrackInfoFormatter.format_duration(354) == "05:54"
+ assert TrackInfoFormatter.format_duration(60) == "01:00"
+ assert TrackInfoFormatter.format_duration(0) == "00:00"
+
+ def test_format_duration_none(self):
+ """Test duration formatting with None input."""
+ assert TrackInfoFormatter.format_duration(None) == "N/A"
+
+ def test_format_codec(self, mock_track):
+ """Test codec formatting with a mock track."""
+ with patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES") as _:
+ codec = TrackInfoFormatter.format_codec(mock_track)
+ assert "HI_RES" in codec.upper()
+
+ def test_format_bitrate(self, mock_track):
+ """Test bitrate formatting."""
+ bitrate = TrackInfoFormatter.format_bitrate(mock_track)
+ assert "24-bit" in bitrate
+ assert "96.0 kHz" in bitrate
+
+ def test_format_bitrate_missing_data(self):
+ """Test bitrate formatting with missing data."""
+ track = Mock(spec=Track)
+ track.bit_depth = None
+ track.sample_rate = None
+ assert TrackInfoFormatter.format_bitrate(track) == "N/A"
+
+
+class TestInfoTabWidget:
+ """Test the InfoTabWidget component."""
+
+ def test_widget_initialization(self, info_tab_widget):
+ """Test that the widget initializes correctly."""
+ assert info_tab_widget is not None
+ # In test context, tab_widget is None because no existing_tab_widget is provided
+ # The widget creates fallback labels for all required fields
+ assert hasattr(info_tab_widget, "lbl_title")
+ assert hasattr(info_tab_widget, "lbl_artists")
+ assert hasattr(info_tab_widget, "lbl_bpm")
+ assert hasattr(info_tab_widget, "cover_label")
+
+ def test_update_on_hover(self, info_tab_widget, mock_track, qt_app):
+ """Test updating widget on hover."""
+ info_tab_widget.update_on_hover(mock_track)
+ qt_app.processEvents()
+
+ assert info_tab_widget.current_media_hovered == mock_track
+ # The details should be updated via signal
+ # We can check if the signal was emitted by verifying internal state
+
+ def test_update_on_selection(self, info_tab_widget, mock_track, qt_app):
+ """Test updating widget on selection."""
+ info_tab_widget.update_on_selection(mock_track)
+ qt_app.processEvents()
+
+ assert info_tab_widget.current_media_selected == mock_track
+
+ def test_revert_to_selection(self, info_tab_widget, mock_track, qt_app):
+ """Test reverting to selected media after hover."""
+ # Set a selection
+ info_tab_widget.update_on_selection(mock_track)
+ qt_app.processEvents()
+
+ # Create a different track for hover
+ hover_track = Mock(spec=Track)
+ hover_track.name = "Different Track"
+ info_tab_widget.update_on_hover(hover_track)
+ qt_app.processEvents()
+
+ # Revert to selection
+ info_tab_widget.revert_to_selection()
+ qt_app.processEvents()
+
+ # Should have reverted to the original selection
+ assert info_tab_widget.current_media_selected == mock_track
+
+ def test_populate_track_details(self, info_tab_widget, mock_track, qt_app):
+ """Test populating details for a track."""
+ with (
+ patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES"),
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Queen"),
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Bohemian Rhapsody"),
+ ):
+ info_tab_widget._populate_track_details(mock_track)
+ qt_app.processEvents()
+
+ assert info_tab_widget.lbl_title.text() == "Bohemian Rhapsody"
+ assert info_tab_widget.lbl_version.text() == "2011 Remaster"
+ assert info_tab_widget.lbl_duration.text() == "05:54"
+ assert info_tab_widget.lbl_bpm.text() == "⏳ Loading..."
+ info_tab_widget._update_extras_ui({"bpm": 72})
+ qt_app.processEvents()
+ assert info_tab_widget.lbl_bpm.text() == "72"
+
+ def test_populate_video_details(self, info_tab_widget, mock_video, qt_app):
+ """Test populating details for a video."""
+ with (
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Michael Jackson"),
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Thriller"),
+ ):
+ info_tab_widget._populate_video_details(mock_video)
+ qt_app.processEvents()
+
+ assert info_tab_widget.lbl_title.text() == "Thriller"
+ assert info_tab_widget.lbl_codec.text() == "1080p"
+
+ def test_clear_display(self, info_tab_widget, qt_app):
+ """Test clearing the display."""
+ info_tab_widget._clear_display()
+ qt_app.processEvents()
+
+ assert info_tab_widget.lbl_title.text() == "—"
+ assert info_tab_widget.lbl_artists.text() == "—"
+ assert info_tab_widget.current_media_hovered is None
+
+
+class TestHoverManager:
+ """Test the HoverManager with debounce logic."""
+
+ def test_hover_manager_initialization(self, tree_view_setup, qt_app):
+ """Test that HoverManager initializes correctly."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ assert hover_manager is not None
+ assert hover_manager.debounce_delay_ms == 100
+ assert hover_manager.pending_media is None
+
+ def test_debounce_single_hover(self, tree_view_setup, qt_app):
+ """Test that a single hover event is confirmed after debounce delay."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ # Mock signal to track emissions
+ hover_confirmed_spy = []
+
+ def on_hover_confirmed(media):
+ hover_confirmed_spy.append(media)
+
+ hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
+
+ # Simulate mouse movement over an item
+ # (This is complex to test properly without actual UI interaction)
+ # For now, we'll test the timer directly
+ mock_media = Mock(spec=Track)
+ mock_media.name = "Test Track"
+
+ hover_manager.pending_media = mock_media
+ hover_manager.debounce_timer.start()
+
+ # Wait for debounce delay + some buffer
+ qt_app.processEvents()
+ time.sleep(0.15)
+ qt_app.processEvents()
+
+ # Signal should have been emitted
+ assert len(hover_confirmed_spy) == 1
+ assert hover_confirmed_spy[0] == mock_media
+
+ def test_debounce_rapid_hovers(self, tree_view_setup, qt_app):
+ """Test that rapid hover events are debounced (only last one fires)."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ hover_confirmed_spy = []
+
+ def on_hover_confirmed(media):
+ hover_confirmed_spy.append(media)
+
+ hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
+
+ # Simulate 5 rapid hover events
+ for i in range(5):
+ mock_media = Mock(spec=Track)
+ mock_media.name = f"Track {i}"
+ hover_manager.pending_media = mock_media
+ hover_manager.debounce_timer.stop()
+ hover_manager.debounce_timer.start()
+ time.sleep(0.02) # Small delay between events (< debounce)
+ qt_app.processEvents()
+
+ # Wait for debounce delay
+ time.sleep(0.15)
+ qt_app.processEvents()
+
+ # Only one signal should have been emitted (the last one)
+ assert len(hover_confirmed_spy) == 1
+ assert hover_confirmed_spy[0].name == "Track 4"
+
+ def test_hover_left_cancels_pending(self, tree_view_setup, qt_app):
+ """Test that leaving hover cancels pending confirmation."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ hover_confirmed_spy = []
+ hover_left_spy = []
+
+ def on_hover_confirmed(media):
+ hover_confirmed_spy.append(media)
+
+ def on_hover_left():
+ hover_left_spy.append(True)
+
+ hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
+ hover_manager.s_hover_left.connect(on_hover_left)
+
+ # Start a hover
+ mock_media = Mock(spec=Track)
+ hover_manager.pending_media = mock_media
+ hover_manager.debounce_timer.start()
+
+ # Leave before debounce completes
+ time.sleep(0.05)
+ qt_app.processEvents()
+ hover_manager._handle_mouse_leave()
+ qt_app.processEvents()
+
+ # Wait to ensure timer would have fired
+ time.sleep(0.1)
+ qt_app.processEvents()
+
+ # Hover confirmed should NOT have been emitted
+ assert len(hover_confirmed_spy) == 0
+ # Hover left should have been emitted
+ assert len(hover_left_spy) == 1
+
+ def test_set_debounce_delay(self, tree_view_setup, qt_app):
+ """Test changing the debounce delay."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ hover_manager.set_debounce_delay(200)
+
+ assert hover_manager.debounce_delay_ms == 200
+ assert hover_manager.debounce_timer.interval() == 200
+
+ def test_reset(self, tree_view_setup, qt_app):
+ """Test resetting the hover manager state."""
+ tree_view, proxy_model, source_model = tree_view_setup
+
+ hover_manager = HoverManager(
+ tree_view=tree_view,
+ proxy_model=proxy_model,
+ source_model=source_model,
+ debounce_delay_ms=100,
+ )
+
+ # Set some state
+ mock_media = Mock(spec=Track)
+ hover_manager.pending_media = mock_media
+ hover_manager.last_hovered_media = mock_media
+ hover_manager.debounce_timer.start()
+
+ # Reset
+ hover_manager.reset()
+
+ assert hover_manager.pending_media is None
+ assert hover_manager.last_hovered_media is None
+ assert not hover_manager.debounce_timer.isActive()
+
+
+class TestDataIntegrity:
+ """Test that metadata is displayed from pre-loaded data without API calls."""
+
+ def test_no_api_calls_on_hover(self, info_tab_widget, mock_track, qt_app):
+ """Test that hovering does not trigger API calls.
+
+ This test verifies that all data displayed on hover comes from
+ pre-loaded track objects, not from additional API requests.
+ """
+ # Patch any potential API call methods
+ with patch.object(mock_track, "album") as mock_album:
+ # Ensure album data is already present (pre-loaded)
+ mock_album.name = "A Night at the Opera"
+ mock_album.id = 67890
+
+ # Update on hover
+ info_tab_widget.update_on_hover(mock_track)
+ qt_app.processEvents()
+
+ # Verify that album was not accessed in a way that would trigger API calls
+ # The album should have been accessed only for reading pre-loaded data
+ assert info_tab_widget.current_media_hovered == mock_track
+
+ def test_all_metadata_from_preloaded_object(self, info_tab_widget, mock_track, qt_app):
+ """Test that all displayed metadata comes from the track object.
+
+ This verifies that the Track object contains all necessary fields
+ to populate the Details tab without additional API calls.
+ """
+ with (
+ patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES"),
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Queen"),
+ patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Bohemian Rhapsody"),
+ ):
+ info_tab_widget._populate_track_details(mock_track)
+ qt_app.processEvents()
+
+ assert info_tab_widget.lbl_title.text() != "—"
+ assert info_tab_widget.lbl_version.text() != "—"
+ assert info_tab_widget.lbl_duration.text() != "—"
+ assert info_tab_widget.lbl_popularity.text() != "—"
+ assert info_tab_widget.lbl_bpm.text() != "—"
+ assert info_tab_widget.lbl_isrc.text() != "—"
diff --git a/tests/test_info_tab_extensions.py b/tests/test_info_tab_extensions.py
new file mode 100644
index 0000000..4288cc8
--- /dev/null
+++ b/tests/test_info_tab_extensions.py
@@ -0,0 +1,28 @@
+from unittest.mock import patch
+
+
+def test_new_fields_initial_state(info_tab_widget, qt_app):
+ """New fields must exist and default to '—'."""
+ assert hasattr(info_tab_widget, "lbl_track_number")
+ assert info_tab_widget.lbl_track_number.text() == "—"
+ assert hasattr(info_tab_widget, "lbl_bpm")
+ assert info_tab_widget.lbl_bpm.text() == "—"
+ # Note: lbl_label, lbl_genres, lbl_producers and lbl_composers are not available
+ # as these fields cannot be retrieved from TIDAL API
+
+
+def test_bpm_population(info_tab_widget, mock_track, qt_app):
+ """Ensure BPM field is populated when present."""
+ # Add BPM attribute to mock_track
+ mock_track.bpm = 120
+
+ with patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Test Track"):
+ info_tab_widget._populate_track_details(mock_track)
+ qt_app.processEvents()
+
+ # Expect loading indicator first due to async extras
+ assert info_tab_widget.lbl_bpm.text() == "⏳ Loading..."
+ # Simulate async arrival of extras
+ info_tab_widget._update_extras_ui({"bpm": 120})
+ qt_app.processEvents()
+ assert info_tab_widget.lbl_bpm.text() == "120"
diff --git a/tests/test_tidal_extras.py b/tests/test_tidal_extras.py
new file mode 100644
index 0000000..916b88b
--- /dev/null
+++ b/tests/test_tidal_extras.py
@@ -0,0 +1,137 @@
+import mutagen
+import pytest
+
+from tidal_dl_ng.helper.tidal import (
+ _normalize_contributors,
+ extract_contributor_names,
+ parse_track_and_album_extras,
+)
+from tidal_dl_ng.metadata import Metadata
+
+
+def test_normalize_contributors_dict_shape():
+ raw = {
+ "producer": [{"name": "Prod A"}, {"name": "Prod B", "foo": "bar"}],
+ "composer": [{"name": "Comp 1"}],
+ "invalid": ["x", {"no_name": True}],
+ }
+
+ result = _normalize_contributors(raw)
+
+ assert set(result.keys()) == {"producer", "composer"}
+ assert result["producer"] == ["Prod A", "Prod B"]
+ assert result["composer"] == ["Comp 1"]
+
+
+def test_normalize_contributors_list_shape():
+ raw = [
+ {"name": "Prod A", "role": "producer"},
+ {"name": "Comp 1", "role": "composer"},
+ {"name": "Bad", "role": ""},
+ "oops",
+ ]
+
+ result = _normalize_contributors(raw)
+
+ assert set(result.keys()) == {"producer", "composer"}
+ assert result["producer"] == ["Prod A"]
+ assert result["composer"] == ["Comp 1"]
+
+
+def test_parse_track_and_album_extras_full():
+ track_json = {
+ "bpm": 123,
+ "contributors": {
+ "producer": [{"name": "Prod A"}],
+ "composer": [{"name": "Comp 1"}],
+ "lyricist": [{"name": "Writer"}],
+ },
+ }
+ album_json = {
+ "label": "Test Label",
+ "genres": [{"name": "Rock"}, {"name": "Indie"}],
+ }
+
+ extras = parse_track_and_album_extras(track_json, album_json)
+
+ assert extras["bpm"] == 123
+ assert extras["label"] == "Test Label"
+ assert extras["genres"] == ["Rock", "Indie"]
+ assert extras["contributors_by_role"]["producer"] == ["Prod A"]
+
+
+def test_parse_track_and_album_extras_missing_fields():
+ extras = parse_track_and_album_extras({}, {})
+
+ assert extras["bpm"] is None
+ assert extras["label"] == ""
+ assert extras["genres"] == []
+ assert extras["contributors_by_role"] == {}
+
+
+@pytest.mark.parametrize(
+ "role,expected",
+ [
+ ("producer", "Prod A, Prod B"),
+ ("composer", "Comp 1"),
+ ("lyricist", ""),
+ ],
+)
+def test_extract_contributor_names(role, expected):
+ contributors = {
+ "producer": ["Prod A", "Prod B"],
+ "composer": ["Comp 1"],
+ }
+
+ names = extract_contributor_names(contributors, role, delimiter=", ")
+
+ assert names == expected
+
+
+def test_metadata_enriched_tags_flac(tmp_path):
+ # Create a valid minimal FLAC file using soundfile
+ path = tmp_path / "test.flac"
+
+ # Import numpy and soundfile to create a minimal valid FLAC
+ try:
+ import numpy as np
+ import soundfile as sf
+
+ # Create 1 second of silence at 44100 Hz
+ silence = np.zeros((44100, 2), dtype=np.float32)
+ sf.write(str(path), silence, 44100, format="FLAC")
+ except ImportError:
+ # Fallback: skip this test if soundfile not available
+ import pytest
+
+ pytest.skip("soundfile required for FLAC test")
+
+ m = Metadata(
+ path_file=path,
+ target_upc={"FLAC": "UPC", "MP3": "UPC", "MP4": "UPC"},
+ title="Title",
+ album="Album",
+ artists="Artist1, Artist2",
+ albumartist="AlbumArtist",
+ genre="Rock, Indie",
+ label="Test Label",
+ bpm=120,
+ producers="Prod A, Prod B",
+ composers_detailed="Comp 1, Comp 2",
+ lyricists="Writer 1, Writer 2",
+ )
+
+ m.save()
+
+ audio_out = mutagen.flac.FLAC(path)
+
+ assert audio_out["TITLE"][0] == "Title"
+ assert audio_out["ALBUM"][0] == "Album"
+ assert audio_out["ARTIST"][0] == "Artist1, Artist2"
+ assert audio_out["ALBUMARTIST"][0] == "AlbumArtist"
+ assert audio_out["GENRE"][0] == "Rock, Indie"
+ assert audio_out["LABEL"][0] == "Test Label"
+ assert audio_out["BPM"][0] == "120"
+ assert audio_out["PRODUCER"][0] == "Prod A, Prod B"
+ assert audio_out["LYRICIST"][0] == "Writer 1, Writer 2"
+ assert audio_out["COMPOSER"][0] == "Comp 1, Comp 2"