synodic-client 0.0.1.dev53__tar.gz → 0.0.1.dev55__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.dev53 → synodic_client-0.0.1.dev55}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/schema.py +19 -1
  4. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/settings.py +16 -30
  5. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/tray.py +1 -2
  6. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/update_controller.py +61 -55
  7. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_settings.py +8 -16
  8. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_controller.py +141 -104
  9. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/LICENSE.md +0 -0
  10. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/README.md +0 -0
  11. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/__init__.py +0 -0
  12. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/__main__.py +0 -0
  13. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/__init__.py +0 -0
  14. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/bootstrap.py +0 -0
  15. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/data.py +0 -0
  16. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/icon.py +0 -0
  17. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/init.py +0 -0
  18. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/instance.py +0 -0
  19. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/qt.py +0 -0
  20. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/schema.py +0 -0
  21. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/action_card.py +0 -0
  23. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/install.py +0 -0
  25. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/install_workers.py +0 -0
  26. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/log_panel.py +0 -0
  27. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/plugin_row.py +0 -0
  28. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/projects.py +0 -0
  29. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/screen.py +0 -0
  30. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/sidebar.py +0 -0
  31. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/spinner.py +0 -0
  32. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/tool_update_controller.py +0 -0
  33. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/update_banner.py +0 -0
  34. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/theme.py +0 -0
  35. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/uri.py +0 -0
  36. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/application/workers.py +0 -0
  37. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/cli.py +0 -0
  38. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/client.py +0 -0
  39. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/config.py +0 -0
  40. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/logging.py +0 -0
  41. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/protocol.py +0 -0
  42. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/py.typed +0 -0
  43. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/resolution.py +0 -0
  44. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/schema.py +0 -0
  45. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/startup.py +0 -0
  46. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/synodic_client/updater.py +0 -0
  47. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/conftest.py +0 -0
  49. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/conftest.py +0 -0
  52. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_action_card.py +0 -0
  53. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_gather_packages.py +0 -0
  54. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_install_preview.py +0 -0
  55. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_log_panel.py +0 -0
  56. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_logging.py +0 -0
  57. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_preview_model.py +0 -0
  58. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_sidebar.py +0 -0
  59. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_tray_window_show.py +0 -0
  60. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_feedback.py +0 -0
  62. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_cli.py +0 -0
  63. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_client_updater.py +0 -0
  64. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_client_version.py +0 -0
  65. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_config.py +0 -0
  66. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_examples.py +0 -0
  67. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_init.py +0 -0
  68. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_resolution.py +0 -0
  70. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_updater.py +0 -0
  71. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev53 → synodic_client-0.0.1.dev55}/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.dev53
3
+ Version: 0.0.1.dev55
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.dev53"
18
+ version = "0.0.1.dev55"
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,27 @@ 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
+ def show_downloading_progress(self, percentage: int) -> None: ...
356
+ def show_ready(self, version: str) -> None: ...
357
+ def show_error(self, message: str) -> None: ...
358
+ def hide_banner(self) -> None: ...
359
+
360
+
343
361
  class UpdateBannerState(Enum):
344
362
  """Visual states for the update banner."""
345
363
 
