Use the running build channel for background checks and add a preference to disable them. Fixes #977
271 lines
9.1 KiB
Python
271 lines
9.1 KiB
Python
"""Tests for the background GitHub update monitor."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from vocalinux.utils.update_checker import ReleaseInfo
|
|
from vocalinux.utils.update_monitor import UpdateMonitor
|
|
|
|
|
|
def _release(tag: str = "v0.99.0") -> ReleaseInfo:
|
|
return ReleaseInfo(
|
|
tag_name=tag,
|
|
version=tag.lstrip("v"),
|
|
name=tag,
|
|
html_url=f"https://github.com/VocaHQ/vocalinux/releases/tag/{tag}",
|
|
body="Notes",
|
|
published_at="2026-08-06T00:00:00Z",
|
|
prerelease=False,
|
|
channel="stable",
|
|
)
|
|
|
|
|
|
class TestUpdateMonitor:
|
|
def test_default_channel_follows_running_build(self) -> None:
|
|
cases = (
|
|
("0.18.3", "stable", _release("v0.99.0")),
|
|
(
|
|
"0.18.3.dev20261019+abc1234",
|
|
"nightly",
|
|
_release("nightly-2026-10-20"),
|
|
),
|
|
)
|
|
for version, expected_channel, release in cases:
|
|
on_result = MagicMock()
|
|
monitor = UpdateMonitor(
|
|
get_channel=None,
|
|
on_result=on_result,
|
|
use_glib=False,
|
|
)
|
|
monitor.start()
|
|
thread_factory = MagicMock()
|
|
|
|
def start_inline() -> None:
|
|
call_args = thread_factory.call_args.kwargs
|
|
call_args["target"](*call_args["args"])
|
|
|
|
thread_factory.return_value.start.side_effect = start_inline
|
|
with (
|
|
patch("vocalinux.utils.update_monitor.__version__", version),
|
|
patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=release,
|
|
) as fetch_mock,
|
|
patch("vocalinux.utils.update_monitor.threading.Thread", thread_factory),
|
|
):
|
|
monitor._run_check()
|
|
|
|
fetch_mock.assert_called_once_with(channel=expected_channel)
|
|
on_result.assert_called_once_with(True, release)
|
|
monitor.stop()
|
|
|
|
def test_disabled_check_does_not_start_worker_or_callback(self) -> None:
|
|
on_result = MagicMock()
|
|
monitor = UpdateMonitor(
|
|
get_enabled=lambda: False,
|
|
on_result=on_result,
|
|
use_glib=False,
|
|
)
|
|
monitor.start()
|
|
|
|
with (
|
|
patch("vocalinux.utils.update_monitor.fetch_latest_release") as fetch_mock,
|
|
patch("vocalinux.utils.update_monitor.threading.Thread") as thread_mock,
|
|
patch.object(monitor, "_schedule") as schedule_mock,
|
|
):
|
|
monitor._run_check()
|
|
|
|
fetch_mock.assert_not_called()
|
|
thread_mock.assert_not_called()
|
|
on_result.assert_not_called()
|
|
schedule_mock.assert_called_once_with(monitor._check_interval)
|
|
assert not monitor._check_in_progress
|
|
monitor.stop()
|
|
|
|
def test_disabled_after_fetch_drops_result(self) -> None:
|
|
enabled = {"value": True}
|
|
on_result = MagicMock()
|
|
monitor = UpdateMonitor(
|
|
get_enabled=lambda: enabled["value"],
|
|
on_result=on_result,
|
|
use_glib=False,
|
|
)
|
|
monitor.start()
|
|
monitor._check_in_progress = True
|
|
enabled["value"] = False
|
|
|
|
with patch.object(monitor, "_schedule") as schedule_mock:
|
|
monitor._finish_check(
|
|
monitor._generation,
|
|
"stable",
|
|
callback=True,
|
|
available=True,
|
|
release=_release(),
|
|
)
|
|
|
|
on_result.assert_not_called()
|
|
schedule_mock.assert_called_once_with(monitor._check_interval)
|
|
assert not monitor._check_in_progress
|
|
monitor.stop()
|
|
|
|
def test_enabled_getter_error_defaults_to_enabled(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
def broken_getter() -> bool:
|
|
raise RuntimeError("config unavailable")
|
|
|
|
monitor = UpdateMonitor(get_enabled=broken_getter, use_glib=False)
|
|
monitor.start()
|
|
|
|
with patch("vocalinux.utils.update_monitor.threading.Thread") as thread_mock:
|
|
monitor._run_check()
|
|
|
|
thread_mock.assert_called_once()
|
|
thread_mock.return_value.start.assert_called_once()
|
|
assert "Failed to read automatic update setting" in caplog.text
|
|
assert caplog.records[0].exc_info is not None
|
|
monitor.stop()
|
|
|
|
def test_check_now_requires_running_and_enabled_monitor(self) -> None:
|
|
stopped = UpdateMonitor(use_glib=False)
|
|
with patch.object(stopped, "_run_check") as run_check:
|
|
stopped.check_now()
|
|
run_check.assert_not_called()
|
|
|
|
disabled = UpdateMonitor(get_enabled=lambda: False, use_glib=False)
|
|
disabled.start()
|
|
with patch.object(disabled, "_run_check") as run_check:
|
|
disabled.check_now()
|
|
run_check.assert_not_called()
|
|
disabled.stop()
|
|
|
|
running = UpdateMonitor(use_glib=False)
|
|
running.start()
|
|
running._check_in_progress = True
|
|
running._timeout_id = 456
|
|
with patch.object(running, "_run_check") as run_check:
|
|
running.check_now()
|
|
run_check.assert_not_called()
|
|
assert running._timeout_id == 456
|
|
|
|
running._check_in_progress = False
|
|
running._timeout_id = 123
|
|
with patch.object(running, "_run_check") as run_check:
|
|
running.check_now()
|
|
run_check.assert_called_once_with()
|
|
assert running._timeout_id is None
|
|
running.stop()
|
|
|
|
def test_start_is_idempotent(self):
|
|
monitor = UpdateMonitor(
|
|
get_channel=lambda: "stable",
|
|
on_result=MagicMock(),
|
|
use_glib=False,
|
|
)
|
|
monitor.start()
|
|
monitor.start()
|
|
assert monitor._running
|
|
monitor.stop()
|
|
assert not monitor._running
|
|
|
|
def test_reports_available_update(self):
|
|
on_result = MagicMock()
|
|
release = _release("v0.99.0")
|
|
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
|
monitor.start()
|
|
|
|
with (
|
|
patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=release,
|
|
),
|
|
patch("vocalinux.utils.update_monitor.__version__", "0.15.0"),
|
|
):
|
|
monitor._worker("stable", "0.15.0", monitor._generation)
|
|
|
|
on_result.assert_called_once_with(True, release)
|
|
monitor.stop()
|
|
|
|
def test_reports_up_to_date(self):
|
|
on_result = MagicMock()
|
|
release = _release("v0.15.0")
|
|
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
|
monitor.start()
|
|
|
|
with patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=release,
|
|
):
|
|
monitor._worker("stable", "0.15.0", monitor._generation)
|
|
|
|
on_result.assert_called_once_with(False, None)
|
|
monitor.stop()
|
|
|
|
def test_failed_fetch_keeps_prior_callback_state(self):
|
|
on_result = MagicMock()
|
|
release = _release("v0.99.0")
|
|
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
|
monitor.start()
|
|
|
|
with patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=release,
|
|
):
|
|
monitor._worker("stable", "0.15.0", monitor._generation)
|
|
on_result.reset_mock()
|
|
|
|
with patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=None,
|
|
):
|
|
monitor._worker("stable", "0.15.0", monitor._generation)
|
|
|
|
on_result.assert_not_called()
|
|
monitor.stop()
|
|
|
|
def test_stale_generation_is_ignored(self):
|
|
on_result = MagicMock()
|
|
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
|
monitor.start()
|
|
stale = monitor._generation
|
|
monitor._generation += 1
|
|
|
|
with patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=_release(),
|
|
):
|
|
monitor._worker("stable", "0.15.0", stale)
|
|
|
|
on_result.assert_not_called()
|
|
monitor.stop()
|
|
|
|
def test_stale_channel_result_is_ignored_and_rechecks(self):
|
|
"""A result for an earlier channel must not update the tray."""
|
|
on_result = MagicMock()
|
|
channel = {"value": "stable"}
|
|
monitor = UpdateMonitor(
|
|
get_channel=lambda: channel["value"],
|
|
on_result=on_result,
|
|
use_glib=False,
|
|
)
|
|
monitor.start()
|
|
generation = monitor._generation
|
|
|
|
channel["value"] = "nightly"
|
|
with (
|
|
patch(
|
|
"vocalinux.utils.update_monitor.fetch_latest_release",
|
|
return_value=_release(),
|
|
) as fetch_mock,
|
|
patch.object(monitor, "_run_check") as run_check,
|
|
):
|
|
monitor._worker("stable", "0.15.0", generation)
|
|
|
|
on_result.assert_not_called()
|
|
run_check.assert_called_once_with()
|
|
# Worker itself should not have re-fetched for nightly — only the
|
|
# deferred _run_check path does that.
|
|
fetch_mock.assert_called_once_with(channel="stable")
|
|
monitor.stop()
|