__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>
196 lines
6.0 KiB
Python
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__
|