Files
lintunes/tests/test_round17.py
T
travandClaude Opus 5.5 6f50248202 v0.31.5: Library button edge to edge with the playlists; one floor; floating top row
The sidebar gets 8px of bare window on its left, and the Library button
spans exactly the playlist list below it, scrollbar included. The Library
button, search box, playlist name, times and playhead all stand on one
floor 5px above the lists (text by its baseline). The top row floats:
equal space before, between and after the controls, with the now-playing
panel a fixed share of the width (700px at 1920). The header row grows if
the app font makes the Library button taller than the strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:04:12 -07:00

461 lines
19 KiB
Python

"""Round 17: cruft cleanup + rating clicks, search-bar move, player shutdown,
volume re-apply, MPRIS marshalling fix, album-art download plumbing."""
import threading
from unittest.mock import MagicMock, patch
from PyQt6.QtCore import QCoreApplication, Qt, QPoint, QPointF, QUrl
from PyQt6.QtGui import QFontMetrics, QMouseEvent
from lintunes import player as player_module
from lintunes import tagging
from lintunes.lastfm import LastFm
from lintunes.library_manager import LibraryManager
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes.player import Player
def _manager(tmp_path, tracks=()):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
return LibraryManager(library, tmp_path)
# ---- 1a: tag-write filter (rating/size edits never rewrite music files) ----
class TestTagWriteFilter:
def test_rating_edit_succeeds_without_file(self, qapp, tmp_path):
track = Track(track_id=1, name="Song",
location=str(tmp_path / "missing.mp3"))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"rating": 80})
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_rating_edit_leaves_file_untouched(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Song", location=str(mp3_file))
manager = _manager(tmp_path, [track])
before = mp3_file.stat().st_mtime_ns
manager.edit_track_fields(1, {"rating": 60})
assert track.rating == 60
assert mp3_file.stat().st_mtime_ns == before
def test_mixed_edit_still_writes_tags(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Old", location=str(mp3_file))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"name": "New", "rating": 40})
assert track.name == "New" and track.rating == 40
assert tagging.read_tags(mp3_file)["name"] == "New"
def test_failed_tag_write_emits_signal(self, qapp, tmp_path, monkeypatch):
track = Track(track_id=1, name="Song", location=str(tmp_path / "x.mp3"))
manager = _manager(tmp_path, [track])
failures = []
manager.tag_write_failed.connect(lambda name, err: failures.append(name))
def boom(path, fields):
raise OSError("disk full")
monkeypatch.setattr("lintunes.tagging.write_tags", boom)
manager.edit_track_fields(1, {"name": "New"})
assert failures == ["Song"]
assert track.name == "Song" # edit not applied when the write failed
# ---- 1c: import dedup index + cached max track id ----
class TestImportIndex:
def test_duplicate_import_returns_existing(self, qapp, tmp_path, mp3_file):
from lintunes.importers.file_importer import import_paths
manager = _manager(tmp_path)
music = tmp_path / "music"
first = import_paths([mp3_file], music, manager)
again = import_paths([mp3_file], music, manager)
assert len(manager.library.tracks) == 1
assert first[0].track_id == again[0].track_id
def test_new_track_id_tracks_additions(self, qapp, tmp_path):
manager = _manager(tmp_path, [Track(track_id=7, name="A")])
assert manager.new_track_id() == 8
manager.add_track(Track(name="B"))
assert manager.new_track_id() == 9
def test_new_track_id_recomputed_on_reload(self, qapp, tmp_path):
from lintunes.storage import json_storage
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
manager.flush()
# Another machine synced in a track with a higher id.
disk = Library()
disk.tracks[1] = Track(track_id=1, name="A")
disk.tracks[42] = Track(track_id=42, name="B")
json_storage.save_tracks(disk, tmp_path)
manager.reload_from_disk()
assert manager.new_track_id() == 43
# ---- 1d: model id→row index ----
class TestModelRowIndex:
def test_refresh_and_row_for_id(self, qapp):
from lintunes.gui.track_table import TrackTableModel
model = TrackTableModel()
a, b = Track(track_id=1, name="A"), Track(track_id=2, name="B")
model.set_tracks([a, b, a]) # duplicate row, as in a playlist
assert model.row_for_id(1) == 0
assert model.row_for_id(2) == 1
assert model.row_for_id(99) is None
changed = []
model.dataChanged.connect(lambda tl, br: changed.append(tl.row()))
model.refresh_track(1)
assert changed == [0, 2] # both occurrences repainted
# ---- 1e: scrobble queue lock ----
class TestScrobbleQueue:
def test_concurrent_enqueue_keeps_all(self, qapp, tmp_path):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
threads = [threading.Thread(
target=lastfm._enqueue, args=({"artist": "A", "track": str(i),
"timestamp": i},))
for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(lastfm._load_queue()) == 20
# ---- 1f: login prefs write marshalled to the GUI thread ----
class TestLoginMarshal:
def test_login_updates_prefs_on_gui_thread(self, qapp, tmp_path, monkeypatch):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
monkeypatch.setattr(
LastFm, "_call",
staticmethod(lambda *a, **k: {"session": {"name": "trav",
"key": "sk123"}}))
results = []
lastfm.login_finished.connect(lambda ok, msg: results.append((ok, msg)))
lastfm.login("key", "secret", "trav", "pw")
for _ in range(100):
QCoreApplication.processEvents()
if results:
break
threading.Event().wait(0.02)
assert results and results[0][0] is True
prefs.update_lastfm.assert_called_once_with(
api_key="key", api_secret="secret", username="trav",
session_key="sk123")
# ---- Task C: MPRIS PropertiesChanged marshalled as "as" ----
class TestMprisSignal:
def test_signal_marshals_as_sa_sv_as(self, qapp):
"""Send the signal over the real session bus and check the signature
it goes out with. A plain [] marshals invalidated_properties as "av"
and GDBus consumers (gsd-media-keys) validate + drop the signal; the
spec requires "as"."""
import pytest
from PyQt6.QtDBus import QDBusConnection
from lintunes.mpris import OBJECT_PATH, build_properties_changed
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusMessage
class Receiver(QObject):
def __init__(self):
super().__init__()
self.messages = []
@pyqtSlot(QDBusMessage)
def on_signal(self, msg):
self.messages.append(msg)
bus = QDBusConnection.sessionBus()
if not bus.isConnected():
pytest.skip("no D-Bus session bus in this environment")
receiver = Receiver()
assert bus.connect(
"", OBJECT_PATH, "org.freedesktop.DBus.Properties",
"PropertiesChanged", receiver.on_signal)
assert bus.send(build_properties_changed(
{"PlaybackStatus": "Playing"}))
for _ in range(100):
QCoreApplication.processEvents()
if receiver.messages:
break
threading.Event().wait(0.02)
assert receiver.messages, "signal was not delivered back over the bus"
assert receiver.messages[0].signature() == "sa{sv}as"
def test_invalidate_artwork_removes_cache_file(self, tmp_path, monkeypatch):
from lintunes import mpris
monkeypatch.setattr(mpris, "_art_cache_dir", lambda: tmp_path)
cached = tmp_path / "ABCD1234.jpg"
cached.write_bytes(b"old art")
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
assert not cached.exists()
# Missing file / no persistent id are silent no-ops.
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
mpris.invalidate_artwork(None)
# ---- Tasks E + F: player shutdown order and volume re-apply ----
def _mock_player(qapp, tmp_path):
manager = _manager(tmp_path)
with patch.multiple(player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock()):
player = Player(manager)
return player
class TestPlayerShutdown:
def test_shutdown_detaches_in_order_and_is_idempotent(self, qapp, tmp_path):
player = _mock_player(qapp, tmp_path)
player.shutdown()
player._local._media.stop.assert_called_once()
player._local._media.setSource.assert_called_once_with(QUrl())
player._local._media.setAudioBufferOutput.assert_called_with(None)
player._local._media.setAudioOutput.assert_called_with(None)
player.shutdown() # second call must be a no-op
player._local._media.stop.assert_called_once()
class TestVolumeReapply:
def test_set_volume_applies_converted_gain(self, qapp, tmp_path):
from PyQt6.QtMultimedia import QAudio
player = _mock_player(qapp, tmp_path)
player.set_volume(0.5)
expected = QAudio.convertVolume(
0.5, QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale)
player._local._audio.setVolume.assert_called_with(expected)
def test_resume_reapplies_volume(self, qapp, tmp_path, monkeypatch):
player = _mock_player(qapp, tmp_path)
player._current_track = Track(track_id=1, name="A")
applied = []
monkeypatch.setattr(player._local, "_apply_volume",
lambda: applied.append(True))
monkeypatch.setattr(player, "is_playing", lambda: False)
player.toggle_play() # paused with a cued track → resume
assert applied == [True]
player._local._media.play.assert_called_once()
def test_long_pause_resume_seeks_in_place(self, qapp, tmp_path,
monkeypatch):
import time as time_module
player = _mock_player(qapp, tmp_path)
player._current_track = Track(track_id=1, name="A")
monkeypatch.setattr(player, "is_playing", lambda: False)
# Short pause: no nudge.
player._local._paused_at = time_module.monotonic() - 5
player.toggle_play()
player._local._media.setPosition.assert_not_called()
# Walk-away pause: seek-in-place re-primes the suspended sink.
player._local._media.position.return_value = 12345
player._local._paused_at = time_module.monotonic() - 600
player.toggle_play()
player._local._media.setPosition.assert_called_once_with(12345)
assert player._local._paused_at is None
# ---- Task B: search bar lives in the header strip ----
class TestSearchBarPlacement:
def test_search_parent_is_top_strip(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
view = LibraryView(manager)
assert view._search.parentWidget() is view._top_strip
def test_search_still_filters(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [
Track(track_id=1, name="Apple Song", artist="X"),
Track(track_id=2, name="Banana Song", artist="Y")])
view = LibraryView(manager)
view._search.setText("banana")
view._apply_search()
assert [t.name for t in view.table.model_.tracks] == ["Banana Song"]
# ---- Task A: rating hover dots + click to rate ----
class TestRatingHelpers:
def test_rating_from_click(self):
from lintunes.gui.track_table import rating_from_click
assert rating_from_click(0, 3) == 60 # set 3 stars
assert rating_from_click(60, 5) == 100 # raise to 5
assert rating_from_click(60, 3) == 0 # click current count clears
assert rating_from_click(100, 1) == 20 # lower to 1
def test_rating_slot_at_bounds(self):
from lintunes.gui.track_table import rating_slot_at
assert rating_slot_at(0, 14) == 1
assert rating_slot_at(-5, 14) == 1 # left edge clamps
assert rating_slot_at(3 + 14 * 2, 14) == 3
assert rating_slot_at(9999, 14) == 5 # right edge clamps
assert rating_slot_at(10, 0) == 1 # degenerate width
class TestRatingClicks:
def _view(self, qapp, tracks):
from lintunes.gui.track_table import TrackTableView
from lintunes.models.playlist import PlaylistSettings
view = TrackTableView(playlist_mode=False)
view.apply_settings(PlaylistSettings(
visible_columns=["name", "rating"], sort_column="name"))
view.set_tracks(tracks)
view.resize(500, 300)
return view
def test_click_on_rating_cell_emits(self, qapp):
from lintunes.gui.track_table import (
RATING_LEFT_PAD, rating_slot_width)
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
emitted = []
view.rating_edited.connect(lambda tid, r: emitted.append((tid, r)))
col = view.model_.fields.index("rating")
rect = view.visualRect(view.model_.index(0, col))
slot_w = rating_slot_width(QFontMetrics(view.font()))
# Click the 4th slot → 80.
x = rect.left() + RATING_LEFT_PAD + int(slot_w * 3.5)
pos = QPointF(x, rect.center().y())
event = QMouseEvent(QMouseEvent.Type.MouseButtonPress, pos,
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier)
view.mousePressEvent(event)
assert emitted == [(1, 80)]
def test_library_view_wiring_edits_and_undoes(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
track = Track(track_id=1, name="A")
manager = _manager(tmp_path, [track])
view = LibraryView(manager)
view.table.rating_edited.emit(1, 80)
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_hover_state_tracks_cell(self, qapp):
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
col = view.model_.fields.index("rating")
index = view.model_.index(0, col)
view._set_rating_hover(index)
assert view.is_rating_hovered(index)
assert not view.is_rating_hovered(view.model_.index(0, 0))
view._set_rating_hover(None)
assert not view.is_rating_hovered(index)
# ---- Task D: album art search plumbing (offline) ----
class TestArtSearch:
def test_upgrade_artwork_url(self):
from lintunes.art_search import upgrade_artwork_url
url = "https://is1.mzstatic.com/image/thumb/x/100x100bb.jpg"
assert "600x600bb.jpg" in upgrade_artwork_url(url)
assert "100x100" not in upgrade_artwork_url(url)
def test_parse_results(self):
from lintunes.art_search import parse_results
payload = {"results": [
{"artistName": "Artist", "collectionName": "Album",
"artworkUrl100": "https://x/100x100bb.jpg"},
{"artistName": "No Art"}, # skipped: no artworkUrl100
]}
candidates = parse_results(payload)
assert len(candidates) == 1
assert candidates[0].artist == "Artist"
assert candidates[0].album == "Album"
assert "600x600" in candidates[0].art_url
def test_embed_updates_file_and_size(self, qapp, tmp_path, mp3_file,
jpeg_bytes):
# The Task D accept path: write_artwork + size refresh, no undo entry.
track = Track(track_id=1, name="A", location=str(mp3_file), size=1)
manager = _manager(tmp_path, [track])
tagging.write_artwork(track.location, jpeg_bytes, "image/jpeg")
manager.update_track_fields(1, {"size": mp3_file.stat().st_size})
assert tagging.read_embedded_artwork(track.location) == jpeg_bytes
assert track.size == mp3_file.stat().st_size
assert not manager.undo_stack.can_undo()
class TestAlbumWideArt:
def test_album_tracks_matches_album_case_insensitively(self):
from lintunes.gui.main_window import album_tracks
library = Library()
library.tracks = {
1: Track(track_id=1, artist="Artist", album="Great Album"),
2: Track(track_id=2, artist="artist", album="great album"),
3: Track(track_id=3, artist="Someone Else", album="Great Album",
album_artist="Artist"), # album_artist wins the grouping
4: Track(track_id=4, artist="Artist", album="Other Album"),
5: Track(track_id=5, artist="Other Artist", album="Great Album"),
}
ids = {t.track_id
for t in album_tracks(library, "Artist", "Great Album")}
assert ids == {1, 2, 3}
def _dialog(self, monkeypatch, image, track_count, album_count):
from lintunes.art_search import AlbumArtFetcher, ArtCandidate
from lintunes.gui.album_art_dialog import AlbumArtDialog
# No network: neuter the fetch and feed the image back by hand.
monkeypatch.setattr(AlbumArtFetcher, "fetch", lambda self, c: None)
candidate = ArtCandidate(artist="A", album="B", art_url="https://x")
dialog = AlbumArtDialog([candidate], track_count,
album_count=album_count)
dialog._on_image({"candidate": candidate, "image": image,
"mime": "image/jpeg"})
return dialog
def test_album_button_applies_to_whole_album(self, qapp, monkeypatch,
jpeg_bytes):
dialog = self._dialog(monkeypatch, jpeg_bytes,
track_count=1, album_count=12)
assert dialog._album_btn is not None
assert dialog._album_btn.isEnabled()
assert "12" in dialog._album_btn.text()
assert not dialog.apply_to_album
dialog._accept_album()
assert dialog.apply_to_album
assert dialog.selected_image == jpeg_bytes
assert dialog.selected_mime == "image/jpeg"
def test_no_album_button_when_selection_covers_album(self, qapp,
monkeypatch,
jpeg_bytes):
dialog = self._dialog(monkeypatch, jpeg_bytes,
track_count=5, album_count=5)
assert dialog._album_btn is None
dialog._accept_current()
assert not dialog.apply_to_album
assert dialog.selected_image == jpeg_bytes
# ---- context menu exposes the new action's signal ----
class TestDownloadArtSignal:
def test_signal_exists_and_carries_ids(self, qapp):
from lintunes.gui.track_table import TrackTableView
view = TrackTableView()
got = []
view.download_art_requested.connect(got.append)
view.download_art_requested.emit([1, 2])
assert got == [[1, 2]]