Import existing LinTunes project

Snapshot of the existing codebase before working through the TASKS.md
backlog. Real library data (data/) and the iTunes import fixture
(itunes-test-library/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:12:01 -04:00
commit f6d35fa594
70 changed files with 8992 additions and 0 deletions

72
tests/conftest.py Normal file
View File

@ -0,0 +1,72 @@
import os
import shutil
import subprocess
import pytest
# Let widget-level tests run headless; harmless for the non-GUI tests.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(scope="session")
def qapp():
"""A Qt application so QObject/QTimer and real widgets work in tests.
Prefers a GUI QApplication (offscreen) so widget tests are possible;
QApplication is a QCoreApplication, so existing non-GUI tests are unaffected.
Falls back to QCoreApplication if no GUI platform is available.
"""
from PyQt6.QtCore import QCoreApplication
inst = QCoreApplication.instance()
if inst is not None:
return inst
try:
from PyQt6.QtWidgets import QApplication
return QApplication([])
except Exception:
return QCoreApplication([])
def _generate_audio(path, codec_args):
"""Generate a 1-second sine-wave audio file with ffmpeg."""
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg not available to generate audio fixtures")
result = subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
*codec_args, str(path)],
capture_output=True,
)
if result.returncode != 0:
pytest.skip(f"ffmpeg could not generate {path.suffix} fixture")
return path
@pytest.fixture
def mp3_file(tmp_path):
return _generate_audio(tmp_path / "test.mp3", ["-codec:a", "libmp3lame", "-b:a", "64k"])
@pytest.fixture
def m4a_file(tmp_path):
return _generate_audio(tmp_path / "test.m4a", ["-codec:a", "aac", "-b:a", "64k"])
@pytest.fixture
def flac_file(tmp_path):
return _generate_audio(tmp_path / "test.flac", ["-codec:a", "flac"])
@pytest.fixture
def jpeg_bytes(tmp_path):
"""A tiny valid JPEG for artwork tests."""
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg not available to generate image fixture")
path = tmp_path / "art.jpg"
result = subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i", "color=red:size=8x8",
"-frames:v", "1", str(path)],
capture_output=True,
)
if result.returncode != 0:
pytest.skip("ffmpeg could not generate jpeg fixture")
return path.read_bytes()

View File

@ -0,0 +1,149 @@
import json
import tempfile
from pathlib import Path
import pytest
from lintunes.storage.conflict_resolver import resolve_conflicts
def _write_json(path: Path, data: dict):
with open(path, "w") as f:
json.dump(data, f)
def _read_json(path: Path) -> dict:
with open(path) as f:
return json.load(f)
class TestConflictResolver:
def test_no_conflicts(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
_write_json(data_dir / "library.json", {"1": {"track_id": 1, "name": "Song"}})
resolve_conflicts(data_dir)
# Should not crash, data should be unchanged
result = _read_json(data_dir / "library.json")
assert result["1"]["name"] == "Song"
def test_nonexistent_dir(self, tmp_path):
# Should not crash
resolve_conflicts(tmp_path / "nonexistent")
def test_merge_play_counts(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {
"1": {"track_id": 1, "name": "Song", "play_count": 5, "skip_count": 2}
}
conflict = {
"1": {"track_id": 1, "name": "Song", "play_count": 8, "skip_count": 1}
}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["play_count"] == 8 # max(5, 8)
assert result["1"]["skip_count"] == 2 # max(2, 1)
def test_merge_loved_or(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "loved": False}}
conflict = {"1": {"track_id": 1, "loved": True}}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["loved"] is True
def test_merge_date_added_earliest(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "date_added": "2022-06-01T00:00:00"}}
conflict = {"1": {"track_id": 1, "date_added": "2020-01-15T00:00:00"}}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["date_added"] == "2020-01-15T00:00:00"
def test_merge_new_track_from_conflict(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "name": "Song A"}}
conflict = {
"1": {"track_id": 1, "name": "Song A"},
"2": {"track_id": 2, "name": "Song B"},
}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert "2" in result
assert result["2"]["name"] == "Song B"
def test_playlist_merge_union(self, tmp_path):
data_dir = tmp_path / ".lintunes"
playlists_dir = data_dir / "playlists"
playlists_dir.mkdir(parents=True)
original = {"name": "My Playlist", "track_ids": [1, 2, 3], "settings": {}}
conflict = {"name": "My Playlist", "track_ids": [2, 3, 4, 5], "settings": {}}
orig_path = playlists_dir / "PL1.json"
conf_path = playlists_dir / "PL1.sync-conflict-20240101-120000-ABCDEFG.json"
_write_json(orig_path, original)
_write_json(conf_path, conflict)
resolve_conflicts(data_dir)
result = _read_json(orig_path)
# All track IDs should be present (union)
assert set(result["track_ids"]) == {1, 2, 3, 4, 5}
def test_conflicts_archived(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
_write_json(data_dir / "library.json", {"1": {"track_id": 1}})
conf_path = data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json"
_write_json(conf_path, {"1": {"track_id": 1}})
resolve_conflicts(data_dir)
assert not conf_path.exists()
resolved_dir = data_dir / ".resolved"
assert resolved_dir.exists()
archived = list(resolved_dir.iterdir())
assert len(archived) == 1

118
tests/test_file_importer.py Normal file
View File

@ -0,0 +1,118 @@
from pathlib import Path
import pytest
from lintunes import tagging
from lintunes.models import Library
from lintunes.library_manager import LibraryManager
from lintunes.importers import file_importer
@pytest.fixture
def manager(qapp, tmp_path):
return LibraryManager(Library(), tmp_path / "data")
class TestOrganizedDestination:
def test_artist_album_layout(self):
dest = file_importer.organized_destination(
Path("/music"), {"artist": "The Band", "album": "Hits"}, "song.mp3")
assert dest == Path("/music/The Band/Hits/song.mp3")
def test_album_artist_preferred(self):
dest = file_importer.organized_destination(
Path("/music"),
{"artist": "Feat Guy", "album_artist": "Main Act", "album": "LP"},
"song.mp3")
assert dest == Path("/music/Main Act/LP/song.mp3")
def test_missing_tags(self):
dest = file_importer.organized_destination(Path("/music"), {}, "song.mp3")
assert dest == Path("/music/Unknown Artist/Unknown Album/song.mp3")
def test_sanitizes_separators(self):
dest = file_importer.organized_destination(
Path("/music"), {"artist": "AC/DC", "album": "Back: In Black?"},
"song.mp3")
assert dest == Path("/music/AC_DC/Back_ In Black_/song.mp3")
class TestImportFile:
def test_copies_and_adds_track(self, manager, mp3_file, tmp_path):
tagging.write_tags(mp3_file, {"name": "My Song", "artist": "Artist X",
"album": "Album Y"})
music_dir = tmp_path / "music"
track = file_importer.import_file(mp3_file, music_dir, manager)
assert track is not None
expected = music_dir / "Artist X" / "Album Y" / "test.mp3"
assert Path(track.location) == expected
assert expected.exists()
assert track.name == "My Song"
assert track.date_added
assert track.track_id in manager.library.tracks
def test_reimport_same_file_is_deduped(self, manager, mp3_file, tmp_path):
music_dir = tmp_path / "music"
track1 = file_importer.import_file(mp3_file, music_dir, manager)
track2 = file_importer.import_file(mp3_file, music_dir, manager)
assert track1.track_id == track2.track_id
assert len(manager.library.tracks) == 1
def test_name_collision_gets_suffix(self, manager, mp3_file, tmp_path):
music_dir = tmp_path / "music"
dest = music_dir / "Unknown Artist" / "Unknown Album" / "test.mp3"
dest.parent.mkdir(parents=True)
dest.write_bytes(b"placeholder different file")
track = file_importer.import_file(mp3_file, music_dir, manager)
assert Path(track.location) == dest.with_stem("test 1")
def test_rejects_non_audio(self, manager, tmp_path):
text = tmp_path / "notes.txt"
text.write_text("hello")
assert file_importer.import_file(text, tmp_path / "music", manager) is None
def test_import_paths_adds_to_playlist(self, manager, mp3_file, tmp_path):
playlist = manager.create_playlist("Drops")
imported = file_importer.import_paths(
[mp3_file], tmp_path / "music", manager, playlist.persistent_id)
assert len(imported) == 1
assert playlist.track_ids == [imported[0].track_id]
class TestLibraryManager:
def test_playlist_crud_and_persistence(self, manager):
folder = manager.create_folder("F")
playlist = manager.create_playlist("P", folder.persistent_id)
manager.rename_playlist(playlist.persistent_id, "P2")
assert manager.library.playlists[playlist.persistent_id].name == "P2"
manager.flush()
path = manager.data_dir / "playlists" / f"{playlist.persistent_id}.json"
assert path.exists()
manager.delete_playlist(folder.persistent_id) # recursive
assert manager.library.playlists == {}
manager.flush()
assert not path.exists()
def test_move_tracks_in_playlist(self, manager):
from lintunes.models import Track
for i in range(1, 6):
manager.add_track(Track(track_id=i, name=f"t{i}"))
playlist = manager.create_playlist("P")
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2, 3, 4, 5])
# Move rows 0,1 (tracks 1,2) so the block starts at manual row 4
manager.move_tracks_in_playlist(playlist.persistent_id, [0, 1], 4)
assert playlist.track_ids == [3, 4, 1, 2, 5]
# Move last row (track 5) to the top
manager.move_tracks_in_playlist(playlist.persistent_id, [4], 0)
assert playlist.track_ids == [5, 3, 4, 1, 2]
def test_record_play(self, manager):
from lintunes.models import Track
manager.add_track(Track(track_id=7, name="x", play_count=3))
manager.record_play(7)
track = manager.library.tracks[7]
assert track.play_count == 4
assert track.play_date_utc is not None

