Files
lintunes/tests/test_round20.py
trav 332b47a018 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>
2026-07-03 18:25:56 -04:00

131 lines
5.0 KiB
Python

"""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)