Files
lintunes/tests/test_round19.py
trav 502a6106f4 v0.1.0: version in the status bar + one-click self-update via git pull
__version__ in lintunes/__init__.py (setup.py regex-reads it) shows
bottom-left via the new VersionButton; the tooltip carries the git short
hash so machines on the same version but different commits are
distinguishable. The new Updater fetches upstream 10s after launch and
every 4h on daemon threads (lastfm.py pattern); commits behind put a *
on the button, and clicking it confirms, runs `git pull --ff-only`,
quits cleanly (library flush + player shutdown), and re-execs
`python -m lintunes.main` on the new code. Positional file args are
stripped from the re-exec so they don't re-import as duplicates. Pull
failures surface the git error: line in the status bar and leave the
running app untouched; outside a git checkout the button is a plain
label.

Also fixes a Round-5 regression found while verifying: the totals label
was a permanent status-bar widget with stretch=1, which squeezed the
transient-message area to zero width — every showMessage (scrobbles,
tag-write errors, import status) has been invisible since. Both
readouts are now non-permanent widgets, so a transient message
temporarily replaces them and they return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00

196 lines
6.0 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(button):
btn, fake = button
assert btn.text() == f"v{__version__}"
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__