Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions synodic_client/application/update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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',
Expand Down Expand Up @@ -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()
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/qt/test_update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down