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
This commit is contained in:
@@ -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()
|
||||
@@ -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() != "—"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user