synodic-client 0.0.1.dev28__tar.gz → 0.0.1.dev30__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 (54) hide show
  1. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/PKG-INFO +3 -3
  2. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/pyproject.toml +3 -3
  3. synodic_client-0.0.1.dev30/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/qt.py +6 -1
  5. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/action_card.py +80 -36
  6. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/install.py +7 -3
  7. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/screen.py +5 -3
  8. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/tray.py +7 -4
  9. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/config.py +10 -1
  10. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/resolution.py +10 -4
  11. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/updater.py +14 -0
  12. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/test_action_card.py +38 -20
  13. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/test_install_preview.py +152 -0
  14. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_config.py +30 -0
  15. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_resolution.py +74 -12
  16. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_updater.py +28 -0
  17. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/windows/test_startup.py +0 -71
  18. synodic_client-0.0.1.dev28/synodic_client/_version.py +0 -1
  19. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/LICENSE.md +0 -0
  20. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/README.md +0 -0
  21. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/__main__.py +0 -0
  23. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/__init__.py +0 -0
  24. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/bootstrap.py +0 -0
  25. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/icon.py +0 -0
  26. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/instance.py +0 -0
  27. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/card.py +0 -0
  29. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/log_panel.py +0 -0
  30. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/screen/spinner.py +0 -0
  31. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/theme.py +0 -0
  32. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/application/uri.py +0 -0
  33. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/cli.py +0 -0
  34. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/client.py +0 -0
  35. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/logging.py +0 -0
  36. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/protocol.py +0 -0
  37. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/py.typed +0 -0
  38. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/synodic_client/startup.py +0 -0
  39. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/__init__.py +0 -0
  40. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/conftest.py +0 -0
  41. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/__init__.py +0 -0
  43. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/conftest.py +0 -0
  44. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/test_log_panel.py +0 -0
  45. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/qt/test_logging.py +0 -0
  46. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_cli.py +0 -0
  47. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_client_updater.py +0 -0
  48. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_client_version.py +0 -0
  49. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_examples.py +0 -0
  50. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_install.py +0 -0
  51. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/test_uri.py +0 -0
  52. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/windows/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/windows/conftest.py +0 -0
  54. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev30}/tests/unit/windows/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev28
3
+ Version: 0.0.1.dev30
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
@@ -8,9 +8,9 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev48
11
+ Requires-Dist: porringer>=0.2.1.dev49
12
12
  Requires-Dist: qasync>=0.28.0
13
- Requires-Dist: velopack>=0.0.1369.dev7516
13
+ Requires-Dist: velopack>=0.0.1442.dev64255
14
14
  Requires-Dist: typer>=0.24.1
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev48",
13
+ "porringer>=0.2.1.dev49",
14
14
  "qasync>=0.28.0",
15
- "velopack>=0.0.1369.dev7516",
15
+ "velopack>=0.0.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev28"
18
+ version = "0.0.1.dev30"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev30'
@@ -44,11 +44,16 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura
44
44
  update_config = resolve_update_config(config)
45
45
  client.initialize_updater(update_config)
46
46
 
47
+ cached_dirs = porringer.cache.list_directories()
48
+
47
49
  logger.info(
48
- 'Synodic Client v%s started (channel: %s, source: %s)',
50
+ 'Synodic Client v%s started (channel: %s, source: %s, '
51
+ 'config_fields_set: %s, cached_projects: %d)',
49
52
  client.version,
50
53
  update_config.channel.name,
51
54
  update_config.repo_url,
55
+ sorted(config.model_fields_set),
56
+ len(cached_dirs),
52
57
  )
53
58
 
54
59
  return client, porringer, config
@@ -14,11 +14,13 @@ from __future__ import annotations
14
14
  import html as html_mod
15
15
  import logging
16
16
 
17
+ from porringer.backend.command.core.action_builder import PHASE_ORDER
17
18
  from porringer.schema import SetupAction, SetupActionResult, SkipReason
18
19
  from porringer.schema.plugin import PluginKind
19
20
  from PySide6.QtCore import QRect, Qt, QTimer, Signal
20
21
  from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor
