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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/schema.py +19 -5
  4. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/settings.py +36 -16
  5. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/tray.py +2 -1
  6. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/update_controller.py +38 -19
  7. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_settings.py +16 -8
  8. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_controller.py +104 -141
  9. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/LICENSE.md +0 -0
  10. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/README.md +0 -0
  11. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/__init__.py +0 -0
  12. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/__main__.py +0 -0
  13. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/__init__.py +0 -0
  14. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/bootstrap.py +0 -0
  15. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/data.py +0 -0
  16. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/icon.py +0 -0
  17. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/init.py +0 -0
  18. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/instance.py +0 -0
  19. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/qt.py +0 -0
  20. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/schema.py +0 -0
  21. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/action_card.py +0 -0
  23. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/install.py +0 -0
  25. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/install_workers.py +0 -0
  26. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/log_panel.py +0 -0
  27. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/plugin_row.py +0 -0
  28. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/projects.py +0 -0
  29. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/screen.py +0 -0
  30. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/sidebar.py +0 -0
  31. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/spinner.py +0 -0
  32. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/tool_update_controller.py +0 -0
  33. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/screen/update_banner.py +0 -0
  34. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/theme.py +0 -0
  35. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/uri.py +0 -0
  36. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/application/workers.py +0 -0
  37. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/cli.py +0 -0
  38. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/client.py +0 -0
  39. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/config.py +0 -0
  40. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/logging.py +0 -0
  41. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/protocol.py +0 -0
  42. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/py.typed +0 -0
  43. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/resolution.py +0 -0
  44. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/schema.py +0 -0
  45. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/startup.py +0 -0
  46. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/synodic_client/updater.py +0 -0
  47. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/conftest.py +0 -0
  49. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/conftest.py +0 -0
  52. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_action_card.py +0 -0
  53. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_gather_packages.py +0 -0
  54. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_install_preview.py +0 -0
  55. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_log_panel.py +0 -0
  56. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_logging.py +0 -0
  57. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_preview_model.py +0 -0
  58. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_sidebar.py +0 -0
  59. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_tray_window_show.py +0 -0
  60. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/qt/test_update_feedback.py +0 -0
  62. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_cli.py +0 -0
  63. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_client_updater.py +0 -0
  64. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_client_version.py +0 -0
  65. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_config.py +0 -0
  66. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_examples.py +0 -0
  67. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_init.py +0 -0
  68. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_resolution.py +0 -0
  70. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_updater.py +0 -0
  71. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev55 → synodic_client-0.0.1.dev56}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev55
3
+ Version: 0.0.1.dev56
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev55"
18
+ version = "0.0.1.dev56"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -351,11 +351,25 @@ class UpdateView(Protocol):
351
351
  status stays in sync.
