synodic-client 0.0.1.dev54__tar.gz → 0.0.1.dev56__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/schema.py +33 -1
  4. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/settings.py +6 -0
  5. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/tray.py +1 -1
  6. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/update_controller.py +66 -55
  7. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_controller.py +1 -76
  8. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/LICENSE.md +0 -0
  9. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/README.md +0 -0
  10. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/__init__.py +0 -0
  11. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/__main__.py +0 -0
  12. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/__init__.py +0 -0
  13. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/bootstrap.py +0 -0
  14. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/data.py +0 -0
  15. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/icon.py +0 -0
  16. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/init.py +0 -0
  17. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/instance.py +0 -0
  18. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/qt.py +0 -0
  19. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/schema.py +0 -0
  20. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/action_card.py +0 -0
  22. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/card.py +0 -0
  23. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/install.py +0 -0
  24. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/install_workers.py +0 -0
  25. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/log_panel.py +0 -0
  26. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/plugin_row.py +0 -0
  27. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/projects.py +0 -0
  28. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/screen.py +0 -0
  29. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/sidebar.py +0 -0
  30. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/spinner.py +0 -0
  31. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/tool_update_controller.py +0 -0
  32. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/update_banner.py +0 -0
  33. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/theme.py +0 -0
  34. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/uri.py +0 -0
  35. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/application/workers.py +0 -0
  36. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/cli.py +0 -0
  37. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/client.py +0 -0
  38. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/config.py +0 -0
  39. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/logging.py +0 -0
  40. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/protocol.py +0 -0
  41. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/py.typed +0 -0
  42. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/resolution.py +0 -0
  43. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/schema.py +0 -0
  44. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/startup.py +0 -0
  45. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/synodic_client/updater.py +0 -0
  46. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/__init__.py +0 -0
  47. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/conftest.py +0 -0
  48. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/conftest.py +0 -0
  51. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_action_card.py +0 -0
  52. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_gather_packages.py +0 -0
  53. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_install_preview.py +0 -0
  54. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_log_panel.py +0 -0
  55. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_logging.py +0 -0
  56. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_preview_model.py +0 -0
  57. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_settings.py +0 -0
  58. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_sidebar.py +0 -0
  59. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_tray_window_show.py +0 -0
  60. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_feedback.py +0 -0
  62. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_cli.py +0 -0
  63. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_client_updater.py +0 -0
  64. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_client_version.py +0 -0
  65. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_config.py +0 -0
  66. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_examples.py +0 -0
  67. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_init.py +0 -0
  68. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_resolution.py +0 -0
  70. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_updater.py +0 -0
  71. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev56}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev54
3
+ Version: 0.0.1.dev56
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev54"
18
+ version = "0.0.1.dev56"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -13,6 +13,7 @@ from collections.abc import Callable
13
13
  from dataclasses import dataclass, field
14
14
  from enum import Enum, auto
15
15
  from pathlib import Path
16
+ from typing import Protocol, runtime_checkable
16
17
 