View File

@ -0,0 +1,184 @@
import plistlib
import tempfile
from datetime import datetime
from pathlib import Path
import pytest
from lintunes.importers.itunes_importer import (
import_itunes_xml, make_location_remapper,
)
MUSIC_FOLDER = "file:///Volumes/Lucia/iTunes/iTunes%20Media/"
MUSIC_ROOT = Path("/run/media/trav/tummult/music/iTunes Media")
def _make_itunes_xml(tracks: dict, playlists: list) -> Path:
plist_data = {
"Major Version": 1,
"Minor Version": 1,
"Application Version": "12.8.3.1",
"Music Folder": MUSIC_FOLDER,
"Tracks": tracks,
"Playlists": playlists,
}
tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
plistlib.dump(plist_data, tmp, fmt=plistlib.FMT_XML)
tmp.close()
return Path(tmp.name)
def _track(track_id, name, location_suffix, **extra):
return {
"Track ID": track_id,
"Name": name,
"Persistent ID": f"PID{track_id}",
"Track Type": "File",
"Location": MUSIC_FOLDER + location_suffix,
**extra,
}
class TestLocationRemapper:
def test_remaps_music_folder_prefix(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a") == \
"/run/media/trav/tummult/music/iTunes Media/Music/A/B/C.m4a"
def test_leaves_outside_paths_alone(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Other/song.mp3") == "/Volumes/Other/song.mp3"
def test_localhost_form(self):
remap = make_location_remapper(
"file://localhost/Users/test/Music/iTunes/iTunes%20Media/",
Path("/home/user/Music"))
assert remap("/Users/test/Music/iTunes/iTunes Media/Music/X/Y/Z.mp3") == \
"/home/user/Music/Music/X/Y/Z.mp3"
class TestItunesImporter:
def test_import_basic_track(self):
tracks = {
"100": _track(
100, "Test Song", "Music/Test%20Artist/Test%20Album/Test%20Song.mp3",
Artist="Test Artist", Album="Test Album", Genre="Rock",
**{"Total Time": 200000,
"Date Added": datetime(2020, 1, 1, 0, 0, 0)},
)
}
playlists = [
{
"Name": "Library",
"Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 100}],
}
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 1
track = library.tracks[100]
assert track.name == "Test Song"
assert track.artist == "Test Artist"
assert track.location == str(MUSIC_ROOT / "Music/Test Artist/Test Album/Test Song.mp3")
# Master playlist is a system playlist — not imported
assert len(library.playlists) == 0
assert report.skipped_system == 1
assert report.track_count == 1
xml_path.unlink()
def test_import_playlists_skips_smart_and_system(self):
tracks = {
"1": _track(1, "Song A", "Music/A/A/A.mp3"),
"2": _track(2, "Song B", "Music/B/B/B.mp3"),
}
playlists = [
{"Name": "Library", "Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 1}, {"Track ID": 2}]},
{"Name": "Movies", "Distinguished Kind": 2,
"Playlist Persistent ID": "MOVIES"},
{"Name": "Smarty", "Smart Info": b"x", "Smart Criteria": b"y",
"Playlist Persistent ID": "SMART1",
"Playlist Items": [{"Track ID": 1}]},
{"Name": "My Folder", "Folder": True,
"Playlist Persistent ID": "FOLDER1"},
{"Name": "My Playlist", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "FOLDER1",
"Playlist Items": [{"Track ID": 2}, {"Track ID": 999}]},
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.playlists.keys()) == {"FOLDER1", "PL1"}
# Track 999 doesn't exist — dropped from the playlist
assert library.playlists["PL1"].track_ids == [2]
assert library.playlists["PL1"].parent_persistent_id == "FOLDER1"
assert report.skipped_smart == 1
assert report.skipped_system == 2
assert report.playlist_count == 1
assert report.folder_count == 1
xml_path.unlink()
def test_orphaned_child_detached_when_parent_skipped(self):
playlists = [
{"Name": "Inside Smart Folder", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "GONE"},
]
xml_path = _make_itunes_xml({}, playlists)
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.playlists["PL1"].parent_persistent_id == ""
xml_path.unlink()
def test_skips_stream_tracks(self):
tracks = {
"1": {"Track ID": 1, "Name": "Radio", "Track Type": "URL",
"Persistent ID": "R1"},
"2": _track(2, "Real Song", "Music/A/A/A.mp3"),
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.tracks.keys()) == {2}
assert report.skipped_tracks == 1
xml_path.unlink()
def test_unmapped_location_reported(self):
tracks = {
"1": {"Track ID": 1, "Name": "Elsewhere", "Persistent ID": "E1",
"Track Type": "File",
"Location": "file:///Volumes/Other/song.mp3"},
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert report.unmapped_locations == ["/Volumes/Other/song.mp3"]
assert 1 in library.tracks # still imported, just flagged
xml_path.unlink()
def test_computed_ratings_dropped(self):
tracks = {
"1": _track(1, "Guessed", "Music/A/A/A.mp3",
**{"Rating": 100, "Rating Computed": True,
"Album Rating": 80, "Album Rating Computed": True}),
"2": _track(2, "Explicit", "Music/B/B/B.mp3",
**{"Rating": 80, "Album Rating": 60}),
}
xml_path = _make_itunes_xml(tracks, [])
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.tracks[1].rating == 0
assert library.tracks[1].album_rating == 0
assert library.tracks[2].rating == 80
assert library.tracks[2].album_rating == 60
xml_path.unlink()
def test_import_empty_library(self):
xml_path = _make_itunes_xml({}, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 0
assert len(library.playlists) == 0
assert report.track_count == 0
xml_path.unlink()

175
tests/test_models.py Normal file
View File

@ -0,0 +1,175 @@
import pytest
from datetime import datetime
from lintunes.models import Track, Playlist, PlaylistSettings, PlaylistType, Library
class TestTrack:
def test_to_dict_omits_defaults(self):
track = Track(track_id=1, name="Test Song")
d = track.to_dict()
assert d == {"track_id": 1, "name": "Test Song"}
assert "genre" not in d
assert "play_count" not in d
def test_from_dict_round_trip(self):
track = Track(
track_id=42,
name="My Song",
artist="The Artist",
album="The Album",
genre="Rock",
total_time=180000,
play_count=5,
rating=80,
loved=True,
date_added="2020-01-15T10:30:00",
)
d = track.to_dict()
restored = Track.from_dict(d)
assert restored.track_id == 42
assert restored.name == "My Song"
assert restored.artist == "The Artist"
assert restored.play_count == 5
assert restored.rating == 80
assert restored.loved is True
def test_from_dict_ignores_unknown_fields(self):
d = {"track_id": 1, "name": "Test", "unknown_field": "ignored"}
track = Track.from_dict(d)
assert track.track_id == 1
assert track.name == "Test"
def test_from_itunes_dict(self):
itunes_data = {
"Track ID": 100,
"Name": "iTunes Song",
"Artist": "iTunes Artist",
"Album": "iTunes Album",
"Genre": "Pop",
"Total Time": 240000,
"Play Count": 10,
"Play Date UTC": datetime(2023, 6, 15, 12, 0, 0),
"Date Added": datetime(2020, 3, 1, 8, 0, 0),
"Rating": 100,
"Loved": True,
"Persistent ID": "ABC123",
"Location": "file://localhost/Users/me/Music/Artist/Album/Song.mp3",
}
track = Track.from_itunes_dict(itunes_data)
assert track.track_id == 100
assert track.name == "iTunes Song"
assert track.total_time == 240000
assert track.play_count == 10
assert track.rating == 100
assert track.loved is True
assert track.play_date_utc == "2023-06-15T12:00:00"
assert track.location == "/Users/me/Music/Artist/Album/Song.mp3"
def test_location_decoding(self):
itunes_data = {
"Track ID": 1,
"Location": "file://localhost/Users/me/Music/The%20Artist/My%20Album/01%20Song.mp3",
}
track = Track.from_itunes_dict(itunes_data)
assert track.location == "/Users/me/Music/The Artist/My Album/01 Song.mp3"
def test_location_decoding_volumes_form(self):
# Modern iTunes XML uses file:///Volumes/... URLs
itunes_data = {
"Track ID": 1,
"Location": "file:///Volumes/Lucia/iTunes/iTunes%20Media/Music/A/B/C.m4a",
}
track = Track.from_itunes_dict(itunes_data)
assert track.location == "/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a"
class TestPlaylist:
def test_to_dict_from_dict_round_trip(self):
playlist = Playlist(
name="My Favorites",
persistent_id="ABCD1234",
playlist_type=PlaylistType.REGULAR,
track_ids=[1, 2, 3, 4, 5],
)
d = playlist.to_dict()
restored = Playlist.from_dict(d)
assert restored.name == "My Favorites"
assert restored.persistent_id == "ABCD1234"
assert restored.playlist_type == PlaylistType.REGULAR
assert restored.track_ids == [1, 2, 3, 4, 5]
def test_from_itunes_dict_regular(self):
itunes_data = {
"Name": "Party Mix",
"Playlist Persistent ID": "XYZ789",
"Playlist Items": [
{"Track ID": 10},
{"Track ID": 20},
{"Track ID": 30},
],
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.name == "Party Mix"
assert playlist.persistent_id == "XYZ789"
assert playlist.playlist_type == PlaylistType.REGULAR
assert playlist.track_ids == [10, 20, 30]
assert playlist.is_system is False
def test_from_itunes_dict_system(self):
itunes_data = {
"Name": "Library",
"Master": True,
"Playlist Persistent ID": "MASTER1",
"Playlist Items": [{"Track ID": 1}],
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.is_system is True
assert playlist.playlist_type == PlaylistType.SYSTEM
def test_from_itunes_dict_folder(self):
itunes_data = {
"Name": "My Folder",
"Folder": True,
"Playlist Persistent ID": "FOLDER1",
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.playlist_type == PlaylistType.FOLDER
def test_from_itunes_dict_smart(self):
itunes_data = {
"Name": "Recently Added",
"Smart Info": b"...",
"Smart Criteria": b"...",
"Playlist Persistent ID": "SMART1",
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.playlist_type == PlaylistType.SMART
class TestPlaylistSettings:
def test_default_settings(self):
settings = PlaylistSettings()
assert "name" in settings.visible_columns
assert settings.sort_ascending is True
def test_round_trip(self):
settings = PlaylistSettings(
visible_columns=["name", "artist"],
sort_column="artist",
sort_ascending=False,
column_widths={"name": 300},
)
d = settings.to_dict()
restored = PlaylistSettings.from_dict(d)
assert restored.visible_columns == ["name", "artist"]
assert restored.sort_column == "artist"
assert restored.sort_ascending is False
assert restored.column_widths == {"name": 300}
class TestLibrary:
def test_empty_library(self):
lib = Library()
assert len(lib.tracks) == 0
assert len(lib.playlists) == 0

107
tests/test_player.py Normal file
View File

@ -0,0 +1,107 @@
from unittest.mock import MagicMock, patch
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes import player as player_module
from lintunes.player import Player
class FakeManager:
"""Minimal stand-in for LibraryManager: the Player reads `library.tracks`,
calls `record_play` on natural end-of-media, and `set_track_location` when
the user relocates a moved/renamed file."""
def __init__(self, library):
self.library = library
self.played = []
def record_play(self, track_id):
self.played.append(track_id)
def set_track_location(self, track_id, location):
self.library.tracks[track_id].location = location
def _make_player(qapp, tracks):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
# The Qt multimedia backend (QMediaPlayer/QAudioOutput/QMediaDevices)
# blocks on init in a headless environment, so stub it out — the
# missing-file path under test never reaches real playback anyway.
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
player = Player(FakeManager(library))
errors, missing = [], []
player.error_occurred.connect(errors.append)
player.track_missing.connect(missing.append)
return player, errors, missing
def test_missing_file_emits_track_missing_and_stops(qapp, tmp_path):
location = str(tmp_path / "not-mounted" / "song.mp3")
track = Track(track_id=1, name="Ghost Song", location=location)
player, errors, missing = _make_player(qapp, [track])
player.play_queue([1], 0)
# Routed to track_missing (so the UI can offer "Locate File…"), not the
# generic error dialog.
assert errors == []
assert missing == [track]
# Stopped, not advanced into a track; setSource was never called.
assert player.current_track is None
player._media.setSource.assert_not_called()
def test_all_missing_queue_does_not_recurse(qapp, tmp_path):
# A whole-library queue with every file gone (drive unmounted) must not
# cascade through the queue and blow the recursion limit.
tracks = [
Track(track_id=i, name=f"T{i}", location=str(tmp_path / f"{i}.mp3"))
for i in range(5000)
]
player, errors, missing = _make_player(qapp, tracks)
player.play_queue([t.track_id for t in tracks], 0)
# One alert for the track we tried to play, then a clean stop.
assert len(missing) == 1
assert player.current_track is None
def test_track_not_in_library_emits_error(qapp):
# Queue references an id that isn't in the library: nothing to locate, so
# this stays a plain error (not track_missing).
player, errors, missing = _make_player(qapp, [])
player.play_queue([99], 0)
assert missing == []
assert len(errors) == 1
assert player.current_track is None
def test_retry_current_plays_after_relocate(qapp, tmp_path):
real = tmp_path / "real.mp3"
real.write_bytes(b"audio")
track = Track(track_id=1, name="Moved Song",
location=str(tmp_path / "gone.mp3"))
player, errors, missing = _make_player(qapp, [track])
player.play_queue([1], 0)
assert missing == [track] and player.current_track is None
# User relocates the file, then the UI retries the same queue position.
player._manager.set_track_location(1, str(real))
missing.clear()
player.retry_current()
assert errors == [] and missing == []
assert player.current_track is track
player._media.setSource.assert_called_once()

47
tests/test_relocate.py Normal file
View File

@ -0,0 +1,47 @@
"""LibraryManager.set_track_location: repoint a track at a moved/renamed file.
It's a path repair, not a content edit — so it persists the new location and
notifies the UI, but must not touch date_modified or write tags.
"""
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
def _manager(tmp_path):
track = Track(track_id=1, name="Song",
location="/old/path/song.mp3",
date_modified="2020-01-01T00:00:00")
return LibraryManager(Library(tracks={1: track}), tmp_path), track
def test_set_track_location_repoints_and_persists(qapp, tmp_path):
manager, track = _manager(tmp_path)
updates, edits = [], []
manager.track_updated.connect(updates.append)
manager.track_fields_edited.connect(lambda tid, fields: edits.append(tid))
manager.set_track_location(1, "/new/path/from there to here.mp3")
assert track.location == "/new/path/from there to here.mp3"
assert manager._dirty_tracks # will be saved
assert updates == [1] # UI refreshes the row
assert edits == [] # not a metadata edit
assert track.date_modified == "2020-01-01T00:00:00" # untouched
def test_set_track_location_noop_when_unchanged(qapp, tmp_path):
manager, track = _manager(tmp_path)
updates = []
manager.track_updated.connect(updates.append)
manager.set_track_location(1, track.location)
assert updates == []
assert not manager._dirty_tracks
def test_set_track_location_ignores_unknown_track(qapp, tmp_path):
manager, _track = _manager(tmp_path)
manager.set_track_location(999, "/whatever.mp3") # no such track
assert not manager._dirty_tracks

105
tests/test_round3.py Normal file
View File

@ -0,0 +1,105 @@
import hashlib
import json
import pytest
from lintunes.lastfm import sign, LastFm
from lintunes.player import make_shuffle_order
from lintunes.preferences import Preferences
class TestLastFmSignature:
def test_signature_matches_hand_computed(self):
params = {
"method": "auth.getMobileSession",
"api_key": "KEY",
"username": "trav",
"password": "hunter2",
"format": "json", # must be excluded from the signature
}
expected = hashlib.md5(
("api_keyKEY"
"methodauth.getMobileSession"
"passwordhunter2"
"usernametrav"
"SECRET").encode()).hexdigest()
assert sign(params, "SECRET") == expected
def test_format_and_callback_excluded(self):
base = {"method": "x", "api_key": "k"}
with_extras = dict(base, format="json", callback="cb")
assert sign(base, "s") == sign(with_extras, "s")
class TestScrobbleQueue:
def test_queue_round_trip(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
lastfm = LastFm(prefs, tmp_path)
entry = {"artist": "A", "track": "T", "album": "L",
"timestamp": 123, "duration": 180}
lastfm._enqueue(entry)
assert lastfm._load_queue() == [entry]
lastfm._save_queue([])
assert lastfm._load_queue() == []
assert not (tmp_path / "scrobble_queue.json").exists()
def test_queue_capped(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
lastfm = LastFm(prefs, tmp_path)
for i in range(600):
lastfm._enqueue({"artist": "A", "track": str(i), "timestamp": i})
queue = lastfm._load_queue()
assert len(queue) == 500
assert queue[-1]["track"] == "599"
class TestShuffleOrder:
def test_permutation_starts_at_current(self):
order = make_shuffle_order(10, 4)
assert order[0] == 4
assert sorted(order) == list(range(10))
def test_single_track(self):
assert make_shuffle_order(1, 0) == [0]
def test_empty(self):
assert make_shuffle_order(0, 0) == []
def test_is_randomized(self):
# With 20 tracks, two consecutive shuffles colliding entirely is
# vanishingly unlikely (1/19!)
orders = {tuple(make_shuffle_order(20, 0)) for _ in range(5)}
assert len(orders) > 1
class TestPreferences:
def test_defaults(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
assert prefs.get("ui_scale") == "medium"
assert prefs.get("highlight") == "blue"
assert prefs.lastfm["scrobble_enabled"] is False
def test_round_trip(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("ui_scale", "large")
prefs.update_lastfm(username="trav", session_key="abc")
reloaded = Preferences(tmp_path)
assert reloaded.get("ui_scale") == "large"
assert reloaded.lastfm["username"] == "trav"
assert reloaded.lastfm["session_key"] == "abc"
# Unknown/missing keys fall back to defaults
assert reloaded.get("highlight") == "blue"
def test_changed_signal(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
fired = []
prefs.changed.connect(lambda: fired.append(1))
prefs.set("highlight", "seafoam")
prefs.set("highlight", "seafoam") # no-op, shouldn't fire again
assert len(fired) == 1
def test_corrupt_file_falls_back(self, qapp, tmp_path):
(tmp_path / "preferences.json").write_text("{not json")
prefs = Preferences(tmp_path)
assert prefs.get("ui_scale") == "medium"

46
tests/test_round5.py Normal file
View File

@ -0,0 +1,46 @@
from lintunes.gui.track_table import format_total_time
from lintunes.gui.library_view import _canonical_values, _artist_sort_key
class TestFormatTotalTime:
def test_minutes_seconds(self):
assert format_total_time((14 * 60 + 21) * 1000) == "14:21"
def test_hours(self):
ms = (9 * 3600 + 47 * 60 + 33) * 1000
assert format_total_time(ms) == "9:47:33"
def test_days_zero_pads_lower_units(self):
ms = (24 * 86400 + 18 * 3600 + 42 * 60 + 7) * 1000
assert format_total_time(ms) == "24:18:42:07"
def test_zero_keeps_min_sec(self):
assert format_total_time(0) == "0:00"
class TestCanonicalValues:
def test_most_tracks_spelling_wins(self):
values = ["The Beatles", "The Beatles", "the beatles"]
assert _canonical_values(values) == ["The Beatles"]
def test_tie_breaks_on_more_capitals(self):
# equal counts -> the spelling with more uppercase letters wins
assert _canonical_values(["abc", "ABC"]) == ["ABC"]
def test_distinct_names_preserved(self):
result = sorted(_canonical_values(["Beat Happening", "The Beatles"]))
assert result == ["Beat Happening", "The Beatles"]
class TestArtistSortKey:
def test_strips_leading_the(self):
assert _artist_sort_key("The Beatles") == "beatles"
def test_files_next_to_neighbor(self):
# "The Beatles" should sort adjacent to "Beat Happening"
names = ["The Beatles", "Beat Happening", "Beck"]
assert sorted(names, key=_artist_sort_key) == [
"Beat Happening", "The Beatles", "Beck"]
def test_no_article_unchanged(self):
assert _artist_sort_key("Beck") == "beck"

59
tests/test_round6.py Normal file
View File

@ -0,0 +1,59 @@
from lintunes.gui.playlist_ops import (
has_duplicates, filter_for_add, add_tracks_with_dup_check)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist
class TestHasDuplicates:
def test_none_present(self):
assert has_duplicates([3, 4], [1, 2]) is False
def test_some_present(self):
assert has_duplicates([2, 3], [1, 2]) is True
def test_empty_incoming(self):
assert has_duplicates([], [1, 2]) is False
class TestFilterForAdd:
def test_add_duplicates_keeps_all_in_order(self):
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=True) == [2, 3, 1]
def test_skip_drops_existing_preserves_order(self):
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=False) == [3]
def test_skip_can_drop_everything(self):
assert filter_for_add([1, 2], [1, 2], add_duplicates=False) == []
def _manager_with(qapp, tmp_path, track_ids, playlist_track_ids=()):
library = Library()
for tid in track_ids:
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
playlist = Playlist(name="PL", persistent_id="PID",
track_ids=list(playlist_track_ids))
library.playlists["PID"] = playlist
manager = LibraryManager(library, tmp_path)
return manager, playlist
class TestAddTracksWithDupCheck:
def test_no_duplicates_adds_all_without_prompt(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1])
added = add_tracks_with_dup_check(manager, None, "PID", [2, 3])
assert added == [2, 3]
assert playlist.track_ids == [1, 2, 3]
def test_position_inserts_in_place(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1, 3])
add_tracks_with_dup_check(manager, None, "PID", [2], position=1)
assert playlist.track_ids == [1, 2, 3]
def test_empty_input_is_noop(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1], [1])
assert add_tracks_with_dup_check(manager, None, "PID", []) == []
assert playlist.track_ids == [1]
def test_unknown_playlist_is_noop(self, qapp, tmp_path):
manager, _ = _manager_with(qapp, tmp_path, [1], [])
assert add_tracks_with_dup_check(manager, None, "NOPE", [1]) == []

14
tests/test_round7.py Normal file
View File

@ -0,0 +1,14 @@
from lintunes.gui import drag_ghost
class TestDragIcon:
"""The cursor-following drag indicator is loaded from a customizable PNG
that ships next to the application icon (packaging/lintunes.png)."""
def test_default_png_ships_next_to_app_icon(self):
assert drag_ghost.ICON_PATH.name == "drag-icon.png"
assert drag_ghost.ICON_PATH.exists()
assert (drag_ghost.ICON_PATH.parent / "lintunes.png").exists()
def test_default_file_is_a_png(self):
assert drag_ghost.ICON_PATH.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"

254
tests/test_round8.py Normal file
View File

@ -0,0 +1,254 @@
"""Round 8: the four 'Open / future' features.
1. "Show in Playlist…" — LibraryManager.playlists_containing.
2. Multi-track Get Info — LibraryManager.edit_tracks_fields (one composite undo,
only genuinely-changed fields per track).
3. Library search — the _matches_search predicate behind the filter box.
4. Custom start/stop times — Get Info time parsing/formatting, library-only
persistence, and the player honoring them during playback.
"""
from unittest.mock import MagicMock, patch
from lintunes.models import Library, Track, Playlist, PlaylistType
from lintunes.library_manager import LibraryManager
from lintunes.gui.library_view import _matches_search
from lintunes.gui.info_dialog import _format_ms, _parse_time_to_ms
from lintunes import player as player_module
from lintunes.player import Player
def _track(tid, **kw):
return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw)
# --------------------------------------------------------------------------
# 1. Show in Playlist
# --------------------------------------------------------------------------
class TestPlaylistsContaining:
def _manager(self, tmp_path):
tracks = {i: _track(i) for i in (1, 2, 3)}
library = Library(tracks=tracks)
# Two regular playlists, a folder, and a smart playlist.
library.playlists = {
"P1": Playlist(name="Beta", persistent_id="P1",
playlist_type=PlaylistType.REGULAR,
track_ids=[1, 2]),
"P2": Playlist(name="alpha", persistent_id="P2",
playlist_type=PlaylistType.REGULAR,
track_ids=[2]),
"F1": Playlist(name="Folder", persistent_id="F1",
playlist_type=PlaylistType.FOLDER,
track_ids=[1]),
"S1": Playlist(name="Smart", persistent_id="S1",
playlist_type=PlaylistType.SMART,
track_ids=[1]),
}
return LibraryManager(library, tmp_path)
def test_lists_only_regular_playlists_with_the_track(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 1 is in P1 (regular), F1 (folder), S1 (smart): only P1 qualifies.
assert manager.playlists_containing(1) == [("P1", "Beta")]
def test_sorted_by_name_casefolded(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 2 is in both regular playlists; "alpha" sorts before "Beta".
assert manager.playlists_containing(2) == [("P2", "alpha"), ("P1", "Beta")]
def test_track_in_no_playlist_is_empty(self, qapp, tmp_path):
manager = self._manager(tmp_path)
assert manager.playlists_containing(3) == []
# --------------------------------------------------------------------------
# 2. Multi-track Get Info
# --------------------------------------------------------------------------
class TestEditTracksFields:
def _manager(self, tmp_path):
tracks = {
1: _track(1, artist="A", album="Old", genre="Rock"),
2: _track(2, artist="B", album="New", genre="Rock"),
3: _track(3, artist="C", album="Old", genre="Jazz"),
}
# No file locations, so _write_track_tags is a no-op success.
return LibraryManager(Library(tracks=tracks), tmp_path), tracks
def test_applies_to_all_selected(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
assert all(t.genre == "Pop" for t in tracks.values())
def test_one_composite_undo_reverts_everything(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
# A single undo entry, not one per track.
assert manager.undo_stack.can_undo()
manager.undo_stack.undo()
assert [t.genre for t in (tracks[1], tracks[2], tracks[3])] == \
["Rock", "Rock", "Jazz"]
assert not manager.undo_stack.can_undo()
def test_only_changed_fields_per_track(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
edited = []
manager.track_fields_edited.connect(lambda tid, f: edited.append(tid))
# album="Old" already matches tracks 1 and 3, so only track 2 changes.
manager.edit_tracks_fields([1, 2, 3], {"album": "Old"})
assert edited == [2]
assert tracks[2].album == "Old"
def test_no_change_pushes_no_undo(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Rock", "album": "Old"})
# Tracks 1 & 3 already match on both touched fields; track 2 changes
# genre? no — genre Rock matches track 2 too. album New != Old -> change.
# So track 2 changes album only; an undo IS pushed.
manager.undo_stack.undo()
assert tracks[2].album == "New"
def test_truly_noop_pushes_nothing(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1], {"genre": "Rock"}) # already Rock
assert not manager.undo_stack.can_undo()
# --------------------------------------------------------------------------
# 3. Library search predicate
# --------------------------------------------------------------------------
class TestSearchMatch:
def test_substring_case_insensitive_across_fields(self):
track = _track(1, name="Yesterday", artist="The Beatles",
album="Help!", year=1965)
assert _matches_search(track, ["beat"]) # artist substring
assert _matches_search(track, ["YESTER"]) # name, case-folded
assert _matches_search(track, ["1965"]) # numeric field stringified
def test_all_tokens_must_match_and(self):
track = _track(1, name="Yesterday", artist="The Beatles", year=1965)
assert _matches_search(track, ["beatles", "1965"])
assert not _matches_search(track, ["beatles", "1999"])
def test_empty_tokens_match_everything(self):
assert _matches_search(_track(1), [])
def test_no_match(self):
track = _track(1, name="Yesterday", artist="The Beatles")
assert not _matches_search(track, ["zzdoesnotexist"])
# --------------------------------------------------------------------------
# 4. Custom start/stop times
# --------------------------------------------------------------------------
class TestTimeParsing:
def test_format_round_seconds(self):
assert _format_ms(30_000) == "0:30"
assert _format_ms(90_000) == "1:30"
assert _format_ms(0) == ""
def test_format_with_millis(self):
assert _format_ms(30_500) == "0:30.500"
def test_parse_variants(self):
assert _parse_time_to_ms("0:30") == 30_000
assert _parse_time_to_ms("1:30") == 90_000
assert _parse_time_to_ms("0:30.5") == 30_500
assert _parse_time_to_ms("") == 0
def test_parse_invalid_is_none(self):
assert _parse_time_to_ms("abc") is None
def test_round_trip(self):
for ms in (15_000, 62_250, 125_000):
assert _parse_time_to_ms(_format_ms(ms)) == ms
class TestStartStopPersistence:
def test_times_persist_to_library_json(self, qapp, tmp_path):
track = _track(1, total_time=200_000) # no location -> no file write
manager = LibraryManager(Library(tracks={1: track}), tmp_path)
manager.edit_track_fields(1, {"start_time": 30_000, "stop_time": 60_000})
assert track.start_time == 30_000 and track.stop_time == 60_000
manager.flush()
# Reload from disk and confirm they survived.
from lintunes.storage import json_storage
reloaded = json_storage.load_library(tmp_path)
assert reloaded.tracks[1].start_time == 30_000
assert reloaded.tracks[1].stop_time == 60_000
class TestPlayerStartStop:
def _player(self, qapp, tracks):
library = Library()
for t in tracks:
library.tracks[t.track_id] = t
manager = MagicMock()
manager.library = library
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
player = Player(manager)
return player, manager
def test_stop_time_arms_only_when_before_end(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=200_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 60_000
def test_stop_time_at_or_past_end_is_disabled(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=60_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 0
def test_position_past_stop_records_play_and_advances(self, qapp, tmp_path):
for name in ("a.mp3", "b.mp3"):
(tmp_path / name).write_bytes(b"x")
t1 = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, stop_time=60_000)
t2 = _track(2, location=str(tmp_path / "b.mp3"), total_time=200_000)
player, manager = self._player(qapp, [t1, t2])
finished = []
player.track_finished.connect(lambda t: finished.append(t.track_id))
player.play_queue([1, 2], 0)
player._on_position(59_999) # before stop: nothing
assert manager.record_play.call_count == 0
player._on_position(60_001) # crosses the stop time
manager.record_play.assert_called_once_with(1)
assert finished == [1]
assert player.current_track.track_id == 2 # advanced
def test_note_finished_guards_double_count(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
player, manager = self._player(qapp, [track])
player._current_track = track
player._counted_finish_id = None
player._note_finished(track)
player._note_finished(track) # same track again
manager.record_play.assert_called_once_with(1)
def test_loaded_media_seeks_to_pending_start(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, start_time=30_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed (one-shot)

164
tests/test_round9.py Normal file
View File

@ -0,0 +1,164 @@
"""Round 9: three 'Open / future' items.
1. New playlist is selected and drops straight into inline rename
(PlaylistTree.create_playlist_interactive).
2. EQ visualizer cycles on / dim / off on click (off goes blank).
3. Album-art recovery plumbing: the .itc cache decoder (lintunes.itc).
"""
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import QAbstractItemView
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.sidebar import PlaylistTree
from lintunes.gui.visualizer import VisualizerWidget, BANDS
from lintunes.itc import decode_itc
def _manager(tmp_path):
library = Library(tracks={1: Track(track_id=1, name="One")})
return LibraryManager(library, tmp_path)
# --------------------------------------------------------------------------
# 1. New playlist: instant select + live inline rename
# --------------------------------------------------------------------------
class TestCreatePlaylistInteractive:
def test_new_playlist_is_selected_and_editing(self, qapp, tmp_path):
manager = _manager(tmp_path)
tree = PlaylistTree(manager)
tree.create_playlist_interactive()
playlists = list(manager.library.playlists.values())
assert len(playlists) == 1
new = playlists[0]
assert new.name == "untitled playlist"
# Selected...
assert tree.current_playlist_id() == new.persistent_id
# ...and the inline editor is open so typing renames it immediately.
assert tree.state() == QAbstractItemView.State.EditingState
def test_committing_the_edit_renames_via_manager(self, qapp, tmp_path):
manager = _manager(tmp_path)
tree = PlaylistTree(manager)
tree.create_playlist_interactive()
pid = tree.current_playlist_id()
# Simulate the user typing a name and the editor committing: the
# itemChanged -> _on_item_renamed -> rename_playlist wiring fires.
item = tree._find_item(pid)
item.setText(0, "Road Trip")
assert manager.library.playlists[pid].name == "Road Trip"
# --------------------------------------------------------------------------
# 2. EQ visualizer blank-when-off
# --------------------------------------------------------------------------
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self):
super().__init__()
self._playing = False
def is_playing(self):
return self._playing
class TestVisualizerToggle:
def test_click_cycles_on_dim_off(self, qapp):
from lintunes.gui.visualizer import MODE_ON, MODE_DIM, MODE_OFF
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
assert vis._mode == MODE_ON
vis.mousePressEvent(None) # on -> dim
assert vis._mode == MODE_DIM
vis.mousePressEvent(None) # dim -> off
assert vis._mode == MODE_OFF
vis.mousePressEvent(None) # off -> on (wraps)
assert vis._mode == MODE_ON
def test_off_goes_blank_and_stops(self, qapp):
from lintunes.gui.visualizer import MODE_OFF
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis._bars = [7] * BANDS # pretend mid-animation
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
assert vis._mode == MODE_OFF
assert vis._bars == [0] * BANDS # blank, not the held frame
assert not vis._levels.any()
assert not vis._timer.isActive() # stopped
def test_dim_keeps_animating_when_playing(self, qapp):
from lintunes.gui.visualizer import MODE_DIM
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
assert vis._mode == MODE_DIM
assert vis._timer.isActive() # dim animates while playing
def test_cycling_back_on_resumes_when_playing(self, qapp):
from lintunes.gui.visualizer import MODE_ON
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.mousePressEvent(None) # off -> on again
assert vis._mode == MODE_ON
assert vis._timer.isActive()
# --------------------------------------------------------------------------
# 3. .itc artwork-cache decoder
# --------------------------------------------------------------------------
def _itc_header():
# A minimal iTunes .itc-style header preamble; decode_itc only cares that
# the real image magic appears after it.
return bytes([0, 0, 1, 0x1c]) + b"itch" + b"\x00" * 8 + b"artw" + \
b"\x00" * 8 + b"item" + b"\x00" * 8
class TestItcDecoder:
def test_extracts_jpeg(self):
image = b"\xff\xd8\xff\xe0" + b"JFIFpayload" + b"\xff\xd9"
blob = _itc_header() + image + b"\x00\x00trailing"
out = decode_itc(blob)
assert out is not None
data, mime = out
assert mime == "image/jpeg"
assert data == image # trimmed to EOI, trailing dropped
def test_extracts_png(self):
sig = b"\x89PNG\r\n\x1a\n"
image = sig + b"IHDRfakechunks" + b"IEND\xae\x42\x60\x82"
blob = _itc_header() + image + b"junk"
out = decode_itc(blob)
assert out is not None
data, mime = out
assert mime == "image/png"
assert data == image
def test_raw_bitmap_is_unsupported(self):
# ARGb/PNGf raw payloads have no JPEG/PNG magic -> can't recover.
blob = _itc_header() + b"ARGb" + bytes(range(64))
assert decode_itc(blob) is None

60
tests/test_tagging.py Normal file
View File

@ -0,0 +1,60 @@
import pytest
from lintunes import tagging
FIELDS = {
"name": "Test Title",
"artist": "Test Artist",
"album_artist": "Test Album Artist",
"album": "Test Album",
"genre": "Electronic",
"composer": "Test Composer",
"comments": "A comment",
"grouping": "Test Group",
"year": 2021,
"track_number": 3,
"track_count": 12,
"disc_number": 1,
"disc_count": 2,
"compilation": True,
"bpm": 120,
}
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
def test_write_read_round_trip(fixture, request):
path = request.getfixturevalue(fixture)
tagging.write_tags(path, FIELDS)
read = tagging.read_tags(path)
for key in ("name", "artist", "album_artist", "album", "genre",
"composer", "comments"):
assert read.get(key) == FIELDS[key], key
assert read.get("year") == 2021
assert read.get("track_number") == 3
assert read.get("track_count") == 12
assert read.get("disc_number") == 1
assert read.get("total_time", 0) > 500 # ~1s of audio
assert read.get("size", 0) > 0
def test_read_tags_reports_audio_properties(mp3_file):
read = tagging.read_tags(mp3_file)
assert read.get("kind") == "MPEG audio file"
assert read.get("sample_rate", 0) > 0
def test_no_artwork_in_generated_file(mp3_file):
assert tagging.read_embedded_artwork(mp3_file) is None
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
def test_artwork_write_read_round_trip(fixture, request, jpeg_bytes):
path = request.getfixturevalue(fixture)
assert tagging.read_embedded_artwork(path) is None
tagging.write_artwork(path, jpeg_bytes, "image/jpeg")
assert tagging.read_embedded_artwork(path) == jpeg_bytes
# Replacing art works too (no duplicate covers accumulate)
tagging.write_artwork(path, jpeg_bytes + b"\x00", "image/jpeg")
assert tagging.read_embedded_artwork(path) == jpeg_bytes + b"\x00"

49
tests/test_tap_tempo.py Normal file
View File

@ -0,0 +1,49 @@
from lintunes.tap_tempo import TapTempo
def test_steady_taps_120_bpm():
tempo = TapTempo()
bpm = None
for i in range(5):
bpm = tempo.tap(10.0 + i * 0.5) # 0.5s intervals = 120 bpm
assert bpm == 120
def test_first_tap_gives_no_bpm():
tempo = TapTempo()
assert tempo.tap(1.0) is None
assert tempo.tap_count == 1
def test_long_gap_resets_series():
tempo = TapTempo()
tempo.tap(0.0)
tempo.tap(0.5)
assert tempo.bpm() == 120
# 5 seconds later: new series, old taps discarded
assert tempo.tap(5.5) is None
assert tempo.tap_count == 1
assert tempo.tap(6.1) == 100 # 0.6s interval
def test_window_uses_recent_taps_only():
tempo = TapTempo()
# 4 slow taps (1s = 60bpm) then 8 fast taps (0.25s = 240bpm);
# only the last 8 taps count, so the slow ones age out entirely
t = 0.0
for _ in range(4):
tempo.tap(t)
t += 1.0
for _ in range(8):
tempo.tap(t)
t += 0.25
assert tempo.bpm() == 240
def test_reset():
tempo = TapTempo()
tempo.tap(0.0)
tempo.tap(0.5)
tempo.reset()
assert tempo.bpm() is None
assert tempo.tap_count == 0

56
tests/test_track_edit.py Normal file
View File

@ -0,0 +1,56 @@
"""Regression tests for live browser refresh after a Get-Info edit.
The bug: renaming a track's artist left the column browser (and its filter)
pointing at the old name, so the renamed artist didn't re-sort and clicking it
returned no tracks. The fix routes a new `track_fields_edited(track_id, fields)`
signal to the library view, which rebuilds the browser only when a field it
groups by changed — and leaves play-count/skip bumps alone.
"""
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.library_view import _BROWSER_FIELDS
def _manager(tmp_path):
track = Track(track_id=1, name="Song", artist="Aardvark",
album="Debut", genre="Rock")
library = Library(tracks={1: track})
return LibraryManager(library, tmp_path), track
def test_edit_emits_changed_fields(qapp, tmp_path):
manager, _track = _manager(tmp_path)
edits, updates = [], []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.track_updated.connect(updates.append)
manager.update_track_fields(1, {"artist": "Zz Top"})
assert edits == [(1, ["artist"])]
assert updates == [1]
assert _BROWSER_FIELDS.intersection(edits[0][1]) # would refresh the browser
def test_no_op_edit_is_silent(qapp, tmp_path):
manager, _track = _manager(tmp_path)
edits = []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.update_track_fields(1, {"artist": "Aardvark"}) # unchanged value
assert edits == []
def test_play_count_does_not_reorganize(qapp, tmp_path):
"""record_play bumps play_count via track_updated but must NOT emit
track_fields_edited, so a finished play never rebuilds the browser."""
manager, _track = _manager(tmp_path)
edits, updates = [], []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.track_updated.connect(updates.append)
manager.record_play(1)
assert edits == []
assert updates == [1]

178
tests/test_undo.py Normal file
View File

@ -0,0 +1,178 @@
"""Undo/redo round-trips for LibraryManager operations (no GUI)."""
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist, PlaylistType
from lintunes.undo import UndoStack, Command
def _manager(tmp_path, track_ids=(), playlists=None):
library = Library()
for tid in track_ids:
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
for pl in (playlists or []):
library.playlists[pl.persistent_id] = pl
return LibraryManager(library, tmp_path)
def _playlist(pid="PID", track_ids=(), parent="", folder=False):
return Playlist(
name=pid, persistent_id=pid, parent_persistent_id=parent,
playlist_type=PlaylistType.FOLDER if folder else PlaylistType.REGULAR,
track_ids=list(track_ids))
# ---- the stack itself ----
class TestUndoStack:
def test_caps_at_maxlen(self):
stack = UndoStack(maxlen=3)
state = []
for i in range(5):
stack.push(Command(str(i), undo=lambda i=i: state.append(-i),
redo=lambda: None))
# Only the last 3 survive; undo them all and the 4th from last is gone.
for _ in range(5):
stack.undo()
assert state == [-4, -3, -2] # commands 0,1 were dropped
def test_redo_branch_cleared_on_new_push(self):
stack = UndoStack()
stack.push(Command("a", undo=lambda: None, redo=lambda: None))
stack.undo()
assert stack.can_redo()
stack.push(Command("b", undo=lambda: None, redo=lambda: None))
assert not stack.can_redo()
def test_labels(self):
stack = UndoStack()
stack.push(Command("Add to Playlist", undo=lambda: None, redo=lambda: None))
assert stack.undo_label() == "Add to Playlist"
stack.undo()
assert stack.redo_label() == "Add to Playlist"
assert stack.undo_label() == ""
# ---- playlist contents ----
class TestPlaylistContentUndo:
def test_add_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1])
m = _manager(tmp_path, [1, 2, 3], [pl])
m.add_tracks_to_playlist("PID", [2, 3])
assert pl.track_ids == [1, 2, 3]
m.undo_stack.undo()
assert pl.track_ids == [1]
m.undo_stack.redo()
assert pl.track_ids == [1, 2, 3]
def test_remove_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2, 3])
m = _manager(tmp_path, [1, 2, 3], [pl])
m.remove_tracks_from_playlist("PID", [0, 2])
assert pl.track_ids == [2]
m.undo_stack.undo()
assert pl.track_ids == [1, 2, 3]
m.undo_stack.redo()
assert pl.track_ids == [2]
def test_move_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2, 3, 4])
m = _manager(tmp_path, [1, 2, 3, 4], [pl])
m.move_tracks_in_playlist("PID", [0], 3)
moved = list(pl.track_ids)
assert moved != [1, 2, 3, 4]
m.undo_stack.undo()
assert pl.track_ids == [1, 2, 3, 4]
m.undo_stack.redo()
assert pl.track_ids == moved
def test_noop_add_does_not_push(self, qapp, tmp_path):
pl = _playlist(track_ids=[1])
m = _manager(tmp_path, [1], [pl])
m.add_tracks_to_playlist("PID", []) # nothing valid
assert not m.undo_stack.can_undo()
# ---- playlist structure ----
class TestPlaylistStructureUndo:
def test_create_playlist(self, qapp, tmp_path):
m = _manager(tmp_path)
pl = m.create_playlist("New")
assert pl.persistent_id in m.library.playlists
m.undo_stack.undo()
assert pl.persistent_id not in m.library.playlists
m.undo_stack.redo()
assert pl.persistent_id in m.library.playlists
def test_rename_playlist(self, qapp, tmp_path):
pl = _playlist()
m = _manager(tmp_path, playlists=[pl])
m.rename_playlist("PID", "Renamed")
assert pl.name == "Renamed"
m.undo_stack.undo()
assert pl.name == "PID"
m.undo_stack.redo()
assert pl.name == "Renamed"
def test_move_playlist(self, qapp, tmp_path):
folder = _playlist("FID", folder=True)
pl = _playlist("PID")
m = _manager(tmp_path, playlists=[folder, pl])
m.move_playlist("PID", "FID")
assert pl.parent_persistent_id == "FID"
m.undo_stack.undo()
assert pl.parent_persistent_id == ""
m.undo_stack.redo()
assert pl.parent_persistent_id == "FID"
def test_delete_playlist_restores_contents(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2])
m = _manager(tmp_path, [1, 2], [pl])
m.delete_playlist("PID")
assert "PID" not in m.library.playlists
m.undo_stack.undo()
assert "PID" in m.library.playlists
assert m.library.playlists["PID"].track_ids == [1, 2]
def test_delete_folder_restores_descendants(self, qapp, tmp_path):
folder = _playlist("FID", folder=True)
child = _playlist("CID", track_ids=[1], parent="FID")
m = _manager(tmp_path, [1], [folder, child])
m.delete_playlist("FID")
assert "FID" not in m.library.playlists
assert "CID" not in m.library.playlists
m.undo_stack.undo()
assert "FID" in m.library.playlists
assert "CID" in m.library.playlists
assert m.library.playlists["CID"].track_ids == [1]
# ---- metadata ----
class TestMetadataUndo:
def test_edit_fields_no_location(self, qapp, tmp_path):
# No file location -> tag write is skipped but library/undo still work.
m = _manager(tmp_path, [1])
m.edit_track_fields(1, {"name": "New Name", "artist": "New Artist"})
track = m.library.tracks[1]
assert track.name == "New Name" and track.artist == "New Artist"
m.undo_stack.undo()
assert track.name == "t1" and (track.artist or "") == ""
m.undo_stack.redo()
assert track.name == "New Name"
def test_edit_unchanged_does_not_push(self, qapp, tmp_path):
m = _manager(tmp_path, [1])
m.edit_track_fields(1, {"name": "t1"}) # same value
assert not m.undo_stack.can_undo()
def test_edit_writes_file_tags_and_reverts(self, qapp, tmp_path, mp3_file):
from lintunes import tagging
track = Track(track_id=1, name="orig", location=str(mp3_file))
library = Library()
library.tracks[1] = track
m = LibraryManager(library, tmp_path)
m.edit_track_fields(1, {"name": "edited", "artist": "Somebody"})
assert tagging.read_tags(str(mp3_file))["name"] == "edited"
m.undo_stack.undo()
assert tagging.read_tags(str(mp3_file))["name"] == "orig"