Files
lintunes/tests/test_round9.py
T
trav 00aa5e7f2e v0.55.0: Kaleidostrobe smooth again; visualizer tile hover controls
The cones move only on the plugin's own 25 fps frames again: driving them
from fresh audio every refresh made bass-heavy songs twitch (a jump on 84%
of refreshes against 41%). The continuous turn, texture crossfade and the
GPU big window stay.

The tile: a click opens it big (and it stays blank while big); hovering
shows a colour wheel (colour / gray / off) and up/down arrows (previous /
next visualizer). No more double-click or click-to-cycle. The big album
art closes on a click or Esc, like the big visualizer.
2026-10-14 12:51:38 -07:00

165 lines
5.7 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.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM
vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF
vis.cycle_mode() # 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.cycle_mode() # on -> dim
vis.cycle_mode() # 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.cycle_mode() # 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.cycle_mode() # on -> dim
vis.cycle_mode() # dim -> off
vis.cycle_mode() # 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