Files
lintunes/tests/test_round19.py
T
travandClaude Opus 5 211cb50a9e v0.7.0: startup speed — the 21k-track library stops freezing
Three reported symptoms, one shape: every operation was whole-library,
whole-file, on the Qt main thread. Measured on the real library (21,482
tracks, 506 playlists, 15 MB library.json).

The track table sorted through a QSortFilterProxyModel, which asks data()
for a value on every comparison — 580k Python round trips, 8.5 s per table
load, paid again on every reload. TrackTableModel now keeps _tracks in
canonical (playlist) order plus an _order index list and sorts a key list
computed once per track. Nothing used the proxy's filtering. Sorting by #
is the identity order, so "source row" still means "playlist position" for
drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s.

Smart rules compile to closures once per evaluate() instead of being
re-dispatched per track — the operator lookup, casefolding the query and
parsing the rule's own date constants all leave the 21k-iteration loop
(_match_date was re-parsing its own constant 21,482 times per rule).
Verified identical membership against the old evaluator on all 20 real
smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint.

Also: _reconcile_track skips two to_dict() round trips per unchanged track
(MERGEABLE_FIELDS in the resolver is the single source of truth for what a
merge touches); no git subprocess on the startup path (Updater.enabled is
probed lazily on the background thread, version-button tooltip deferred);
and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB
across 73 snapshots inside the Syncthing share.

Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when
Syncthing delivers a change — a full reload + recompute + table rebuild
with the window already on screen, which is what GNOME was offering to
force-quit — ~16 s -> 3.8 s.

The merge rework itself (playlist ordering, per-machine play journal,
quieting the dialog) is queued in TASKS.md as Round 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:09:07 -04:00

199 lines
6.2 KiB
Python

"""Round 19: version display + git self-update (updater, version button)."""
import subprocess
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes import __version__
from lintunes.updater import Updater
class FakeCompleted:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def make_updater(monkeypatch, git_responses):
"""An Updater whose git calls are served from `git_responses`:
{subcommand: FakeCompleted or Exception}. Unlisted subcommands succeed
with empty output."""
def fake_run(cmd, **kwargs):
assert cmd[0] == "git" and cmd[1] == "-C"
sub = cmd[3]
response = git_responses.get(sub, FakeCompleted())
if isinstance(response, Exception):
raise response
return response
monkeypatch.setattr(subprocess, "run", fake_run)
return Updater()
def collect(signal):
got = []
signal.connect(lambda *args: got.append(args[0] if args else None))
return got
def test_updater_disabled_outside_git_checkout(qapp, monkeypatch):
updater = make_updater(monkeypatch, {
"rev-parse": FakeCompleted(returncode=128, stderr="not a git repo")})
assert not updater.enabled
# disabled updater never spawns work
updater.check_async()
updater.update_async()
def test_check_emits_update_available(qapp, monkeypatch):
updater = make_updater(monkeypatch, {
"rev-parse": FakeCompleted(stdout="/repo\n"),
"rev-list": FakeCompleted(stdout="3\n"),
})
assert updater.enabled
got = collect(updater.update_available)
updater._check() # run synchronously; check_async just threads this
assert got == [3]
def test_check_quiet_when_up_to_date(qapp, monkeypatch):
updater = make_updater(monkeypatch, {
"rev-list": FakeCompleted(stdout="0\n")})
got = collect(updater.update_available)
updater._check()
assert got == []
def test_check_quiet_when_fetch_fails(qapp, monkeypatch):
updater = make_updater(monkeypatch, {
"fetch": FakeCompleted(returncode=128, stderr="ssh: no route to host"),
"rev-list": FakeCompleted(stdout="3\n"),
})
got = collect(updater.update_available)
updater._check()
assert got == [] # offline is normal — no signal, no error
def test_update_applied_on_clean_pull(qapp, monkeypatch):
updater = make_updater(monkeypatch, {"pull": FakeCompleted(returncode=0)})
applied = collect(updater.update_applied)
failed = collect(updater.update_failed)
updater._update()
assert len(applied) == 1
assert failed == []
def test_update_failed_surfaces_git_error(qapp, monkeypatch):
# The message is the error:/fatal: line, not git's trailing "Aborting"
updater = make_updater(monkeypatch, {
"pull": FakeCompleted(returncode=1,
stderr="error: Your local changes to the "
"following files would be overwritten "
"by merge:\n\ttasks-done.md\n"
"Aborting")})
applied = collect(updater.update_applied)
failed = collect(updater.update_failed)
updater._update()
assert applied == []
assert failed == ["error: Your local changes to the following files "
"would be overwritten by merge:"]
def test_short_hash(qapp, monkeypatch):
updater = make_updater(monkeypatch, {
"rev-parse": FakeCompleted(stdout="abc1234\n")})
assert updater.short_hash() == "abc1234"
# ---- version button ----
class FakeUpdater(QObject): # QObject with the Updater's signal surface
update_available = pyqtSignal(int)
update_applied = pyqtSignal()
update_failed = pyqtSignal(str)
def __init__(self):
super().__init__()
self.enabled = True
self.update_calls = 0
def short_hash(self):
return "abc1234"
def update_async(self):
self.update_calls += 1
@pytest.fixture
def button(qapp):
from lintunes.gui.version_button import VersionButton
fake = FakeUpdater()
return VersionButton(fake), fake
def test_button_shows_version_and_hash(qapp, button):
btn, fake = button
assert btn.text() == f"v{__version__}"
# The commit hash is filled in just after construction (short_hash() shells
# out to git, so it stays off the startup path).
qapp.processEvents()
assert "abc1234" in btn.toolTip()
def test_button_star_on_update_available(button):
btn, fake = button
fake.update_available.emit(2)
assert btn.text() == f"v{__version__}*"
assert "click to update" in btn.toolTip()
def test_click_without_update_does_nothing(button):
btn, fake = button
btn._on_clicked()
assert fake.update_calls == 0
def test_click_with_update_confirms_then_pulls(button, monkeypatch):
btn, fake = button
fake.update_available.emit(1)
monkeypatch.setattr(btn, "_confirm", lambda: True)
btn._on_clicked()
assert fake.update_calls == 1
assert btn.text() == "updating…"
assert not btn.isEnabled()
def test_click_with_update_declined(button, monkeypatch):
btn, fake = button
fake.update_available.emit(1)
monkeypatch.setattr(btn, "_confirm", lambda: False)
btn._on_clicked()
assert fake.update_calls == 0
assert btn.isEnabled()
def test_failed_update_reenables_button(button, monkeypatch):
btn, fake = button
fake.update_available.emit(1)
monkeypatch.setattr(btn, "_confirm", lambda: True)
btn._on_clicked()
messages = collect(btn.status_message)
fake.update_failed.emit("fatal: no route to host")
assert btn.isEnabled()
assert btn.text() == f"v{__version__}*"
assert messages == ["Update failed: fatal: no route to host"]
def test_version_matches_setup_py():
import re
from pathlib import Path
setup_py = Path(__file__).parents[1] / "setup.py"
# setup.py reads the version from lintunes/__init__.py textually
assert "__init__.py" in setup_py.read_text()
init = Path(__file__).parents[1] / "lintunes" / "__init__.py"
found = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M)
assert found and found[1] == __version__