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