352
352
  """
353
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: ...
354
+ def show_downloading(self, version: str) -> None:
355
+ """Indicate that *version* is being downloaded."""
356
+ ...
357
+
358
+ def show_downloading_progress(self, percentage: int) -> None:
359
+ """Update the download progress indicator."""
360
+ ...
361
+
362
+ def show_ready(self, version: str) -> None:
363
+ """Indicate that *version* is downloaded and ready to install."""
364
+ ...
365
+
366
+ def show_error(self, message: str) -> None:
367
+ """Display an error *message* in the update area."""
368
+ ...
369
+
370
+ def hide_banner(self) -> None:
371
+ """Hide the update banner."""
372
+ ...
359
373
 
360
374
 
361
375
  class UpdateBannerState(Enum):
@@ -31,8 +31,7 @@ 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.screen.update_banner import UpdateBanner
35
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
34
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
36
35
  from synodic_client.logging import log_path, set_debug_level
37
36
  from synodic_client.resolution import ResolvedConfig, update_user_config
38
37
  from synodic_client.schema import GITHUB_REPO_URL
@@ -55,6 +54,9 @@ class SettingsWindow(QMainWindow):
55
54
  check_updates_requested = Signal()
56
55
  """Emitted when the user clicks the *Check for Updates* button."""
57
56
 
57
+ restart_requested = Signal()
58
+ """Emitted when the user clicks the *Restart & Update* button."""
59
+
58
60
  def showEvent(self, event: QShowEvent) -> None: # noqa: N802
59
61
  """[DIAG] Log every show event with a stack trace."""
60
62
  geo = self.geometry()
@@ -197,13 +199,18 @@ class SettingsWindow(QMainWindow):
197
199
  self._check_updates_btn = QPushButton('Check for Updates\u2026')
198
200
  self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
199
201
  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
+
200
211
  row.addStretch()
201
212
  content.addLayout(row)
202
213
 
203
- # Embedded update banner (same widget used in the main window)
204
- self._update_banner = UpdateBanner()
205
- content.addWidget(self._update_banner)
206
-
207
214
  # Last client update timestamp
208
215
  self._last_client_update_label = QLabel('')
209
216
  self._last_client_update_label.setStyleSheet('color: #808080; font-size: 11px;')
@@ -273,25 +280,37 @@ class SettingsWindow(QMainWindow):
273
280
  else:
274
281
  self._last_client_update_label.setText('')
275
282
 
276
- @property
277
- def update_banner(self) -> UpdateBanner:
278
- """The embedded :class:`UpdateBanner` for this window."""
279
- return self._update_banner
283
+ def set_update_status(self, text: str, style: str = '') -> None:
284
+ """Set the inline status text next to the *Check for Updates* button.
280
285
 
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}')
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)
286
292
 
287
293
  def set_checking(self) -> None:
288
- """Enter the *checking* state — disable button."""
294
+ """Enter the *checking* state — disable button and show status."""
289
295
  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)
290
299
 
291
300
  def reset_check_updates_button(self) -> None:
292
301
  """Re-enable the *Check for Updates* button after a check completes."""
293
302
  self._check_updates_btn.setEnabled(True)
294
303
 
304
+ def set_last_checked(self, timestamp: str) -> None:
305
+ """Update the *last updated* label from an ISO 8601 timestamp."""
306
+ relative = _format_relative_time(timestamp)
307
+ self._last_client_update_label.setText(f'Last updated: {relative}')
308
+ self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
309
+
310
+ def show_restart_button(self) -> None:
311
+ """Show the *Restart & Update* button."""
312
+ self._restart_btn.show()
313
+
295
314
  def show(self) -> None:
296
315
  """Sync controls from config, then show the window."""
297
316
  self.sync_from_config()
@@ -337,6 +356,7 @@ class SettingsWindow(QMainWindow):
337
356
  def _on_check_updates_clicked(self) -> None:
338
357
  """Handle the *Check for Updates* button click."""
339
358
  self._check_updates_btn.setEnabled(False)
359
+ self._update_status_label.setText('Checking\u2026')
340
360
  self.check_updates_requested.emit()
341
361
 
342
362
  def _on_channel_changed(self, index: int) -> None:
@@ -68,10 +68,11 @@ 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
71
72
  self._update_controller = UpdateController(
72
73
  app,
73
74
  client,
74
- [window.update_banner, self._settings_window.update_banner],
75
+ [self._banner],
75
76
  settings_window=self._settings_window,
76
77
  config=config,
77
78
  )
@@ -19,6 +19,11 @@ from PySide6.QtWidgets import QApplication
19
19
 
20
20
  from synodic_client.application.screen.schema import UpdateView
21
21
  from synodic_client.application.screen.update_banner import UpdateBanner
22
+ from synodic_client.application.theme import (
23
+ UPDATE_STATUS_AVAILABLE_STYLE,
24
+ UPDATE_STATUS_ERROR_STYLE,
25
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
26
+ )
22
27
  from synodic_client.application.workers import check_for_update, download_update
