"""Round 18: the 2026-07-02 "New backlog" batch. 1. Visualizer: gray-mode color slider pref + mode persists across launches. 2. External file drops insert at the drop position in a playlist. 3. Renaming artist/album/album_artist moves the file to keep the music folder organized (Artist/Album, Unknown Artist fallback). """ import shutil from pathlib import Path import pytest from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtGui import QColor from lintunes import theme from lintunes.library_manager import LibraryManager from lintunes.models import Library, Track from lintunes.preferences import Preferences from lintunes.gui.visualizer import ( VisualizerWidget, MODE_ON, MODE_DIM, MODE_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 # -------------------------------------------------------------------------- # 1. Visualizer: gray color pref + persistent mode # -------------------------------------------------------------------------- class TestVisualizerModePersistence: def test_no_prefs_defaults_on(self, qapp): vis = VisualizerWidget(_FakePlayer()) assert vis._mode == MODE_ON def test_saved_mode_restored(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_mode", MODE_DIM) vis = VisualizerWidget(_FakePlayer(), prefs) assert vis._mode == MODE_DIM def test_saved_off_restored_and_stays_idle(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_mode", MODE_OFF) vis = VisualizerWidget(_FakePlayer(), prefs) assert vis._mode == MODE_OFF vis._on_playing_changed(True) # playback starts assert not vis._timer.isActive() def test_bogus_saved_mode_falls_back_to_on(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_mode", "sparkle") # hand-edited prefs file vis = VisualizerWidget(_FakePlayer(), prefs) assert vis._mode == MODE_ON def test_click_persists_mode_to_disk(self, qapp, tmp_path): prefs = Preferences(tmp_path) vis = VisualizerWidget(_FakePlayer(), prefs) vis.mousePressEvent(None) # on -> dim assert prefs.get("visualizer_mode") == MODE_DIM # A fresh Preferences (= next launch) sees it too. assert Preferences(tmp_path).get("visualizer_mode") == MODE_DIM class TestVisualizerGrayColor: def test_default_gray_resolves_for_visualizer_key(self, qapp): value = theme.default_gray("color_visualizer_gray") assert isinstance(value, int) assert 0 <= value <= 255 def test_slider_pref_drives_dim_color(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("color_visualizer_gray", 100) vis = VisualizerWidget(_FakePlayer(), prefs) vis._mode = MODE_DIM assert vis._bar_color() == QColor(100, 100, 100) def test_unset_pref_keeps_derived_color(self, qapp, tmp_path): prefs = Preferences(tmp_path) with_prefs = VisualizerWidget(_FakePlayer(), prefs) without = VisualizerWidget(_FakePlayer()) with_prefs._mode = MODE_DIM without._mode = MODE_DIM assert with_prefs._bar_color() == without._bar_color() def test_on_mode_ignores_gray_pref(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("color_visualizer_gray", 100) vis = VisualizerWidget(_FakePlayer(), prefs) assert vis._mode == MODE_ON assert vis._bar_color() == QColor(vis.palette().highlight().color()) def test_visualizer_row_in_preferences_dialog(self, qapp): from lintunes.gui.preferences_dialog import _COLOR_ROWS assert "color_visualizer_gray" in [key for key, _label in _COLOR_ROWS] # -------------------------------------------------------------------------- # 2. External file drops insert at the drop position # -------------------------------------------------------------------------- def _seeded_manager(tmp_path, track_ids=(1, 2, 3)): tracks = {tid: Track(track_id=tid, name=f"T{tid}") for tid in track_ids} return LibraryManager(Library(tracks=tracks), tmp_path / "data") class TestFileDropPosition: def test_import_paths_inserts_at_position(self, qapp, tmp_path, mp3_file): from lintunes.importers import file_importer manager = _seeded_manager(tmp_path) playlist = manager.create_playlist("Drops") manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2, 3]) imported = file_importer.import_paths( [mp3_file], tmp_path / "music", manager, playlist.persistent_id, position=1) assert len(imported) == 1 new_id = imported[0].track_id assert playlist.track_ids == [1, new_id, 2, 3] def test_import_paths_default_appends(self, qapp, tmp_path, mp3_file): from lintunes.importers import file_importer manager = _seeded_manager(tmp_path) playlist = manager.create_playlist("Drops") manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2]) imported = file_importer.import_paths( [mp3_file], tmp_path / "music", manager, playlist.persistent_id) assert playlist.track_ids == [1, 2, imported[0].track_id] def test_duplicate_file_inserts_without_new_track(self, qapp, tmp_path, mp3_file): from lintunes.importers import file_importer manager = _seeded_manager(tmp_path) existing = file_importer.import_file( mp3_file, tmp_path / "music", manager) playlist = manager.create_playlist("Drops") manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2]) count_before = len(manager.library.tracks) imported = file_importer.import_paths( [mp3_file], tmp_path / "music", manager, playlist.persistent_id, position=0) assert imported[0].track_id == existing.track_id assert playlist.track_ids == [existing.track_id, 1, 2] assert len(manager.library.tracks) == count_before def test_playlist_view_relays_drop_row(self, qapp, tmp_path): from lintunes.gui.playlist_view import PlaylistView manager = _seeded_manager(tmp_path) playlist = manager.create_playlist("Drops") view = PlaylistView(manager) view.show_playlist(playlist) received = [] view.files_dropped.connect( lambda paths, pid, row: received.append((paths, pid, row))) view._on_files_dropped(["/x.mp3"], 2) view._on_files_dropped(["/y.mp3"], None) assert received == [(["/x.mp3"], playlist.persistent_id, 2), (["/y.mp3"], playlist.persistent_id, None)] # -------------------------------------------------------------------------- # 3. Renaming artist/album moves the file (keep the music folder organized) # -------------------------------------------------------------------------- @pytest.fixture def organized(qapp, tmp_path, mp3_file): """A library whose music folder holds one organized track: /Music/Old Artist/Old Album/test.mp3.""" share = tmp_path / "share" src = share / "Music" / "Old Artist" / "Old Album" / "test.mp3" src.parent.mkdir(parents=True) shutil.copy2(mp3_file, src) track = Track(track_id=1, name="Song", artist="Old Artist", album="Old Album", location=str(src)) library = Library(tracks={1: track}, music_folder=str(share)) manager = LibraryManager(library, share / "lintunes") return manager, track, share class TestRenameMovesFile: def test_artist_edit_moves_file(self, organized): manager, track, share = organized old_path = Path(track.location) manager.edit_track_fields(1, {"artist": "New Artist"}) expected = share / "Music" / "New Artist" / "Old Album" / "test.mp3" assert Path(track.location) == expected assert expected.is_file() assert not old_path.exists() assert track.artist == "New Artist" def test_album_edit_moves_file(self, organized): manager, track, share = organized manager.edit_track_fields(1, {"album": "New Album"}) expected = share / "Music" / "Old Artist" / "New Album" / "test.mp3" assert Path(track.location) == expected assert expected.is_file() def test_album_artist_shields_artist_edit(self, organized): manager, track, share = organized track.album_artist = "Main Act" dest = share / "Music" / "Main Act" / "Old Album" / "test.mp3" dest.parent.mkdir(parents=True) Path(track.location).rename(dest) track.location = str(dest) # Folder is keyed on album_artist, so an artist edit changes nothing. manager.edit_track_fields(1, {"artist": "Feat Guy"}) assert Path(track.location) == dest assert dest.is_file() assert track.artist == "Feat Guy" def test_empty_dirs_pruned_but_not_root(self, organized): manager, track, share = organized manager.edit_track_fields(1, {"artist": "New Artist", "album": "New Album"}) assert not (share / "Music" / "Old Artist").exists() # pruned assert (share / "Music").is_dir() # root survives def test_outside_root_file_untouched(self, organized, mp3_file): manager, _track, share = organized outside = Path(mp3_file) external = Track(track_id=2, name="Ext", artist="Someone", location=str(outside)) manager.library.tracks[2] = external manager.edit_track_fields(2, {"artist": "Someone Else"}) assert Path(external.location) == outside assert outside.is_file() assert external.artist == "Someone Else" def test_undo_moves_back_redo_removes(self, organized): manager, track, share = organized old_path = Path(track.location) manager.edit_track_fields(1, {"artist": "New Artist"}) new_path = Path(track.location) manager.undo_stack.undo() assert Path(track.location) == old_path assert old_path.is_file() and not new_path.exists() assert track.artist == "Old Artist" manager.undo_stack.redo() assert Path(track.location) == new_path assert new_path.is_file() and not old_path.exists() assert track.artist == "New Artist" def test_collision_gets_suffix(self, organized): manager, track, share = organized taken = share / "Music" / "New Artist" / "Old Album" / "test.mp3" taken.parent.mkdir(parents=True) taken.write_bytes(b"other file already here") manager.edit_track_fields(1, {"artist": "New Artist"}) assert Path(track.location) == taken.with_stem("test 1") assert Path(track.location).is_file() assert taken.read_bytes() == b"other file already here" def test_move_failure_keeps_edit_and_old_location(self, organized, monkeypatch): manager, track, share = organized old_path = Path(track.location) failures = [] manager.file_move_failed.connect( lambda name, err: failures.append((name, err))) monkeypatch.setattr( LibraryManager, "_move_file", lambda self, src, dest: (_ for _ in ()).throw(OSError("nope"))) manager.edit_track_fields(1, {"artist": "New Artist"}) assert track.artist == "New Artist" # edit applied assert Path(track.location) == old_path # location kept assert old_path.is_file() assert failures and failures[0][0] == "Song" def test_bulk_album_rename_moves_all_and_one_undo(self, organized, mp3_file): manager, track, share = organized second_src = share / "Music" / "Old Artist" / "Old Album" / "two.mp3" shutil.copy2(mp3_file, second_src) second = Track(track_id=2, name="Two", artist="Old Artist", album="Old Album", location=str(second_src)) manager.library.tracks[2] = second manager.edit_tracks_fields([1, 2], {"album": "Remaster"}) new_dir = share / "Music" / "Old Artist" / "Remaster" assert Path(track.location).parent == new_dir assert Path(second.location).parent == new_dir assert Path(track.location).is_file() assert Path(second.location).is_file() manager.undo_stack.undo() # ONE undo restores both old_dir = share / "Music" / "Old Artist" / "Old Album" assert Path(track.location).parent == old_dir assert Path(second.location).parent == old_dir assert Path(track.location).is_file() assert Path(second.location).is_file() assert not new_dir.exists() # pruned again class TestLocationSync: def test_merge_track_fields_newest_wins_location(self): from lintunes.storage.conflict_resolver import _merge_track_fields orig = {"location": "Music/A/B/x.mp3", "date_modified": "2026-01-01"} newer = {"location": "Music/C/B/x.mp3", "date_modified": "2026-06-01"} _merge_track_fields(orig, newer) assert orig["location"] == "Music/C/B/x.mp3" older = {"location": "Music/Z/B/x.mp3", "date_modified": "2025-01-01"} _merge_track_fields(orig, older) assert orig["location"] == "Music/C/B/x.mp3" # older copy loses def test_reload_from_disk_adopts_external_move(self, organized): from lintunes.storage import json_storage manager, track, share = organized data_dir = manager.data_dir json_storage.save_library(manager.library, data_dir) # Another machine renamed the artist: file moved + library.json # rewritten with a newer date_modified and the new relative path. new_loc = share / "Music" / "Elsewhere" / "Old Album" / "test.mp3" new_loc.parent.mkdir(parents=True) Path(track.location).rename(new_loc) from lintunes.storage.json_storage import read_json, write_json disk = read_json(data_dir / "library.json") entry = disk["1"] entry["artist"] = "Elsewhere" entry["location"] = "../Music/Elsewhere/Old Album/test.mp3" entry["date_modified"] = "2099-01-01T00:00:00" write_json(data_dir / "library.json", disk) manager.reload_from_disk() assert Path(track.location) == new_loc assert track.artist == "Elsewhere"