21
22
  from PySide6.QtWidgets import (
23
+ QApplication,
22
24
  QCheckBox,
23
25
  QFrame,
24
26
  QHBoxLayout,
@@ -26,6 +28,7 @@ from PySide6.QtWidgets import (
26
28
  QScrollArea,
27
29
  QSizePolicy,
28
30
  QTextEdit,
31
+ QToolButton,
29
32
  QVBoxLayout,
30
33
  QWidget,
31
34
  )
@@ -53,6 +56,10 @@ from synodic_client.application.theme import (
53
56
  ACTION_CARD_STYLE,
54
57
  ACTION_CARD_TYPE_BADGE_STYLE,
55
58
  ACTION_CARD_VERSION_STYLE,
59
+ COPY_BTN_SIZE,
60
+ COPY_BTN_STYLE,
61
+ COPY_FEEDBACK_MS,
62
+ COPY_ICON,
56
63
  LOG_COLOR_ERROR,
57
64
  LOG_COLOR_PHASE,
58
65
  LOG_COLOR_STDERR,
@@ -74,19 +81,9 @@ _SPINNER_INTERVAL = 50
74
81
  _SPINNER_ARC = 90
75
82
 
76
83
 
77
- #: Sort priority for each :class:`PluginKind`.
78
- #: Lower numbers appear first. Matches the execution phase order
79
- #: defined in ``porringer.backend.command.core.action_builder.PHASE_ORDER``
80
- #: so that cards are displayed in the same order they execute:
81
- #: runtime → package → tool → project → SCM.
82
- _KIND_ORDER: dict[PluginKind | None, int] = {
83
- PluginKind.RUNTIME: 0,
84
- PluginKind.PACKAGE: 1,
85
- PluginKind.TOOL: 2,
86
- PluginKind.PROJECT: 3,
87
- PluginKind.SCM: 4,
88
- None: 99, # bare commands are excluded from ActionCardList anyway
89
- }
84
+ #: Sort priority derived from porringer's execution phase order so the
85
+ #: display order always matches the order actions actually execute.
86
+ _KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
90
87
 
91
88
 
92
89
  def action_key(action: SetupAction) -> tuple[object, ...]:
@@ -102,17 +99,17 @@ def action_key(action: SetupAction) -> tuple[object, ...]:
102
99
  return (action.kind, action.installer, pkg_name, pt_name, cmd)
103
100
 
104
101
 
105
- def action_sort_key(action: SetupAction) -> tuple[int, str]:
106
- """Return a sort key so cards are grouped by kind then alphabetical.
102
+ def action_sort_key(action: SetupAction) -> int:
103
+ """Return a sort key that groups cards by execution phase.
107
104
 
108
- The ordering matches the execution phase order
109
- (runtime package tool project → SCM) so that displayed
110
- cards appear in the same sequence as they execute. Within a
111
- group, actions are sorted case-insensitively by package name.
105
+ The ordering is derived from :data:`porringer.backend.command.core.
106
+ action_builder.PHASE_ORDER` so that displayed cards appear in the
107
+ same sequence as they execute. Within a phase group the original
108
+ order from porringer is preserved (Python sort is stable), which
109
+ respects dependency ordering (e.g. a tool must be installed before
110
+ its plugins).
112
111
  """
113
- kind_order = _KIND_ORDER.get(action.kind, 50)
114
- pkg_name = str(action.package.name).lower() if action.package else ''
115
- return (kind_order, pkg_name)
112
+ return _KIND_ORDER.get(action.kind, len(PHASE_ORDER))
116
113
 
117
114
 
118
115
  def _format_command(action: SetupAction) -> str:
@@ -284,7 +281,13 @@ class ActionCard(QFrame):
284
281
  outer.setContentsMargins(6, 6, 6, 6)
285
282
  outer.setSpacing(2)
286
283
 
287
- # --- Top row: type badge | package name ... version | status/spinner | prerelease ---
284
+ outer.addLayout(self._build_top_row())
285
+ outer.addWidget(self._build_description_row())
286
+ outer.addWidget(self._build_command_row())
287
+ outer.addWidget(self._build_log_output())
288
+
289
+ def _build_top_row(self) -> QHBoxLayout:
290
+ """Build the top row: type badge | package name ... version | status/spinner | prerelease."""
288
291
  top = QHBoxLayout()
289
292
  top.setSpacing(8)
290
293
 
@@ -317,24 +320,45 @@ class ActionCard(QFrame):
317
320
  self._prerelease_cb.hide()
318
321
  top.addWidget(self._prerelease_cb)
319
322
 
320
- outer.addLayout(top)
323
+ return top
321
324
 
322
- # --- Description row ---
325
+ def _build_description_row(self) -> QLabel:
326
+ """Build the description label."""
323
327
  self._desc_label = QLabel()
324
328
  self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
325
329
  self._desc_label.setWordWrap(True)
326
- outer.addWidget(self._desc_label)
330
+ return self._desc_label
331
+
332
+ def _build_command_row(self) -> QWidget:
333
+ """Build the CLI command row with copy button."""
334
+ self._command_row = QWidget()
335
+ cmd_layout = QHBoxLayout(self._command_row)
336
+ cmd_layout.setContentsMargins(0, 0, 0, 0)
337
+ cmd_layout.setSpacing(4)
327
338
 
328
- # --- CLI command row (always visible, muted monospace) ---
329
339
  self._command_label = QLabel()
330
340
  self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE)
331
341
  self._command_label.setTextInteractionFlags(
332
342
  Qt.TextInteractionFlag.TextSelectableByMouse,
333
343
  )
334
- self._command_label.hide()
335
- outer.addWidget(self._command_label)
344
+ cmd_layout.addWidget(self._command_label)
345
+
346
+ self._copy_btn = QToolButton()
347
+ self._copy_btn.setText(COPY_ICON)
348
+ self._copy_btn.setToolTip('Copy to clipboard')
349
+ self._copy_btn.setFixedSize(*COPY_BTN_SIZE)
350
+ self._copy_btn.setStyleSheet(COPY_BTN_STYLE)
351
+ self._copy_btn.setCursor(Qt.CursorShape.PointingHandCursor)
352
+ self._copy_btn.clicked.connect(self._copy_command)
353
+ cmd_layout.addWidget(self._copy_btn)
354
+
355
+ cmd_layout.addStretch()
356
+
357
+ self._command_row.hide()
358
+ return self._command_row
336
359
 
337
- # --- Inline log body (hidden by default) ---
360
+ def _build_log_output(self) -> QTextEdit:
361
+ """Build the inline log body (hidden by default)."""
338
362
  self._log_output = QTextEdit()
339
363
  self._log_output.setReadOnly(True)
340
364
  self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE))
@@ -343,16 +367,19 @@ class ActionCard(QFrame):
343
367
  self._log_output.setMaximumHeight(250)
344
368
  self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
345
369
  self._log_output.hide()
346
- outer.addWidget(self._log_output)
370
+ return self._log_output
347
371
 
348
372
  # ------------------------------------------------------------------
349
373
  # Mouse events (toggle log)
350
374
  # ------------------------------------------------------------------
351
375
 
352
- def mousePressEvent(self, _event: object) -> None: # noqa: N802
376
+ def mousePressEvent(self, event: object) -> None: # noqa: N802
353
377
  """Toggle the inline log body on click."""
354
378
  if self._is_skeleton or not hasattr(self, '_log_output'):
355
379
  return
380
+ # Don't toggle the log when clicking the copy button
381
+ if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
382
+ return
356
383
  self._toggle_log()
357
384
 
358
385
  def _toggle_log(self) -> None:
@@ -360,6 +387,23 @@ class ActionCard(QFrame):
360
387
  self._log_expanded = not self._log_expanded
361
388
  self._log_output.setVisible(self._log_expanded)
362
389
 
390
+ def _copy_command(self) -> None:
391
+ """Copy the command label text to the clipboard with brief feedback."""
392
+ clipboard = QApplication.clipboard()
393
+ if clipboard:
394
+ clipboard.setText(self._command_label.text())
395
+ self._copy_btn.setText('\u2713')
396
+ self._copy_btn.setToolTip('Copied!')
397
+
398
+ def _restore() -> None:
399
+ try:
400
+ self._copy_btn.setText(COPY_ICON)
401
+ self._copy_btn.setToolTip('Copy to clipboard')
402
+ except RuntimeError:
403
+ pass
404
+
405
+ QTimer.singleShot(COPY_FEEDBACK_MS, _restore)
406
+
363
407
  # ------------------------------------------------------------------
364
408
  # Public API — populate from action data
365
409
  # ------------------------------------------------------------------
@@ -409,9 +453,9 @@ class ActionCard(QFrame):
409
453
  cmd_text = _format_command(action)
410
454
  if cmd_text:
411
455
  self._command_label.setText(cmd_text)
412
- self._command_label.show()
456
+ self._command_row.show()
413
457
  else:
414
- self._command_label.hide()
458
+ self._command_row.hide()
415
459
 
416
460
  # Version — populated later by set_check_result()
417
461
 
@@ -467,9 +511,9 @@ class ActionCard(QFrame):
467
511
  cmd_text = _format_command(action)
468
512
  if cmd_text:
469
513
  self._command_label.setText(cmd_text)
470
- self._command_label.show()
514
+ self._command_row.show()
471
515
  else:
472
- self._command_label.hide()
516
+ self._command_row.hide()
473
517
 
474
518
  def initial_status(self) -> str:
475
519
  """Return the initial status text set during :meth:`populate`."""
@@ -400,9 +400,9 @@ class SetupPreviewWidget(QWidget):
400
400
  self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
401
401
  outer.addWidget(self._card_list, stretch=1)
402
402
 
403
- # Post-install section lives below the card list but still scrolls
403
+ # Post-install section lives below the card list but still scrolls.
404
+ # It starts hidden and is inserted into the layout after populate().
404
405
  self._post_install_section = PostInstallSection()
405
- self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section)
406
406
 
407
407
  # --- Button bar (fixed at bottom) ---
408
408
  button_bar = self._init_button_bar()
@@ -653,8 +653,12 @@ class SetupPreviewWidget(QWidget):
653
653
  if installer_missing:
654
654
  self._action_statuses[i] = 'Not installed'
655
655
 
656
- # Populate the post-install commands section
656
+ # Populate post-install commands and place them after all cards.
657
657
  self._post_install_section.populate(preview.actions)
658
+ self._card_list._layout.insertWidget(
659
+ self._card_list._layout.count() - 1,
660
+ self._post_install_section,
661
+ )
658
662
 
659
663
  self._install_btn.setEnabled(True)
660
664
 
@@ -753,12 +753,14 @@ class ProjectsView(QWidget):
753
753
  manifest_key = normalize_manifest_key(str(selected_path))
754
754
  overrides = set((self._config.prerelease_packages or {}).get(manifest_key, []))
755
755
 
756
- # Defer project directory assignment until the preview result
757
- # provides root_directory handles both file and directory inputs.
756
+ # For file paths, use the parent directory so the dry-run
757
+ # can detect already-cloned repositories on disk. The final
758
+ # project directory may still be overridden once porringer
759
+ # returns ``root_directory`` in the preview result.
758
760
  preview_worker = PreviewWorker(
759
761
  self._porringer,
760
762
  str(selected_path),
761
- project_directory=selected_path if selected_path.is_dir() else None,
763
+ project_directory=selected_path if selected_path.is_dir() else selected_path.parent,
762
764
  detect_updates=self._config.detect_updates,
763
765
  prerelease_packages=overrides or None,
764
766
  )
@@ -485,11 +485,14 @@ class TrayScreen:
485
485
 
486
486
  if result.error:
487
487
  if not silent:
488
- self.tray.showMessage(
489
- 'Update Check Failed',
490
- f'Failed to check for updates: {result.error}',
491
- QSystemTrayIcon.MessageIcon.Warning,
488
+ # Distinguish informational messages (no releases for channel)
489
+ # from genuine failures.
490
+ is_no_releases = 'No releases found' in result.error
491
+ title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
492
+ icon = (
493
+ QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
492
494
  )
495
+ self.tray.showMessage(title, result.error, icon)
493
496
  else:
494
497
  logger.warning('Automatic update check failed: %s', result.error)
495
498
  return
@@ -194,6 +194,12 @@ def _load_global_config() -> GlobalConfiguration:
194
194
  def save_config(config: GlobalConfiguration) -> None:
195
195
  """Save configuration to the global (system) config directory.
196
196
 
197
+ Only fields that have been explicitly set (either loaded from the
198
+ existing config file or changed at runtime) are written. This
199
+ sparse serialisation ensures that build-time local-config values
200
+ do not leak into the user's global config and that future defaults
201
+ can take effect for fields the user has not customised.
202
+
197
203
  Args:
198
204
  config: The configuration to persist.
199
205
  """
@@ -202,7 +208,10 @@ def save_config(config: GlobalConfiguration) -> None:
202
208
  path = directory / _CONFIG_FILENAME
203
209
 
204
210
  try:
205
- path.write_text(config.model_dump_json(indent=2), encoding='utf-8')
211
+ path.write_text(
212
+ config.model_dump_json(indent=2, exclude_unset=True),
213
+ encoding='utf-8',
214
+ )
206
215
  logger.info('Saved config to %s', path)
207
216
  except Exception:
208
217
  logger.exception('Failed to save config to %s', path)
@@ -32,8 +32,13 @@ def merge_config(
32
32
  ) -> GlobalConfiguration:
33
33
  """Merge local overrides into a global configuration.
34
34
 
35
- Fields explicitly set (not None) in the local config override the
36
- corresponding global values.
35
+ Fields that the user has explicitly saved (present in the global
36
+ config file) take priority over local overrides. Local config
37
+ fields only fill in values the user has **not** set.
38
+
39
+ The returned object preserves the global config's
40
+ ``model_fields_set`` so that :func:`save_config` can use
41
+ ``exclude_unset=True`` to write only user-changed fields.
37
42
 
38
43
  Args:
39
44
  global_config: The user-scoped global configuration.
@@ -45,12 +50,13 @@ def merge_config(
45
50
  if local_config is None:
46
51
  return global_config
47
52
 
53
+ user_set = global_config.model_fields_set
48
54
  merged = global_config.model_dump()
49
55
  for field_name, value in local_config.model_dump().items():
50
- if value is not None:
56
+ if value is not None and field_name not in user_set:
51
57
  merged[field_name] = value
52
58
 
53
- return GlobalConfiguration.model_validate(merged)
59
+ return GlobalConfiguration.model_construct(_fields_set=set(user_set), **merged)
54
60
 
55
61
 
56
62
  def resolve_config() -> GlobalConfiguration:
@@ -193,6 +193,20 @@ class Updater:
193
193
  return self._update_info
194
194
 
195
195
  except Exception as e:
196
+ if '404' in str(e):
197
+ channel = self._config.channel_name
198
+ msg = (
199
+ f"No releases found for the '{channel}' channel. "
200
+ "Try switching to the 'Development' channel in Settings \u2192 Channel."
201
+ )
202
+ logger.debug('No releases for channel %s: %s', channel, e)
203
+ self._state = UpdateState.NO_UPDATE
204
+ return UpdateInfo(
205
+ available=False,
206
+ current_version=self._current_version,
207
+ error=msg,
208
+ )
209
+
196
210
  logger.exception('Failed to check for updates')
197
211
  self._state = UpdateState.FAILED
198
212
  return UpdateInfo(
@@ -664,7 +664,7 @@ class TestActionCardCommandLabel:
664
664
  action = _make_action(package='ruff', installer='pip')
665
665
  card.populate(action)
666
666
  assert card._command_label.text() == 'pip install ruff'
667
- assert not card._command_label.isHidden()
667
+ assert not card._command_row.isHidden()
668
668
 
669
669
  @staticmethod
670
670
  def test_explicit_cli_command() -> None:
@@ -694,22 +694,22 @@ class TestActionCardCommandLabel:
694
694
  resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
695
695
  card.update_command(resolved)
696
696
  assert card._command_label.text() == 'uv tool install ruff'
697
- assert not card._command_label.isHidden()
697
+ assert not card._command_row.isHidden()
698
698
 
699
699
  @staticmethod
700
700
  def test_update_command_hides_label_when_empty() -> None:
701
- """update_command hides the label when the resolved action has no command."""
701
+ """update_command hides the row when the resolved action has no command."""
702
702
  card = ActionCard()
703
703
  action = _make_action(package='ruff', installer='pip')
704
704
  card.populate(action)
705
- assert not card._command_label.isHidden()
705
+ assert not card._command_row.isHidden()
706
706
 
707
707
  empty_action = _make_action(kind=PluginKind.RUNTIME)
708
708
  empty_action.cli_command = None
709
709
  empty_action.command = None
710
710
  empty_action.package = None
711
711
  card.update_command(empty_action)
712
- assert card._command_label.isHidden()
712
+ assert card._command_row.isHidden()
713
713
 
714
714
  @staticmethod
715
715
  def test_update_command_noop_on_skeleton() -> None:
@@ -719,6 +719,29 @@ class TestActionCardCommandLabel:
719
719
  # Should not raise — skeleton simply returns early
720
720
  card.update_command(action)
721
721
 
722
+ @staticmethod
723
+ def test_copy_button_copies_command(monkeypatch: object) -> None:
724
+ """Clicking the copy button copies the command text to the clipboard."""
725
+ card = ActionCard()
726
+ action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
727
+ card.populate(action)
728
+
729
+ clipboard = QApplication.clipboard()
730
+ assert clipboard is not None
731
+ clipboard.clear()
732
+ card._copy_btn.click()
733
+ assert clipboard.text() == 'uv tool install ruff'
734
+
735
+ @staticmethod
736
+ def test_copy_button_shows_feedback() -> None:
737
+ """Clicking copy shows a check-mark on the button."""
738
+ card = ActionCard()
739
+ action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
740
+ card.populate(action)
741
+
742
+ card._copy_btn.click()
743
+ assert card._copy_btn.text() == '\u2713'
744
+
722
745
 
723
746
  # ---------------------------------------------------------------------------
724
747
  # ActionCard — per-card spinner
@@ -806,18 +829,11 @@ class TestActionSortKey:
806
829
  assert action_sort_key(package) < action_sort_key(tool)
807
830
 
808
831
  @staticmethod
809
- def test_alphabetical_within_kind() -> None:
810
- """Same-kind actions are sorted alphabetically by package name."""
832
+ def test_same_kind_returns_equal_key() -> None:
833
+ """Same-kind actions get equal sort keys so stable sort preserves order."""
811
834
  alpha = _make_action(package='alpha')
812
835
  beta = _make_action(package='beta')
813
- assert action_sort_key(alpha) < action_sort_key(beta)
814
-
815
- @staticmethod
816
- def test_case_insensitive() -> None:
817
- """Package name comparison is case-insensitive."""
818
- upper = _make_action(package='Alpha')
819
- lower = _make_action(package='alpha')
820
- assert action_sort_key(upper) == action_sort_key(lower)
836
+ assert action_sort_key(alpha) == action_sort_key(beta)
821
837
 
822
838
 
823
839
  # ---------------------------------------------------------------------------
@@ -829,19 +845,21 @@ class TestActionCardListOrdering:
829
845
  """Tests for card ordering in ActionCardList."""
830
846
 
831
847
  @staticmethod
832
- def test_cards_sorted_by_kind_then_name() -> None:
833
- """Cards are sorted by kind priority, then alphabetically."""
848
+ def test_cards_grouped_by_kind_preserving_order() -> None:
849
+ """Cards are grouped by execution phase, preserving porringer order within."""
834
850
  card_list = ActionCardList()
835
851
  a_pkg_b = _make_action(kind=PluginKind.PACKAGE, package='beta')
836
852
  a_tool = _make_action(kind=PluginKind.TOOL, package='ruff')
837
853
  a_pkg_a = _make_action(kind=PluginKind.PACKAGE, package='alpha')
838
854
  a_runtime = _make_action(kind=PluginKind.RUNTIME, package='python')
839
855
 
840
- # Populate in unsorted order
856
+ # Populate in porringer's execution order
841
857
  card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime])
842
858
 
843
- # Execution-phase order: RUNTIME(0) → PACKAGE(1) → TOOL(2)
844
- expected = ['python', 'alpha', 'beta', 'ruff']
859
+ # Grouped by phase: RUNTIME(0) → PACKAGE(1) → TOOL(2)
860
+ # Within PACKAGE group, original (porringer) order is preserved:
861
+ # beta came before alpha in the input list.
862
+ expected = ['python', 'beta', 'alpha', 'ruff']
845
863
  assert card_list.card_count() == len(expected)
846
864
  for i, name in enumerate(expected):
847
865
  card = card_list.card_at(i)
@@ -842,6 +842,158 @@ class TestNormalizeManifestKey:
842
842
  assert Path(result).is_absolute()
843
843
 
844
844
 
845
+ class TestPreviewWorkerProjectDirectory:
846
+ """Tests for project_directory forwarding to the dry-run."""
847
+
848
+ @staticmethod
849
+ def test_file_path_forwards_parent_as_project_directory(tmp_path: Path) -> None:
850
+ """When a manifest *file* is selected, its parent dir is forwarded."""
851
+ manifest = tmp_path / 'porringer.json'
852
+ manifest.write_text('{}')
853
+
854
+ porringer = MagicMock()
855
+ preview = SetupResults(actions=[])
856
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
857
+
858
+ captured_params: list[Any] = []
859
+
860
+ async def mock_stream(params: Any) -> Any:
861
+ captured_params.append(params)
862
+ yield manifest_event
863
+
864
+ porringer.sync.execute_stream = mock_stream
865
+
866
+ worker = PreviewWorker(
867
+ porringer,
868
+ str(manifest),
869
+ project_directory=manifest.parent,
870
+ )
871
+ worker.run()
872
+
873
+ assert len(captured_params) == 1
874
+ assert captured_params[0].project_directory == tmp_path
875
+
876
+ @staticmethod
877
+ def test_directory_path_forwarded_directly(tmp_path: Path) -> None:
878
+ """When a directory is selected its path is forwarded as-is."""
879
+ manifest = tmp_path / 'porringer.json'
880
+ manifest.write_text('{}')
881
+
882
+ porringer = MagicMock()
883
+ preview = SetupResults(actions=[])
884
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
885
+
886
+ captured_params: list[Any] = []
887
+
888
+ async def mock_stream(params: Any) -> Any:
889
+ captured_params.append(params)
890
+ yield manifest_event
891
+
892
+ porringer.sync.execute_stream = mock_stream
893
+
894
+ worker = PreviewWorker(
895
+ porringer,
896
+ str(tmp_path),
897
+ project_directory=tmp_path,
898
+ )
899
+ worker.run()
900
+
901
+ assert len(captured_params) == 1
902
+ assert captured_params[0].project_directory == tmp_path
903
+
904
+
905
+ class TestSCMPreviewActions:
906
+ """Tests for SCM (git clone) actions in the preview dry-run flow."""
907
+
908
+ @staticmethod
909
+ def _make_scm_action(description: str = 'Clone repo') -> MagicMock:
910
+ action = MagicMock()
911
+ action.kind = PluginKind.SCM
912
+ action.description = description
913
+ action.installer = 'git'
914
+ action.package = None
915
+ action.command = None
916
+ action.cli_command = None
917
+ return action
918
+
919
+ def test_scm_already_installed_emits_correct_result(self, tmp_path: Path) -> None:
920
+ """An SCM action with ALREADY_INSTALLED is surfaced via action_checked."""
921
+ manifest = tmp_path / 'porringer.json'
922
+ manifest.write_text('{}')
923
+
924
+ porringer = MagicMock()
925
+ action = self._make_scm_action()
926
+ preview = SetupResults(actions=[action])
927
+
928
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
929
+ result = SetupActionResult(
930
+ action=action,
931
+ success=True,
932
+ skipped=True,
933
+ skip_reason=SkipReason.ALREADY_INSTALLED,
934
+ )
935
+ completed_event = ProgressEvent(
936
+ kind=ProgressEventKind.ACTION_COMPLETED,
937
+ action=action,
938
+ result=result,
939
+ )
940
+
941
+ async def mock_stream(*args: Any, **kwargs: Any) -> Any:
942
+ yield manifest_event
943
+ yield completed_event
944
+
945
+ porringer.sync.execute_stream = mock_stream
946
+
947
+ worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
948
+
949
+ checked: list[tuple[int, SetupActionResult]] = []
950
+ worker.action_checked.connect(lambda row, r: checked.append((row, r)))
951
+ worker.run()
952
+
953
+ assert len(checked) == 1
954
+ assert checked[0] == (0, result)
955
+ assert checked[0][1].skipped is True
956
+ assert checked[0][1].skip_reason == SkipReason.ALREADY_INSTALLED
957
+
958
+ def test_scm_needed_emits_correct_result(self, tmp_path: Path) -> None:
959
+ """An SCM action that is *not* installed is surfaced as Needed."""
960
+ manifest = tmp_path / 'porringer.json'
961
+ manifest.write_text('{}')
962
+
963
+ porringer = MagicMock()
964
+ action = self._make_scm_action()
965
+ preview = SetupResults(actions=[action])
966
+
967
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
968
+ result = SetupActionResult(
969
+ action=action,
970
+ success=True,
971
+ skipped=False,
972
+ skip_reason=None,
973
+ )
974
+ completed_event = ProgressEvent(
975
+ kind=ProgressEventKind.ACTION_COMPLETED,
976
+ action=action,
977
+ result=result,
978
+ )
979
+
980
+ async def mock_stream(*args: Any, **kwargs: Any) -> Any:
981
+ yield manifest_event
982
+ yield completed_event
983
+
984
+ porringer.sync.execute_stream = mock_stream
985
+
986
+ worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
987
+
988
+ checked: list[tuple[int, SetupActionResult]] = []
989
+ worker.action_checked.connect(lambda row, r: checked.append((row, r)))
990
+ worker.run()
991
+
992
+ assert len(checked) == 1
993
+ assert checked[0][1].skipped is False
994
+ assert checked[0][1].skip_reason is None
995
+
996
+
845
997
  class TestPrereleaseCheckboxLock:
846
998
  """Tests for the pre-release checkbox lock/unlock decision logic.
847
999
 
@@ -165,6 +165,20 @@ class TestSaveConfig:
165
165
  assert data['update_source'] == '/my/releases'
166
166
  assert data['update_channel'] == 'stable'
167
167
 
168
+ @staticmethod
169
+ def test_sparse_serialization(tmp_path: Path) -> None:
170
+ """Verify save_config only writes user-set fields (exclude_unset)."""
171
+ config = GlobalConfiguration(update_channel='dev')
172
+
173
+ with patch('synodic_client.config.config_dir', return_value=tmp_path):
174
+ save_config(config)
175
+
176
+ data = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8'))
177
+ # Only 'update_channel' should be in the file
178
+ assert data == {'update_channel': 'dev'}
179
+ assert 'update_source' not in data
180
+ assert 'auto_update_interval_minutes' not in data
181
+
168
182
  @staticmethod
169
183
  def test_creates_directory(tmp_path: Path) -> None:
170
184
  """Verify save_config creates the directory if missing."""
@@ -189,3 +203,19 @@ class TestSaveConfig:
189
203
 
190
204
  data = json.loads(config_path.read_text(encoding='utf-8'))
191
205
  assert data['update_source'] == 'http://new-source'
206
+
207
+ @staticmethod
208
+ def test_save_load_round_trip(tmp_path: Path) -> None:
209
+ """Verify saved config can be loaded back with correct fields_set."""
210
+ original = GlobalConfiguration(update_channel='dev', auto_start=False)
211
+
212
+ with patch('synodic_client.config.config_dir', return_value=tmp_path):
213
+ save_config(original)
214
+
215
+ data = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8'))
216
+ loaded = GlobalConfiguration.model_validate(data)
217
+ assert loaded.update_channel == 'dev'
218
+ assert loaded.auto_start is False
219
+ # Only saved fields should be in model_fields_set
220
+ assert loaded.model_fields_set == {'update_channel', 'auto_start'}
221
+ assert loaded.update_source is None
@@ -34,8 +34,8 @@ class TestMergeConfig:
34
34
 
35
35
  @staticmethod
36
36
  def test_local_overrides_global() -> None:
37
- """Verify local fields override global values."""
38
- global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable')
37
+ """Verify local fields fill in values the user hasn't set."""
38
+ global_cfg = GlobalConfiguration(update_channel='stable')
39
39
  local_cfg = LocalConfiguration(update_source='/local')
40
40
  result = merge_config(global_cfg, local_cfg)
41
41
  assert result.update_source == '/local'
@@ -52,21 +52,57 @@ class TestMergeConfig:
52
52
 
53
53
  @staticmethod
54
54
  def test_full_override() -> None:
55
- """Verify all local fields override when set."""
55
+ """Verify local fields are ignored when the user has saved both."""
56
56
  global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable')
57
57
  local_cfg = LocalConfiguration(update_source='/local', update_channel='dev')
58
58
  result = merge_config(global_cfg, local_cfg)
59
- assert result.update_source == '/local'
59
+ assert result.update_source == '/system'
60
+ assert result.update_channel == 'stable'
61
+
62
+ @staticmethod
63
+ def test_user_saved_wins_over_local() -> None:
64
+ """Verify a user-saved field takes priority over local config."""
65
+ global_cfg = GlobalConfiguration(update_channel='dev')
66
+ local_cfg = LocalConfiguration(update_channel='stable')
67
+ result = merge_config(global_cfg, local_cfg)
68
+ assert result.update_channel == 'dev'
69
+
70
+ @staticmethod
71
+ def test_local_fills_unsaved_fields() -> None:
72
+ """Verify local config fills in fields the user hasn't saved."""
73
+ global_cfg = GlobalConfiguration(update_channel='dev')
74
+ local_cfg = LocalConfiguration(update_source='/local/releases')
75
+ result = merge_config(global_cfg, local_cfg)
60
76
  assert result.update_channel == 'dev'
77
+ assert result.update_source == '/local/releases'
78
+
79
+ @staticmethod
80
+ def test_preserves_model_fields_set() -> None:
81
+ """Verify merge preserves the global config's model_fields_set."""
82
+ global_cfg = GlobalConfiguration(update_channel='dev')
83
+ local_cfg = LocalConfiguration(update_source='/local')
84
+ result = merge_config(global_cfg, local_cfg)
85
+ # Only 'update_channel' was in the user's config file
86
+ assert result.model_fields_set == {'update_channel'}
87
+ # But the runtime value from local config is available
88
+ assert result.update_source == '/local'
61
89
 
62
90
  @staticmethod
63
91
  def test_local_overrides_plugin_auto_update() -> None:
64
- """Verify local plugin_auto_update overrides global."""
65
- global_cfg = GlobalConfiguration(plugin_auto_update={'pip': False})
92
+ """Verify local plugin_auto_update fills in when user hasn't set it."""
93
+ global_cfg = GlobalConfiguration()
66
94
  local_cfg = LocalConfiguration(plugin_auto_update={'pip': True, 'pipx': False})
67
95
  result = merge_config(global_cfg, local_cfg)
68
96
  assert result.plugin_auto_update == {'pip': True, 'pipx': False}
69
97
 
98
+ @staticmethod
99
+ def test_user_plugin_auto_update_wins() -> None:
100
+ """Verify user-saved plugin_auto_update wins over local."""
101
+ global_cfg = GlobalConfiguration(plugin_auto_update={'pip': False})
102
+ local_cfg = LocalConfiguration(plugin_auto_update={'pip': True, 'pipx': False})
103
+ result = merge_config(global_cfg, local_cfg)
104
+ assert result.plugin_auto_update == {'pip': False}
105
+
70
106
 
71
107
  class TestResolveAutoStart:
72
108
  """Tests for resolve_auto_start."""
@@ -169,7 +205,7 @@ class TestResolveConfig:
169
205
 
170
206
  @staticmethod
171
207
  def test_local_overrides_global_per_field(tmp_path: Path) -> None:
172
- """Verify local config overrides global on a per-field basis."""
208
+ """Verify local config fills in fields the user hasn't saved."""
173
209
  local_data = {'update_source': '/local/releases'}
174
210
  local_path = tmp_path / 'local' / 'config.json'
175
211
  local_path.parent.mkdir()
@@ -177,7 +213,8 @@ class TestResolveConfig:
177
213
 
178
214
  system_dir = tmp_path / 'system'
179
215
  system_dir.mkdir()
180
- system_data = {'update_source': '/system/releases', 'update_channel': 'stable'}
216
+ # User has only saved update_channel, not update_source
217
+ system_data = {'update_channel': 'stable'}
181
218
  (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8')
182
219
 
183
220
  with (
@@ -186,7 +223,9 @@ class TestResolveConfig:
186
223
  ):
187
224
  config = resolve_config()
188
225
 
226
+ # Local fills in update_source since user didn't set it
189
227
  assert config.update_source == '/local/releases'
228
+ # User's saved update_channel is preserved
190
229
  assert config.update_channel == 'stable'
191
230
 
192
231
  @staticmethod
@@ -226,15 +265,15 @@ class TestResolveConfig:
226
265
 
227
266
  @staticmethod
228
267
  def test_portable_takes_precedence(tmp_path: Path) -> None:
229
- """Verify portable config values override system config."""
268
+ """Verify portable config fills in fields user hasn't saved."""
230
269
  portable_data = {'update_source': '/portable/releases', 'update_channel': 'dev'}
231
270
  portable_path = tmp_path / 'config.json'
232
271
  portable_path.write_text(json.dumps(portable_data), encoding='utf-8')
233
272
 
234
273
  system_dir = tmp_path / 'system'
235
274
  system_dir.mkdir()
236
- system_data = {'update_source': '/system/releases', 'update_channel': 'stable'}
237
- (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8')
275
+ # User has NOT saved any config (empty file or missing)
276
+ (system_dir / 'config.json').write_text('{}', encoding='utf-8')
238
277
 
239
278
  with (
240
279
  patch('synodic_client.config._portable_config_path', return_value=portable_path),
@@ -245,6 +284,27 @@ class TestResolveConfig:
245
284
  assert config.update_source == '/portable/releases'
246
285
  assert config.update_channel == 'dev'
247
286
 
287
+ @staticmethod
288
+ def test_user_saved_wins_over_portable(tmp_path: Path) -> None:
289
+ """Verify user-saved values in global config win over portable."""
290
+ portable_data = {'update_source': '/portable/releases', 'update_channel': 'dev'}
291
+ portable_path = tmp_path / 'config.json'
292
+ portable_path.write_text(json.dumps(portable_data), encoding='utf-8')
293
+
294
+ system_dir = tmp_path / 'system'
295
+ system_dir.mkdir()
296
+ system_data = {'update_source': '/system/releases', 'update_channel': 'stable'}
297
+ (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8')
298
+
299
+ with (
300
+ patch('synodic_client.config._portable_config_path', return_value=portable_path),
301
+ patch('synodic_client.config.config_dir', return_value=system_dir),
302
+ ):
303
+ config = resolve_config()
304
+
305
+ assert config.update_source == '/system/releases'
306
+ assert config.update_channel == 'stable'
307
+
248
308
 
249
309
  class TestResolveUpdateConfig:
250
310
  """Tests for resolve_update_config."""
@@ -339,7 +399,9 @@ class TestUpdateAndResolve:
339
399
  assert result.channel == UpdateChannel.DEVELOPMENT
340
400
  assert result.repo_url == '/my/source'
341
401
 
342
- # Verify file was saved
402
+ # Verify file was saved (sparse — only user-set fields)
343
403
  saved = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8'))
344
404
  assert saved['update_source'] == '/my/source'
345
405
  assert saved['update_channel'] == 'dev'
406
+ # Unset fields should not appear in the sparse output
407
+ assert 'auto_update_interval_minutes' not in saved
@@ -150,6 +150,34 @@ class TestUpdaterCheckForUpdate:
150
150
  assert info.error == 'Network error'
151
151
  assert updater.state == UpdateState.FAILED
152
152
 
153
+ @staticmethod
154
+ def test_check_404_returns_friendly_message(updater: Updater) -> None:
155
+ """Verify a 404 from GitHub returns a friendly no-releases message."""
156
+ mock_manager = MagicMock()
157
+ mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404')
158
+
159
+ with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
160
+ info = updater.check_for_update()
161
+
162
+ assert info.available is False
163
+ assert info.error is not None
164
+ assert 'No releases found' in info.error
165
+ assert updater._config.channel_name in info.error
166
+ # A missing channel is informational, not a hard failure
167
+ assert updater.state == UpdateState.NO_UPDATE
168
+
169
+ @staticmethod
170
+ def test_check_non_404_http_error_is_failed(updater: Updater) -> None:
171
+ """Verify non-404 HTTP errors still produce FAILED state."""
172
+ mock_manager = MagicMock()
173
+ mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500')
174
+
175
+ with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
176
+ info = updater.check_for_update()
177
+
178
+ assert info.available is False
179
+ assert updater.state == UpdateState.FAILED
180
+
153
181
 
154
182
  class TestUpdaterDownloadUpdate:
155
183
  """Tests for download_update method."""
@@ -3,8 +3,6 @@
3
3
  import winreg
4
4
  from unittest.mock import MagicMock, patch
5
5
 
6
- import pytest
7
-
8
6
  from synodic_client.startup import (
9
7
  RUN_KEY_PATH,
10
8
  STARTUP_VALUE_NAME,
@@ -13,9 +11,6 @@ from synodic_client.startup import (
13
11
  remove_startup,
14
12
  )
15
13
 
16
- _TEST_VALUE_NAME = f'{STARTUP_VALUE_NAME}_test'
17
- """Temporary value name used by integration tests to avoid clobbering the real registration."""
18
-
19
14
 
20
15
  class TestRegisterStartup:
21
16
  """Tests for register_startup."""
@@ -123,70 +118,4 @@ class TestIsStartupRegistered:
123
118
  patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
124
119
  ):
125
120
  assert is_startup_registered() is False
126
-
127
-
128
- class TestStartupIntegration:
129
- """Integration tests that read/write real registry values under a test name."""
130
-
131
- @staticmethod
132
- def test_register_creates_valid_entry() -> None:
133
- """Register under a test value name, verify, then clean up."""
134
- test_exe = r'C:\test\synodic_test.exe'
135
-
136
- try:
137
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
138
- register_startup(test_exe)
139
-
140
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
141
- value, reg_type = winreg.QueryValueEx(key, _TEST_VALUE_NAME)
142
- assert reg_type == winreg.REG_SZ
143
- assert test_exe in value
144
-
145
- finally:
146
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
147
- remove_startup()
148
-
149
- @staticmethod
150
- def test_remove_deletes_entry() -> None:
151
- """Register then remove under a test value name, verify it is gone."""
152
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
153
- register_startup(r'C:\test\synodic_test.exe')
154
- remove_startup()
155
-
156
- with (
157
- winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key,
158
- pytest.raises(FileNotFoundError),
159
- ):
160
- winreg.QueryValueEx(key, _TEST_VALUE_NAME)
161
-
162
- @staticmethod
163
- def test_register_is_idempotent() -> None:
164
- """Calling register twice with a different exe updates the value."""
165
- exe_v1 = r'C:\test\v1\synodic.exe'
166
- exe_v2 = r'C:\test\v2\synodic.exe'
167
-
168
- try:
169
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
170
- register_startup(exe_v1)
171
- register_startup(exe_v2)
172
-
173
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
174
- value, _ = winreg.QueryValueEx(key, _TEST_VALUE_NAME)
175
- assert exe_v2 in value
176
- assert exe_v1 not in value
177
-
178
- finally:
179
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
180
- remove_startup()
181
-
182
- @staticmethod
183
- def test_is_startup_registered_reflects_state() -> None:
184
- """Verify is_startup_registered returns the correct state."""
185
- with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME):
186
- assert is_startup_registered() is False
187
-
188
- register_startup(r'C:\test\synodic_test.exe')
189
- assert is_startup_registered() is True
190
-
191
- remove_startup()
192
121
  assert is_startup_registered() is False
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev28'