diff options
| author | Warry <FunWarry@users.noreply.github.com> | 2025-11-29 12:10:06 +0100 |
|---|---|---|
| committer | Warry <FunWarry@users.noreply.github.com> | 2025-11-29 12:10:06 +0100 |
| commit | d2c3e8ab8f1dcaec2b8be7ff9d1fb6c0df2d5296 (patch) | |
| tree | 2cf51e324cca81e198758e57a1c6e5780627e255 /tests | |
| parent | 28a6fe5a7dbadc9532de228a539c47ec49ca4aa2 (diff) | |
| download | tidal-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.py | 115 | ||||
| -rw-r--r-- | tests/test_hover_info.py | 499 | ||||
| -rw-r--r-- | tests/test_info_tab_extensions.py | 28 | ||||
| -rw-r--r-- | tests/test_tidal_extras.py | 137 |
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" |
