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