synodic-client 0.0.1.dev54__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.dev54 → synodic_client-0.0.1.dev55}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/schema.py +19 -1
  4. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/settings.py +16 -30
  5. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/tray.py +1 -2
  6. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/update_controller.py +45 -53
  7. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_settings.py +8 -16
  8. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_controller.py +75 -113
  9. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/LICENSE.md +0 -0
  10. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/README.md +0 -0
  11. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/__init__.py +0 -0
  12. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/__main__.py +0 -0
  13. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/__init__.py +0 -0
  14. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/bootstrap.py +0 -0
  15. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/data.py +0 -0
  16. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/icon.py +0 -0
  17. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/init.py +0 -0
  18. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/instance.py +0 -0
  19. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/qt.py +0 -0
  20. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/schema.py +0 -0
  21. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/action_card.py +0 -0
  23. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/install.py +0 -0
  25. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/install_workers.py +0 -0
  26. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/log_panel.py +0 -0
  27. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/plugin_row.py +0 -0
  28. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/projects.py +0 -0
  29. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/screen.py +0 -0
  30. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/sidebar.py +0 -0
  31. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/spinner.py +0 -0
  32. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/tool_update_controller.py +0 -0
  33. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/screen/update_banner.py +0 -0
  34. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/theme.py +0 -0
  35. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/uri.py +0 -0
  36. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/application/workers.py +0 -0
  37. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/cli.py +0 -0
  38. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/client.py +0 -0
  39. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/config.py +0 -0
  40. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/logging.py +0 -0
  41. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/protocol.py +0 -0
  42. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/py.typed +0 -0
  43. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/resolution.py +0 -0
  44. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/schema.py +0 -0
  45. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/startup.py +0 -0
  46. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/synodic_client/updater.py +0 -0
  47. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/conftest.py +0 -0
  49. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/conftest.py +0 -0
  52. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_action_card.py +0 -0
  53. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_gather_packages.py +0 -0
  54. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_install_preview.py +0 -0
  55. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_log_panel.py +0 -0
  56. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_logging.py +0 -0
  57. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_preview_model.py +0 -0
  58. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_sidebar.py +0 -0
  59. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_tray_window_show.py +0 -0
  60. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/qt/test_update_feedback.py +0 -0
  62. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_cli.py +0 -0
  63. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_client_updater.py +0 -0
  64. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_client_version.py +0 -0
  65. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_config.py +0 -0
  66. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_examples.py +0 -0
  67. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_init.py +0 -0
  68. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_resolution.py +0 -0
  70. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_updater.py +0 -0
  71. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev54 → synodic_client-0.0.1.dev55}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev54 → 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.dev54
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.dev54"
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,13 +67,13 @@ 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
@@ -94,13 +88,14 @@ class UpdateController:
94
88
  self._auto_update_timer: QTimer | None = None
95
89
  self._restart_auto_update_timer()
96
90
 
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))
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))
100
96
 
101
97
  # Wire settings check-updates button
102
98
  self._settings_window.check_updates_requested.connect(self._on_manual_check)
103
- self._settings_window.restart_requested.connect(self._apply_update)
104
99
 
105
100
  def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