23
28
  from synodic_client.resolution import (
24
29
  ResolvedConfig,
@@ -94,8 +99,9 @@ class UpdateController:
94
99
  view.restart_requested.connect(self._apply_update)
95
100
  view.retry_requested.connect(lambda: self.check_now(silent=True))
96
101
 
97
- # Wire settings check-updates button
102
+ # Wire settings check-updates and restart buttons
98
103
  self._settings_window.check_updates_requested.connect(self._on_manual_check)
104
+ self._settings_window.restart_requested.connect(self._apply_update)
99
105
 
100
106
  def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
101
107
  """Set the predicate used to defer auto-apply when the user is active.
@@ -123,6 +129,25 @@ class UpdateController:
123
129
  """
124
130
  return self._auto_apply and not self._is_user_active()
125
131
 
132
+ def _persist_check_timestamp(self) -> None:
133
+ """Persist the current time as *last_client_update* and refresh the label."""
134
+ ts = datetime.now(UTC).isoformat()
135
+ update_user_config(last_client_update=ts)
136
+ self._settings_window.set_last_checked(ts)
137
+
138
+ def _report_error(self, message: str, *, silent: bool) -> None:
139
+ """Show an error to the user or log it, depending on *silent*.
140
+
141
+ Always updates the settings status line. When not *silent*,
142
+ also broadcasts the error to all update-banner views.
143
+ """
144
+ self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
145
+ if silent:
146
+ logger.warning('%s', message)
147
+ else:
148
+ for view in self._views:
149
+ view.show_error(message)
150
+
126
151
  # ------------------------------------------------------------------
127
152
  # Timer management
128
153
  # ------------------------------------------------------------------
@@ -233,22 +258,18 @@ class UpdateController:
233
258
  self._settings_window.reset_check_updates_button()
234
259
 
235
260
  if result is None:
236
- if not silent:
237
- for view in self._views:
238
- view.show_error('Failed to check for updates.')
239
- else:
240
- logger.warning('Automatic update check failed (no result)')
261
+ self._report_error('Failed to check for updates.', silent=silent)
241
262
  return
242
263
 
243
264
  if result.error:
244
- if not silent:
245
- for view in self._views:
246
- view.show_error(result.error)
247
- else:
248
- logger.warning('Automatic update check failed: %s', result.error)
265
+ self._report_error(result.error, silent=silent)
249
266
  return
250
267
 
268
+ # Successful check — refresh the "last updated" timestamp
269
+ self._persist_check_timestamp()
270
+
251
271
  if not result.available:
272
+ self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
252
273
  if not silent:
253
274
  logger.info('No updates available (current: %s)', result.current_version)
254
275
  else:
@@ -263,6 +284,7 @@ class UpdateController:
263
284
  return
264
285
 
265
286
  # New update available — download it
287
+ self._settings_window.set_update_status(f'v{version} available', UPDATE_STATUS_AVAILABLE_STYLE)
266
288
  for view in self._views:
267
289
  view.show_downloading(version)
268
290
  self._start_download(version)
@@ -270,12 +292,7 @@ class UpdateController:
270
292
  def _on_check_error(self, error: str, *, silent: bool = False) -> None:
271
293
  """Handle unexpected exception during update check."""
272
294
  self._settings_window.reset_check_updates_button()
273
-
274
- if not silent:
275
- for view in self._views:
276
- view.show_error(f'Update check error: {error}')
277
- else:
278
- logger.warning('Automatic update check error: %s', error)
295
+ self._report_error(f'Update check error: {error}', silent=silent)
279
296
 
280
297
  # ------------------------------------------------------------------
281
298
  # Download flow
@@ -305,14 +322,14 @@ class UpdateController:
305
322
  def _on_download_finished(self, success: bool, version: str) -> None:
306
323
  """Handle download completion."""
307
324
  if not success:
325
+ self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
308
326
  for view in self._views:
309
327
  view.show_error('Download failed. Please try again later.')
310
328
  return
311
329
 
312
- # Persist and display the client update timestamp
330
+ # Persist the client-update timestamp (actual update downloaded)
313
331
  ts = datetime.now(UTC).isoformat()
314
332
  update_user_config(last_client_update=ts)
315
- self._settings_window.set_last_updated(ts)
316
333
 
317
334
  self._pending_version = version
318
335
 
@@ -326,6 +343,8 @@ class UpdateController:
326
343
 
327
344
  def _show_ready(self, version: str) -> None:
328
345
  """Present the *ready to restart* state across all views."""
346
+ self._settings_window.set_update_status(f'v{version} ready', UPDATE_STATUS_UP_TO_DATE_STYLE)
347
+ self._settings_window.show_restart_button()
329
348
  for view in self._views:
330
349
  view.show_ready(version)
331
350
 
@@ -334,18 +334,16 @@ class TestSyncDoesNotEmit:
334
334
 
335
335
 
336
336
  class TestCheckForUpdatesButton:
337
- """Verify the Check for Updates button and embedded update banner."""
337
+ """Verify the Check for Updates button and inline status label."""
338
338
 
339
339
  @staticmethod
340
- def test_button_and_banner_exist() -> None:
341
- """Window has the check-updates button and an embedded UpdateBanner."""
340
+ def test_button_and_label_exist() -> None:
341
+ """Window has the check-updates button and status label."""
342
342
  window = _make_window()
343
343
  assert hasattr(window, '_check_updates_btn')
344
+ assert hasattr(window, '_update_status_label')
344
345
  assert window._check_updates_btn.text() == 'Check for Updates\u2026'
345
- assert hasattr(window, '_update_banner')
346
- from synodic_client.application.screen.update_banner import UpdateBanner
347
-
348
- assert isinstance(window.update_banner, UpdateBanner)
346
+ assert not window._update_status_label.text()
349
347
 
350
348
  @staticmethod
351
349
  def test_click_emits_signal_and_disables() -> None:
@@ -358,6 +356,15 @@ class TestCheckForUpdatesButton:
358
356
 
359
357
  signal_spy.assert_called_once()
360
358
  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()
361
368
 
362
369
  @staticmethod
363
370
  def test_reset_check_updates_button() -> None:
@@ -371,7 +378,8 @@ class TestCheckForUpdatesButton:
371
378
 
372
379
  @staticmethod
373
380
  def test_set_checking() -> None:
374
- """set_checking disables the button."""
381
+ """set_checking disables the button and shows 'Checking\u2026' status."""
375
382
  window = _make_window()
376
383
  window.set_checking()
377
384
  assert window._check_updates_btn.isEnabled() is False
385
+ assert window._update_status_label.text() == 'Checking\u2026'
@@ -8,6 +8,11 @@ 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
+ )
11
16
  from synodic_client.application.update_controller import UpdateController
