diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index 283dc49..0de9e19 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -351,11 +351,25 @@ class UpdateView(Protocol): status stays in sync. """ - def show_downloading(self, version: str) -> None: ... - def show_downloading_progress(self, percentage: int) -> None: ... - def show_ready(self, version: str) -> None: ... - def show_error(self, message: str) -> None: ... - def hide_banner(self) -> None: ... + def show_downloading(self, version: str) -> None: + """Indicate that *version* is being downloaded.""" + ... + + def show_downloading_progress(self, percentage: int) -> None: + """Update the download progress indicator.""" + ... + + def show_ready(self, version: str) -> None: + """Indicate that *version* is downloaded and ready to install.""" + ... + + def show_error(self, message: str) -> None: + """Display an error *message* in the update area.""" + ... + + def hide_banner(self) -> None: + """Hide the update banner.""" + ... class UpdateBannerState(Enum): diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 3e7f8ae..40bd4c0 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -31,8 +31,7 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen import _format_relative_time from synodic_client.application.screen.card import CardFrame -from synodic_client.application.screen.update_banner import UpdateBanner -from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE +from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE from synodic_client.logging import log_path, set_debug_level from synodic_client.resolution import ResolvedConfig, update_user_config from synodic_client.schema import GITHUB_REPO_URL @@ -55,6 +54,9 @@ class SettingsWindow(QMainWindow): check_updates_requested = Signal() """Emitted when the user clicks the *Check for Updates* button.""" + restart_requested = Signal() + """Emitted when the user clicks the *Restart & Update* button.""" + def showEvent(self, event: QShowEvent) -> None: # noqa: N802 """[DIAG] Log every show event with a stack trace.""" geo = self.geometry() @@ -197,13 +199,18 @@ def _add_update_controls(self, content: QVBoxLayout) -> None: 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('') + self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + row.addWidget(self._update_status_label) + + self._restart_btn = QPushButton('Restart \u0026 Update') + self._restart_btn.clicked.connect(self.restart_requested.emit) + self._restart_btn.hide() + row.addWidget(self._restart_btn) + row.addStretch() content.addLayout(row) - # Embedded update banner (same widget used in the main window) - self._update_banner = UpdateBanner() - content.addWidget(self._update_banner) - # Last client update timestamp self._last_client_update_label = QLabel('') self._last_client_update_label.setStyleSheet('color: #808080; font-size: 11px;') @@ -273,25 +280,37 @@ def sync_from_config(self) -> None: else: self._last_client_update_label.setText('') - @property - def update_banner(self) -> UpdateBanner: - """The embedded :class:`UpdateBanner` for this window.""" - return self._update_banner + def set_update_status(self, text: str, style: str = '') -> None: + """Set the inline status text next to the *Check for Updates* button. - def set_last_updated(self, timestamp: str) -> None: - """Refresh the *Last updated* label from a raw ISO timestamp.""" - relative = _format_relative_time(timestamp) - self._last_client_update_label.setText(f'Last updated: {relative}') - self._last_client_update_label.setToolTip(f'Last updated: {timestamp}') + Args: + text: The status message. + style: Optional stylesheet for the label (e.g. color). + """ + self._update_status_label.setText(text) + self._update_status_label.setStyleSheet(style) def set_checking(self) -> None: - """Enter the *checking* state — disable button.""" + """Enter the *checking* state — disable button and show status.""" self._check_updates_btn.setEnabled(False) + self._restart_btn.hide() + self._update_status_label.setText('Checking\u2026') + self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE) 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 set_last_checked(self, timestamp: str) -> None: + """Update the *last updated* label from an ISO 8601 timestamp.""" + relative = _format_relative_time(timestamp) + self._last_client_update_label.setText(f'Last updated: {relative}') + self._last_client_update_label.setToolTip(f'Last updated: {timestamp}') + + def show_restart_button(self) -> None: + """Show the *Restart & Update* button.""" + self._restart_btn.show() + def show(self) -> None: """Sync controls from config, then show the window.""" self.sync_from_config() @@ -337,6 +356,7 @@ def _block_signals(self) -> Iterator[None]: 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: diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index cccfb53..22b1986 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -68,10 +68,11 @@ def __init__( window.settings_requested.connect(self._show_settings) # Update controller - owns the self-update lifecycle & timer + self._banner = window.update_banner self._update_controller = UpdateController( app, client, - [window.update_banner, self._settings_window.update_banner], + [self._banner], settings_window=self._settings_window, config=config, ) diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 27e887f..d01a3bc 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -19,6 +19,11 @@ from synodic_client.application.screen.schema import UpdateView from synodic_client.application.screen.update_banner import UpdateBanner +from synodic_client.application.theme import ( + UPDATE_STATUS_AVAILABLE_STYLE, + UPDATE_STATUS_ERROR_STYLE, + UPDATE_STATUS_UP_TO_DATE_STYLE, +) from synodic_client.application.workers import check_for_update, download_update from synodic_client.resolution import ( ResolvedConfig, @@ -94,8 +99,9 @@ def __init__( view.restart_requested.connect(self._apply_update) view.retry_requested.connect(lambda: self.check_now(silent=True)) - # Wire settings check-updates button + # Wire settings check-updates and restart buttons self._settings_window.check_updates_requested.connect(self._on_manual_check) + self._settings_window.restart_requested.connect(self._apply_update) def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None: """Set the predicate used to defer auto-apply when the user is active. @@ -123,6 +129,25 @@ def _can_auto_apply(self) -> bool: """ return self._auto_apply and not self._is_user_active() + def _persist_check_timestamp(self) -> None: + """Persist the current time as *last_client_update* and refresh the label.""" + ts = datetime.now(UTC).isoformat() + update_user_config(last_client_update=ts) + self._settings_window.set_last_checked(ts) + + def _report_error(self, message: str, *, silent: bool) -> None: + """Show an error to the user or log it, depending on *silent*. + + Always updates the settings status line. When not *silent*, + also broadcasts the error to all update-banner views. + """ + self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) + if silent: + logger.warning('%s', message) + else: + for view in self._views: + view.show_error(message) + # ------------------------------------------------------------------ # Timer management # ------------------------------------------------------------------ @@ -233,22 +258,18 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) self._settings_window.reset_check_updates_button() if result is None: - if not silent: - for view in self._views: - view.show_error('Failed to check for updates.') - else: - logger.warning('Automatic update check failed (no result)') + self._report_error('Failed to check for updates.', silent=silent) return if result.error: - if not silent: - for view in self._views: - view.show_error(result.error) - else: - logger.warning('Automatic update check failed: %s', result.error) + self._report_error(result.error, silent=silent) return + # Successful check — refresh the "last updated" timestamp + self._persist_check_timestamp() + if not result.available: + self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) if not silent: logger.info('No updates available (current: %s)', result.current_version) else: @@ -263,6 +284,7 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) return # New update available — download it + self._settings_window.set_update_status(f'v{version} available', UPDATE_STATUS_AVAILABLE_STYLE) for view in self._views: view.show_downloading(version) self._start_download(version) @@ -270,12 +292,7 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) def _on_check_error(self, error: str, *, silent: bool = False) -> None: """Handle unexpected exception during update check.""" self._settings_window.reset_check_updates_button() - - if not silent: - for view in self._views: - view.show_error(f'Update check error: {error}') - else: - logger.warning('Automatic update check error: %s', error) + self._report_error(f'Update check error: {error}', silent=silent) # ------------------------------------------------------------------ # Download flow @@ -305,14 +322,14 @@ def _on_download_progress(self, percentage: int) -> None: def _on_download_finished(self, success: bool, version: str) -> None: """Handle download completion.""" if not success: + self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE) for view in self._views: view.show_error('Download failed. Please try again later.') return - # Persist and display the client update timestamp + # Persist the client-update timestamp (actual update downloaded) ts = datetime.now(UTC).isoformat() update_user_config(last_client_update=ts) - self._settings_window.set_last_updated(ts) self._pending_version = version @@ -326,6 +343,8 @@ def _on_download_finished(self, success: bool, version: str) -> None: def _show_ready(self, version: str) -> None: """Present the *ready to restart* state across all views.""" + self._settings_window.set_update_status(f'v{version} ready', UPDATE_STATUS_UP_TO_DATE_STYLE) + self._settings_window.show_restart_button() for view in self._views: view.show_ready(version) diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index dc25c05..33d3b08 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -334,18 +334,16 @@ def test_sync_no_signal() -> None: class TestCheckForUpdatesButton: - """Verify the Check for Updates button and embedded update banner.""" + """Verify the Check for Updates button and inline status label.""" @staticmethod - def test_button_and_banner_exist() -> None: - """Window has the check-updates button and an embedded UpdateBanner.""" + 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 hasattr(window, '_update_banner') - from synodic_client.application.screen.update_banner import UpdateBanner - - assert isinstance(window.update_banner, UpdateBanner) + assert not window._update_status_label.text() @staticmethod def test_click_emits_signal_and_disables() -> None: @@ -358,6 +356,15 @@ def test_click_emits_signal_and_disables() -> None: 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 and style.""" + window = _make_window() + window.set_update_status('Up to date', 'color: green;') + assert window._update_status_label.text() == 'Up to date' + assert 'green' in window._update_status_label.styleSheet() @staticmethod def test_reset_check_updates_button() -> None: @@ -371,7 +378,8 @@ def test_reset_check_updates_button() -> None: @staticmethod def test_set_checking() -> None: - """set_checking disables the button.""" + """set_checking disables the button and shows 'Checking\u2026' status.""" window = _make_window() window.set_checking() assert window._check_updates_btn.isEnabled() is False + assert window._update_status_label.text() == 'Checking\u2026' diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index 94f44d1..2a195a6 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -8,6 +8,11 @@ from packaging.version import Version from synodic_client.application.screen.update_banner import UpdateBanner +from synodic_client.application.theme import ( + UPDATE_STATUS_AVAILABLE_STYLE, + UPDATE_STATUS_ERROR_STYLE, + UPDATE_STATUS_UP_TO_DATE_STYLE, +) from synodic_client.application.update_controller import UpdateController from synodic_client.resolution import ResolvedConfig from synodic_client.schema import ( @@ -46,10 +51,10 @@ def _make_controller( auto_apply: bool = True, auto_update_interval_minutes: int = 0, is_user_active: bool = False, -) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, UpdateBanner, MagicMock]: +) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]: """Build an ``UpdateController`` with mocked collaborators. - Returns (controller, app_mock, client_mock, banner1, banner2, settings_mock). + Returns (controller, app_mock, client_mock, banner, settings_mock). """ config = _make_config( auto_apply=auto_apply, @@ -59,11 +64,8 @@ def _make_controller( app = MagicMock() client = MagicMock() client.updater = MagicMock() - banner1 = UpdateBanner() - banner2 = UpdateBanner() + banner = UpdateBanner() settings = MagicMock() - # settings mock only needs: check_updates_requested, set_checking, - # reset_check_updates_button, set_last_updated with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg: mock_ucfg.return_value = MagicMock( @@ -72,13 +74,13 @@ def _make_controller( controller = UpdateController( app, client, - [banner1, banner2], + [banner], settings_window=settings, config=config, ) controller.set_user_active_predicate(lambda: is_user_active) - return controller, app, client, banner1, banner2, settings + return controller, app, client, banner, settings # --------------------------------------------------------------------------- @@ -90,55 +92,61 @@ class TestCheckFinished: """Verify _on_check_finished routes results correctly.""" @staticmethod - def test_none_result_shows_error_banner_when_not_silent() -> None: - """A None result with silent=False should show error on all banners.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + def test_none_result_sets_error_status() -> None: + """A None result should set 'Check failed' in red.""" + ctrl, _app, _client, banner, settings = _make_controller() ctrl._on_check_finished(None, silent=False) settings.reset_check_updates_button.assert_called_once() - assert b1.state.name == 'ERROR' - assert b2.state.name == 'ERROR' + settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + + @staticmethod + def test_none_result_shows_banner_when_not_silent() -> None: + """A None result with silent=False should show the error banner.""" + ctrl, _app, _client, banner, settings = _make_controller() + ctrl._on_check_finished(None, silent=False) + + assert banner.state.name == 'ERROR' @staticmethod def test_none_result_no_banner_when_silent() -> None: """A None result with silent=True should NOT show the error banner.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + ctrl, _app, _client, banner, settings = _make_controller() ctrl._on_check_finished(None, silent=True) - assert b1.state.name == 'HIDDEN' - assert b2.state.name == 'HIDDEN' + assert banner.state.name == 'HIDDEN' @staticmethod - def test_error_result_shows_error_banner() -> None: - """An error result should show error on all banners.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + def test_error_result_sets_error_status() -> None: + """An error result should set 'Check failed' status.""" + ctrl, _app, _client, banner, settings = _make_controller() result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found') ctrl._on_check_finished(result, silent=False) - assert b1.state.name == 'ERROR' - assert b2.state.name == 'ERROR' + settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE) @staticmethod - def test_no_update_available_banners_remain_hidden() -> None: - """No update available should leave banners hidden.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + def test_no_update_sets_up_to_date() -> None: + """No update available should set 'Up to date' in green.""" + ctrl, _app, _client, banner, settings = _make_controller() result = UpdateInfo(available=False, current_version=Version('1.0.0')) ctrl._on_check_finished(result, silent=False) - assert b1.state.name == 'HIDDEN' - assert b2.state.name == 'HIDDEN' + settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) @staticmethod - def test_update_available_shows_downloading_and_starts_download() -> None: - """Available update should show downloading on all banners and start download.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + def test_update_available_sets_status_and_starts_download() -> None: + """Available update should set orange status and start download.""" + ctrl, _app, _client, banner, settings = _make_controller() result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) with patch.object(ctrl, '_start_download') as mock_dl: ctrl._on_check_finished(result, silent=False) - assert b1.state.name == 'DOWNLOADING' - assert b2.state.name == 'DOWNLOADING' + settings.set_update_status.assert_called_once_with( + 'v2.0.0 available', + UPDATE_STATUS_AVAILABLE_STYLE, + ) mock_dl.assert_called_once_with('2.0.0') @@ -153,7 +161,7 @@ class TestDownloadFinished: @staticmethod def test_auto_apply_calls_apply_update() -> None: """When auto_apply=True, a successful download should call _apply_update(silent=True).""" - ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) with patch.object(ctrl, '_apply_update') as mock_apply: ctrl._on_download_finished(True, '2.0.0') @@ -163,120 +171,62 @@ def test_auto_apply_calls_apply_update() -> None: @staticmethod def test_auto_apply_does_not_show_ready_banner() -> None: """When auto_apply=True, the ready banner should NOT be shown.""" - ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) with patch.object(ctrl, '_apply_update'): ctrl._on_download_finished(True, '2.0.0') - assert b1.state.name != 'READY' - assert b2.state.name != 'READY' + # Banner should not be in READY state + assert banner.state.name != 'READY' @staticmethod - def test_no_auto_apply_shows_ready_banners() -> None: - """When auto_apply=False, a successful download should show ready on all banners.""" - ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False) + def test_no_auto_apply_shows_ready_banner() -> None: + """When auto_apply=False, a successful download should show the ready banner.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=False) ctrl._on_download_finished(True, '2.0.0') - assert b1.state.name == 'READY' - assert b2.state.name == 'READY' - - @staticmethod - def test_user_active_shows_ready_banners() -> None: - """When user is active, the ready banners should be shown.""" - ctrl, app, client, b1, b2, settings = _make_controller( - auto_apply=True, - is_user_active=True, - ) - - with patch.object(ctrl, '_apply_update'): - ctrl._on_download_finished(True, '2.0.0') - - assert b1.state.name == 'READY' - assert b2.state.name == 'READY' + assert banner.state.name == 'READY' @staticmethod - def test_download_failure_shows_error() -> None: - """A failed download should show an error on all banners.""" - ctrl, app, client, b1, b2, settings = _make_controller() - ctrl._on_download_finished(False, '2.0.0') - - assert b1.state.name == 'ERROR' - assert b2.state.name == 'ERROR' - - @staticmethod - def test_download_sets_pending_version() -> None: - """A successful download should set _pending_version.""" - ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False) + def test_no_auto_apply_sets_ready_status() -> None: + """When auto_apply=False, status should show 'v2.0.0 ready' in green.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=False) ctrl._on_download_finished(True, '2.0.0') - assert ctrl._pending_version == '2.0.0' - - -# --------------------------------------------------------------------------- -# Pending version — skip redundant downloads -# --------------------------------------------------------------------------- - - -class TestPendingVersion: - """Verify behaviour when an update is already downloaded and pending.""" - - @staticmethod - def test_check_skips_download_when_version_already_pending() -> None: - """Re-checking the same version should restore ready state, not re-download.""" - ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False) - ctrl._pending_version = '2.0.0' - - result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) - - with patch.object(ctrl, '_start_download') as mock_dl: - ctrl._on_check_finished(result, silent=True) - - mock_dl.assert_not_called() - assert b1.state.name == 'READY' - assert b2.state.name == 'READY' - - @staticmethod - def test_check_downloads_when_newer_version() -> None: - """A different version should trigger a fresh download.""" - ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False) - ctrl._pending_version = '1.5.0' - - result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) - - with patch.object(ctrl, '_start_download') as mock_dl: - ctrl._on_check_finished(result, silent=True) - - mock_dl.assert_called_once_with('2.0.0') + settings.set_update_status.assert_called_with( + 'v2.0.0 ready', + UPDATE_STATUS_UP_TO_DATE_STYLE, + ) @staticmethod - def test_do_check_preserves_banner_when_pending() -> None: - """set_checking should NOT be called when an update is already pending.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() - ctrl._pending_version = '2.0.0' - - with patch('asyncio.create_task'): - ctrl._do_check(silent=True) + def test_no_auto_apply_shows_restart_button() -> None: + """When auto_apply=False, the restart button should be shown in settings.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=False) + ctrl._on_download_finished(True, '2.0.0') - settings.set_checking.assert_not_called() + settings.show_restart_button.assert_called_once() @staticmethod - def test_do_check_shows_checking_when_no_pending() -> None: - """set_checking should be called when there is no pending update.""" - ctrl, _app, _client, b1, b2, settings = _make_controller() + def test_user_active_shows_restart_button() -> None: + """When user is active, the restart button should be shown in settings.""" + ctrl, app, client, banner, settings = _make_controller( + auto_apply=True, + is_user_active=True, + ) - with patch('asyncio.create_task'): - ctrl._do_check(silent=True) + with patch.object(ctrl, '_apply_update'): + ctrl._on_download_finished(True, '2.0.0') - settings.set_checking.assert_called_once() + settings.show_restart_button.assert_called_once() @staticmethod - def test_apply_clears_pending_version() -> None: - """_apply_update should clear _pending_version.""" - ctrl, app, client, b1, b2, settings = _make_controller() - ctrl._pending_version = '2.0.0' - ctrl._apply_update() + def test_download_failure_shows_error() -> None: + """A failed download should show an error banner.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_download_finished(False, '2.0.0') - assert ctrl._pending_version is None + assert banner.state.name == 'ERROR' + settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE) # --------------------------------------------------------------------------- @@ -294,7 +244,7 @@ class TestUserActiveGating: @staticmethod def test_auto_check_always_runs() -> None: """_on_auto_check should call _do_check even when user is active.""" - ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True) + ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True) with patch.object(ctrl, '_do_check') as mock_check: ctrl._on_auto_check() @@ -304,7 +254,7 @@ def test_auto_check_always_runs() -> None: @staticmethod def test_manual_check_unaffected_by_active_user() -> None: """_on_manual_check should always call _do_check regardless of user activity.""" - ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True) + ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True) with patch.object(ctrl, '_do_check') as mock_check: ctrl._on_manual_check() @@ -313,8 +263,8 @@ def test_manual_check_unaffected_by_active_user() -> None: @staticmethod def test_auto_apply_deferred_when_user_active() -> None: - """When auto_apply=True but user is active, show READY banners instead of applying.""" - ctrl, app, client, b1, b2, settings = _make_controller( + """When auto_apply=True but user is active, show READY banner instead of applying.""" + ctrl, app, client, banner, settings = _make_controller( auto_apply=True, is_user_active=True, ) @@ -323,13 +273,12 @@ def test_auto_apply_deferred_when_user_active() -> None: ctrl._on_download_finished(True, '2.0.0') mock_apply.assert_not_called() - assert b1.state.name == 'READY' - assert b2.state.name == 'READY' + assert banner.state.name == 'READY' @staticmethod def test_auto_apply_proceeds_when_user_inactive() -> None: """When auto_apply=True and user is inactive, _apply_update is called.""" - ctrl, app, client, b1, b2, settings = _make_controller( + ctrl, app, client, banner, settings = _make_controller( auto_apply=True, is_user_active=False, ) @@ -369,7 +318,7 @@ class TestApplyUpdate: @staticmethod def test_apply_update_calls_client_and_quits() -> None: """_apply_update should call client.apply_update_on_exit and app.quit.""" - ctrl, app, client, b1, b2, settings = _make_controller() + ctrl, app, client, banner, settings = _make_controller() ctrl._apply_update() client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False) @@ -378,13 +327,21 @@ def test_apply_update_calls_client_and_quits() -> None: @staticmethod def test_apply_update_noop_without_updater() -> None: """_apply_update should be a no-op when client.updater is None.""" - ctrl, app, client, b1, b2, settings = _make_controller() + ctrl, app, client, banner, settings = _make_controller() client.updater = None ctrl._apply_update() client.apply_update_on_exit.assert_not_called() app.quit.assert_not_called() + @staticmethod + def test_restart_requested_signal_triggers_apply() -> None: + """The settings restart_requested signal should be connected to _apply_update.""" + ctrl, app, client, banner, settings = _make_controller() + + # Verify the signal was connected + settings.restart_requested.connect.assert_called_once_with(ctrl._apply_update) + # --------------------------------------------------------------------------- # Settings changed → immediate check @@ -397,7 +354,7 @@ class TestSettingsChanged: @staticmethod def test_settings_changed_triggers_reinit_and_check() -> None: """Changing settings should reinitialise the updater and check.""" - ctrl, app, client, b1, b2, settings = _make_controller() + ctrl, app, client, banner, settings = _make_controller() new_config = _make_config(update_channel='dev') @@ -413,7 +370,7 @@ def test_settings_changed_triggers_reinit_and_check() -> None: @staticmethod def test_settings_changed_updates_auto_apply() -> None: """Changing settings should update the auto_apply flag.""" - ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) new_config = _make_config(auto_apply=False) @@ -434,20 +391,26 @@ def test_settings_changed_updates_auto_apply() -> None: class TestCheckError: """Verify _on_check_error routes errors correctly.""" + @staticmethod + def test_check_error_sets_failed_status() -> None: + """An exception during check should set 'Check failed' status.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_check_error('connection refused', silent=False) + + settings.set_update_status.assert_called_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + @staticmethod def test_check_error_shows_banner_when_not_silent() -> None: """An exception during check should show banner when not silent.""" - ctrl, app, client, b1, b2, settings = _make_controller() + ctrl, app, client, banner, settings = _make_controller() ctrl._on_check_error('timeout', silent=False) - assert b1.state.name == 'ERROR' - assert b2.state.name == 'ERROR' + assert banner.state.name == 'ERROR' @staticmethod def test_check_error_no_banner_when_silent() -> None: """An exception during check should NOT show banner when silent.""" - ctrl, app, client, b1, b2, settings = _make_controller() + ctrl, app, client, banner, settings = _make_controller() ctrl._on_check_error('timeout', silent=True) - assert b1.state.name == 'HIDDEN' - assert b2.state.name == 'HIDDEN' + assert banner.state.name == 'HIDDEN'