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>
179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
"""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"
|