summaryrefslogtreecommitdiff
path: root/tests/test_hover_info.py
diff options
context:
space:
mode:
authorWarry <FunWarry@users.noreply.github.com>2025-12-03 09:40:49 +0100
committerWarry <FunWarry@users.noreply.github.com>2025-12-03 09:40:49 +0100
commit85f7e33b28985afd62a99d64b3e7a6883d62b072 (patch)
tree07d59dae4ed8be7b03cc2058eefdcf73d69596f6 /tests/test_hover_info.py
parent0facd232dc089b164acd7255983f8f078aaaa462 (diff)
parentc077e2b4977ea3219bce9a0f7c4518e0838baf87 (diff)
downloadtidal-dl-ng-85f7e33b28985afd62a99d64b3e7a6883d62b072.tar.gz
Merge branch 'feature/informations-window' into fusion-refafac-setings
# Conflicts: # .gitignore
Diffstat (limited to 'tests/test_hover_info.py')
-rw-r--r--tests/test_hover_info.py499
1 files changed, 499 insertions, 0 deletions
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() != "—"