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
500 lines
17 KiB
Python
500 lines
17 KiB
Python
"""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() != "—"
|