@@ -31,7 +31,8 @@ from PySide6.QtWidgets import (
31
31
  from synodic_client.application.icon import app_icon
32
32
  from synodic_client.application.screen import _format_relative_time
33
33
  from synodic_client.application.screen.card import CardFrame
34
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
34
+ from synodic_client.application.screen.update_banner import UpdateBanner
35
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
35
36
  from synodic_client.logging import log_path, set_debug_level
36
37
  from synodic_client.resolution import ResolvedConfig, update_user_config
37
38
  from synodic_client.schema import GITHUB_REPO_URL
@@ -54,9 +55,6 @@ class SettingsWindow(QMainWindow):
54
55
  check_updates_requested = Signal()
55
56
  """Emitted when the user clicks the *Check for Updates* button."""
56
57
 
57
- restart_requested = Signal()
58
- """Emitted when the user clicks the *Restart & Update* button."""
59
-
60
58
  def showEvent(self, event: QShowEvent) -> None: # noqa: N802
61
59
  """[DIAG] Log every show event with a stack trace."""
62
60
  geo = self.geometry()
@@ -199,18 +197,13 @@ class SettingsWindow(QMainWindow):
199
197
  self._check_updates_btn = QPushButton('Check for Updates\u2026')
200
198
  self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
201
199
  row.addWidget(self._check_updates_btn)
202
- self._update_status_label = QLabel('')
203
- self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
204
- row.addWidget(self._update_status_label)
205
-
206
- self._restart_btn = QPushButton('Restart \u0026 Update')
207
- self._restart_btn.clicked.connect(self.restart_requested.emit)
208
- self._restart_btn.hide()
209
- row.addWidget(self._restart_btn)
210
-
211
200
  row.addStretch()
212
201
  content.addLayout(row)
213
202
 
203
+ # Embedded update banner (same widget used in the main window)
204
+ self._update_banner = UpdateBanner()
205
+ content.addWidget(self._update_banner)
206
+
214
207
  # Last client update timestamp
215
208
  self._last_client_update_label = QLabel('')
216
209
  self._last_client_update_label.setStyleSheet('color: #808080; font-size: 11px;')
@@ -280,31 +273,25 @@ class SettingsWindow(QMainWindow):
280
273
  else:
281
274
  self._last_client_update_label.setText('')
282
275
 
283
- def set_update_status(self, text: str, style: str = '') -> None:
284
- """Set the inline status text next to the *Check for Updates* button.
276
+ @property
277
+ def update_banner(self) -> UpdateBanner:
278
+ """The embedded :class:`UpdateBanner` for this window."""
279
+ return self._update_banner
285
280
 
286
- Args:
287
- text: The status message.
288
- style: Optional stylesheet for the label (e.g. color).
289
- """
290
- self._update_status_label.setText(text)
291
- self._update_status_label.setStyleSheet(style)
281
+ def set_last_updated(self, timestamp: str) -> None:
282
+ """Refresh the *Last updated* label from a raw ISO timestamp."""
283
+ relative = _format_relative_time(timestamp)
284
+ self._last_client_update_label.setText(f'Last updated: {relative}')
285
+ self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
292
286
 
293
287
  def set_checking(self) -> None:
294
- """Enter the *checking* state — disable button and show status."""
288
+ """Enter the *checking* state — disable button."""
295
289
  self._check_updates_btn.setEnabled(False)
296
- self._restart_btn.hide()
297
- self._update_status_label.setText('Checking\u2026')
298
- self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
299
290
 
300
291
  def reset_check_updates_button(self) -> None:
301
292
  """Re-enable the *Check for Updates* button after a check completes."""
302
293
  self._check_updates_btn.setEnabled(True)
303
294
 
304
- def show_restart_button(self) -> None:
305
- """Show the *Restart & Update* button."""
306
- self._restart_btn.show()
307
-
308
295
  def show(self) -> None:
309
296
  """Sync controls from config, then show the window."""
310
297
  self.sync_from_config()
@@ -350,7 +337,6 @@ class SettingsWindow(QMainWindow):
350
337
  def _on_check_updates_clicked(self) -> None:
351
338
  """Handle the *Check for Updates* button click."""
352
339
  self._check_updates_btn.setEnabled(False)
353
- self._update_status_label.setText('Checking\u2026')
354
340
  self.check_updates_requested.emit()
355
341
 
356
342
  def _on_channel_changed(self, index: int) -> None:
@@ -68,11 +68,10 @@ class TrayScreen:
68
68
  window.settings_requested.connect(self._show_settings)
69
69
 
70
70
  # Update controller - owns the self-update lifecycle & timer
71
- self._banner = window.update_banner
72
71
  self._update_controller = UpdateController(
73
72
  app,
74
73
  client,
75
- self._banner,
74
+ [window.update_banner, self._settings_window.update_banner],
76
75
  settings_window=self._settings_window,
77
76
  config=config,
78
77
  )
@@ -17,12 +17,8 @@ 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
- from synodic_client.application.theme import (
22
- UPDATE_STATUS_AVAILABLE_STYLE,
23
- UPDATE_STATUS_ERROR_STYLE,
24
- UPDATE_STATUS_UP_TO_DATE_STYLE,
25
- )
26
22
  from synodic_client.application.workers import check_for_update, download_update
27
23
  from synodic_client.resolution import (
28
24
  ResolvedConfig,
@@ -48,22 +44,20 @@ class UpdateController:
48
44
  The running ``QApplication`` (needed for ``quit()`` on auto-apply).
49
45
  client:
50
46
  The Synodic Client service facade.
51
- banner:
52
- The in-app ``UpdateBanner`` widget.
47
+ views:
48
+ One or more :class:`UpdateView` implementations to broadcast
49
+ state transitions to (typically ``UpdateBanner`` instances).
53
50
  settings_window:
54
- The ``SettingsWindow`` (receives status text + colour).
51
+ The ``SettingsWindow`` (check button + last-updated label).
55
52
  config:
56
53
  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
54
  """
61
55
 
62
56
  def __init__(
63
57
  self,
64
58
  app: QApplication,
65
59
  client: Client,
66
- banner: UpdateBanner,
60
+ views: list[UpdateView],
67
61
  *,
68
62
  settings_window: SettingsWindow,
69
63
  config: ResolvedConfig | None = None,
@@ -73,17 +67,18 @@ class UpdateController:
73
67
  Args:
74
68
  app: The running ``QApplication``.
75
69
  client: The Synodic Client service facade.
76
- banner: The in-app ``UpdateBanner`` widget.
77
- settings_window: The settings window for status feedback.
70
+ views: One or more :class:`UpdateView` implementations.
71
+ settings_window: The settings window (check button + timestamp).
78
72
  config: Optional pre-resolved configuration.
79
73
  """
80
74
  self._app = app
81
75
  self._client = client
82
- self._banner = banner
76
+ self._views = views
83
77
  self._settings_window = settings_window
84
78
  self._config = config
85
79
  self._is_user_active: Callable[[], bool] = lambda: False
86
80
  self._update_task: asyncio.Task[None] | None = None
81
+ self._pending_version: str | None = None
87
82
 
88
83
  # Derive auto-apply preference from config
89
84
  resolved = self._resolve_config()
@@ -93,13 +88,14 @@ class UpdateController:
93
88
  self._auto_update_timer: QTimer | None = None
94
89
  self._restart_auto_update_timer()
95
90
 
96
- # Wire banner signals
97
- self._banner.restart_requested.connect(self._apply_update)
98
- self._banner.retry_requested.connect(lambda: self.check_now(silent=True))
91
+ # Wire banner signals (UpdateBanner-specific, outside the protocol)
92
+ for view in self._views:
93
+ if isinstance(view, UpdateBanner):
94
+ view.restart_requested.connect(self._apply_update)
95
+ view.retry_requested.connect(lambda: self.check_now(silent=True))
99
96
 
100
97
  # Wire settings check-updates button
101
98
  self._settings_window.check_updates_requested.connect(self._on_manual_check)
102
- self._settings_window.restart_requested.connect(self._apply_update)
103
99
 
104
100
  def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
105
101
  """Set the predicate used to defer auto-apply when the user is active.
@@ -211,11 +207,13 @@ class UpdateController:
211
207
  """Run an update check."""
212
208
  if self._client.updater is None:
213
209
  if not silent:
214
- self._banner.show_error('Updater is not initialized.')
210
+ for view in self._views:
211
+ view.show_error('Updater is not initialized.')
215
212
  return
216
213
 
217
- # Show checking state in settings
218
- self._settings_window.set_checking()
214
+ # Preserve the banner state when an update is already pending
215
+ if self._pending_version is None:
216
+ self._settings_window.set_checking()
219
217
 
220
218
  self._update_task = asyncio.create_task(self._async_check(silent=silent))
221
219
 
@@ -235,45 +233,47 @@ class UpdateController:
235
233
  self._settings_window.reset_check_updates_button()
236
234
 
237
235
  if result is None:
238
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
239
236
  if not silent:
240
- self._banner.show_error('Failed to check for updates.')
237
+ for view in self._views:
238
+ view.show_error('Failed to check for updates.')
241
239
  else:
242
240
  logger.warning('Automatic update check failed (no result)')
243
241
  return
244
242
 
245
243
  if result.error:
246
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
247
244
  if not silent:
248
- self._banner.show_error(result.error)
245
+ for view in self._views:
246
+ view.show_error(result.error)
249
247
  else:
250
248
  logger.warning('Automatic update check failed: %s', result.error)
251
249
  return
252
250
 
253
251
  if not result.available:
254
- self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
255
252
  if not silent:
256
253
  logger.info('No updates available (current: %s)', result.current_version)
257
254
  else:
258
255
  logger.debug('Automatic update check: no update available')
259
256
  return
260
257
 
261
- # Update available — show status and start download
262
258
  version = str(result.latest_version)
263
- self._settings_window.set_update_status(
264
- f'v{version} available',
265
- UPDATE_STATUS_AVAILABLE_STYLE,
266
- )
267
- self._banner.show_downloading(version)
259
+
260
+ # Already downloaded — restore the ready state without re-downloading
261
+ if version == self._pending_version:
262
+ self._show_ready(version)
263
+ return
264
+
265
+ # New update available — download it
266
+ for view in self._views:
267
+ view.show_downloading(version)
268
268
  self._start_download(version)
269
269
 
270
270
  def _on_check_error(self, error: str, *, silent: bool = False) -> None:
271
271
  """Handle unexpected exception during update check."""
272
272
  self._settings_window.reset_check_updates_button()
273
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
274
273
 
275
274
  if not silent:
276
- self._banner.show_error(f'Update check error: {error}')
275
+ for view in self._views:
276
+ view.show_error(f'Update check error: {error}')
277
277
  else:
278
278
  logger.warning('Automatic update check error: %s', error)
279
279
 
@@ -290,45 +290,49 @@ class UpdateController:
290
290
  try:
291
291
  success = await download_update(
292
292
  self._client,
293
- on_progress=self._banner.show_downloading_progress,
293
+ on_progress=self._on_download_progress,
294
294
  )
295
295
  self._on_download_finished(success, version)
296
296
  except Exception as exc:
297
297
  logger.exception('Update download failed')
298
298
  self._on_download_error(str(exc))
299
299
 
300
+ def _on_download_progress(self, percentage: int) -> None:
301
+ """Broadcast download progress to all views."""
302
+ for view in self._views:
303
+ view.show_downloading_progress(percentage)
304
+
300
305
  def _on_download_finished(self, success: bool, version: str) -> None:
301
306
  """Handle download completion."""
302
307
  if not success:
303
- self._banner.show_error('Download failed. Please try again later.')
304
- self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
308
+ for view in self._views:
309
+ view.show_error('Download failed. Please try again later.')
305
310
  return
306
311
 
307
- # Persist the client update timestamp
308
- update_user_config(last_client_update=datetime.now(UTC).isoformat())
312
+ # Persist and display the client update timestamp
313
+ ts = datetime.now(UTC).isoformat()
314
+ update_user_config(last_client_update=ts)
315
+ self._settings_window.set_last_updated(ts)
316
+
317
+ self._pending_version = version
309
318
 
310
319
  if self._can_auto_apply():
311
320
  # Silently apply and restart — no banner, no user interaction
312
321
  logger.info('Auto-applying update v%s', version)
313
- self._settings_window.set_update_status(
314
- f'v{version} installing\u2026',
315
- UPDATE_STATUS_AVAILABLE_STYLE,
316
- )
317
322
  self._apply_update(silent=True)
318
323
  return
319
324
 
320
- # Manual mode (or user is active) — show ready banner and let user choose when to restart
321
- self._banner.show_ready(version)
322
- self._settings_window.set_update_status(
323
- f'v{version} ready',
324
- UPDATE_STATUS_UP_TO_DATE_STYLE,
325
- )
326
- self._settings_window.show_restart_button()
325
+ self._show_ready(version)
326
+
327
+ def _show_ready(self, version: str) -> None:
328
+ """Present the *ready to restart* state across all views."""
329
+ for view in self._views:
330
+ view.show_ready(version)
327
331
 
328
332
  def _on_download_error(self, error: str) -> None:
329
- """Handle download error — show error banner."""
330
- self._banner.show_error(f'Download error: {error}')
331
- self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
333
+ """Handle download error — show error across all views."""
334
+ for view in self._views:
335
+ view.show_error(f'Download error: {error}')
332
336
 
333
337
  # ------------------------------------------------------------------
334
338
  # Apply
@@ -345,9 +349,11 @@ class UpdateController:
345
349
  return
346
350
 
347
351
  try:
352
+ self._pending_version = None
348
353
  self._client.apply_update_on_exit(restart=True, silent=silent)
349
354
  logger.info('Update scheduled — restarting application')
350
355
  self._app.quit()
351
356
  except Exception as e:
352
357
  logger.error('Failed to apply update: %s', e)
353
- self._banner.show_error(f'Failed to apply update: {e}')
358
+ for view in self._views:
359
+ view.show_error(f'Failed to apply update: {e}')
@@ -334,16 +334,18 @@ class TestSyncDoesNotEmit:
334
334
 
335
335
 
336
336
  class TestCheckForUpdatesButton:
337
- """Verify the Check for Updates button and inline status label."""
337
+ """Verify the Check for Updates button and embedded update banner."""
338
338
 
339
339
  @staticmethod
340
- def test_button_and_label_exist() -> None:
341
- """Window has the check-updates button and status label."""
340
+ def test_button_and_banner_exist() -> None:
341
+ """Window has the check-updates button and an embedded UpdateBanner."""
342
342
  window = _make_window()
343
343
  assert hasattr(window, '_check_updates_btn')
344
- assert hasattr(window, '_update_status_label')
345
344
  assert window._check_updates_btn.text() == 'Check for Updates\u2026'
346
- assert not window._update_status_label.text()
345
+ assert hasattr(window, '_update_banner')
346
+ from synodic_client.application.screen.update_banner import UpdateBanner
347
+
348
+ assert isinstance(window.update_banner, UpdateBanner)
347
349
 
348
350
  @staticmethod
349
351
  def test_click_emits_signal_and_disables() -> None:
@@ -356,15 +358,6 @@ class TestCheckForUpdatesButton:
356
358
 
357
359
  signal_spy.assert_called_once()
358
360
  assert window._check_updates_btn.isEnabled() is False
359
- assert window._update_status_label.text() == 'Checking\u2026'
360
-
361
- @staticmethod
362
- def test_set_update_status() -> None:
363
- """set_update_status sets the label text and style."""
364
- window = _make_window()
365
- window.set_update_status('Up to date', 'color: green;')
366
- assert window._update_status_label.text() == 'Up to date'
367
- assert 'green' in window._update_status_label.styleSheet()
368
361
 
369
362
  @staticmethod
370
363
  def test_reset_check_updates_button() -> None:
@@ -378,8 +371,7 @@ class TestCheckForUpdatesButton:
378
371
 
379
372
  @staticmethod
380
373
  def test_set_checking() -> None:
381
- """set_checking disables the button and shows 'Checking\u2026' status."""
374
+ """set_checking disables the button."""
382
375
  window = _make_window()
383
376
  window.set_checking()
384
377
  assert window._check_updates_btn.isEnabled() is False
385
- assert window._update_status_label.text() == 'Checking\u2026'
@@ -8,11 +8,6 @@ from unittest.mock import MagicMock, patch
8
8
  from packaging.version import Version
9
9
 
10
10
  from synodic_client.application.screen.update_banner import UpdateBanner
11
- from synodic_client.application.theme import (
12
- UPDATE_STATUS_AVAILABLE_STYLE,
13
- UPDATE_STATUS_ERROR_STYLE,
14
- UPDATE_STATUS_UP_TO_DATE_STYLE,
15
- )
16
11
  from synodic_client.application.update_controller import UpdateController
17
12
  from synodic_client.resolution import ResolvedConfig
18
13
  from synodic_client.schema import (
@@ -51,10 +46,10 @@ def _make_controller(
51
46
  auto_apply: bool = True,
52
47
  auto_update_interval_minutes: int = 0,
53
48
  is_user_active: bool = False,
54
- ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]:
49
+ ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, UpdateBanner, MagicMock]:
55
50
  """Build an ``UpdateController`` with mocked collaborators.
56
51
 
57
- Returns (controller, app_mock, client_mock, banner, settings_mock).
52
+ Returns (controller, app_mock, client_mock, banner1, banner2, settings_mock).
58
53
  """
59
54
  config = _make_config(
60
55
  auto_apply=auto_apply,
@@ -64,8 +59,11 @@ def _make_controller(
64
59
  app = MagicMock()
65
60
  client = MagicMock()
66
61
  client.updater = MagicMock()
67
- banner = UpdateBanner()
62
+ banner1 = UpdateBanner()
63
+ banner2 = UpdateBanner()
68
64
  settings = MagicMock()
65
+ # settings mock only needs: check_updates_requested, set_checking,
66
+ # reset_check_updates_button, set_last_updated
69
67
 
70
68
  with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg:
71
69
  mock_ucfg.return_value = MagicMock(
@@ -74,13 +72,13 @@ def _make_controller(
74
72
  controller = UpdateController(
75
73
  app,
76
74
  client,
77
- banner,
75
+ [banner1, banner2],
78
76
  settings_window=settings,
79
77
  config=config,
80
78
  )
81
79
  controller.set_user_active_predicate(lambda: is_user_active)
82
80
 
83
- return controller, app, client, banner, settings
81
+ return controller, app, client, banner1, banner2, settings
84
82
 
85
83
 
86
84
  # ---------------------------------------------------------------------------
@@ -92,61 +90,55 @@ class TestCheckFinished:
92
90
  """Verify _on_check_finished routes results correctly."""
93
91
 
94
92
  @staticmethod
95
- def test_none_result_sets_error_status() -> None:
96
- """A None result should set 'Check failed' in red."""
97
- ctrl, _app, _client, banner, settings = _make_controller()
93
+ def test_none_result_shows_error_banner_when_not_silent() -> None:
94
+ """A None result with silent=False should show error on all banners."""
95
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
98
96
  ctrl._on_check_finished(None, silent=False)
99
97
 
100
98
  settings.reset_check_updates_button.assert_called_once()
101
- settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
102
-
103
- @staticmethod
104
- def test_none_result_shows_banner_when_not_silent() -> None:
105
- """A None result with silent=False should show the error banner."""
106
- ctrl, _app, _client, banner, settings = _make_controller()
107
- ctrl._on_check_finished(None, silent=False)
108
-
109
- assert banner.state.name == 'ERROR'
99
+ assert b1.state.name == 'ERROR'
100
+ assert b2.state.name == 'ERROR'
110
101
 
111
102
  @staticmethod
112
103
  def test_none_result_no_banner_when_silent() -> None:
113
104
  """A None result with silent=True should NOT show the error banner."""
114
- ctrl, _app, _client, banner, settings = _make_controller()
105
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
115
106
  ctrl._on_check_finished(None, silent=True)
116
107
 
117
- assert banner.state.name == 'HIDDEN'
108
+ assert b1.state.name == 'HIDDEN'
109
+ assert b2.state.name == 'HIDDEN'
118
110
 
119
111
  @staticmethod
120
- def test_error_result_sets_error_status() -> None:
121
- """An error result should set 'Check failed' status."""
122
- ctrl, _app, _client, banner, settings = _make_controller()
112
+ def test_error_result_shows_error_banner() -> None:
113
+ """An error result should show error on all banners."""
114
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
123
115
  result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found')
124
116
  ctrl._on_check_finished(result, silent=False)
125
117
 
126
- settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
118
+ assert b1.state.name == 'ERROR'
119
+ assert b2.state.name == 'ERROR'
127
120
 
128
121
  @staticmethod
129
- def test_no_update_sets_up_to_date() -> None:
130
- """No update available should set 'Up to date' in green."""
131
- ctrl, _app, _client, banner, settings = _make_controller()
122
+ def test_no_update_available_banners_remain_hidden() -> None:
123
+ """No update available should leave banners hidden."""
124
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
132
125
  result = UpdateInfo(available=False, current_version=Version('1.0.0'))
133
126
  ctrl._on_check_finished(result, silent=False)
134
127
 
135
- settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
128
+ assert b1.state.name == 'HIDDEN'
129
+ assert b2.state.name == 'HIDDEN'
136
130
 
137
131
  @staticmethod
138
- def test_update_available_sets_status_and_starts_download() -> None:
139
- """Available update should set orange status and start download."""
140
- ctrl, _app, _client, banner, settings = _make_controller()
132
+ def test_update_available_shows_downloading_and_starts_download() -> None:
133
+ """Available update should show downloading on all banners and start download."""
134
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
141
135
  result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
142
136
 
143
137
  with patch.object(ctrl, '_start_download') as mock_dl:
144
138
  ctrl._on_check_finished(result, silent=False)
145
139
 
146
- settings.set_update_status.assert_called_once_with(
147
- 'v2.0.0 available',
148
- UPDATE_STATUS_AVAILABLE_STYLE,
149
- )
140
+ assert b1.state.name == 'DOWNLOADING'
141
+ assert b2.state.name == 'DOWNLOADING'
150
142
  mock_dl.assert_called_once_with('2.0.0')
151
143
 
152
144
 
@@ -161,7 +153,7 @@ class TestDownloadFinished:
161
153
  @staticmethod
162
154
  def test_auto_apply_calls_apply_update() -> None:
163
155
  """When auto_apply=True, a successful download should call _apply_update(silent=True)."""
164
- ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
156
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
165
157
 
166
158
  with patch.object(ctrl, '_apply_update') as mock_apply:
167
159
  ctrl._on_download_finished(True, '2.0.0')
@@ -171,45 +163,27 @@ class TestDownloadFinished:
171
163
  @staticmethod
172
164
  def test_auto_apply_does_not_show_ready_banner() -> None:
173
165
  """When auto_apply=True, the ready banner should NOT be shown."""
174
- ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
166
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
175
167
 
176
168
  with patch.object(ctrl, '_apply_update'):
177
169
  ctrl._on_download_finished(True, '2.0.0')
178
170
 
179
- # Banner should not be in READY state
180
- assert banner.state.name != 'READY'
181
-
182
- @staticmethod
183
- def test_no_auto_apply_shows_ready_banner() -> None:
184
- """When auto_apply=False, a successful download should show the ready banner."""
185
- ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
186
- ctrl._on_download_finished(True, '2.0.0')
187
-
188
- assert banner.state.name == 'READY'
189
-
190
- @staticmethod
191
- def test_no_auto_apply_sets_ready_status() -> None:
192
- """When auto_apply=False, status should show 'v2.0.0 ready' in green."""
193
- ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
194
- ctrl._on_download_finished(True, '2.0.0')
195
-
196
- settings.set_update_status.assert_called_with(
197
- 'v2.0.0 ready',
198
- UPDATE_STATUS_UP_TO_DATE_STYLE,
199
- )
171
+ assert b1.state.name != 'READY'
172
+ assert b2.state.name != 'READY'
200
173
 
201
174
  @staticmethod
202
- def test_no_auto_apply_shows_restart_button() -> None:
203
- """When auto_apply=False, the restart button should be shown in settings."""
204
- ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
175
+ def test_no_auto_apply_shows_ready_banners() -> None:
176
+ """When auto_apply=False, a successful download should show ready on all banners."""
177
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False)
205
178
  ctrl._on_download_finished(True, '2.0.0')
206
179
 
207
- settings.show_restart_button.assert_called_once()
180
+ assert b1.state.name == 'READY'
181
+ assert b2.state.name == 'READY'
208
182
 
209
183
  @staticmethod
210
- def test_user_active_shows_restart_button() -> None:
211
- """When user is active, the restart button should be shown in settings."""
212
- ctrl, app, client, banner, settings = _make_controller(
184
+ def test_user_active_shows_ready_banners() -> None:
185
+ """When user is active, the ready banners should be shown."""
186
+ ctrl, app, client, b1, b2, settings = _make_controller(
213
187
  auto_apply=True,
214
188
  is_user_active=True,
215
189
  )
@@ -217,16 +191,92 @@ class TestDownloadFinished:
217
191
  with patch.object(ctrl, '_apply_update'):
218
192
  ctrl._on_download_finished(True, '2.0.0')
219
193
 
220
- settings.show_restart_button.assert_called_once()
194
+ assert b1.state.name == 'READY'
195
+ assert b2.state.name == 'READY'
221
196
 
222
197
  @staticmethod
223
198
  def test_download_failure_shows_error() -> None:
224
- """A failed download should show an error banner."""
225
- ctrl, app, client, banner, settings = _make_controller()
199
+ """A failed download should show an error on all banners."""
200
+ ctrl, app, client, b1, b2, settings = _make_controller()
226
201
  ctrl._on_download_finished(False, '2.0.0')
227
202
 
228
- assert banner.state.name == 'ERROR'
229
- settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
203
+ assert b1.state.name == 'ERROR'
204
+ assert b2.state.name == 'ERROR'
205
+
206
+ @staticmethod
207
+ def test_download_sets_pending_version() -> None:
208
+ """A successful download should set _pending_version."""
209
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False)
210
+ ctrl._on_download_finished(True, '2.0.0')
211
+
212
+ assert ctrl._pending_version == '2.0.0'
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Pending version — skip redundant downloads
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ class TestPendingVersion:
221
+ """Verify behaviour when an update is already downloaded and pending."""
222
+
223
+ @staticmethod
224
+ def test_check_skips_download_when_version_already_pending() -> None:
225
+ """Re-checking the same version should restore ready state, not re-download."""
226
+ ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False)
227
+ ctrl._pending_version = '2.0.0'
228
+
229
+ result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
230
+
231
+ with patch.object(ctrl, '_start_download') as mock_dl:
232
+ ctrl._on_check_finished(result, silent=True)
233
+
234
+ mock_dl.assert_not_called()
235
+ assert b1.state.name == 'READY'
236
+ assert b2.state.name == 'READY'
237
+
238
+ @staticmethod
239
+ def test_check_downloads_when_newer_version() -> None:
240
+ """A different version should trigger a fresh download."""
241
+ ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False)
242
+ ctrl._pending_version = '1.5.0'
243
+
244
+ result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
245
+
246
+ with patch.object(ctrl, '_start_download') as mock_dl:
247
+ ctrl._on_check_finished(result, silent=True)
248
+
249
+ mock_dl.assert_called_once_with('2.0.0')
250
+
251
+ @staticmethod
252
+ def test_do_check_preserves_banner_when_pending() -> None:
253
+ """set_checking should NOT be called when an update is already pending."""
254
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
255
+ ctrl._pending_version = '2.0.0'
256
+
257
+ with patch('asyncio.create_task'):
258
+ ctrl._do_check(silent=True)
259
+
260
+ settings.set_checking.assert_not_called()
261
+
262
+ @staticmethod
263
+ def test_do_check_shows_checking_when_no_pending() -> None:
264
+ """set_checking should be called when there is no pending update."""
265
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
266
+
267
+ with patch('asyncio.create_task'):
268
+ ctrl._do_check(silent=True)
269
+
270
+ settings.set_checking.assert_called_once()
271
+
272
+ @staticmethod
273
+ def test_apply_clears_pending_version() -> None:
274
+ """_apply_update should clear _pending_version."""
275
+ ctrl, app, client, b1, b2, settings = _make_controller()
276
+ ctrl._pending_version = '2.0.0'
277
+ ctrl._apply_update()
278
+
279
+ assert ctrl._pending_version is None
230
280
 
231
281
 
232
282
  # ---------------------------------------------------------------------------
@@ -244,7 +294,7 @@ class TestUserActiveGating:
244
294
  @staticmethod
245
295
  def test_auto_check_always_runs() -> None:
246
296
  """_on_auto_check should call _do_check even when user is active."""
247
- ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
297
+ ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
248
298
 
249
299
  with patch.object(ctrl, '_do_check') as mock_check:
250
300
  ctrl._on_auto_check()
@@ -254,7 +304,7 @@ class TestUserActiveGating:
254
304
  @staticmethod
255
305
  def test_manual_check_unaffected_by_active_user() -> None:
256
306
  """_on_manual_check should always call _do_check regardless of user activity."""
257
- ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
307
+ ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
258
308
 
259
309
  with patch.object(ctrl, '_do_check') as mock_check:
260
310
  ctrl._on_manual_check()
@@ -263,8 +313,8 @@ class TestUserActiveGating:
263
313
 
264
314
  @staticmethod
265
315
  def test_auto_apply_deferred_when_user_active() -> None:
266
- """When auto_apply=True but user is active, show READY banner instead of applying."""
267
- ctrl, app, client, banner, settings = _make_controller(
316
+ """When auto_apply=True but user is active, show READY banners instead of applying."""
317
+ ctrl, app, client, b1, b2, settings = _make_controller(
268
318
  auto_apply=True,
269
319
  is_user_active=True,
270
320
  )
@@ -273,12 +323,13 @@ class TestUserActiveGating:
273
323
  ctrl._on_download_finished(True, '2.0.0')
274
324
 
275
325
  mock_apply.assert_not_called()
276
- assert banner.state.name == 'READY'
326
+ assert b1.state.name == 'READY'
327
+ assert b2.state.name == 'READY'
277
328
 
278
329
  @staticmethod
279
330
  def test_auto_apply_proceeds_when_user_inactive() -> None:
280
331
  """When auto_apply=True and user is inactive, _apply_update is called."""
281
- ctrl, app, client, banner, settings = _make_controller(
332
+ ctrl, app, client, b1, b2, settings = _make_controller(
282
333
  auto_apply=True,
283
334
  is_user_active=False,
284
335
  )
@@ -318,7 +369,7 @@ class TestApplyUpdate:
318
369
  @staticmethod
319
370
  def test_apply_update_calls_client_and_quits() -> None:
320
371
  """_apply_update should call client.apply_update_on_exit and app.quit."""
321
- ctrl, app, client, banner, settings = _make_controller()
372
+ ctrl, app, client, b1, b2, settings = _make_controller()
322
373
  ctrl._apply_update()
323
374
 
324
375
  client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False)
@@ -327,21 +378,13 @@ class TestApplyUpdate:
327
378
  @staticmethod
328
379
  def test_apply_update_noop_without_updater() -> None:
329
380
  """_apply_update should be a no-op when client.updater is None."""
330
- ctrl, app, client, banner, settings = _make_controller()
381
+ ctrl, app, client, b1, b2, settings = _make_controller()
331
382
  client.updater = None
332
383
  ctrl._apply_update()
333
384
 
334
385
  client.apply_update_on_exit.assert_not_called()
335
386
  app.quit.assert_not_called()
336
387
 
337
- @staticmethod
338
- def test_restart_requested_signal_triggers_apply() -> None:
339
- """The settings restart_requested signal should be connected to _apply_update."""
340
- ctrl, app, client, banner, settings = _make_controller()
341
-
342
- # Verify the signal was connected
343
- settings.restart_requested.connect.assert_called_once_with(ctrl._apply_update)
344
-
345
388
 
346
389
  # ---------------------------------------------------------------------------
347
390
  # Settings changed → immediate check
@@ -354,7 +397,7 @@ class TestSettingsChanged:
354
397
  @staticmethod
355
398
  def test_settings_changed_triggers_reinit_and_check() -> None:
356
399
  """Changing settings should reinitialise the updater and check."""
357
- ctrl, app, client, banner, settings = _make_controller()
400
+ ctrl, app, client, b1, b2, settings = _make_controller()
358
401
 
359
402
  new_config = _make_config(update_channel='dev')
360
403
 
@@ -370,7 +413,7 @@ class TestSettingsChanged:
370
413
  @staticmethod
371
414
  def test_settings_changed_updates_auto_apply() -> None:
372
415
  """Changing settings should update the auto_apply flag."""
373
- ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
416
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
374
417
 
375
418
  new_config = _make_config(auto_apply=False)
376
419
 
@@ -391,26 +434,20 @@ class TestSettingsChanged:
391
434
  class TestCheckError:
392
435
  """Verify _on_check_error routes errors correctly."""
393
436
 
394
- @staticmethod
395
- def test_check_error_sets_failed_status() -> None:
396
- """An exception during check should set 'Check failed' status."""
397
- ctrl, app, client, banner, settings = _make_controller()
398
- ctrl._on_check_error('connection refused', silent=False)
399
-
400
- settings.set_update_status.assert_called_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
401
-
402
437
  @staticmethod
403
438
  def test_check_error_shows_banner_when_not_silent() -> None:
404
439
  """An exception during check should show banner when not silent."""
405
- ctrl, app, client, banner, settings = _make_controller()
440
+ ctrl, app, client, b1, b2, settings = _make_controller()
406
441
  ctrl._on_check_error('timeout', silent=False)
407
442
 
408
- assert banner.state.name == 'ERROR'
443
+ assert b1.state.name == 'ERROR'
444
+ assert b2.state.name == 'ERROR'
409
445
 
410
446
  @staticmethod
411
447
  def test_check_error_no_banner_when_silent() -> None:
412
448
  """An exception during check should NOT show banner when silent."""
413
- ctrl, app, client, banner, settings = _make_controller()
449
+ ctrl, app, client, b1, b2, settings = _make_controller()
414
450
  ctrl._on_check_error('timeout', silent=True)
415
451
 
416
- assert banner.state.name == 'HIDDEN'
452
+ assert b1.state.name == 'HIDDEN'
453
+ assert b2.state.name == 'HIDDEN'