"""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"