12
17
  from synodic_client.resolution import ResolvedConfig
13
18
  from synodic_client.schema import (
@@ -46,10 +51,10 @@ def _make_controller(
46
51
  auto_apply: bool = True,
47
52
  auto_update_interval_minutes: int = 0,
48
53
  is_user_active: bool = False,
49
- ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, UpdateBanner, MagicMock]:
54
+ ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]:
50
55
  """Build an ``UpdateController`` with mocked collaborators.
51
56
 
52
- Returns (controller, app_mock, client_mock, banner1, banner2, settings_mock).
57
+ Returns (controller, app_mock, client_mock, banner, settings_mock).
53
58
  """
54
59
  config = _make_config(
55
60
  auto_apply=auto_apply,
@@ -59,11 +64,8 @@ def _make_controller(
59
64
  app = MagicMock()
60
65
  client = MagicMock()
61
66
  client.updater = MagicMock()
62
- banner1 = UpdateBanner()
63
- banner2 = UpdateBanner()
67
+ banner = UpdateBanner()
64
68
  settings = MagicMock()
65
- # settings mock only needs: check_updates_requested, set_checking,
66
- # reset_check_updates_button, set_last_updated
67
69
 
68
70
  with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg:
69
71
  mock_ucfg.return_value = MagicMock(
@@ -72,13 +74,13 @@ def _make_controller(
72
74
  controller = UpdateController(
73
75
  app,
74
76
  client,
75
- [banner1, banner2],
77
+ [banner],
76
78
  settings_window=settings,
77
79
  config=config,
78
80
  )
79
81
  controller.set_user_active_predicate(lambda: is_user_active)
80
82
 
81
- return controller, app, client, banner1, banner2, settings
83
+ return controller, app, client, banner, settings
82
84
 
83
85
 
84
86
  # ---------------------------------------------------------------------------
@@ -90,55 +92,61 @@ class TestCheckFinished:
90
92
  """Verify _on_check_finished routes results correctly."""
91
93
 
92
94
  @staticmethod
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()
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()
96
98
  ctrl._on_check_finished(None, silent=False)
97
99
 
98
100
  settings.reset_check_updates_button.assert_called_once()
99
- assert b1.state.name == 'ERROR'
100
- assert b2.state.name == 'ERROR'
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'
101
110
 
102
111
  @staticmethod
103
112
  def test_none_result_no_banner_when_silent() -> None:
104
113
  """A None result with silent=True should NOT show the error banner."""
105
- ctrl, _app, _client, b1, b2, settings = _make_controller()
114
+ ctrl, _app, _client, banner, settings = _make_controller()
106
115
  ctrl._on_check_finished(None, silent=True)
107
116
 
108
- assert b1.state.name == 'HIDDEN'
109
- assert b2.state.name == 'HIDDEN'
117
+ assert banner.state.name == 'HIDDEN'
110
118
 
111
119
  @staticmethod
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()
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()
115
123
  result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found')
116
124
  ctrl._on_check_finished(result, silent=False)
117
125
 
118
- assert b1.state.name == 'ERROR'
119
- assert b2.state.name == 'ERROR'
126
+ settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE)
120
127
 
121
128
  @staticmethod
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()
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()
125
132
  result = UpdateInfo(available=False, current_version=Version('1.0.0'))
126
133
  ctrl._on_check_finished(result, silent=False)
127
134
 
128
- assert b1.state.name == 'HIDDEN'
129
- assert b2.state.name == 'HIDDEN'
135
+ settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
130
136
 
131
137
  @staticmethod
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()
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()
135
141
  result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0'))
136
142
 
137
143
  with patch.object(ctrl, '_start_download') as mock_dl:
138
144
  ctrl._on_check_finished(result, silent=False)
139
145
 
140
- assert b1.state.name == 'DOWNLOADING'
141
- assert b2.state.name == 'DOWNLOADING'
146
+ settings.set_update_status.assert_called_once_with(
147
+ 'v2.0.0 available',
148
+ UPDATE_STATUS_AVAILABLE_STYLE,
149
+ )
142
150
  mock_dl.assert_called_once_with('2.0.0')
143
151
 
144
152
 
@@ -153,7 +161,7 @@ class TestDownloadFinished:
153
161
  @staticmethod
154
162
  def test_auto_apply_calls_apply_update() -> None:
155
163
  """When auto_apply=True, a successful download should call _apply_update(silent=True)."""
156
- ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
164
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
157
165
 
158
166
  with patch.object(ctrl, '_apply_update') as mock_apply:
159
167
  ctrl._on_download_finished(True, '2.0.0')
@@ -163,120 +171,62 @@ class TestDownloadFinished:
163
171
  @staticmethod
164
172
  def test_auto_apply_does_not_show_ready_banner() -> None:
165
173
  """When auto_apply=True, the ready banner should NOT be shown."""
166
- ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
174
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
167
175
 
168
176
  with patch.object(ctrl, '_apply_update'):
169
177
  ctrl._on_download_finished(True, '2.0.0')
170
178
 
171
- assert b1.state.name != 'READY'
172
- assert b2.state.name != 'READY'
179
+ # Banner should not be in READY state
180
+ assert banner.state.name != 'READY'
173
181
 
174
182
  @staticmethod
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)
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)
178
186
  ctrl._on_download_finished(True, '2.0.0')
179
187
 
180
- assert b1.state.name == 'READY'
181
- assert b2.state.name == 'READY'
182
-
183
- @staticmethod
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(
187
- auto_apply=True,
188
- is_user_active=True,
189
- )
190
-
191
- with patch.object(ctrl, '_apply_update'):
192
- ctrl._on_download_finished(True, '2.0.0')
193
-
194
- assert b1.state.name == 'READY'
195
- assert b2.state.name == 'READY'
188
+ assert banner.state.name == 'READY'
196
189
 
197
190
  @staticmethod
198
- def test_download_failure_shows_error() -> None:
199
- """A failed download should show an error on all banners."""
200
- ctrl, app, client, b1, b2, settings = _make_controller()
201
- ctrl._on_download_finished(False, '2.0.0')
202
-
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)
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)
210
194
  ctrl._on_download_finished(True, '2.0.0')
