diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index 0784a29..283dc49 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field from enum import Enum, auto from pathlib import Path +from typing import Protocol, runtime_checkable from porringer.schema import ( PluginInfo, @@ -336,10 +337,27 @@ class _DispatchState: # --------------------------------------------------------------------------- -# Update banner data models (from update_banner.py) +# Update view protocol & banner data models # --------------------------------------------------------------------------- +@runtime_checkable +class UpdateView(Protocol): + """Minimal display contract for the self-update lifecycle. + + :class:`UpdateBanner` satisfies this protocol implicitly via + structural typing. The controller broadcasts state transitions + through a ``list[UpdateView]`` so that every window showing update + 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: ... + + class UpdateBannerState(Enum): """Visual states for the update banner.""" diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 748dd8b..3e7f8ae 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -31,7 +31,8 @@ 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.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE +from synodic_client.application.screen.update_banner import UpdateBanner +from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE 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 @@ -54,9 +55,6 @@ 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() @@ -199,18 +197,13 @@ 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;') @@ -280,31 +273,25 @@ def sync_from_config(self) -> None: else: self._last_client_update_label.setText('') - def set_update_status(self, text: str, style: str = '') -> None: - """Set the inline status text next to the *Check for Updates* button. + @property + def update_banner(self) -> UpdateBanner: + """The embedded :class:`UpdateBanner` for this window.""" + return self._update_banner - 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_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}') def set_checking(self) -> None: - """Enter the *checking* state — disable button and show status.""" + """Enter the *checking* state — disable button.""" 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 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() @@ -350,7 +337,6 @@ 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 1a4d68b..cccfb53 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -68,11 +68,10 @@ 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, - self._banner, + [window.update_banner, self._settings_window.update_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 5e7f1a0..27e887f 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -17,12 +17,8 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication +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, @@ -48,22 +44,20 @@ class UpdateController: The running ``QApplication`` (needed for ``quit()`` on auto-apply). client: The Synodic Client service facade. - banner: - The in-app ``UpdateBanner`` widget. + views: + One or more :class:`UpdateView` implementations to broadcast + state transitions to (typically ``UpdateBanner`` instances). settings_window: - The ``SettingsWindow`` (receives status text + colour). + The ``SettingsWindow`` (check button + last-updated label). config: Optional pre-resolved configuration. ``None`` resolves from disk. - is_user_active: - Predicate returning ``True`` when the user has a visible window. - Auto-apply is deferred while active; checks still run normally. """ def __init__( self, app: QApplication, client: Client, - banner: UpdateBanner, + views: list[UpdateView], *, settings_window: SettingsWindow, config: ResolvedConfig | None = None, @@ -73,13 +67,13 @@ def __init__( Args: app: The running ``QApplication``. client: The Synodic Client service facade. - banner: The in-app ``UpdateBanner`` widget. - settings_window: The settings window for status feedback. + views: One or more :class:`UpdateView` implementations. + settings_window: The settings window (check button + timestamp). config: Optional pre-resolved configuration. """ self._app = app self._client = client - self._banner = banner + self._views = views self._settings_window = settings_window self._config = config self._is_user_active: Callable[[], bool] = lambda: False @@ -94,13 +88,14 @@ def __init__( self._auto_update_timer: QTimer | None = None self._restart_auto_update_timer() - # Wire banner signals - self._banner.restart_requested.connect(self._apply_update) - self._banner.retry_requested.connect(lambda: self.check_now(silent=True)) + # Wire banner signals (UpdateBanner-specific, outside the protocol) + for view in self._views: + if isinstance(view, UpdateBanner): + view.restart_requested.connect(self._apply_update) + view.retry_requested.connect(lambda: self.check_now(silent=True)) # Wire settings check-updates button 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. @@ -212,10 +207,11 @@ def _do_check(self, *, silent: bool) -> None: """Run an update check.""" if self._client.updater is None: if not silent: - self._banner.show_error('Updater is not initialized.') + for view in self._views: + view.show_error('Updater is not initialized.') return - # Preserve the restart button when an update is already pending + # Preserve the banner state when an update is already pending if self._pending_version is None: self._settings_window.set_checking() @@ -237,23 +233,22 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) self._settings_window.reset_check_updates_button() if result is None: - self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) if not silent: - self._banner.show_error('Failed to check for updates.') + for view in self._views: + view.show_error('Failed to check for updates.') else: logger.warning('Automatic update check failed (no result)') return if result.error: - self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) if not silent: - self._banner.show_error(result.error) + for view in self._views: + view.show_error(result.error) else: logger.warning('Automatic update check failed: %s', result.error) return 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: @@ -268,20 +263,17 @@ 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, - ) - self._banner.show_downloading(version) + for view in self._views: + view.show_downloading(version) self._start_download(version) def _on_check_error(self, error: str, *, silent: bool = False) -> None: """Handle unexpected exception during update check.""" self._settings_window.reset_check_updates_button() - self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) if not silent: - self._banner.show_error(f'Update check error: {error}') + for view in self._views: + view.show_error(f'Update check error: {error}') else: logger.warning('Automatic update check error: %s', error) @@ -298,50 +290,49 @@ async def _async_download(self, version: str) -> None: try: success = await download_update( self._client, - on_progress=self._banner.show_downloading_progress, + on_progress=self._on_download_progress, ) self._on_download_finished(success, version) except Exception as exc: logger.exception('Update download failed') self._on_download_error(str(exc)) + def _on_download_progress(self, percentage: int) -> None: + """Broadcast download progress to all views.""" + for view in self._views: + view.show_downloading_progress(percentage) + def _on_download_finished(self, success: bool, version: str) -> None: """Handle download completion.""" if not success: - self._banner.show_error('Download failed. Please try again later.') - 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 the client update timestamp - update_user_config(last_client_update=datetime.now(UTC).isoformat()) + # Persist and display the client update timestamp + ts = datetime.now(UTC).isoformat() + update_user_config(last_client_update=ts) + self._settings_window.set_last_updated(ts) self._pending_version = version if self._can_auto_apply(): # Silently apply and restart — no banner, no user interaction logger.info('Auto-applying update v%s', version) - self._settings_window.set_update_status( - f'v{version} installing\u2026', - UPDATE_STATUS_AVAILABLE_STYLE, - ) self._apply_update(silent=True) return self._show_ready(version) def _show_ready(self, version: str) -> None: - """Present the *ready to restart* state in both UIs.""" - self._banner.show_ready(version) - self._settings_window.set_update_status( - f'v{version} ready', - UPDATE_STATUS_UP_TO_DATE_STYLE, - ) - self._settings_window.show_restart_button() + """Present the *ready to restart* state across all views.""" + for view in self._views: + view.show_ready(version) def _on_download_error(self, error: str) -> None: - """Handle download error — show error banner.""" - self._banner.show_error(f'Download error: {error}') - self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE) + """Handle download error — show error across all views.""" + for view in self._views: + view.show_error(f'Download error: {error}') # ------------------------------------------------------------------ # Apply @@ -364,4 +355,5 @@ def _apply_update(self, *, silent: bool = False) -> None: self._app.quit() except Exception as e: logger.error('Failed to apply update: %s', e) - self._banner.show_error(f'Failed to apply update: {e}') + for view in self._views: + view.show_error(f'Failed to apply update: {e}') diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 33d3b08..dc25c05 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -334,16 +334,18 @@ def test_sync_no_signal() -> None: class TestCheckForUpdatesButton: - """Verify the Check for Updates button and inline status label.""" + """Verify the Check for Updates button and embedded update banner.""" @staticmethod - def test_button_and_label_exist() -> None: - """Window has the check-updates button and status label.""" + def test_button_and_banner_exist() -> None: + """Window has the check-updates button and an embedded UpdateBanner.""" 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 not window._update_status_label.text() + assert hasattr(window, '_update_banner') + from synodic_client.application.screen.update_banner import UpdateBanner + + assert isinstance(window.update_banner, UpdateBanner) @staticmethod def test_click_emits_signal_and_disables() -> None: @@ -356,15 +358,6 @@ 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: @@ -378,8 +371,7 @@ def test_reset_check_updates_button() -> None: @staticmethod def test_set_checking() -> None: - """set_checking disables the button and shows 'Checking\u2026' status.""" + """set_checking disables the button.""" 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 8146f9a..94f44d1 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -8,11 +8,6 @@ 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 ( @@ -51,10 +46,10 @@ def _make_controller( auto_apply: bool = True, auto_update_interval_minutes: int = 0, is_user_active: bool = False, -) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]: +) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, UpdateBanner, MagicMock]: """Build an ``UpdateController`` with mocked collaborators. - Returns (controller, app_mock, client_mock, banner, settings_mock). + Returns (controller, app_mock, client_mock, banner1, banner2, settings_mock). """ config = _make_config( auto_apply=auto_apply, @@ -64,8 +59,11 @@ def _make_controller( app = MagicMock() client = MagicMock() client.updater = MagicMock() - banner = UpdateBanner() + banner1 = UpdateBanner() + banner2 = 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( @@ -74,13 +72,13 @@ def _make_controller( controller = UpdateController( app, client, - banner, + [banner1, banner2], settings_window=settings, config=config, ) controller.set_user_active_predicate(lambda: is_user_active) - return controller, app, client, banner, settings + return controller, app, client, banner1, banner2, settings # --------------------------------------------------------------------------- @@ -92,61 +90,55 @@ class TestCheckFinished: """Verify _on_check_finished routes results correctly.""" @staticmethod - def test_none_result_sets_error_status() -> None: - """A None result should set 'Check failed' in red.""" - ctrl, _app, _client, banner, settings = _make_controller() + 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() ctrl._on_check_finished(None, silent=False) settings.reset_check_updates_button.assert_called_once() - 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' + assert b1.state.name == 'ERROR' + assert b2.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, banner, settings = _make_controller() + ctrl, _app, _client, b1, b2, settings = _make_controller() ctrl._on_check_finished(None, silent=True) - assert banner.state.name == 'HIDDEN' + assert b1.state.name == 'HIDDEN' + assert b2.state.name == 'HIDDEN' @staticmethod - def test_error_result_sets_error_status() -> None: - """An error result should set 'Check failed' status.""" - ctrl, _app, _client, banner, settings = _make_controller() + 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() result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found') ctrl._on_check_finished(result, silent=False) - settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + assert b1.state.name == 'ERROR' + assert b2.state.name == 'ERROR' @staticmethod - 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() + def test_no_update_available_banners_remain_hidden() -> None: + """No update available should leave banners hidden.""" + ctrl, _app, _client, b1, b2, settings = _make_controller() result = UpdateInfo(available=False, current_version=Version('1.0.0')) ctrl._on_check_finished(result, silent=False) - settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) + assert b1.state.name == 'HIDDEN' + assert b2.state.name == 'HIDDEN' @staticmethod - 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() + 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() 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) - settings.set_update_status.assert_called_once_with( - 'v2.0.0 available', - UPDATE_STATUS_AVAILABLE_STYLE, - ) + assert b1.state.name == 'DOWNLOADING' + assert b2.state.name == 'DOWNLOADING' mock_dl.assert_called_once_with('2.0.0') @@ -161,7 +153,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, banner, settings = _make_controller(auto_apply=True) + ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) with patch.object(ctrl, '_apply_update') as mock_apply: ctrl._on_download_finished(True, '2.0.0') @@ -171,45 +163,27 @@ 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, banner, settings = _make_controller(auto_apply=True) + ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) with patch.object(ctrl, '_apply_update'): ctrl._on_download_finished(True, '2.0.0') - # Banner should not be in READY state - assert banner.state.name != 'READY' + assert b1.state.name != 'READY' + assert b2.state.name != 'READY' @staticmethod - 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) + 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) ctrl._on_download_finished(True, '2.0.0') - assert banner.state.name == 'READY' - - @staticmethod - 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') - - settings.set_update_status.assert_called_with( - 'v2.0.0 ready', - UPDATE_STATUS_UP_TO_DATE_STYLE, - ) + assert b1.state.name == 'READY' + assert b2.state.name == 'READY' @staticmethod - 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.show_restart_button.assert_called_once() - - @staticmethod - 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( + 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, ) @@ -217,21 +191,22 @@ def test_user_active_shows_restart_button() -> None: with patch.object(ctrl, '_apply_update'): ctrl._on_download_finished(True, '2.0.0') - settings.show_restart_button.assert_called_once() + assert b1.state.name == 'READY' + assert b2.state.name == 'READY' @staticmethod def test_download_failure_shows_error() -> None: - """A failed download should show an error banner.""" - ctrl, app, client, banner, settings = _make_controller() + """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 banner.state.name == 'ERROR' - settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE) + 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, banner, settings = _make_controller(auto_apply=False) + ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False) ctrl._on_download_finished(True, '2.0.0') assert ctrl._pending_version == '2.0.0' @@ -248,7 +223,7 @@ class TestPendingVersion: @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, banner, settings = _make_controller(auto_apply=False) + 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')) @@ -257,13 +232,13 @@ def test_check_skips_download_when_version_already_pending() -> None: ctrl._on_check_finished(result, silent=True) mock_dl.assert_not_called() - assert banner.state.name == 'READY' - settings.show_restart_button.assert_called_once() + 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, banner, settings = _make_controller(auto_apply=False) + 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')) @@ -274,9 +249,9 @@ def test_check_downloads_when_newer_version() -> None: mock_dl.assert_called_once_with('2.0.0') @staticmethod - def test_do_check_preserves_settings_ui_when_pending() -> None: + def test_do_check_preserves_banner_when_pending() -> None: """set_checking should NOT be called when an update is already pending.""" - ctrl, _app, _client, banner, settings = _make_controller() + ctrl, _app, _client, b1, b2, settings = _make_controller() ctrl._pending_version = '2.0.0' with patch('asyncio.create_task'): @@ -287,7 +262,7 @@ def test_do_check_preserves_settings_ui_when_pending() -> None: @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, banner, settings = _make_controller() + ctrl, _app, _client, b1, b2, settings = _make_controller() with patch('asyncio.create_task'): ctrl._do_check(silent=True) @@ -297,7 +272,7 @@ def test_do_check_shows_checking_when_no_pending() -> None: @staticmethod def test_apply_clears_pending_version() -> None: """_apply_update should clear _pending_version.""" - ctrl, app, client, banner, settings = _make_controller() + ctrl, app, client, b1, b2, settings = _make_controller() ctrl._pending_version = '2.0.0' ctrl._apply_update() @@ -319,7 +294,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, banner, settings = _make_controller(is_user_active=True) + ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True) with patch.object(ctrl, '_do_check') as mock_check: ctrl._on_auto_check() @@ -329,7 +304,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, banner, settings = _make_controller(is_user_active=True) + ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True) with patch.object(ctrl, '_do_check') as mock_check: ctrl._on_manual_check() @@ -338,8 +313,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 banner instead of applying.""" - ctrl, app, client, banner, settings = _make_controller( + """When auto_apply=True but user is active, show READY banners instead of applying.""" + ctrl, app, client, b1, b2, settings = _make_controller( auto_apply=True, is_user_active=True, ) @@ -348,12 +323,13 @@ def test_auto_apply_deferred_when_user_active() -> None: ctrl._on_download_finished(True, '2.0.0') mock_apply.assert_not_called() - assert banner.state.name == 'READY' + assert b1.state.name == 'READY' + assert b2.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, banner, settings = _make_controller( + ctrl, app, client, b1, b2, settings = _make_controller( auto_apply=True, is_user_active=False, ) @@ -393,7 +369,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, banner, settings = _make_controller() + ctrl, app, client, b1, b2, settings = _make_controller() ctrl._apply_update() client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False) @@ -402,21 +378,13 @@ 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, banner, settings = _make_controller() + ctrl, app, client, b1, b2, 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 @@ -429,7 +397,7 @@ class TestSettingsChanged: @staticmethod def test_settings_changed_triggers_reinit_and_check() -> None: """Changing settings should reinitialise the updater and check.""" - ctrl, app, client, banner, settings = _make_controller() + ctrl, app, client, b1, b2, settings = _make_controller() new_config = _make_config(update_channel='dev') @@ -445,7 +413,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, banner, settings = _make_controller(auto_apply=True) + ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True) new_config = _make_config(auto_apply=False) @@ -466,26 +434,20 @@ 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, banner, settings = _make_controller() + ctrl, app, client, b1, b2, settings = _make_controller() ctrl._on_check_error('timeout', silent=False) - assert banner.state.name == 'ERROR' + assert b1.state.name == 'ERROR' + assert b2.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, banner, settings = _make_controller() + ctrl, app, client, b1, b2, settings = _make_controller() ctrl._on_check_error('timeout', silent=True) - assert banner.state.name == 'HIDDEN' + assert b1.state.name == 'HIDDEN' + assert b2.state.name == 'HIDDEN'