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

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"