17
18
  from porringer.schema import (
18
19
  PluginInfo,
@@ -336,10 +337,41 @@ class _DispatchState:
336
337
 
337
338
 
338
339
  # ---------------------------------------------------------------------------
339
- # Update banner data models (from update_banner.py)
340
+ # Update view protocol & banner data models
340
341
  # ---------------------------------------------------------------------------
341
342
 
342
343
 
344
+ @runtime_checkable
345
+ class UpdateView(Protocol):
346
+ """Minimal display contract for the self-update lifecycle.
347
+
348
+ :class:`UpdateBanner` satisfies this protocol implicitly via
349
+ structural typing. The controller broadcasts state transitions
350
+ through a ``list[UpdateView]`` so that every window showing update
351
+ status stays in sync.
352
+ """
353
+
354
+ def show_downloading(self, version: str) -> None:
355
+ """Indicate that *version* is being downloaded."""
356
+ ...
357
+
358
+ def show_downloading_progress(self, percentage: int) -> None:
359
+ """Update the download progress indicator."""
360
+ ...
361
+
362
+ def show_ready(self, version: str) -> None:
363
+ """Indicate that *version* is downloaded and ready to install."""
364
+ ...
365
+
366
+ def show_error(self, message: str) -> None:
367
+ """Display an error *message* in the update area."""
368
+ ...
369
+
370
+ def hide_banner(self) -> None:
371
+ """Hide the update banner."""
372
+ ...
373
+
374
+
343
375
  class UpdateBannerState(Enum):
344
376
  """Visual states for the update banner."""
345
377
 
@@ -301,6 +301,12 @@ class SettingsWindow(QMainWindow):
301
301
  """Re-enable the *Check for Updates* button after a check completes."""
302
302
  self._check_updates_btn.setEnabled(True)
303
303
 
304
+ def set_last_checked(self, timestamp: str) -> None:
305
+ """Update the *last updated* label from an ISO 8601 timestamp."""
306
+ relative = _format_relative_time(timestamp)
307
+ self._last_client_update_label.setText(f'Last updated: {relative}')
308
+ self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
309
+
304
310
  def show_restart_button(self) -> None:
305
311
  """Show the *Restart & Update* button."""
306
312
  self._restart_btn.show()
@@ -72,7 +72,7 @@ class TrayScreen:
72
72
  self._update_controller = UpdateController(
73
73
  app,
74
74
  client,
75
- self._banner,
75
+ [self._banner],
76
76
  settings_window=self._settings_window,
77
77
  config=config,
78
78
  )
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING
17
17
  from PySide6.QtCore import QTimer
18
18
  from PySide6.QtWidgets import QApplication
19
19
 
20
+ from synodic_client.application.screen.schema import UpdateView
20
21
  from synodic_client.application.screen.update_banner import UpdateBanner
21
22
  from synodic_client.application.theme import (
22
23
  UPDATE_STATUS_AVAILABLE_STYLE,
@@ -48,22 +49,20 @@ class UpdateController:
48
49
  The running ``QApplication`` (needed for ``quit()`` on auto-apply).
49
50
  client:
50
51
  The Synodic Client service facade.
51
- banner:
52
- The in-app ``UpdateBanner`` widget.
52
+ views:
53
+ One or more :class:`UpdateView` implementations to broadcast
54
+ state transitions to (typically ``UpdateBanner`` instances).
53
55
  settings_window:
54
- The ``SettingsWindow`` (receives status text + colour).
56
+ The ``SettingsWindow`` (check button + last-updated label).
55
57
  config:
56
58
  Optional pre-resolved configuration. ``None`` resolves from disk.
57
- is_user_active:
58
- Predicate returning ``True`` when the user has a visible window.
59
- Auto-apply is deferred while active; checks still run normally.
60
59
  """
61
60
 
62
61
  def __init__(
63
62
  self,
64
63
  app: QApplication,
65
64
  client: Client,
66
- banner: UpdateBanner,
65
+ views: list[UpdateView],
67
66
  *,
68
67
  settings_window: SettingsWindow,
69
68
  config: ResolvedConfig | None = None,
@@ -73,13 +72,13 @@ class UpdateController:
73
72
  Args:
74
73
  app: The running ``QApplication``.
75
74
  client: The Synodic Client service facade.
76
- banner: The in-app ``UpdateBanner`` widget.
77
- settings_window: The settings window for status feedback.
75
+ views: One or more :class:`UpdateView` implementations.
76
+ settings_window: The settings window (check button + timestamp).
78
77
  config: Optional pre-resolved configuration.
79
78
  """
80
79
  self._app = app
81
80
  self._client = client
82
- self._banner = banner
81
+ self._views = views
83
82
  self._settings_window = settings_window
84
83
  self._config = config
85
84
  self._is_user_active: Callable[[], bool] = lambda: False
@@ -94,11 +93,13 @@ class UpdateController:
94
93
  self._auto_update_timer: QTimer | None = None
95
94
  self._restart_auto_update_timer()
96
95
 
97
- # Wire banner signals
98
- self._banner.restart_requested.connect(self._apply_update)
99
- self._banner.retry_requested.connect(lambda: self.check_now(silent=True))
96
+ # Wire banner signals (UpdateBanner-specific, outside the protocol)
97
+ for view in self._views:
98
+ if isinstance(view, UpdateBanner):
99
+ view.restart_requested.connect(self._apply_update)
100
+ view.retry_requested.connect(lambda: self.check_now(silent=True))
100
101
 
101
- # Wire settings check-updates button
102
+ # Wire settings check-updates and restart buttons
102
103
  self._settings_window.check_updates_requested.connect(self._on_manual_check)
103
104
  self._settings_window.restart_requested.connect(self._apply_update)
104
105
 
@@ -128,6 +129,25 @@ class UpdateController:
128
129
  """
129
130
  return self._auto_apply and not self._is_user_active()
130
131
 
132
+ def _persist_check_timestamp(self) -> None:
133
+ """Persist the current time as *last_client_update* and refresh the label."""
134
+ ts = datetime.now(UTC).isoformat()
135
+ update_user_config(last_client_update=ts)
136
+ self._settings_window.set_last_checked(ts)
137
+
138
+ def _report_error(self, message: str, *, silent: bool) -> None:
139
+ """Show an error to the user or log it, depending on *silent*.
140
+
141
+ Always updates the settings status line. When not *silent*,
142
+ also broadcasts the error to all update-banner views.
143
+ """
144
+ self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
145
+ if silent:
146
+ logger.warning('%s', message)
147
+ else:
148
+ for view in self._views:
149
+ view.show_error(message)
150
+
131
151
  # ------------------------------------------------------------------
132
152
  # Timer management
133
153
  # ------------------------------------------------------------------
@@ -212,10 +232,11 @@ class UpdateController:
212
232
  """Run an update check."""
213
233
  if self._client.updater is None:
214
234
  if not silent:
215
- self._banner.show_error('Updater is not initialized.')
235
+ for view in self._views:
236
+ view.show_error('Updater is not initialized.')
216
237
  return
217
238
 
218
- # Preserve the restart button when an update is already pending
239
+ # Preserve the banner state when an update is already pending
219
240
  if self._pending_version is None:
220
241
  self._settings_window.set_checking()
221
242
 
@@ -237,21 +258,16 @@ class UpdateController:
237
258
  self._settings_window.reset_check_updates_button()
238
259
 
239
260
  if result is None:
240
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
241
- if not silent:
242
- self._banner.show_error('Failed to check for updates.')
243
- else:
244
- logger.warning('Automatic update check failed (no result)')
261
+ self._report_error('Failed to check for updates.', silent=silent)
245
262
  return
246
263
 
247
264
  if result.error:
248
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
249
- if not silent:
250
- self._banner.show_error(result.error)
251
- else:
252
- logger.warning('Automatic update check failed: %s', result.error)
265
+ self._report_error(result.error, silent=silent)
253
266
  return
254
267
 
268
+ # Successful check — refresh the "last updated" timestamp
269
+ self._persist_check_timestamp()
270
+
255
271
  if not result.available:
256
272
  self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
257
273
  if not silent:
@@ -268,22 +284,15 @@ class UpdateController:
268
284
  return
269
285
 
270
286
  # New update available — download it
271
- self._settings_window.set_update_status(
272
- f'v{version} available',
273
- UPDATE_STATUS_AVAILABLE_STYLE,
274
- )
275
- self._banner.show_downloading(version)
287
+ self._settings_window.set_update_status(f'v{version} available', UPDATE_STATUS_AVAILABLE_STYLE)
288
+ for view in self._views:
289
+ view.show_downloading(version)
276
290
  self._start_download(version)
277
291
 
278
292
  def _on_check_error(self, error: str, *, silent: bool = False) -> None:
279
293
  """Handle unexpected exception during update check."""
280
294
  self._settings_window.reset_check_updates_button()
281
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
282
-
283
- if not silent:
284
- self._banner.show_error(f'Update check error: {error}')
285
- else:
286
- logger.warning('Automatic update check error: %s', error)
295
+ self._report_error(f'Update check error: {error}', silent=silent)
287
296
 
288
297
  # ------------------------------------------------------------------
289
298
  # Download flow
@@ -298,50 +307,51 @@ class UpdateController:
298
307
  try:
299
308
  success = await download_update(
300
309
  self._client,
301
- on_progress=self._banner.show_downloading_progress,
310
+ on_progress=self._on_download_progress,
302
311
  )
303
312
  self._on_download_finished(success, version)
304
313
  except Exception as exc:
305
314
  logger.exception('Update download failed')
306
315
  self._on_download_error(str(exc))
307
316
 
317
+ def _on_download_progress(self, percentage: int) -> None:
318
+ """Broadcast download progress to all views."""
319
+ for view in self._views:
320
+ view.show_downloading_progress(percentage)
321
+
308
322
  def _on_download_finished(self, success: bool, version: str) -> None:
309
323
  """Handle download completion."""
310
324
  if not success:
311
- self._banner.show_error('Download failed. Please try again later.')
312
325
  self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
326
+ for view in self._views:
327
+ view.show_error('Download failed. Please try again later.')
313
328
  return
314
329
 
315
- # Persist the client update timestamp
316
- update_user_config(last_client_update=datetime.now(UTC).isoformat())
330
+ # Persist the client-update timestamp (actual update downloaded)
331
+ ts = datetime.now(UTC).isoformat()
332
+ update_user_config(last_client_update=ts)
317
333
 
318
334
  self._pending_version = version
319
335
 
320
336
  if self._can_auto_apply():
321
337
  # Silently apply and restart — no banner, no user interaction
322
338
  logger.info('Auto-applying update v%s', version)
323
- self._settings_window.set_update_status(
324
- f'v{version} installing\u2026',
325
- UPDATE_STATUS_AVAILABLE_STYLE,
326
- )
327
339
  self._apply_update(silent=True)
328
340
  return
329
341
 
330
342
  self._show_ready(version)
331
343
 
332
344
  def _show_ready(self, version: str) -> None:
333
- """Present the *ready to restart* state in both UIs."""
334
- self._banner.show_ready(version)
335
- self._settings_window.set_update_status(
336
- f'v{version} ready',
337
- UPDATE_STATUS_UP_TO_DATE_STYLE,
338
- )
345
+ """Present the *ready to restart* state across all views."""
346
+ self._settings_window.set_update_status(f'v{version} ready', UPDATE_STATUS_UP_TO_DATE_STYLE)
339
347
  self._settings_window.show_restart_button()
348
+ for view in self._views:
349
+ view.show_ready(version)
340
350
 
341
351
  def _on_download_error(self, error: str) -> None:
342
- """Handle download error — show error banner."""
343
- self._banner.show_error(f'Download error: {error}')
344
- self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
352
+ """Handle download error — show error across all views."""
353
+ for view in self._views:
354
+ view.show_error(f'Download error: {error}')
345
355
 
346
356
  # ------------------------------------------------------------------
347
357
  # Apply
@@ -364,4 +374,5 @@ class UpdateController:
364
374
  self._app.quit()
365
375
  except Exception as e:
366
376
  logger.error('Failed to apply update: %s', e)
367
- self._banner.show_error(f'Failed to apply update: {e}')
377
+ for view in self._views:
378
+ view.show_error(f'Failed to apply update: {e}')
@@ -74,7 +74,7 @@ def _make_controller(
74
74
  controller = UpdateController(
75
75
  app,
76
76
  client,
77
- banner,
77
+ [banner],
78
78
  settings_window=settings,
79
79
  config=config,
80
80
  )
@@ -228,81 +228,6 @@ class TestDownloadFinished:
228
228
  assert banner.state.name == 'ERROR'
229
229
  settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
230
230
 
231
- @staticmethod
232
- def test_download_sets_pending_version() -> None:
233
- """A successful download should set _pending_version."""
234
- ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
235
- ctrl._on_download_finished(True, '2.0.0')
236
-
237
- assert ctrl._pending_version == '2.0.0'
238
-
239
-
240
- # ---------------------------------------------------------------------------
241
- # Pending version — skip redundant downloads
242
- # ---------------------------------------------------------------------------
243
-
244
-
245
- class TestPendingVersion:
246
- """Verify behaviour when an update is already downloaded and pending."""
247
-
248
- @staticmethod
249
- def test_check_skips_download_when_version_already_pending() -> None:
250
- """Re-checking the same version should restore ready state, not re-download."""
251
- ctrl, _app, _client, banner, settings = _make_controller(auto_apply=False)
252
- ctrl._pending_version = '2.0.0'
253
-
254
- result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
255
-
256
- with patch.object(ctrl, '_start_download') as mock_dl:
257
- ctrl._on_check_finished(result, silent=True)
258
-
259
- mock_dl.assert_not_called()
260
- assert banner.state.name == 'READY'
261
- settings.show_restart_button.assert_called_once()
262
-
263
- @staticmethod
264
- def test_check_downloads_when_newer_version() -> None:
265
- """A different version should trigger a fresh download."""
266
- ctrl, _app, _client, banner, settings = _make_controller(auto_apply=False)
267
- ctrl._pending_version = '1.5.0'
268
-
269
- result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
270
-
271
- with patch.object(ctrl, '_start_download') as mock_dl:
272
- ctrl._on_check_finished(result, silent=True)
273
-
274
- mock_dl.assert_called_once_with('2.0.0')
275
-
276
- @staticmethod
277
- def test_do_check_preserves_settings_ui_when_pending() -> None:
278
- """set_checking should NOT be called when an update is already pending."""
279
- ctrl, _app, _client, banner, settings = _make_controller()
280
- ctrl._pending_version = '2.0.0'
281
-
282
- with patch('asyncio.create_task'):
283
- ctrl._do_check(silent=True)
284
-
285
- settings.set_checking.assert_not_called()
286
-
287
- @staticmethod
288
- def test_do_check_shows_checking_when_no_pending() -> None:
289
- """set_checking should be called when there is no pending update."""
290
- ctrl, _app, _client, banner, settings = _make_controller()
291
-
292
- with patch('asyncio.create_task'):
293
- ctrl._do_check(silent=True)
294
-
295
- settings.set_checking.assert_called_once()
296
-
297
- @staticmethod
298
- def test_apply_clears_pending_version() -> None:
299
- """_apply_update should clear _pending_version."""
300
- ctrl, app, client, banner, settings = _make_controller()
301
- ctrl._pending_version = '2.0.0'
302
- ctrl._apply_update()
303
-
304
- assert ctrl._pending_version is None
305
-
306
231
 
307
232
  # ---------------------------------------------------------------------------
308
233
  # User-active gating