From 913a8d8f226410230b996f0c2bf55b89d89caf4a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 4 Mar 2026 19:15:14 -0800 Subject: [PATCH] Shared `_pending_version` --- .../application/update_controller.py | 22 +++++- tests/unit/qt/test_update_controller.py | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index fb84024..5e7f1a0 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -84,6 +84,7 @@ def __init__( self._config = config self._is_user_active: Callable[[], bool] = lambda: False self._update_task: asyncio.Task[None] | None = None + self._pending_version: str | None = None # Derive auto-apply preference from config resolved = self._resolve_config() @@ -214,8 +215,9 @@ def _do_check(self, *, silent: bool) -> None: self._banner.show_error('Updater is not initialized.') return - # Show checking state in settings - self._settings_window.set_checking() + # Preserve the restart button when an update is already pending + if self._pending_version is None: + self._settings_window.set_checking() self._update_task = asyncio.create_task(self._async_check(silent=silent)) @@ -258,8 +260,14 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) logger.debug('Automatic update check: no update available') return - # Update available — show status and start download version = str(result.latest_version) + + # Already downloaded — restore the ready state without re-downloading + if version == self._pending_version: + self._show_ready(version) + return + + # New update available — download it self._settings_window.set_update_status( f'v{version} available', UPDATE_STATUS_AVAILABLE_STYLE, @@ -307,6 +315,8 @@ def _on_download_finished(self, success: bool, version: str) -> None: # Persist the client update timestamp update_user_config(last_client_update=datetime.now(UTC).isoformat()) + 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) @@ -317,7 +327,10 @@ def _on_download_finished(self, success: bool, version: str) -> None: self._apply_update(silent=True) return - # Manual mode (or user is active) — show ready banner and let user choose when to restart + 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', @@ -345,6 +358,7 @@ def _apply_update(self, *, silent: bool = False) -> None: return try: + self._pending_version = None self._client.apply_update_on_exit(restart=True, silent=silent) logger.info('Update scheduled — restarting application') self._app.quit() diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index 0559377..8146f9a 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -228,6 +228,81 @@ def test_download_failure_shows_error() -> None: assert banner.state.name == 'ERROR' settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE) + @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._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, banner, 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 banner.state.name == 'READY' + settings.show_restart_button.assert_called_once() + + @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._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') + + @staticmethod + def test_do_check_preserves_settings_ui_when_pending() -> None: + """set_checking should NOT be called when an update is already pending.""" + ctrl, _app, _client, banner, settings = _make_controller() + ctrl._pending_version = '2.0.0' + + with patch('asyncio.create_task'): + ctrl._do_check(silent=True) + + settings.set_checking.assert_not_called() + + @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() + + with patch('asyncio.create_task'): + ctrl._do_check(silent=True) + + settings.set_checking.assert_called_once() + + @staticmethod + def test_apply_clears_pending_version() -> None: + """_apply_update should clear _pending_version.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._pending_version = '2.0.0' + ctrl._apply_update() + + assert ctrl._pending_version is None + # --------------------------------------------------------------------------- # User-active gating