211
195
 
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')
196
+ settings.set_update_status.assert_called_with(
197
+ 'v2.0.0 ready',
198
+ UPDATE_STATUS_UP_TO_DATE_STYLE,
199
+ )
250
200
 
251
201
  @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)
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')
259
206
 
260
- settings.set_checking.assert_not_called()
207
+ settings.show_restart_button.assert_called_once()
261
208
 
262
209
  @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()
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(
213
+ auto_apply=True,
214
+ is_user_active=True,
215
+ )
266
216
 
267
- with patch('asyncio.create_task'):
268
- ctrl._do_check(silent=True)
217
+ with patch.object(ctrl, '_apply_update'):
218
+ ctrl._on_download_finished(True, '2.0.0')
269
219
 
270
- settings.set_checking.assert_called_once()
220
+ settings.show_restart_button.assert_called_once()
271
221
 
272
222
  @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()
223
+ def test_download_failure_shows_error() -> None:
224
+ """A failed download should show an error banner."""
225
+ ctrl, app, client, banner, settings = _make_controller()
226
+ ctrl._on_download_finished(False, '2.0.0')
278
227
 
279
- assert ctrl._pending_version is None
228
+ assert banner.state.name == 'ERROR'
229
+ settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
280
230
 
281
231
 
282
232
  # ---------------------------------------------------------------------------
