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>
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
"""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
|