v0.1.1: perf timings + single-save tag writes + coalesced browser rebuild
Diagnosing the Ctrl+I save hang on the Debian machine (GUI thread blocks past mutter's 5s check-alive): - lintunes/perf.py: timed() context manager logging wall-clock ms at INFO on lintunes.perf — instruments tag/artwork saves, file moves, edit_track(s)_fields, the browser rebuild, smart recompute, and the debounced JSON flush, so the slow machine can tell us which stage eats the time (journalctl --user or a terminal run). - tagging.write_tags: ONE parse + ONE save per edit. grouping/ compilation/bpm ride the main save via registered Easy keys (GRP1/TCMP/TBPM on EasyID3, cpil on EasyMP4) instead of _write_extra_tags re-parsing and re-saving the audio file. - LibraryView: browser rebuilds coalesce through a 0ms single-shot timer — an N-track Get Info edit rebuilds the genre/artist/album cascade once instead of N times over the whole library. Moving the writes off the GUI thread is deliberately deferred until the Debian timings say which stage dominates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
130
tests/test_round20.py
Normal file
130
tests/test_round20.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Round 20: Ctrl+I save-path performance.
|
||||
|
||||
- ``tagging.write_tags`` now does ONE mutagen parse + ONE save per call:
|
||||
grouping/compilation/bpm ride the same save (via registered Easy keys)
|
||||
instead of a second full parse+save of the audio file.
|
||||
- ``LibraryView`` coalesces browser rebuilds through a 0 ms single-shot
|
||||
timer, so an N-track edit rebuilds the genre/artist/album cascade once.
|
||||
- ``perf.timed`` logs wall-clock durations on the ``lintunes.perf`` logger.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import mutagen
|
||||
import pytest
|
||||
|
||||
from lintunes import tagging
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Track
|
||||
from lintunes.models.library import Library
|
||||
from lintunes.perf import timed
|
||||
|
||||
|
||||
def _manager(tmp_path, tracks=()):
|
||||
library = Library()
|
||||
for track in tracks:
|
||||
library.tracks[track.track_id] = track
|
||||
return LibraryManager(library, tmp_path)
|
||||
|
||||
|
||||
EXTRAS = {"grouping": "Test Group", "compilation": True, "bpm": 128}
|
||||
|
||||
|
||||
# ---- single parse + save in write_tags ----
|
||||
|
||||
class TestSingleSave:
|
||||
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
||||
def test_one_parse_per_write(self, fixture, request, monkeypatch):
|
||||
path = request.getfixturevalue(fixture)
|
||||
opens = []
|
||||
real_file = mutagen.File
|
||||
|
||||
def counting_file(*args, **kwargs):
|
||||
opens.append(args)
|
||||
return real_file(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(tagging.mutagen, "File", counting_file)
|
||||
tagging.write_tags(path, {"name": "Title", **EXTRAS})
|
||||
assert len(opens) == 1
|
||||
|
||||
def test_mp3_extras_use_itunes_frames(self, mp3_file):
|
||||
tagging.write_tags(mp3_file, EXTRAS)
|
||||
raw = mutagen.File(mp3_file)
|
||||
assert [str(t) for t in raw.tags["GRP1"].text] == ["Test Group"]
|
||||
assert str(raw.tags["TCMP"].text[0]) == "1"
|
||||
assert str(raw.tags["TBPM"].text[0]) == "128"
|
||||
|
||||
def test_m4a_extras_use_itunes_atoms(self, m4a_file):
|
||||
tagging.write_tags(m4a_file, EXTRAS)
|
||||
raw = mutagen.File(m4a_file)
|
||||
assert raw.tags["\xa9grp"] == ["Test Group"]
|
||||
assert bool(raw.tags["cpil"])
|
||||
assert list(raw.tags["tmpo"]) == [128]
|
||||
|
||||
def test_flac_extras_use_vorbis_comments(self, flac_file):
|
||||
tagging.write_tags(flac_file, EXTRAS)
|
||||
raw = mutagen.File(flac_file)
|
||||
assert raw["grouping"] == ["Test Group"]
|
||||
assert raw["compilation"] == ["1"]
|
||||
assert raw["bpm"] == ["128"]
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
||||
def test_clearing_extras_removes_them(self, fixture, request):
|
||||
path = request.getfixturevalue(fixture)
|
||||
tagging.write_tags(path, EXTRAS)
|
||||
tagging.write_tags(path, {"grouping": "", "compilation": False,
|
||||
"bpm": 0})
|
||||
easy = mutagen.File(path, easy=True)
|
||||
for key in ("grouping", "compilation", "bpm"):
|
||||
assert not easy.tags.get(key), key
|
||||
|
||||
|
||||
# ---- coalesced browser rebuild ----
|
||||
|
||||
class TestCoalescedBrowserRebuild:
|
||||
def _view_with_counter(self, qapp, tmp_path, monkeypatch, tracks):
|
||||
from lintunes.gui.library_view import LibraryView
|
||||
manager = _manager(tmp_path, tracks)
|
||||
view = LibraryView(manager)
|
||||
rebuilds = []
|
||||
# _rebuild_browser looks methods up on self, so patching one of them
|
||||
# counts rebuilds without disturbing the timer wiring.
|
||||
monkeypatch.setattr(view, "_populate_genres",
|
||||
lambda: rebuilds.append(1))
|
||||
return manager, view, rebuilds
|
||||
|
||||
def test_multi_edit_rebuilds_once(self, qapp, tmp_path, monkeypatch):
|
||||
tracks = [Track(track_id=i, name=f"T{i}", artist="Old")
|
||||
for i in (1, 2, 3)]
|
||||
manager, view, rebuilds = self._view_with_counter(
|
||||
qapp, tmp_path, monkeypatch, tracks)
|
||||
manager.edit_tracks_fields([1, 2, 3], {"artist": "New"})
|
||||
assert rebuilds == [] # deferred to the event loop
|
||||
qapp.processEvents()
|
||||
assert len(rebuilds) == 1
|
||||
assert all(t.artist == "New" for t in tracks)
|
||||
|
||||
def test_single_edit_still_rebuilds(self, qapp, tmp_path, monkeypatch):
|
||||
track = Track(track_id=1, name="T", genre="Pop")
|
||||
manager, view, rebuilds = self._view_with_counter(
|
||||
qapp, tmp_path, monkeypatch, [track])
|
||||
manager.edit_track_fields(1, {"genre": "Rock"})
|
||||
qapp.processEvents()
|
||||
assert len(rebuilds) == 1
|
||||
|
||||
def test_non_browser_edit_skips_rebuild(self, qapp, tmp_path, monkeypatch):
|
||||
track = Track(track_id=1, name="T")
|
||||
manager, view, rebuilds = self._view_with_counter(
|
||||
qapp, tmp_path, monkeypatch, [track])
|
||||
manager.edit_track_fields(1, {"name": "Renamed"})
|
||||
qapp.processEvents()
|
||||
assert rebuilds == []
|
||||
|
||||
|
||||
# ---- perf logging ----
|
||||
|
||||
def test_timed_logs_duration(caplog):
|
||||
with caplog.at_level(logging.INFO, logger="lintunes.perf"):
|
||||
with timed("unit test %s", "label"):
|
||||
pass
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any("unit test label" in m and "ms" in m for m in messages)
|
||||
Reference in New Issue
Block a user