- The emboss hugs the edges: thin lit and shaded rims, a defined outline that's a touch darker along the bottom, and a nearly flat face — shape without depth. - Hover darkens the whole button slightly instead of Qt's pale panel on top of it. - One face for every button (theme.button_face_qss): regular buttons, Library and its ▾, the purple friend-mode buttons, and the transport boxes. Shuffle and bpm are boxes that are one button, so the whole box darkens (bpm's whole box is now its tap target); the play box's three tiles each darken on their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.1 KiB
Python
176 lines
7.1 KiB
Python
"""Round 63: groundwork for Cassette that isn't Cassette.
|
|
|
|
* Every individual import stamps *date added = now*, even for a file that is
|
|
years old. The iTunes XML migration is the exception: it recreates a library
|
|
rather than adding to one, so it keeps the dates the XML carries.
|
|
* A brand-new library (a fresh data dir, or a fresh iTunes import) starts with
|
|
a "New Tracks" smart playlist: added in the last 3 months, newest first. Its
|
|
persistent id is fixed, so two machines seeding the same unsynced share merge
|
|
into one playlist rather than two.
|
|
* Buttons get a very slight emboss, computed from the live palette.
|
|
"""
|
|
import os
|
|
import plistlib
|
|
import shutil
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from PyQt6.QtGui import QColor
|
|
|
|
from lintunes import theme
|
|
from lintunes.importers.file_importer import import_paths
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import PlaylistType, Track
|
|
from lintunes.models.library import Library
|
|
from lintunes.seed_playlists import (
|
|
NEW_TRACKS_NAME, NEW_TRACKS_PID, new_tracks_playlist, seed_new_library,
|
|
)
|
|
from lintunes.smart import evaluate
|
|
from lintunes.storage.json_storage import load_library
|
|
|
|
|
|
def _utc_now():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
# ---- date added ----
|
|
|
|
class TestDateAdded:
|
|
def test_import_stamps_now_even_for_an_old_file(self, qapp, tmp_path, mp3_file):
|
|
old = tmp_path / "old.mp3"
|
|
shutil.copyfile(mp3_file, old)
|
|
ancient = datetime(2004, 5, 1).timestamp()
|
|
os.utime(old, (ancient, ancient))
|
|
manager = LibraryManager(Library(), tmp_path / "data")
|
|
|
|
before = _utc_now() - timedelta(seconds=1)
|
|
[track] = import_paths([old], tmp_path / "Music", manager)
|
|
|
|
added = datetime.fromisoformat(track.date_added)
|
|
assert before <= added <= _utc_now() + timedelta(seconds=1)
|
|
|
|
def test_itunes_migration_keeps_its_dates(self, tmp_path):
|
|
from lintunes.main import run_import
|
|
xml = tmp_path / "Library.xml"
|
|
with open(xml, "wb") as fh:
|
|
plistlib.dump({
|
|
"Music Folder": "file:///Volumes/M/iTunes%20Media/",
|
|
"Tracks": {"7": {
|
|
"Track ID": 7, "Name": "Old Song", "Persistent ID": "P7",
|
|
"Track Type": "File",
|
|
"Date Added": datetime(2009, 3, 4, 5, 6, 7),
|
|
"Location": "file:///Volumes/M/iTunes%20Media/Music/a.mp3",
|
|
}},
|
|
"Playlists": [],
|
|
}, fh)
|
|
data = tmp_path / "data"
|
|
run_import(xml, str(tmp_path / "media"), data)
|
|
|
|
library = load_library(data)
|
|
assert library.tracks[7].date_added.startswith("2009-03-04")
|
|
|
|
|
|
# ---- New Tracks ----
|
|
|
|
class TestNewTracks:
|
|
def test_shape(self):
|
|
playlist = new_tracks_playlist()
|
|
assert playlist.name == NEW_TRACKS_NAME
|
|
assert playlist.persistent_id == NEW_TRACKS_PID
|
|
assert playlist.playlist_type == PlaylistType.SMART
|
|
[rule] = playlist.smart_criteria.root.children
|
|
assert (rule.field, rule.operator, rule.value, rule.unit) == \
|
|
("date_added", "in_last", 3, "months")
|
|
assert playlist.settings.sort_column == "date_added"
|
|
assert playlist.settings.sort_ascending is False
|
|
|
|
def test_matches_only_recent_tracks(self):
|
|
now = _utc_now()
|
|
recent = Track(track_id=1, date_added=(now - timedelta(days=20)).isoformat())
|
|
stale = Track(track_id=2, date_added=(now - timedelta(days=200)).isoformat())
|
|
criteria = new_tracks_playlist().smart_criteria
|
|
assert evaluate(criteria, [recent, stale]) == [1]
|
|
|
|
def test_seed_is_idempotent(self):
|
|
playlists = {}
|
|
assert len(seed_new_library(playlists)) == 1
|
|
assert seed_new_library(playlists) == []
|
|
assert list(playlists) == [NEW_TRACKS_PID]
|
|
|
|
def test_manager_seed_persists(self, qapp, tmp_path):
|
|
manager = LibraryManager(Library(), tmp_path)
|
|
manager.seed_new_library()
|
|
manager.flush()
|
|
reloaded = load_library(tmp_path)
|
|
assert reloaded.playlists[NEW_TRACKS_PID].name == NEW_TRACKS_NAME
|
|
assert reloaded.playlists[NEW_TRACKS_PID].is_smart
|
|
|
|
def test_itunes_import_is_seeded(self, tmp_path):
|
|
from lintunes.main import run_import
|
|
xml = tmp_path / "Library.xml"
|
|
with open(xml, "wb") as fh:
|
|
plistlib.dump({"Music Folder": "file:///M/", "Tracks": {},
|
|
"Playlists": []}, fh)
|
|
run_import(xml, str(tmp_path), tmp_path / "data")
|
|
assert NEW_TRACKS_PID in load_library(tmp_path / "data").playlists
|
|
|
|
def test_existing_library_is_not_seeded(self):
|
|
"""run_gui seeds only when library.json is absent; pin the predicate
|
|
by source rather than launching a GUI."""
|
|
import inspect
|
|
from lintunes import main
|
|
source = inspect.getsource(main.run_gui)
|
|
assert 'not (data_dir / "library.json").exists()' in source
|
|
assert "manager.seed_new_library()" in source
|
|
|
|
|
|
# ---- emboss ----
|
|
|
|
class TestEmboss:
|
|
def test_gradient_keeps_the_color_at_its_middle(self):
|
|
color = QColor("#808080")
|
|
gradient = theme.emboss_gradient(color)
|
|
assert "stop:0.5 #808080" in gradient
|
|
assert color.lighter(theme.EMBOSS_LIGHTER).name() in gradient
|
|
assert color.darker(theme.EMBOSS_DARKER).name() in gradient
|
|
|
|
def test_pressed_flips(self):
|
|
color = QColor("#808080")
|
|
up = theme.emboss_gradient(color)
|
|
down = theme.emboss_gradient(color, pressed=True)
|
|
rim = color.lighter(theme.EMBOSS_RIM).name()
|
|
assert up.startswith(f"qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {rim}")
|
|
assert down.endswith(f"stop:1 {rim})")
|
|
|
|
def test_crisp_rims_hug_the_edges(self):
|
|
"""The shape lives in thin rims, not a wide soft slope."""
|
|
gradient = theme.emboss_gradient(QColor("#808080"))
|
|
assert f"stop:{theme.EMBOSS_RIM_WIDTH} " in gradient
|
|
assert theme.EMBOSS_RIM_WIDTH <= 0.1
|
|
assert theme.EMBOSS_RIM > theme.EMBOSS_LIGHTER
|
|
|
|
def test_hover_darkens_the_whole_button(self):
|
|
color = QColor("#c0c0c0")
|
|
qss = theme.button_face_qss("QPushButton", color)
|
|
hover = qss.split("QPushButton:hover {", 1)[1].split("}", 1)[0]
|
|
assert f"stop:0.5 {color.darker(theme.HOVER_DARKER).name()}" in hover
|
|
|
|
def test_every_button_shares_the_face(self, qapp):
|
|
from unittest.mock import MagicMock
|
|
from lintunes.gui.sidebar import SidebarPanel
|
|
from lintunes.gui.transport import _box_style
|
|
from lintunes.gui import cassette_art
|
|
manager = MagicMock()
|
|
manager.library.playlists = {}
|
|
sidebar = SidebarPanel(manager, MagicMock())
|
|
for qss in (sidebar.library_button.styleSheet(),
|
|
sidebar.friend_button.styleSheet(),
|
|
_box_style(QColor("#dddddd"), button=True),
|
|
cassette_art.friend_button_qss()):
|
|
assert ":hover" in qss
|
|
assert "border-bottom-color" in qss
|
|
|
|
def test_flat_buttons_opt_out(self):
|
|
qss = theme.button_emboss_qss(QColor("#808080"))
|
|
assert "QPushButton:!flat" in qss
|
|
assert "QPushButton {" not in qss
|