diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 9c57187..5414642 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -1,8 +1,8 @@ """Settings window for the Synodic Client application. Provides a single-page window with grouped sections for all application -settings. Quick-access items (Channel, Check for Updates) remain in the -tray menu; the full set is available here. +settings including update-channel selection and a manual *Check for +Updates* button with inline status feedback. """ import logging @@ -52,6 +52,9 @@ class SettingsWindow(QMainWindow): settings_changed = Signal() """Emitted whenever a setting is changed and persisted.""" + check_updates_requested = Signal() + """Emitted when the user clicks the *Check for Updates* button.""" + def __init__( self, config: GlobalConfiguration, @@ -155,6 +158,16 @@ def _build_updates_section(self) -> CardFrame: self._detect_updates_check.toggled.connect(self._on_detect_updates_changed) content.addWidget(self._detect_updates_check) + # Check for Updates + row = QHBoxLayout() + self._check_updates_btn = QPushButton('Check for Updates\u2026') + self._check_updates_btn.clicked.connect(self._on_check_updates_clicked) + row.addWidget(self._check_updates_btn) + self._update_status_label = QLabel('') + row.addWidget(self._update_status_label) + row.addStretch() + content.addLayout(row) + return card def _build_startup_section(self) -> CardFrame: @@ -209,6 +222,14 @@ def sync_from_config(self) -> None: self._detect_updates_check.setChecked(config.detect_updates) self._auto_start_check.setChecked(is_startup_registered()) + def set_update_status(self, text: str) -> None: + """Set the inline status text next to the *Check for Updates* button.""" + self._update_status_label.setText(text) + + def reset_check_updates_button(self) -> None: + """Re-enable the *Check for Updates* button after a check completes.""" + self._check_updates_btn.setEnabled(True) + def show(self) -> None: """Sync controls from config, then show the window.""" self.sync_from_config() @@ -235,6 +256,7 @@ def _block_signals(self) -> Iterator[None]: self._tool_update_spin, self._detect_updates_check, self._auto_start_check, + self._check_updates_btn, ) for w in widgets: w.blockSignals(True) @@ -244,6 +266,12 @@ def _block_signals(self) -> Iterator[None]: for w in widgets: w.blockSignals(False) + def _on_check_updates_clicked(self) -> None: + """Handle the *Check for Updates* button click.""" + self._check_updates_btn.setEnabled(False) + self._update_status_label.setText('Checking\u2026') + self.check_updates_requested.emit() + def _on_channel_changed(self, index: int) -> None: self._config.update_channel = 'dev' if index == 1 else 'stable' self._persist() diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 06fe7ed..c77c8bd 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -2,11 +2,10 @@ import asyncio import logging -from pathlib import Path +from collections.abc import Callable from porringer.api import API -from porringer.schema import SetupParameters, SyncStrategy -from PySide6.QtCore import QThread, QTimer, Signal +from PySide6.QtCore import QThread, QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, @@ -19,6 +18,7 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen.screen import MainWindow from synodic_client.application.screen.settings import SettingsWindow +from synodic_client.application.workers import ToolUpdateWorker, UpdateCheckWorker, UpdateDownloadWorker from synodic_client.client import Client from synodic_client.config import GlobalConfiguration from synodic_client.resolution import ( @@ -27,106 +27,11 @@ resolve_update_config, update_and_resolve, ) -from synodic_client.updater import UpdateChannel, UpdateInfo +from synodic_client.updater import UpdateInfo logger = logging.getLogger(__name__) -class UpdateCheckWorker(QThread): - """Worker for checking updates in a background thread.""" - - finished = Signal(object) # UpdateInfo - error = Signal(str) - - def __init__(self, client: Client) -> None: - """Initialize the worker.""" - super().__init__() - self._client = client - - def run(self) -> None: - """Run the update check.""" - try: - result = self._client.check_for_update() - self.finished.emit(result) - except Exception as e: - logger.exception('Update check failed') - self.error.emit(str(e)) - - -class UpdateDownloadWorker(QThread): - """Worker for downloading updates in a background thread.""" - - finished = Signal(bool) # success status - progress = Signal(int) # percentage (0-100) - error = Signal(str) - - def __init__(self, client: Client) -> None: - """Initialize the worker.""" - super().__init__() - self._client = client - - def run(self) -> None: - """Run the update download.""" - try: - - def progress_callback(percentage: int) -> None: - self.progress.emit(percentage) - - success = self._client.download_update(progress_callback) - self.finished.emit(success) - except Exception as e: - logger.exception('Update download failed') - self.error.emit(str(e)) - - -class ToolUpdateWorker(QThread): - """Worker for re-syncing manifest-declared tools in a background thread.""" - - finished = Signal(int) # number of manifests processed - error = Signal(str) - - def __init__(self, porringer: API, plugins: list[str] | None = None) -> None: - """Initialize the worker. - - Args: - porringer: The porringer API instance. - plugins: Optional include-list of plugin names. When set, only - actions handled by these plugins are executed. ``None`` - means all plugins. - """ - super().__init__() - self._porringer = porringer - self._plugins = plugins - - def run(self) -> None: - """Re-sync all cached project manifests.""" - try: - directories = self._porringer.cache.list_directories() - count = 0 - for directory in directories: - path = Path(directory.path) - if not self._porringer.sync.has_manifest(path): - logger.debug('Skipping path without manifest: %s', path) - continue - params = SetupParameters( - paths=[path], - project_directory=path if path.is_dir() else None, - strategy=SyncStrategy.LATEST, - plugins=self._plugins, - ) - asyncio.run(self._sync(params)) - count += 1 - self.finished.emit(count) - except Exception as e: - logger.exception('Tool update failed') - self.error.emit(str(e)) - - async def _sync(self, params: SetupParameters) -> None: - """Execute a sync stream for the given parameters.""" - async for _event in self._porringer.sync.execute_stream(params): - pass # consume events to completion - - class TrayScreen: """Tray screen for the application.""" @@ -169,17 +74,18 @@ def __init__( # Settings window (created once, shown/hidden on demand) self._settings_window = SettingsWindow(self._resolve_config()) self._settings_window.settings_changed.connect(self._on_settings_changed) + self._settings_window.check_updates_requested.connect(self._on_check_updates) # MainWindow gear button → open settings window.settings_requested.connect(self._show_settings) # Periodic auto-update checking self._auto_update_timer: QTimer | None = None - self._start_auto_update_timer() + self._restart_auto_update_timer() # Periodic tool update checking self._tool_update_timer: QTimer | None = None - self._start_tool_update_timer() + self._restart_tool_update_timer() # Connect PluginsView signals when available plugins_view = window.plugins_view @@ -201,23 +107,6 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.update_action.triggered.connect(self._on_check_updates) self.menu.addAction(self.update_action) - # Update Channel submenu - self.channel_menu = QMenu('Update Channel', self.menu) - self.menu.addMenu(self.channel_menu) - - self._channel_stable_action = QAction('Stable', self.channel_menu) - self._channel_stable_action.setCheckable(True) - self._channel_stable_action.triggered.connect(lambda: self._on_channel_changed(UpdateChannel.STABLE)) - self.channel_menu.addAction(self._channel_stable_action) - - self._channel_dev_action = QAction('Development', self.channel_menu) - self._channel_dev_action.setCheckable(True) - self._channel_dev_action.triggered.connect(lambda: self._on_channel_changed(UpdateChannel.DEVELOPMENT)) - self.channel_menu.addAction(self._channel_dev_action) - - # Set initial channel check state from config - self._sync_channel_checks() - self.menu.addSeparator() self.settings_action = QAction('Settings\u2026', self.menu) @@ -240,50 +129,57 @@ def _resolve_config(self) -> GlobalConfiguration: return self._config return resolve_config() - def _start_auto_update_timer(self) -> None: - """Start (or restart) the periodic auto-update timer from config.""" - if self._auto_update_timer is not None: - self._auto_update_timer.stop() - self._auto_update_timer = None - - config = resolve_update_config(self._resolve_config()) - interval_minutes = config.auto_update_interval_minutes - if interval_minutes <= 0: - logger.info('Automatic update checking is disabled') - return + def _restart_timer( + self, + current: QTimer | None, + interval_minutes: int, + slot: Callable[[], None], + label: str, + ) -> QTimer | None: + """Stop *current* and return a new periodic timer, or ``None``. - interval_ms = interval_minutes * 60 * 1000 - self._auto_update_timer = QTimer() - self._auto_update_timer.setInterval(interval_ms) - self._auto_update_timer.timeout.connect(self._on_auto_check_updates) - self._auto_update_timer.start() - logger.info('Automatic update checking enabled (every %d minute(s))', interval_minutes) + Args: + current: The existing timer to stop (may be ``None``). + interval_minutes: Interval in minutes. ``0`` disables. + slot: The callable to invoke on each tick. + label: Human-readable name for log messages. - def _start_tool_update_timer(self) -> None: - """Start (or restart) the periodic tool update timer from config.""" - if self._tool_update_timer is not None: - self._tool_update_timer.stop() - self._tool_update_timer = None + Returns: + A running ``QTimer``, or ``None`` when disabled. + """ + if current is not None: + current.stop() - config = resolve_update_config(self._resolve_config()) - interval_minutes = config.tool_update_interval_minutes if interval_minutes <= 0: - logger.info('Automatic tool updating is disabled') - return + logger.info('%s is disabled', label) + return None - interval_ms = interval_minutes * 60 * 1000 - self._tool_update_timer = QTimer() - self._tool_update_timer.setInterval(interval_ms) - self._tool_update_timer.timeout.connect(self._on_tool_update) - self._tool_update_timer.start() - logger.info('Automatic tool updating enabled (every %d minute(s))', interval_minutes) + timer = QTimer() + timer.setInterval(interval_minutes * 60 * 1000) + timer.timeout.connect(slot) + timer.start() + logger.info('%s enabled (every %d minute(s))', label, interval_minutes) + return timer - def _sync_channel_checks(self) -> None: - """Synchronize channel checkmarks with the current config.""" - config = self._resolve_config() - is_dev = config.update_channel == 'dev' - self._channel_stable_action.setChecked(not is_dev) - self._channel_dev_action.setChecked(is_dev) + def _restart_auto_update_timer(self) -> None: + """Start (or restart) the periodic auto-update timer from config.""" + config = resolve_update_config(self._resolve_config()) + self._auto_update_timer = self._restart_timer( + self._auto_update_timer, + config.auto_update_interval_minutes, + self._on_auto_check_updates, + 'Automatic update checking', + ) + + def _restart_tool_update_timer(self) -> None: + """Start (or restart) the periodic tool update timer from config.""" + config = resolve_update_config(self._resolve_config()) + self._tool_update_timer = self._restart_timer( + self._tool_update_timer, + config.tool_update_interval_minutes, + self._on_tool_update, + 'Automatic tool updating', + ) def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: """Handle tray icon activation (e.g. double-click).""" @@ -300,25 +196,13 @@ def _on_settings_changed(self) -> None: """React to a change made in the settings window.""" config = self._resolve_config() self._reinitialize_updater(config) - self._sync_channel_checks() - - def _on_channel_changed(self, channel: UpdateChannel) -> None: - """Handle channel selection change from the tray submenu.""" - config = self._resolve_config() - config.update_channel = 'dev' if channel == UpdateChannel.DEVELOPMENT else 'stable' - logger.info('Update channel changed to: %s', config.update_channel) - self._sync_channel_checks() - self._reinitialize_updater(config) - # Keep the settings window in sync if it is visible - if self._settings_window.isVisible(): - self._settings_window.sync_from_config() def _reinitialize_updater(self, config: GlobalConfiguration) -> None: """Re-derive update settings and restart the updater and timers.""" update_cfg = update_and_resolve(config) self._client.initialize_updater(update_cfg) - self._start_auto_update_timer() - self._start_tool_update_timer() + self._restart_auto_update_timer() + self._restart_tool_update_timer() logger.info('Updater re-initialized (channel: %s, source: %s)', update_cfg.channel.name, update_cfg.repo_url) def _reset_update_action(self) -> None: @@ -361,9 +245,11 @@ def _do_check_updates(self, *, silent: bool) -> None: ) return - # Disable the action while checking + # Disable both the tray action and the settings button while checking self.update_action.setEnabled(False) self.update_action.setText('Checking for Updates...') + self._settings_window._check_updates_btn.setEnabled(False) + self._settings_window.set_update_status('Checking\u2026') worker = UpdateCheckWorker(self._client) worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent)) @@ -375,8 +261,10 @@ def _do_check_updates(self, *, silent: bool) -> None: def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None: """Handle update check completion.""" self._reset_update_action() + self._settings_window.reset_check_updates_button() if result is None: + self._settings_window.set_update_status('Check failed') if not silent: self.tray.showMessage( 'Update Check Failed', @@ -388,6 +276,7 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = return if result.error: + self._settings_window.set_update_status(result.error) if not silent: # Distinguish informational messages (no releases for channel) # from genuine failures. @@ -402,6 +291,9 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = return if not result.available: + self._settings_window.set_update_status( + f'Up to date ({result.current_version})', + ) if not silent: self.tray.showMessage( 'No Updates Available', @@ -414,6 +306,9 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = # Update available - always show notification, clicking it starts download self._pending_update_info = result + self._settings_window.set_update_status( + f'Update available: {result.latest_version}', + ) self.tray.showMessage( 'Update Available', f'Version {result.latest_version} is available (current: {result.current_version}).\nClick to download.', @@ -423,6 +318,8 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = def _on_update_check_error(self, error: str, *, silent: bool = False) -> None: """Handle update check error.""" self._reset_update_action() + self._settings_window.reset_check_updates_button() + self._settings_window.set_update_status(f'Error: {error}') if not silent: self.tray.showMessage( diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py new file mode 100644 index 0000000..c0085ad --- /dev/null +++ b/synodic_client/application/workers.py @@ -0,0 +1,112 @@ +"""Background worker threads for the Synodic Client application. + +Each worker wraps an off-main-thread operation and communicates results +back via Qt signals so that callers remain responsive. +""" + +import asyncio +import logging +from pathlib import Path + +from porringer.api import API +from porringer.schema import SetupParameters, SyncStrategy +from PySide6.QtCore import QThread, Signal + +from synodic_client.client import Client + +logger = logging.getLogger(__name__) + + +class UpdateCheckWorker(QThread): + """Worker for checking updates in a background thread.""" + + finished = Signal(object) # UpdateInfo + error = Signal(str) + + def __init__(self, client: Client) -> None: + """Initialize the worker.""" + super().__init__() + self._client = client + + def run(self) -> None: + """Run the update check.""" + try: + result = self._client.check_for_update() + self.finished.emit(result) + except Exception as e: + logger.exception('Update check failed') + self.error.emit(str(e)) + + +class UpdateDownloadWorker(QThread): + """Worker for downloading updates in a background thread.""" + + finished = Signal(bool) # success status + progress = Signal(int) # percentage (0-100) + error = Signal(str) + + def __init__(self, client: Client) -> None: + """Initialize the worker.""" + super().__init__() + self._client = client + + def run(self) -> None: + """Run the update download.""" + try: + + def progress_callback(percentage: int) -> None: + self.progress.emit(percentage) + + success = self._client.download_update(progress_callback) + self.finished.emit(success) + except Exception as e: + logger.exception('Update download failed') + self.error.emit(str(e)) + + +class ToolUpdateWorker(QThread): + """Worker for re-syncing manifest-declared tools in a background thread.""" + + finished = Signal(int) # number of manifests processed + error = Signal(str) + + def __init__(self, porringer: API, plugins: list[str] | None = None) -> None: + """Initialize the worker. + + Args: + porringer: The porringer API instance. + plugins: Optional include-list of plugin names. When set, only + actions handled by these plugins are executed. ``None`` + means all plugins. + """ + super().__init__() + self._porringer = porringer + self._plugins = plugins + + def run(self) -> None: + """Re-sync all cached project manifests.""" + try: + directories = self._porringer.cache.list_directories() + count = 0 + for directory in directories: + path = Path(directory.path) + if not self._porringer.sync.has_manifest(path): + logger.debug('Skipping path without manifest: %s', path) + continue + params = SetupParameters( + paths=[path], + project_directory=path if path.is_dir() else None, + strategy=SyncStrategy.LATEST, + plugins=self._plugins, + ) + asyncio.run(self._sync(params)) + count += 1 + self.finished.emit(count) + except Exception as e: + logger.exception('Tool update failed') + self.error.emit(str(e)) + + async def _sync(self, params: SetupParameters) -> None: + """Execute a sync stream for the given parameters.""" + async for _event in self._porringer.sync.execute_stream(params): + pass # consume events to completion diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 2332fdc..310e7e5 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -210,7 +210,7 @@ def check_for_update(self) -> UpdateInfo: velopack_info = manager.check_for_updates() if velopack_info is not None: - latest = Version(velopack_info.target_full_release.version) + latest = Version(velopack_info.TargetFullRelease.Version) self._update_info = UpdateInfo( available=True, diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index eb27378..3c8f766 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -306,3 +306,51 @@ def test_sync_no_signal() -> None: window.sync_from_config() signal_spy.assert_not_called() + + +# --------------------------------------------------------------------------- +# Check for Updates button +# --------------------------------------------------------------------------- + + +class TestCheckForUpdatesButton: + """Verify the Check for Updates button and inline status label.""" + + @staticmethod + def test_button_and_label_exist() -> None: + """Window has the check-updates button and status label.""" + window = _make_window() + assert hasattr(window, '_check_updates_btn') + assert hasattr(window, '_update_status_label') + assert window._check_updates_btn.text() == 'Check for Updates\u2026' + assert window._update_status_label.text() == '' + + @staticmethod + def test_click_emits_signal_and_disables() -> None: + """Clicking the button emits check_updates_requested and disables it.""" + window = _make_window() + signal_spy = MagicMock() + window.check_updates_requested.connect(signal_spy) + + window._check_updates_btn.click() + + signal_spy.assert_called_once() + assert window._check_updates_btn.isEnabled() is False + assert window._update_status_label.text() == 'Checking\u2026' + + @staticmethod + def test_set_update_status() -> None: + """set_update_status sets the label text.""" + window = _make_window() + window.set_update_status('Up to date (v1.0.0)') + assert window._update_status_label.text() == 'Up to date (v1.0.0)' + + @staticmethod + def test_reset_check_updates_button() -> None: + """reset_check_updates_button re-enables the button.""" + window = _make_window() + window._check_updates_btn.setEnabled(False) + + window.reset_check_updates_button() + + assert window._check_updates_btn.isEnabled() is True diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 3a5814d..f00f14b 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest +import velopack from packaging.version import Version from synodic_client.updater import ( @@ -119,7 +120,7 @@ def test_is_installed_not_velopack(updater: Updater) -> None: @staticmethod def test_is_installed_with_velopack(updater: Updater) -> None: """Verify is_installed returns True when Velopack manager available.""" - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): assert updater.is_installed is True @@ -146,7 +147,7 @@ def test_check_not_installed(updater: Updater) -> None: @staticmethod def test_check_no_update(updater: Updater) -> None: """Verify check_for_update handles no update available.""" - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.check_for_updates.return_value = None with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): @@ -159,10 +160,12 @@ def test_check_no_update(updater: Updater) -> None: @staticmethod def test_check_update_available(updater: Updater) -> None: """Verify check_for_update handles update available.""" - mock_velopack_info = MagicMock() - mock_velopack_info.target_full_release.version = '2.0.0' + mock_target = MagicMock(spec=velopack.VelopackAsset) + mock_target.Version = '2.0.0' + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + mock_velopack_info.TargetFullRelease = mock_target - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.check_for_updates.return_value = mock_velopack_info with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): @@ -176,7 +179,7 @@ def test_check_update_available(updater: Updater) -> None: @staticmethod def test_check_error(updater: Updater) -> None: """Verify check_for_update handles errors gracefully.""" - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.check_for_updates.side_effect = Exception('Network error') with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): @@ -189,7 +192,7 @@ def test_check_error(updater: Updater) -> None: @staticmethod def test_check_404_returns_friendly_message(updater: Updater) -> None: """Verify a 404 from GitHub returns a friendly no-releases message.""" - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404') with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): @@ -205,7 +208,7 @@ def test_check_404_returns_friendly_message(updater: Updater) -> None: @staticmethod def test_check_non_404_http_error_is_failed(updater: Updater) -> None: """Verify non-404 HTTP errors still produce FAILED state.""" - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500') with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): @@ -238,7 +241,7 @@ def test_download_no_update_available(updater: Updater) -> None: @staticmethod def test_download_success(updater: Updater) -> None: """Verify download_update succeeds with valid update info.""" - mock_velopack_info = MagicMock() + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, current_version=Version('1.0.0'), @@ -247,7 +250,7 @@ def test_download_success(updater: Updater) -> None: ) updater._state = UpdateState.UPDATE_AVAILABLE - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) with ( patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), @@ -262,7 +265,7 @@ def test_download_success(updater: Updater) -> None: @staticmethod def test_download_with_progress_callback(updater: Updater) -> None: """Verify download_update passes progress callback.""" - mock_velopack_info = MagicMock() + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, current_version=Version('1.0.0'), @@ -271,7 +274,7 @@ def test_download_with_progress_callback(updater: Updater) -> None: ) updater._state = UpdateState.UPDATE_AVAILABLE - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) progress_cb = MagicMock() with ( @@ -286,7 +289,7 @@ def test_download_with_progress_callback(updater: Updater) -> None: @staticmethod def test_download_error(updater: Updater) -> None: """Verify download_update handles errors gracefully.""" - mock_velopack_info = MagicMock() + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, current_version=Version('1.0.0'), @@ -295,7 +298,7 @@ def test_download_error(updater: Updater) -> None: ) updater._state = UpdateState.UPDATE_AVAILABLE - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) mock_manager.download_updates.side_effect = Exception('Download failed') with ( @@ -351,7 +354,7 @@ def test_apply_on_exit_no_downloaded_update(updater: Updater) -> None: @staticmethod def test_apply_on_exit_success(updater: Updater) -> None: """Verify apply_update_on_exit schedules update.""" - mock_velopack_info = MagicMock() + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, current_version=Version('1.0.0'), @@ -360,7 +363,7 @@ def test_apply_on_exit_success(updater: Updater) -> None: ) updater._state = UpdateState.DOWNLOADED - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) with ( patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), @@ -374,7 +377,7 @@ def test_apply_on_exit_success(updater: Updater) -> None: @staticmethod def test_apply_on_exit_no_restart(updater: Updater) -> None: """Verify apply_update_on_exit can disable restart (note: not supported by Velopack).""" - mock_velopack_info = MagicMock() + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, current_version=Version('1.0.0'), @@ -383,7 +386,7 @@ def test_apply_on_exit_no_restart(updater: Updater) -> None: ) updater._state = UpdateState.DOWNLOADED - mock_manager = MagicMock() + mock_manager = MagicMock(spec=velopack.UpdateManager) with ( patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), @@ -401,7 +404,7 @@ class TestInitializeVelopack: @staticmethod def test_initialize_success() -> None: """Verify initialize_velopack calls App().run().""" - mock_app = MagicMock() + mock_app = MagicMock(spec=velopack.App) with patch('synodic_client.updater.velopack.App', return_value=mock_app) as mock_app_class: initialize_velopack() mock_app_class.assert_called_once() @@ -410,7 +413,7 @@ def test_initialize_success() -> None: @staticmethod def test_initialize_handles_exception() -> None: """Verify initialize_velopack handles exceptions gracefully.""" - mock_app = MagicMock() + mock_app = MagicMock(spec=velopack.App) mock_app.run.side_effect = Exception('Test') with patch('synodic_client.updater.velopack.App', return_value=mock_app): # Should not raise