@@ -294,7 +244,7 @@ class TestUserActiveGating:
294
244
  @staticmethod
295
245
  def test_auto_check_always_runs() -> None:
296
246
  """_on_auto_check should call _do_check even when user is active."""
297
- ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
247
+ ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
298
248
 
299
249
  with patch.object(ctrl, '_do_check') as mock_check:
300
250
  ctrl._on_auto_check()
@@ -304,7 +254,7 @@ class TestUserActiveGating:
304
254
  @staticmethod
305
255
  def test_manual_check_unaffected_by_active_user() -> None:
306
256
  """_on_manual_check should always call _do_check regardless of user activity."""
307
- ctrl, _app, _client, b1, b2, settings = _make_controller(is_user_active=True)
257
+ ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
308
258
 
309
259
  with patch.object(ctrl, '_do_check') as mock_check:
310
260
  ctrl._on_manual_check()
@@ -313,8 +263,8 @@ class TestUserActiveGating:
313
263
 
314
264
  @staticmethod
315
265
  def test_auto_apply_deferred_when_user_active() -> None:
316
- """When auto_apply=True but user is active, show READY banners instead of applying."""
317
- ctrl, app, client, b1, b2, settings = _make_controller(
266
+ """When auto_apply=True but user is active, show READY banner instead of applying."""
267
+ ctrl, app, client, banner, settings = _make_controller(
318
268
  auto_apply=True,
319
269
  is_user_active=True,
320
270
  )
@@ -323,13 +273,12 @@ class TestUserActiveGating:
323
273
  ctrl._on_download_finished(True, '2.0.0')
324
274
 
325
275
  mock_apply.assert_not_called()
326
- assert b1.state.name == 'READY'
327
- assert b2.state.name == 'READY'
276
+ assert banner.state.name == 'READY'
328
277
 
329
278
  @staticmethod
330
279
  def test_auto_apply_proceeds_when_user_inactive() -> None:
331
280
  """When auto_apply=True and user is inactive, _apply_update is called."""
332
- ctrl, app, client, b1, b2, settings = _make_controller(
281
+ ctrl, app, client, banner, settings = _make_controller(
333
282
  auto_apply=True,
334
283
  is_user_active=False,
335
284
  )
@@ -369,7 +318,7 @@ class TestApplyUpdate:
369
318
  @staticmethod
370
319
  def test_apply_update_calls_client_and_quits() -> None:
371
320
  """_apply_update should call client.apply_update_on_exit and app.quit."""
372
- ctrl, app, client, b1, b2, settings = _make_controller()
321
+ ctrl, app, client, banner, settings = _make_controller()
373
322
  ctrl._apply_update()
374
323
 
375
324
  client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False)
@@ -378,13 +327,21 @@ class TestApplyUpdate:
378
327
  @staticmethod
379
328
  def test_apply_update_noop_without_updater() -> None:
380
329
  """_apply_update should be a no-op when client.updater is None."""
381
- ctrl, app, client, b1, b2, settings = _make_controller()
330
+ ctrl, app, client, banner, settings = _make_controller()
382
331
  client.updater = None
383
332
  ctrl._apply_update()
384
333
 
385
334
  client.apply_update_on_exit.assert_not_called()
386
335
  app.quit.assert_not_called()
387
336
 
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
+
388
345
 
389
346
  # ---------------------------------------------------------------------------
390
347
  # Settings changed → immediate check
@@ -397,7 +354,7 @@ class TestSettingsChanged:
397
354
  @staticmethod
398
355
  def test_settings_changed_triggers_reinit_and_check() -> None:
399
356
  """Changing settings should reinitialise the updater and check."""
400
- ctrl, app, client, b1, b2, settings = _make_controller()
357
+ ctrl, app, client, banner, settings = _make_controller()
401
358
 
402
359
  new_config = _make_config(update_channel='dev')
403
360
 
@@ -413,7 +370,7 @@ class TestSettingsChanged:
413
370
  @staticmethod
414
371
  def test_settings_changed_updates_auto_apply() -> None:
415
372
  """Changing settings should update the auto_apply flag."""
416
- ctrl, app, client, b1, b2, settings = _make_controller(auto_apply=True)
373
+ ctrl, app, client, banner, settings = _make_controller(auto_apply=True)
417
374
 
418
375
  new_config = _make_config(auto_apply=False)
419
376
 
@@ -434,20 +391,26 @@ class TestSettingsChanged:
434
391
  class TestCheckError:
435
392
  """Verify _on_check_error routes errors correctly."""
436
393
 
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
+
437
402
  @staticmethod
438
403
  def test_check_error_shows_banner_when_not_silent() -> None:
439
404
  """An exception during check should show banner when not silent."""
440
- ctrl, app, client, b1, b2, settings = _make_controller()
405
+ ctrl, app, client, banner, settings = _make_controller()
441
406
  ctrl._on_check_error('timeout', silent=False)
442
407
 
443
- assert b1.state.name == 'ERROR'
444
- assert b2.state.name == 'ERROR'
408
+ assert banner.state.name == 'ERROR'
445
409
 
446
410
  @staticmethod
447
411
  def test_check_error_no_banner_when_silent() -> None:
448
412
  """An exception during check should NOT show banner when silent."""
449
- ctrl, app, client, b1, b2, settings = _make_controller()
413
+ ctrl, app, client, banner, settings = _make_controller()
450
414
  ctrl._on_check_error('timeout', silent=True)
451
415
 
452
- assert b1.state.name == 'HIDDEN'
453
- assert b2.state.name == 'HIDDEN'
416
+ assert banner.state.name == 'HIDDEN'