106
101
  """Set the predicate used to defer auto-apply when the user is active.
@@ -212,10 +207,11 @@ class UpdateController:
212
207
  """Run an update check."""
213
208
  if self._client.updater is None:
214
209
  if not silent:
215
- self._banner.show_error('Updater is not initialized.')
210
+ for view in self._views:
211
+ view.show_error('Updater is not initialized.')
216
212
  return
217
213
 
218
- # Preserve the restart button when an update is already pending
214
+ # Preserve the banner state when an update is already pending
219
215
  if self._pending_version is None:
220
216
  self._settings_window.set_checking()
221
217
 
@@ -237,23 +233,22 @@ class UpdateController:
237
233
  self._settings_window.reset_check_updates_button()
238
234
 
239
235
  if result is None:
240
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
241
236
  if not silent:
242
- self._banner.show_error('Failed to check for updates.')
237
+ for view in self._views:
238
+ view.show_error('Failed to check for updates.')
243
239
  else:
244
240
  logger.warning('Automatic update check failed (no result)')
245
241
  return
246
242
 
247
243
  if result.error:
248
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
249
244
  if not silent:
250
- self._banner.show_error(result.error)
245
+ for view in self._views:
246
+ view.show_error(result.error)
251
247
  else:
252
248
  logger.warning('Automatic update check failed: %s', result.error)
253
249
  return
254
250
 
255
251
  if not result.available:
256
- self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
257
252
  if not silent:
258
253
  logger.info('No updates available (current: %s)', result.current_version)
259
254
  else:
@@ -268,20 +263,17 @@ class UpdateController:
268
263
  return
269
264
 
270
265
  # 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)
266
+ for view in self._views:
267
+ view.show_downloading(version)
276
268
  self._start_download(version)
277
269
 
278
270
  def _on_check_error(self, error: str, *, silent: bool = False) -> None:
279
271
  """Handle unexpected exception during update check."""
280
272
  self._settings_window.reset_check_updates_button()
281
- self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
282
273
 
283
274
  if not silent:
284
- self._banner.show_error(f'Update check error: {error}')
275
+ for view in self._views:
276
+ view.show_error(f'Update check error: {error}')
285
277
  else:
286
278
  logger.warning('Automatic update check error: %s', error)
287
279
 
@@ -298,50 +290,49 @@ class UpdateController:
298
290
  try:
299
291
  success = await download_update(
300
292
  self._client,
301
- on_progress=self._banner.show_downloading_progress,
293
+ on_progress=self._on_download_progress,
302
294
  )
303
295
  self._on_download_finished(success, version)
304
296
  except Exception as exc:
305
297
  logger.exception('Update download failed')
306
298
  self._on_download_error(str(exc))
307
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
+
308
305
  def _on_download_finished(self, success: bool, version: str) -> None:
309
306
  """Handle download completion."""
310
307
  if not success:
311
- self._banner.show_error('Download failed. Please try again later.')
312
- 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.')
313
310
  return
314
311
 
315
- # Persist the client update timestamp
316
- 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)
317
316
 
318
317
  self._pending_version = version
319
318
 
320
319
  if self._can_auto_apply():
321
320
  # Silently apply and restart — no banner, no user interaction
322
321
  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
322
  self._apply_update(silent=True)
328
323
  return
329
324
 
330
325
  self._show_ready(version)
331
326
 
332
327
  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
- )
339
- self._settings_window.show_restart_button()
328
+ """Present the *ready to restart* state across all views."""
329
+ for view in self._views:
330
+ view.show_ready(version)
340
331
 
341
332
  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)
333
+ """Handle download error — show error across all views."""
334
+ for view in self._views:
335
+ view.show_error(f'Download error: {error}')
345
336
 
346
337
  # ------------------------------------------------------------------
347
338
  # Apply
@@ -364,4 +355,5 @@ class UpdateController:
364
355
  self._app.quit()
365
356
  except Exception as e:
366
357
  logger.error('Failed to apply update: %s', e)
367
- 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'
171
+ assert b1.state.name != 'READY'
172
+ assert b2.state.name != 'READY'
181
173
 
182
174
  @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)
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)
186
178
  ctrl._on_download_finished(True, '2.0.0')
187
179
 
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
- )
180
+ assert b1.state.name == 'READY'
181
+ assert b2.state.name == 'READY'
200
182
 
201
183
  @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)
205
- ctrl._on_download_finished(True, '2.0.0')
206
-
207
- settings.show_restart_button.assert_called_once()
208
-
209
- @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,21 +191,22 @@ 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'
230
205
 
231
206
  @staticmethod
232
207
  def test_download_sets_pending_version() -> None:
233
208
  """A successful download should set _pending_version."""
234
- ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
209
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=False)
235
210
  ctrl._on_download_finished(True, '2.0.0')
236
211
 
237
212
  assert ctrl._pending_version == '2.0.0'
@@ -248,7 +223,7 @@ class TestPendingVersion:
248
223
  @staticmethod
249
224
  def test_check_skips_download_when_version_already_pending() -> None:
250
225
  """Re-checking the same version should restore ready state, not re-download."""
251
- ctrl, _app, _client, banner, settings = _make_controller(auto_apply=False)
226
+ ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False)
252
227
  ctrl._pending_version = '2.0.0'
253
228
 
254
229
  result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
@@ -257,13 +232,13 @@ class TestPendingVersion:
257
232
  ctrl._on_check_finished(result, silent=True)
258
233
 
259
234
  mock_dl.assert_not_called()
260
- assert banner.state.name == 'READY'
261
- settings.show_restart_button.assert_called_once()
235
+ assert b1.state.name == 'READY'
236
+ assert b2.state.name == 'READY'
262
237
 
263
238
  @staticmethod
264
239
  def test_check_downloads_when_newer_version() -> None:
265
240
  """A different version should trigger a fresh download."""
266
- ctrl, _app, _client, banner, settings = _make_controller(auto_apply=False)
241
+ ctrl, _app, _client, b1, b2, settings = _make_controller(auto_apply=False)
267
242
  ctrl._pending_version = '1.5.0'
268
243
 
269
244
  result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
@@ -274,9 +249,9 @@ class TestPendingVersion:
274
249
  mock_dl.assert_called_once_with('2.0.0')
275
250
 
276
251
  @staticmethod
277
- def test_do_check_preserves_settings_ui_when_pending() -> None:
252
+ def test_do_check_preserves_banner_when_pending() -> None:
278
253
  """set_checking should NOT be called when an update is already pending."""
279
- ctrl, _app, _client, banner, settings = _make_controller()
254
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
280
255
  ctrl._pending_version = '2.0.0'
281
256
 
282
257
  with patch('asyncio.create_task'):
@@ -287,7 +262,7 @@ class TestPendingVersion:
287
262
  @staticmethod
288
263
  def test_do_check_shows_checking_when_no_pending() -> None:
289
264
  """set_checking should be called when there is no pending update."""
290
- ctrl, _app, _client, banner, settings = _make_controller()
265
+ ctrl, _app, _client, b1, b2, settings = _make_controller()
291
266
 
292
267
  with patch('asyncio.create_task'):
293
268
  ctrl._do_check(silent=True)
@@ -297,7 +272,7 @@ class TestPendingVersion:
297
272
  @staticmethod
298
273
  def test_apply_clears_pending_version() -> None:
299
274
  """_apply_update should clear _pending_version."""
300
- ctrl, app, client, banner, settings = _make_controller()
275
+ ctrl, app, client, b1, b2, settings = _make_controller()
301
276
  ctrl._pending_version = '2.0.0'
302
277
  ctrl._apply_update()
303
278
 
@@ -319,7 +294,7 @@ class TestUserActiveGating:
319
294
  @staticmethod
320
295
  def test_auto_check_always_runs() -> None:
321
296
  """_on_auto_check should call _do_check even when user is active."""
322
- ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
297
+ ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
323
298
 
324
299
  with patch.object(ctrl, '_do_check') as mock_check:
325
300
  ctrl._on_auto_check()
@@ -329,7 +304,7 @@ class TestUserActiveGating:
329
304
  @staticmethod
330
305
  def test_manual_check_unaffected_by_active_user() -> None:
331
306
  """_on_manual_check should always call _do_check regardless of user activity."""
332
- ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
307
+ ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
333
308
 
334
309
  with patch.object(ctrl, '_do_check') as mock_check:
335
310
  ctrl._on_manual_check()
@@ -338,8 +313,8 @@ class TestUserActiveGating:
338
313
 
339
314
  @staticmethod
340
315
  def test_auto_apply_deferred_when_user_active() -> None:
341
- """When auto_apply=True but user is active, show READY banner instead of applying."""
342
- 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(
343
318
  auto_apply=True,
344
319
  is_user_active=True,
345
320
  )
@@ -348,12 +323,13 @@ class TestUserActiveGating:
348
323
  ctrl._on_download_finished(True, '2.0.0')
349
324
 
350
325
  mock_apply.assert_not_called()
351
- assert banner.state.name == 'READY'
326
+ assert b1.state.name == 'READY'
327
+ assert b2.state.name == 'READY'
352
328
 
353
329
  @staticmethod
354
330
  def test_auto_apply_proceeds_when_user_inactive() -> None:
355
331
  """When auto_apply=True and user is inactive, _apply_update is called."""
356
- ctrl, app, client, banner, settings = _make_controller(
332
+ ctrl, app, client, b1, b2, settings = _make_controller(
357
333
  auto_apply=True,
358
334
  is_user_active=False,
359
335
  )
@@ -393,7 +369,7 @@ class TestApplyUpdate:
393
369
  @staticmethod
394
370
  def test_apply_update_calls_client_and_quits() -> None:
395
371
  """_apply_update should call client.apply_update_on_exit and app.quit."""
396
- ctrl, app, client, banner, settings = _make_controller()
372
+ ctrl, app, client, b1, b2, settings = _make_controller()
397
373
  ctrl._apply_update()
398
374
 
399
375
  client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False)
@@ -402,21 +378,13 @@ class TestApplyUpdate:
402
378
  @staticmethod
403
379
  def test_apply_update_noop_without_updater() -> None:
404
380
  """_apply_update should be a no-op when client.updater is None."""
405
- ctrl, app, client, banner, settings = _make_controller()
381
+ ctrl, app, client, b1, b2, settings = _make_controller()
406
382
  client.updater = None
407
383
  ctrl._apply_update()
408
384
 
409
385
  client.apply_update_on_exit.assert_not_called()
410
386
  app.quit.assert_not_called()
411
387
 
412
- @staticmethod
413
- def test_restart_requested_signal_triggers_apply() -> None:
414
- """The settings restart_requested signal should be connected to _apply_update."""
415
- ctrl, app, client, banner, settings = _make_controller()
416
-
417
- # Verify the signal was connected
418
- settings.restart_requested.connect.assert_called_once_with(ctrl._apply_update)
419
-
420
388
 
421
389
  # ---------------------------------------------------------------------------
422
390
  # Settings changed → immediate check
@@ -429,7 +397,7 @@ class TestSettingsChanged:
429
397
  @staticmethod
430
398
  def test_settings_changed_triggers_reinit_and_check() -> None:
431
399
  """Changing settings should reinitialise the updater and check."""
432
- ctrl, app, client, banner, settings = _make_controller()
400
+ ctrl, app, client, b1, b2, settings = _make_controller()
433
401
 
434
402
  new_config = _make_config(update_channel='dev')
435
403
 
@@ -445,7 +413,7 @@ class TestSettingsChanged:
445
413
  @staticmethod
446
414
  def test_settings_changed_updates_auto_apply() -> None:
447
415
  """Changing settings should update the auto_apply flag."""
448
- ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
416
+ ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
449
417
 
450
418
  new_config = _make_config(auto_apply=False)
451
419
 
@@ -466,26 +434,20 @@ class TestSettingsChanged:
466
434
  class TestCheckError:
467
435
  """Verify _on_check_error routes errors correctly."""
468
436
 
469
- @staticmethod
470
- def test_check_error_sets_failed_status() -> None:
471
- """An exception during check should set 'Check failed' status."""
472
- ctrl, app, client, banner, settings = _make_controller()
473
- ctrl._on_check_error('connection refused', silent=False)
474
-
475
- settings.set_update_status.assert_called_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
476
-
477
437
  @staticmethod
478
438
  def test_check_error_shows_banner_when_not_silent() -> None:
479
439
  """An exception during check should show banner when not silent."""
480
- ctrl, app, client, banner, settings = _make_controller()
440
+ ctrl, app, client, b1, b2, settings = _make_controller()
481
441
  ctrl._on_check_error('timeout', silent=False)
482
442
 
483
- assert banner.state.name == 'ERROR'
443
+ assert b1.state.name == 'ERROR'
444
+ assert b2.state.name == 'ERROR'
484
445
 
485
446
  @staticmethod
486
447
  def test_check_error_no_banner_when_silent() -> None:
487
448
  """An exception during check should NOT show banner when silent."""
488
- ctrl, app, client, banner, settings = _make_controller()
449
+ ctrl, app, client, b1, b2, settings = _make_controller()
489
450
  ctrl._on_check_error('timeout', silent=True)
490
451
 
491
- assert banner.state.name == 'HIDDEN'
452
+ assert b1.state.name == 'HIDDEN'
453
+ assert b2.state.name == 'HIDDEN'