diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index e827a30..7cfc9c9 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -239,8 +239,8 @@ DEFAULT_CONFIG = { "whispercpp_gpu_device": None, # None = auto-select discrete GPU; int = specific device index }, "updates": { - # "stable" follows GitHub /releases/latest; "nightly" follows nightly-YYYY-MM-DD tags. - "channel": "stable", + # Background checks control the tray entry and desktop notification. + "check_automatically": True, # Tag last announced via desktop notification (avoids re-notifying every 6h). "last_notified_version": "", }, diff --git a/src/vocalinux/ui/settings_about.py b/src/vocalinux/ui/settings_about.py index 59f74d6..e387a7c 100644 --- a/src/vocalinux/ui/settings_about.py +++ b/src/vocalinux/ui/settings_about.py @@ -9,7 +9,11 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa: E402 -from ..utils.update_checker import is_trusted_release_url, normalize_channel +from ..utils.update_checker import ( + DEFAULT_CHECK_AUTOMATICALLY, + build_channel, + is_trusted_release_url, +) from ..version import __copyright__, __url__, __version__ logger = logging.getLogger(__name__) @@ -283,6 +287,7 @@ class AboutSectionMixin(Gtk.Dialog): about_tab = getattr(self, "about_tab") config_manager = getattr(self, "config_manager") on_check_updates_clicked = getattr(self, "_on_check_updates_clicked") + on_update_checks_toggled = getattr(self, "_on_update_checks_toggled") on_update_channel_changed = getattr(self, "_on_update_channel_changed") on_open_release_clicked = getattr(self, "_on_open_release_clicked") @@ -291,7 +296,6 @@ class AboutSectionMixin(Gtk.Dialog): from ..utils.resource_manager import ResourceManager from .settings_dialog import ( _CONTROL_WIDTH, - DEFAULT_UPDATE_CHANNEL, PreferenceRow, PreferencesGroup, _combo_chrome, @@ -427,12 +431,33 @@ class AboutSectionMixin(Gtk.Dialog): ) about_tab.pack_start(talk_group, False, False, 0) + build = build_channel() updates_group = PreferencesGroup( title="Updates", - description="Stable for numbered releases. Nightly for dated development builds.", + description=( + f"This is a {build} build, so background checks only look for " + f"{build} releases. Pick a channel below to look one up by hand." + ), keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), ) + self.update_checks_switch = Gtk.Switch() + self.update_checks_switch.set_active( + config_manager.get_bool("updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY) + ) + self.update_checks_switch.connect("state-set", on_update_checks_toggled) + updates_group.add_row( + PreferenceRow( + title="Check for updates automatically", + subtitle=( + "Look for new releases in the background and show a tray entry and a " + "notification when one is out" + ), + widget=self.update_checks_switch, + keywords=("update", "notification", "automatic", "check", "disable"), + ) + ) + self.update_channel_combo = Gtk.ComboBoxText() self.update_channel_combo.append("stable", "Stable") self.update_channel_combo.append("nightly", "Nightly") @@ -443,10 +468,7 @@ class AboutSectionMixin(Gtk.Dialog): "nightly-YYYY-MM-DD build." ), ) - saved_channel = normalize_channel( - config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL) - ) - self.update_channel_combo.set_active_id(saved_channel) + self.update_channel_combo.set_active_id(build_channel()) self.check_updates_btn = Gtk.Button(label="Check") self.check_updates_btn.get_style_context().add_class("suffix-button") diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 710113b..ea64f1d 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -74,8 +74,10 @@ from ..utils.system_language import ( # noqa: E402 language_for_active_layout, ) from ..utils.update_checker import ( # noqa: E402 + DEFAULT_CHECK_AUTOMATICALLY, DEFAULT_UPDATE_CHANNEL, ReleaseInfo, + build_channel, fetch_latest_release, format_release_notes, is_trusted_release_url, @@ -2452,12 +2454,13 @@ class SettingsDialog(AboutSectionMixin): initial_page: Optional[str] = None, pending_update: Optional[ReleaseInfo] = None, update_status_callback: Optional[Callable[..., Any]] = None, + update_checks_callback: Optional[Callable[[bool], None]] = None, overlay_enabled_callback: Optional[Callable[[bool], None]] = None, hotkey_listener_update_callback: Optional[Callable[[], None]] = None, language_shortcuts_update_callback: Optional[Callable[[], None]] = None, history_update_callback: Optional[Callable[[], None]] = None, history_clear_callback: Optional[Callable[[], None]] = None, - ): + ) -> None: super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) # Force window decorations (title-bar close) on all WMs. An in-window # Close button also lives in the sidebar footer so the dialog always @@ -2470,6 +2473,7 @@ class SettingsDialog(AboutSectionMixin): self.dictionary_manager = getattr(speech_engine, "dictionary_manager", None) self.shortcut_update_callback = shortcut_update_callback self.update_status_callback = update_status_callback + self.update_checks_callback = update_checks_callback self.overlay_enabled_callback = overlay_enabled_callback self.hotkey_listener_update_callback = hotkey_listener_update_callback self.language_shortcuts_update_callback = language_shortcuts_update_callback @@ -2836,14 +2840,21 @@ class SettingsDialog(AboutSectionMixin): if row is not None: self.settings_stack.set_visible_child_name(row.page_name) - def _on_settings_page_changed(self, stack, pspec): + def _on_settings_page_changed(self, stack: Gtk.Stack, pspec: Any) -> None: """Persist deferred edits when the visible settings page changes.""" visible = stack.get_visible_child_name() if visible != "advanced": self._flush_advanced_prompt_if_dirty() if visible == "about" and not self._update_auto_checked: self._update_auto_checked = True - self._start_update_check() + if self.config_manager.get_bool( + "updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY + ): + self._start_update_check() + else: + self.update_status_row.set_subtitle( + "Automatic checks are off. Press Check to look now." + ) def _open_web_url(self, url: str) -> None: """Open a trusted project URL in the user's default browser.""" @@ -6345,13 +6356,25 @@ class SettingsDialog(AboutSectionMixin): channel_id = self.update_channel_combo.get_active_id() return normalize_channel(channel_id or DEFAULT_UPDATE_CHANNEL) - def _on_update_channel_changed(self, widget): - """Persist channel choice and refresh the update check.""" + def _on_update_checks_toggled(self, widget: Gtk.Switch, state: bool) -> bool: + """Persist automatic update-check preference and notify the tray.""" + if _handlers_suppressed(self): + return False + + enabled = bool(state) + self.config_manager.set("updates", "check_automatically", enabled) + self.config_manager.save_settings() + if self.update_checks_callback is not None: + try: + self.update_checks_callback(enabled) + except Exception: + logger.error("Automatic update checks callback failed", exc_info=True) + return False + + def _on_update_channel_changed(self, widget: Gtk.ComboBoxText) -> None: + """Refresh the manual update lookup for the selected channel.""" if _handlers_suppressed(self): return - channel = self._current_update_channel() - self.config_manager.set("updates", "channel", channel) - self.config_manager.save_settings() self._start_update_check() def _on_check_updates_clicked(self, widget): @@ -6399,10 +6422,10 @@ class SettingsDialog(AboutSectionMixin): def _apply_update_check_result( self, - release, + release: Optional[ReleaseInfo], channel: Optional[str] = None, generation: Optional[int] = None, - ): + ) -> bool: """Update About-page UI from a release lookup result.""" if not self._dialog_is_alive(): return False @@ -6467,7 +6490,13 @@ class SettingsDialog(AboutSectionMixin): self._set_about_update_badge(False) # Keep the tray menu in sync with an About-page check (no extra notification). - if self.update_status_callback is not None: + if ( + channel == build_channel() + and self.config_manager.get_bool( + "updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY + ) + and self.update_status_callback is not None + ): try: self.update_status_callback(update_available, release if update_available else None) except Exception: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index a373e84..b26096b 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -49,7 +49,7 @@ from ..speech_recognition.diarization import ( from ..suspend_handler import SuspendHandler from ..utils.host_process import host_env from ..utils.resource_manager import ResourceManager -from ..utils.update_checker import ReleaseInfo +from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo from ..utils.update_monitor import UpdateMonitor from ..utils.whispercpp_model_info import TDRZ_MODEL, WHISPERCPP_MODEL_INFO, is_model_downloaded from . import notifications @@ -288,7 +288,7 @@ class TrayIndicator: # Background GitHub update checks (tray menu + optional notification) self._update_monitor = UpdateMonitor( - get_channel=self._get_update_channel, + get_enabled=self._update_checks_enabled, on_result=self._on_update_check_result, ) self._update_monitor.start() @@ -1343,6 +1343,7 @@ class TrayIndicator: update_status_callback=lambda available, release: self._apply_update_status( available, release, notify=False ), + update_checks_callback=self._on_update_checks_toggled, overlay_enabled_callback=self.set_overlay_enabled, hotkey_listener_update_callback=self._setup_keyboard_shortcuts, language_shortcuts_update_callback=self.refresh_language_shortcuts, @@ -1364,9 +1365,18 @@ class TrayIndicator: if self._settings_dialog is dialog: self._settings_dialog = None - def _get_update_channel(self) -> str: - """Return the configured release channel for background update checks.""" - return self.config_manager.get_str("updates", "channel", "stable") + def _update_checks_enabled(self) -> bool: + """Return whether background update checks are enabled.""" + return self.config_manager.get_bool( + "updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY + ) + + def _on_update_checks_toggled(self, enabled: bool) -> None: + """Update tray state when automatic update checks change.""" + if not enabled: + self._apply_update_status(False, None, notify=False) + else: + self._update_monitor.check_now() def _on_update_check_result(self, available: bool, release: Optional[ReleaseInfo]): """Handle a background update-check result (already on the GLib main loop).""" @@ -1403,6 +1413,8 @@ class TrayIndicator: def _maybe_notify_update(self, release: ReleaseInfo) -> None: """Send one desktop notification per new version when notifications are on.""" + if not self._update_checks_enabled(): + return if not self.config_manager.get_bool("ui", "show_notifications", True): return if self.config_manager.get_str("updates", "last_notified_version", "") == release.tag_name: diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py index ea47b07..4d12804 100644 --- a/src/vocalinux/utils/update_checker.py +++ b/src/vocalinux/utils/update_checker.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Optional from urllib.parse import urlparse +from .. import version as _version_module from ..version import __url__ from .proxy import requests_proxies @@ -13,6 +14,7 @@ logger = logging.getLogger(__name__) UPDATE_CHANNELS = ("stable", "nightly") DEFAULT_UPDATE_CHANNEL = "stable" +DEFAULT_CHECK_AUTOMATICALLY = True _GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$") _PRE_RELEASE_SUFFIX_RE = re.compile( @@ -111,6 +113,13 @@ def _nightly_date(tag_or_version: str) -> Optional[str]: return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}" +def build_channel(version: Optional[str] = None) -> str: + """Return the channel for this build; nightly builds carry .devYYYYMMDD from CI.""" + if version is None: + version = _version_module.__version__ + return "nightly" if _nightly_date(version) is not None else "stable" + + def is_update_available( current: str, latest: ReleaseInfo, channel: str = DEFAULT_UPDATE_CHANNEL ) -> bool: diff --git a/src/vocalinux/utils/update_monitor.py b/src/vocalinux/utils/update_monitor.py index bd40295..c43a826 100644 --- a/src/vocalinux/utils/update_monitor.py +++ b/src/vocalinux/utils/update_monitor.py @@ -12,8 +12,10 @@ from typing import Callable, Optional from ..version import __version__ from .update_checker import ( + DEFAULT_CHECK_AUTOMATICALLY, DEFAULT_UPDATE_CHANNEL, ReleaseInfo, + build_channel, fetch_latest_release, is_update_available, normalize_channel, @@ -31,8 +33,11 @@ class UpdateMonitor: Args: get_channel: Returns ``stable`` / ``nightly`` (re-read each check). + When None, the running build determines the channel. on_result: ``on_result(available, release)`` on the GLib main loop. ``release`` is set only when ``available`` is True. + get_enabled: Returns whether automatic update checks are enabled. + When None, automatic checks are always enabled. startup_delay_seconds: Delay before the first check. check_interval_seconds: Delay between subsequent checks. use_glib: Schedule via GLib (False = tests drive ``_run_check``). @@ -40,14 +45,16 @@ class UpdateMonitor: def __init__( self, - get_channel: Callable[[], str], + get_channel: Optional[Callable[[], str]] = None, on_result: Optional[Callable[[bool, Optional[ReleaseInfo]], None]] = None, + get_enabled: Optional[Callable[[], bool]] = None, startup_delay_seconds: float = DEFAULT_STARTUP_DELAY_SECONDS, check_interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS, use_glib: bool = True, - ): + ) -> None: self._get_channel = get_channel self._on_result = on_result + self._get_enabled = get_enabled self._startup_delay = max(0.0, float(startup_delay_seconds)) self._check_interval = max(60.0, float(check_interval_seconds)) self._use_glib = use_glib @@ -103,7 +110,7 @@ class UpdateMonitor: pass self._timeout_id = None - def _on_timer(self): + def _on_timer(self) -> bool: self._timeout_id = None if self._running: self._run_check() @@ -112,6 +119,11 @@ class UpdateMonitor: def _run_check(self) -> None: if self._check_in_progress: return + if not self._is_enabled(): + logger.debug("Automatic update checks are off") + if self._running: + self._schedule(self._check_interval) + return self._check_in_progress = True self._generation += 1 generation = self._generation @@ -145,13 +157,32 @@ class UpdateMonitor: ) def _current_channel(self) -> str: - """Read and normalize the live channel preference.""" + """Read the live channel preference or infer the running build's channel.""" try: + if self._get_channel is None: + return build_channel(__version__) return normalize_channel(self._get_channel()) except Exception: logger.error("Failed to read update channel", exc_info=True) return DEFAULT_UPDATE_CHANNEL + def _is_enabled(self) -> bool: + """Return whether automatic update checks are enabled.""" + if self._get_enabled is None: + return True + try: + return bool(self._get_enabled()) + except Exception: + logger.error("Failed to read automatic update setting", exc_info=True) + return DEFAULT_CHECK_AUTOMATICALLY + + def check_now(self) -> None: + """Run an update check now when the monitor is active and enabled.""" + if not self._running or self._check_in_progress or not self._is_enabled(): + return + self._cancel_timeout() + self._run_check() + def _finish_check( self, generation: int, @@ -165,6 +196,11 @@ class UpdateMonitor: self._check_in_progress = False if not self._running or generation != self._generation: return False + if callback and not self._is_enabled(): + logger.debug("Automatic update checks are off") + if self._running: + self._schedule(self._check_interval) + return False # Drop results from an earlier channel (About-page checks do the same). if callback: current_channel = self._current_channel() diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 75a56e1..3e53ee1 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -77,15 +77,24 @@ class TestConfigManager(unittest.TestCase): self.logger_patcher.stop() self.temp_dir.cleanup() - def test_init_default_config(self): + def test_init_default_config(self) -> None: """Test initialization with default configuration.""" config_manager = ConfigManager() self.assertEqual(config_manager.config, DEFAULT_CONFIG) + self.assertTrue(DEFAULT_CONFIG["updates"]["check_automatically"]) self.mock_logger.info.assert_called_with( f"Config file not found at {self.temp_config_file}. Using defaults." ) self.assertTrue(os.path.exists(self.temp_config_dir)) + def test_old_update_channel_config_loads_with_automatic_checks_enabled(self) -> None: + self._write_config({"updates": {"channel": "nightly"}}) + + config_manager = ConfigManager() + + self.assertTrue(config_manager.get_bool("updates", "check_automatically")) + self.assertEqual(config_manager.config["updates"]["channel"], "nightly") + def test_paste_long_text_defaults_to_off_and_can_be_changed(self) -> None: config_manager = ConfigManager() self.assertFalse(DEFAULT_CONFIG["text_injection"]["paste_long_text"]) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index db49d21..a98aaeb 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1328,7 +1328,7 @@ class TestAboutPage(unittest.TestCase): cls.source += cls.about_source cls.about = cls.about_source.split("def _build_about_section")[1].split("\n def ")[0] - def test_three_groups_keep_updater(self): + def test_three_groups_keep_updater(self) -> None: self.assertIn('title="Vocalinux"', self.about) self.assertIn('title="Part of VocaHQ"', self.about) self.assertIn('title="Talk to us"', self.about) @@ -1336,6 +1336,7 @@ class TestAboutPage(unittest.TestCase): self.assertIn("What's New", self.about) self.assertIn("_on_check_updates_clicked", self.about) self.assertIn("self.update_channel_combo", self.about) + self.assertIn("self.update_checks_switch", self.about) def test_product_status_and_honest_network_copy(self): self.assertIn("Available now on Linux (X11 and Wayland)", self.about) @@ -1582,6 +1583,68 @@ class TestAboutPage(unittest.TestCase): self.assertIn("_FAMILY_ICON_PX", self.source) +class TestUpdatePreferences(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + source_dir = os.path.join(os.path.dirname(__file__), "..", "src", "vocalinux", "ui") + with open(os.path.join(source_dir, "settings_dialog.py"), encoding="utf-8") as source_file: + cls.settings_source = source_file.read() + with open(os.path.join(source_dir, "settings_about.py"), encoding="utf-8") as source_file: + cls.about_source = source_file.read() + + @classmethod + def _method_source(cls, source: str, name: str) -> str: + start = source.index(f" def {name}(") + end = source.find("\n def ", start + 1) + return source[start:end] if end != -1 else source[start:] + + def test_update_checks_toggle_persists_and_calls_callback(self) -> None: + method = self._method_source(self.settings_source, "_on_update_checks_toggled") + + self.assertIn('self.config_manager.set("updates", "check_automatically", enabled)', method) + self.assertIn("self.config_manager.save_settings()", method) + self.assertIn("self.update_checks_callback(enabled)", method) + self.assertIn( + 'logger.error("Automatic update checks callback failed", exc_info=True)', method + ) + + def test_channel_change_runs_manual_lookup_without_persisting(self) -> None: + method = self._method_source(self.settings_source, "_on_update_channel_changed") + + self.assertIn("self._start_update_check()", method) + self.assertNotIn('self.config_manager.set("updates", "channel"', method) + self.assertNotIn("self.config_manager.save_settings()", method) + + def test_manual_about_check_syncs_tray_only_for_enabled_build_channel(self) -> None: + method = self._method_source(self.settings_source, "_apply_update_check_result") + tray_sync = method.split("# Keep the tray menu in sync", 1)[1] + guard = tray_sync.split("):", 1)[0] + + self.assertIn("channel == build_channel()", guard) + self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', guard) + self.assertIn("self.update_status_callback(update_available", tray_sync) + + def test_about_auto_check_is_skipped_when_disabled(self) -> None: + method = self._method_source(self.settings_source, "_on_settings_page_changed") + + self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', method) + self.assertIn("self._start_update_check()", method) + self.assertIn("Automatic checks are off. Press Check to look now.", method) + + def test_update_switch_loads_state_before_connecting(self) -> None: + switch_start = self.about_source.index("self.update_checks_switch = Gtk.Switch()") + active_position = self.about_source.index( + "self.update_checks_switch.set_active(", switch_start + ) + connect_position = self.about_source.index( + 'self.update_checks_switch.connect("state-set", on_update_checks_toggled)', + switch_start, + ) + + self.assertLess(active_position, connect_position) + self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', self.about_source) + + class TestAboutInk(unittest.TestCase): """Ink for About marks follows light vs dark surfaces.""" diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 866a437..ebc821e 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -325,7 +325,7 @@ class TestTrayIndicator(unittest.TestCase): self.assertEqual(kwargs.get("initial_page"), "about") mock_dialog_instance.show.assert_called_once() - def test_update_available_shows_menu_and_opens_about(self): + def test_update_available_shows_menu_and_opens_about(self) -> None: """Background update check reveals tray item and opens About with release.""" from vocalinux.utils.update_checker import ReleaseInfo @@ -375,6 +375,32 @@ class TestTrayIndicator(unittest.TestCase): self.assertEqual(kwargs.get("initial_page"), "about") self.assertEqual(kwargs.get("pending_update"), release) self.assertIn("update_status_callback", kwargs) + self.assertIn("update_checks_callback", kwargs) + + def test_update_monitor_follows_build_without_channel_getter(self) -> None: + monitor = self.tray_indicator._update_monitor + + self.assertIsNone(monitor._get_channel) + self.assertIs(monitor._get_enabled.__self__, self.tray_indicator) + self.assertIs( + monitor._get_enabled.__func__, + type(self.tray_indicator)._update_checks_enabled, + ) + + def test_automatic_update_checks_toggle_updates_tray(self) -> None: + menu_item = MagicMock() + self.tray_indicator._update_menu_item = menu_item + self.tray_indicator.menu = MagicMock() + self.tray_indicator._pending_update = MagicMock() + + self.tray_indicator._on_update_checks_toggled(False) + + self.assertIsNone(self.tray_indicator._pending_update) + menu_item.hide.assert_called_once() + + self.tray_indicator._update_monitor.check_now = MagicMock() + self.tray_indicator._on_update_checks_toggled(True) + self.tray_indicator._update_monitor.check_now.assert_called_once_with() def test_update_notification_not_marked_when_notify_send_missing(self): """Do not record last_notified_version if notify-send cannot be spawned.""" @@ -407,6 +433,33 @@ class TestTrayIndicator(unittest.TestCase): self.assertEqual(recorded_sets, []) + def test_disabled_automatic_checks_do_not_send_notification(self) -> None: + from vocalinux.utils.update_checker import ReleaseInfo + + release = ReleaseInfo( + tag_name="v0.99.0", + version="0.99.0", + name="v0.99.0", + html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.99.0", + body="Notes", + published_at="2026-08-06T00:00:00Z", + prerelease=False, + channel="stable", + ) + + def get_bool(section: str, key: str, default: bool = False) -> bool: + if section == "updates" and key == "check_automatically": + return False + return default + + self.tray_indicator.config_manager = self.mock_config_manager + self.mock_config_manager.get_bool.side_effect = get_bool + + with patch("subprocess.Popen") as mock_popen: + self.tray_indicator._maybe_notify_update(release) + + mock_popen.assert_not_called() + def test_update_notification_persists_after_successful_spawn(self): """Record last_notified_version only after notify-send is spawned.""" from vocalinux.utils.update_checker import ReleaseInfo diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index 09591e3..694a851 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from vocalinux.utils.update_checker import ( ReleaseInfo, + build_channel, fetch_latest_release, format_release_notes, is_newer_version, @@ -42,6 +43,25 @@ class TestNormalizeAndCompare: class TestChannels: + def test_build_channel_from_version(self) -> None: + cases = ( + ("0.18.3", "stable"), + ("v0.18.3", "stable"), + ("0.18.3+flatpak", "stable"), + ("0.14.0-beta", "stable"), + ("0.18.3.dev20261020+4799f06", "nightly"), + ("0.18.3.dev20261020", "nightly"), + ) + for version, expected in cases: + assert build_channel(version) == expected + + def test_build_channel_reads_version_module_at_call_time(self) -> None: + with patch( + "vocalinux.version.__version__", + "0.18.3.dev20261020+4799f06", + ): + assert build_channel() == "nightly" + def test_normalize_channel(self): assert normalize_channel("stable") == "stable" assert normalize_channel("NIGHTLY") == "nightly" diff --git a/tests/test_update_monitor.py b/tests/test_update_monitor.py index 812a368..f832810 100644 --- a/tests/test_update_monitor.py +++ b/tests/test_update_monitor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch +import pytest + from vocalinux.utils.update_checker import ReleaseInfo from vocalinux.utils.update_monitor import UpdateMonitor @@ -20,6 +22,141 @@ def _release(tag: str = "v0.99.0") -> ReleaseInfo: 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",