synodic-client 0.0.1.dev66__tar.gz → 0.0.1.dev67__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 (80) hide show
  1. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/pyproject.toml +2 -2
  3. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/__init__.py +14 -6
  4. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/action_card.py +19 -42
  5. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/install.py +5 -11
  6. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/install_workers.py +8 -8
  7. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/log_panel.py +3 -4
  8. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/schema.py +7 -10
  9. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/theme.py +2 -2
  10. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/update_model.py +1 -0
  11. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_action_card.py +52 -108
  12. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_install_preview.py +29 -13
  13. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_log_panel.py +0 -1
  14. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_preview_model.py +2 -4
  15. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_controller.py +21 -0
  16. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/LICENSE.md +0 -0
  17. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/README.md +0 -0
  18. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/__init__.py +0 -0
  19. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/__main__.py +0 -0
  20. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/bootstrap.py +0 -0
  22. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/config_store.py +0 -0
  23. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/data.py +0 -0
  24. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/icon.py +0 -0
  25. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/init.py +0 -0
  26. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/instance.py +0 -0
  27. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/package_state.py +0 -0
  28. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/qt.py +0 -0
  29. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/schema.py +0 -0
  30. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/card.py +0 -0
  31. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/plugin_row.py +0 -0
  32. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/projects.py +0 -0
  33. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/screen.py +0 -0
  34. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/settings.py +0 -0
  35. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/sidebar.py +0 -0
  36. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/spinner.py +0 -0
  37. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/tool_update_controller.py +0 -0
  38. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/tray.py +0 -0
  39. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/update_banner.py +0 -0
  40. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/update_controller.py +0 -0
  41. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/uri.py +0 -0
  42. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/application/workers.py +0 -0
  43. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/cli.py +0 -0
  44. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/client.py +0 -0
  45. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/config.py +0 -0
  46. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/logging.py +0 -0
  47. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/protocol.py +0 -0
  48. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/py.typed +0 -0
  49. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/resolution.py +0 -0
  50. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/schema.py +0 -0
  51. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/startup.py +0 -0
  52. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/subprocess_patch.py +0 -0
  53. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/synodic_client/updater.py +0 -0
  54. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/conftest.py +0 -0
  56. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/__init__.py +0 -0
  58. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/conftest.py +0 -0
  59. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_gather_packages.py +0 -0
  60. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_logging.py +0 -0
  61. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_settings.py +0 -0
  62. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_sidebar.py +0 -0
  63. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_tray_window_show.py +0 -0
  64. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_banner.py +0 -0
  65. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_feedback.py +0 -0
  66. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_cli.py +0 -0
  67. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_client_updater.py +0 -0
  68. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_client_version.py +0 -0
  69. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_config.py +0 -0
  70. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_examples.py +0 -0
  71. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_init.py +0 -0
  72. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_resolution.py +0 -0
  74. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_updater.py +0 -0
  75. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_uri.py +0 -0
  76. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/test_workers.py +0 -0
  77. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/windows/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/windows/conftest.py +0 -0
  79. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/tests/unit/windows/test_protocol.py +0 -0
  80. {synodic_client-0.0.1.dev66 → synodic_client-0.0.1.dev67}/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.dev66
3
+ Version: 0.0.1.dev67
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,7 +8,7 @@ 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.dev78
11
+ Requires-Dist: porringer>=0.2.1.dev79
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -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.dev78",
13
+ "porringer>=0.2.1.dev79",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev66"
18
+ version = "0.0.1.dev67"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from datetime import UTC, datetime
10
10
 
11
- from porringer.schema import SetupAction, SkipReason
11
+ from porringer.schema import SetupAction, SetupActionResult, SkipReason
12
12
  from porringer.schema.plugin import PluginKind
13
13
 
14
14
  _SECONDS_PER_MINUTE = 60
@@ -63,18 +63,26 @@ def skip_reason_label(reason: SkipReason | None) -> str:
63
63
  return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
64
64
 
65
65
 
66
- def format_cli_command(action: SetupAction, *, suppress_description: bool = False) -> str:
66
+ def format_cli_command(
67
+ action: SetupAction,
68
+ *,
69
+ result: SetupActionResult | None = None,
70
+ suppress_description: bool = False,
71
+ ) -> str:
67
72
  """Return a human-readable CLI command string for *action*.
68
73
 
69
- Prefers ``cli_command``, falls back to ``command``, then synthesises
70
- an ``installer install <package>`` string for package actions, and
74
+ Prefers ``result.cli_command`` (populated after dry-run), falls
75
+ back to ``action.command``, then synthesises an
76
+ ``installer install <package>`` string for package actions, and
71
77
  finally returns the action description as a last resort.
72
78
 
73
79
  When *suppress_description* is ``True`` the final description
74
80
  fallback returns an empty string instead.
75
81
  """
76
- if parts := (action.cli_command or action.command):
77
- return ' '.join(parts)
82
+ if result is not None and result.cli_command:
83
+ return ' '.join(result.cli_command)
84
+ if action.command:
85
+ return ' '.join(action.command)
78
86
  if action.kind == PluginKind.PACKAGE and action.package:
79
87
  return f'{action.installer or "pip"} install {action.package}'
80
88
  return '' if suppress_description else action.description
@@ -73,19 +73,6 @@ _SPINNER_INTERVAL = 50
73
73
  _KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
74
74
 
75
75
 
76
- def action_key(action: SetupAction) -> tuple[object, ...]:
77
- """Return a stable identity key for *action*.
78
-
79
- Uses content-based fields (kind, installer, package name, command)
80
- so the same logical action from different ``execute_stream`` runs
81
- resolves to the same key.
82
- """
83
- pkg_name = str(action.package.name) if action.package else None
84
- pt_name = str(action.plugin_target.name) if action.plugin_target else None
85
- cmd = tuple(action.command) if action.command else None
86
- return (action.kind, action.installer, pkg_name, pt_name, cmd)
87
-
88
-
89
76
  def action_sort_key(action: SetupAction) -> int:
90
77
  """Return a sort key that groups cards by execution phase.
91
78
 
@@ -396,24 +383,6 @@ class ActionCard(QFrame):
396
383
  else:
397
384
  self._prerelease_cb.hide()
398
385
 
399
- def update_command(self, action: SetupAction) -> None:
400
- """Update the CLI command label after the resolved preview arrives.
401
-
402
- Called from the two-phase display flow once ``MANIFEST_LOADED``
403
- provides actions with their ``cli_command`` populated.
404
-
405
- Args:
406
- action: The setup action with resolved CLI command.
407
- """
408
- if self._is_skeleton:
409
- return
410
- cmd_text = format_cli_command(action, suppress_description=True)
411
- if cmd_text:
412
- self._command_label.setText(cmd_text)
413
- self._command_row.show()
414
- else:
415
- self._command_row.hide()
416
-
417
386
  def _populate_status(
418
387
  self,
419
388
  action: SetupAction,
@@ -494,7 +463,7 @@ class ActionCard(QFrame):
494
463
  self._status_label.setText(label)
495
464
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE)
496
465
  elif result.skipped:
497
- label = skip_reason_label(result.skip_reason)
466
+ label = '\u2713 ' + skip_reason_label(result.skip_reason)
498
467
  self._status_label.setText(label)
499
468
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
500
469
  elif not result.success:
@@ -517,6 +486,13 @@ class ActionCard(QFrame):
517
486
  else:
518
487
  self._status_label.setToolTip('')
519
488
 
489
+ # CLI command — update with resolved cli_command from result
490
+ assert self._action is not None
491
+ cmd_text = format_cli_command(self._action, result=result, suppress_description=True)
492
+ if cmd_text:
493
+ self._command_label.setText(cmd_text)
494
+ self._command_row.show()
495
+
520
496
  # Version column
521
497
  self._check_available_version = result.available_version
522
498
  if result.installed_version and result.available_version:
@@ -524,6 +500,9 @@ class ActionCard(QFrame):
524
500
  self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: #d7ba7d;')
525
501
  elif result.installed_version:
526
502
  self._version_label.setText(result.installed_version)
503
+ elif result.available_version:
504
+ self._version_label.setText(f'\u2192 {result.available_version}')
505
+ self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;')
527
506
 
528
507
  def finalize_checking(self) -> None:
529
508
  """Resolve a still-pending 'Checking\u2026' status to 'Needed'.
@@ -611,9 +590,8 @@ class ActionCard(QFrame):
611
590
  class ActionCardList(QWidget):
612
591
  """Container of :class:`ActionCard` widgets.
613
592
 
614
- Cards are keyed by :func:`action_key` (content-based) so that
615
- look-ups work across different ``execute_stream`` runs where the
616
- ``SetupAction`` objects are different Python instances.
593
+ Cards are keyed by ``SetupAction`` directly (frozen dataclass) so
594
+ that look-ups work across different ``execute_stream`` runs.
617
595
  """
618
596
 
619
597
  prerelease_toggled = Signal(str, bool)
@@ -629,7 +607,7 @@ class ActionCardList(QWidget):
629
607
  self._layout.addStretch()
630
608
 
631
609
  self._cards: list[ActionCard] = []
632
- self._action_map: dict[tuple[object, ...], ActionCard] = {}
610
+ self._action_map: dict[SetupAction, ActionCard] = {}
633
611
 
634
612
  # ------------------------------------------------------------------
635
613
  # Skeleton loading
@@ -679,7 +657,7 @@ class ActionCardList(QWidget):
679
657
  card.prerelease_toggled.connect(self.prerelease_toggled.emit)
680
658
  self._layout.insertWidget(self._layout.count() - 1, card)
681
659
  self._cards.append(card)
682
- self._action_map[action_key(act)] = card
660
+ self._action_map[act] = card
683
661
 
684
662
  # ------------------------------------------------------------------
685
663
  # Card lookup
@@ -696,11 +674,10 @@ class ActionCardList(QWidget):
696
674
  return len(self._cards)
697
675
 
698
676
  def get_card(self, action: SetupAction) -> ActionCard | None:
699
- """Look up the card for a given action by stable content key.
677
+ """Look up the card for a given action.
700
678
 
701
- Works across different ``execute_stream`` runs the preview
702
- and install phases produce different ``SetupAction`` instances
703
- but the same logical action maps to the same card.
679
+ ``SetupAction`` is a frozen dataclass, so the same logical
680
+ action from different ``execute_stream`` runs hashes equally.
704
681
 
705
682
  Args:
706
683
  action: The setup action to find.
@@ -708,7 +685,7 @@ class ActionCardList(QWidget):
708
685
  Returns:
709
686
  The card widget, or ``None`` if not found.
710
687
  """
711
- return self._action_map.get(action_key(action))
688
+ return self._action_map.get(action)
712
689
 
713
690
  # ------------------------------------------------------------------
714
691
  # Bulk operations
@@ -45,7 +45,7 @@ from PySide6.QtWidgets import (
45
45
 
46
46
  from synodic_client.application.package_state import PackageStateStore
47
47
  from synodic_client.application.screen import skip_reason_label
48
- from synodic_client.application.screen.action_card import ActionCardList, action_key
48
+ from synodic_client.application.screen.action_card import ActionCardList
49
49
  from synodic_client.application.screen.card import CardFrame
50
50
  from synodic_client.application.screen.install_workers import run_install, run_preview
51
51
  from synodic_client.application.screen.log_panel import ExecutionLogPanel
@@ -595,11 +595,11 @@ class SetupPreviewWidget(QWidget):
595
595
  self._install_btn.setEnabled(True)
596
596
 
597
597
  def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None:
598
- """Handle the fully-resolved preview (CLI commands populated).
598
+ """Handle the fully-resolved preview.
599
599
 
600
600
  Called after ``MANIFEST_LOADED`` — cards are already visible
601
- from the earlier ``_on_manifest_parsed`` handler. This only
602
- updates CLI command text and the temp-dir reference.
601
+ from the earlier ``_on_manifest_parsed`` handler. This updates
602
+ the temp-dir reference and emits metadata.
603
603
  """
604
604
  if self._model.preview is None:
605
605
  return
@@ -609,19 +609,13 @@ class SetupPreviewWidget(QWidget):
609
609
  if preview.metadata:
610
610
  self.metadata_ready.emit(preview)
611
611
 
612
- for action in preview.actions:
613
- if action.cli_command:
614
- card = self._card_list.get_card(action)
615
- if card is not None:
616
- card.update_command(action)
617
-
618
612
  def _on_action_checked(self, row: int, result: SetupActionResult) -> None:
619
613
  """Update the model and action card with a dry-run result."""
620
614
  m = self._model
621
615
  if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE:
622
616
  label = skip_reason_label(result.skip_reason)
623
617
  if 0 <= row < len(m.action_states):
624
- m.upgradable_keys.add(action_key(m.action_states[row].action))
618
+ m.upgradable_keys.add(m.action_states[row].action)
625
619
  elif result.skipped:
626
620
  label = skip_reason_label(result.skip_reason)
627
621
  elif not result.success:
@@ -159,10 +159,9 @@ def _dispatch_preview_event(
159
159
  ) -> None:
160
160
  """Route a single preview stream event to the appropriate callback.
161
161
 
162
- Mutates *state* in-place with updated ``action_index`` / ``got_parsed``.
162
+ Mutates *state* in-place (``got_parsed`` flag).
163
163
  """
164
164
  if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest:
165
- state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
166
165
  if cb.on_manifest_parsed is not None:
167
166
  cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str)
168
167
  state.got_parsed = True
@@ -176,16 +175,17 @@ def _dispatch_preview_event(
176
175
  return
177
176
 
178
177
  if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest:
179
- if not state.got_parsed:
180
- state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
181
178
  if cb.on_preview_ready is not None:
182
179
  cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str)
183
180
  return
184
181
 
185
- if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action:
186
- row = state.action_index.get(id(event.action))
187
- if row is not None and cb.on_action_checked is not None:
188
- cb.on_action_checked(row, event.result)
182
+ if (
183
+ event.kind == ProgressEventKind.ACTION_COMPLETED
184
+ and event.result
185
+ and event.action_index is not None
186
+ and cb.on_action_checked is not None
187
+ ):
188
+ cb.on_action_checked(event.action_index, event.result)
189
189
 
190
190
 
191
191
  # ---------------------------------------------------------------------------
@@ -26,7 +26,6 @@ from PySide6.QtWidgets import (
26
26
  )
27
27
 
28
28
  from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
29
- from synodic_client.application.screen.action_card import action_key
30
29
  from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader
31
30
  from synodic_client.application.theme import (
32
31
  LOG_CHEVRON_STYLE,
@@ -186,7 +185,7 @@ class ExecutionLogPanel(QWidget):
186
185
  self._layout.addStretch()
187
186
 
188
187
  # Map action content-key → section widget for quick lookup
189
- self._sections: dict[tuple[object, ...], ActionLogSection] = {}
188
+ self._sections: dict[SetupAction, ActionLogSection] = {}
190
189
  self._section_count = 0
191
190
 
192
191
  # --- Public API ---
@@ -204,7 +203,7 @@ class ExecutionLogPanel(QWidget):
204
203
  section = ActionLogSection(action, self._section_count, self)
205
204
  # Insert before the stretch
206
205
  self._layout.insertWidget(self._layout.count() - 1, section)
207
- self._sections[action_key(action)] = section
206
+ self._sections[action] = section
208
207
 
209
208
  return section
210
209
 
@@ -217,7 +216,7 @@ class ExecutionLogPanel(QWidget):
217
216
  Returns:
218
217
  The section widget, or ``None`` if not found.
219
218
  """
220
- return self._sections.get(action_key(action))
219
+ return self._sections.get(action)
221
220
 
222
221
  def on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None:
223
222
  """Handle a sub-action progress event.
@@ -23,7 +23,6 @@ from porringer.schema import (
23
23
  )
24
24
  from porringer.schema.plugin import RuntimePackageResult
25
25
 
26
- from synodic_client.application.screen.action_card import action_key
27
26
  from synodic_client.application.uri import normalize_manifest_key
28
27
 
29
28
  # ---------------------------------------------------------------------------
@@ -237,7 +236,6 @@ class PreviewModel:
237
236
 
238
237
  def __init__(self) -> None:
239
238
  """Initialise a blank preview model."""
240
- self._action_key = action_key
241
239
  self._normalize = normalize_manifest_key
242
240
 
243
241
  self.phase: PreviewPhase = PreviewPhase.IDLE
@@ -248,19 +246,19 @@ class PreviewModel:
248
246
  self.plugin_installed: dict[str, bool] = {}
249
247
  self.prerelease_overrides: set[str] = set()
250
248
  self.action_states: list[ActionState] = []
251
- self._action_state_map: dict[tuple[object, ...], ActionState] = {}
249
+ self._action_state_map: dict[SetupAction, ActionState] = {}
252
250
  self._action_state_map_len: int = 0
253
- self.upgradable_keys: set[tuple[object, ...]] = set()
251
+ self.upgradable_keys: set[SetupAction] = set()
254
252
  self.checked_count: int = 0
255
253
  self.completed_count: int = 0
256
254
  self.temp_dir: str | None = None
257
255
 
258
256
  # -- Computed helpers --------------------------------------------------
259
257
 
260
- def _ensure_action_state_map(self) -> dict[tuple[object, ...], ActionState]:
261
- """Return the action-key → state lookup, rebuilding if stale."""
258
+ def _ensure_action_state_map(self) -> dict[SetupAction, ActionState]:
259
+ """Return the action → state lookup, rebuilding if stale."""
262
260
  if len(self.action_states) != self._action_state_map_len:
263
- self._action_state_map = {self._action_key(s.action): s for s in self.action_states}
261
+ self._action_state_map = {s.action: s for s in self.action_states}
264
262
  self._action_state_map_len = len(self.action_states)
265
263
  return self._action_state_map
266
264
 
@@ -279,8 +277,8 @@ class PreviewModel:
279
277
  return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states)
280
278
 
281
279
  def action_state_for(self, act: SetupAction) -> ActionState | None:
282
- """Look up :class:`ActionState` by content key (O(1) amortized)."""
283
- return self._ensure_action_state_map().get(self._action_key(act))
280
+ """Look up :class:`ActionState` for *act* (O(1) amortized)."""
281
+ return self._ensure_action_state_map().get(act)
284
282
 
285
283
  def has_same_manifest(self, key: str) -> bool:
286
284
  """Return ``True`` if *key* matches the current manifest key."""
@@ -339,5 +337,4 @@ class PreviewConfig:
339
337
  class _DispatchState:
340
338
  """Mutable accumulator for :func:`_dispatch_preview_event`."""
341
339
 
342
- action_index: dict[int, int] = field(default_factory=dict)
343
340
  got_parsed: bool = False
@@ -427,8 +427,8 @@ ACTION_CARD_STATUS_CHECKING = 'color: grey; font-size: 11px;'
427
427
  ACTION_CARD_STATUS_NEEDED = 'color: palette(text); font-size: 11px; font-weight: bold;'
428
428
  """Status label: Needed."""
429
429
 
430
- ACTION_CARD_STATUS_SATISFIED = 'color: grey; font-size: 11px;'
431
- """Status label: Already installed."""
430
+ ACTION_CARD_STATUS_SATISFIED = 'color: #6a9955; font-size: 11px;'
431
+ """Status label: Already installed (muted green with checkmark)."""
432
432
 
433
433
  ACTION_CARD_STATUS_UPDATE = 'color: #d7ba7d; font-size: 11px; font-weight: bold;'
434
434
  """Status label: Update available (amber)."""
@@ -50,6 +50,7 @@ class UpdateModel(QObject):
50
50
  last_checked_changed = Signal(str)
51
51
 
52
52
  def __init__(self, parent: QObject | None = None) -> None:
53
+ """Initialize the update model."""
53
54
  super().__init__(parent)
54
55
  self._phase = UpdatePhase.IDLE
55
56
  self._version: str = ''
@@ -17,7 +17,6 @@ from PySide6.QtWidgets import QApplication
17
17
  from synodic_client.application.screen.action_card import (
18
18
  ActionCard,
19
19
  ActionCardList,
20
- action_key,
21
20
  action_sort_key,
22
21
  )
23
22
  from synodic_client.application.theme import (
@@ -43,7 +42,7 @@ def _make_action(
43
42
  *,
44
43
  kind: PluginKind | None = PluginKind.PACKAGE,
45
44
  description: str = 'Install requests',
46
- installer: str = 'pip',
45
+ installer: str | None = 'pip',
47
46
  package: str = 'requests',
48
47
  **overrides: Any,
49
48
  ) -> SetupAction:
@@ -63,7 +62,6 @@ def _make_action(
63
62
  action.package = pkg_mock
64
63
  action.package_description = overrides.get('package_description', description)
65
64
  action.command = overrides.get('command')
66
- action.cli_command = overrides.get('cli_command')
67
65
  action.include_prereleases = overrides.get('include_prereleases', False)
68
66
  action.plugin_target = overrides.get('plugin_target')
69
67
  return action
@@ -80,7 +78,7 @@ def _make_result(
80
78
  """Create a SetupActionResult.
81
79
 
82
80
  Extra keyword arguments (``action``, ``installed_version``,
83
- ``available_version``) are forwarded to the constructor.
81
+ ``available_version``, ``cli_command``) are forwarded to the constructor.
84
82
  """
85
83
  return SetupActionResult(
86
84
  action=overrides.get('action') or _make_action(),
@@ -90,6 +88,7 @@ def _make_result(
90
88
  message=message,
91
89
  installed_version=overrides.get('installed_version'),
92
90
  available_version=overrides.get('available_version'),
91
+ cli_command=overrides.get('cli_command'),
93
92
  )
94
93
 
95
94
 
@@ -244,7 +243,7 @@ class TestActionCardCheckResult:
244
243
 
245
244
  @staticmethod
246
245
  def test_already_installed_status() -> None:
247
- """Skipped ALREADY_INSTALLED shows 'Already installed'."""
246
+ """Skipped ALREADY_INSTALLED shows '\u2713 Already installed'."""
248
247
  card = ActionCard()
249
248
  card.populate(_make_action())
250
249
  result = _make_result(
@@ -253,7 +252,7 @@ class TestActionCardCheckResult:
253
252
  installed_version='3.5.2',
254
253
  )
255
254
  card.set_check_result(result)
256
- assert card.status_text() == 'Already installed'
255
+ assert card.status_text() == '\u2713 Already installed'
257
256
  assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet()
258
257
 
259
258
  @staticmethod
@@ -301,6 +300,20 @@ class TestActionCardCheckResult:
301
300
  card.set_check_result(result)
302
301
  assert card._version_label.text() == '3.5.2'
303
302
 
303
+ @staticmethod
304
+ def test_available_version_only_shown() -> None:
305
+ """Version label shows '→ target' when only available_version is set."""
306
+ card = ActionCard()
307
+ card.populate(_make_action())
308
+ result = _make_result(
309
+ success=True,
310
+ skipped=False,
311
+ available_version='1.2.0',
312
+ )
313
+ card.set_check_result(result)
314
+ assert '\u2192 1.2.0' in card._version_label.text()
315
+ assert 'grey' in card._version_label.styleSheet()
316
+
304
317
  @staticmethod
305
318
  def test_finalize_checking_resolves_to_needed() -> None:
306
319
  """finalize_checking stops spinner and changes to 'Needed'."""
@@ -349,8 +362,7 @@ class TestActionCardCheckFailure:
349
362
  def test_failed_check_shows_error_tooltip() -> None:
350
363
  """A failed check result surfaces the error message as a tooltip."""
351
364
  card = ActionCard()
352
- action = _make_action(kind=PluginKind.SCM, package='repo')
353
- action.installer = None # Simulate an unresolved deferred action
365
+ action = _make_action(kind=PluginKind.SCM, package='repo', installer=None)
354
366
  card.populate(action)
355
367
  msg = "SCM environment 'None' is not available"
356
368
  result = _make_result(success=False, skipped=False, message=msg)
@@ -506,8 +518,7 @@ class TestActionCardList:
506
518
  """Populate includes actions with kind=None."""
507
519
  card_list = ActionCardList()
508
520
  a1 = _make_action(package='pkg1')
509
- a2 = _make_action(package='pkg2')
510
- a2.kind = None # bare command
521
+ a2 = _make_action(package='pkg2', kind=None)
511
522
  actions = [a1, a2]
512
523
  card_list.populate(actions)
513
524
  assert card_list.card_count() == len(actions)
@@ -528,21 +539,6 @@ class TestActionCardList:
528
539
  assert c1._package_label.text() == 'first'
529
540
  assert c2._package_label.text() == 'second'
530
541
 
531
- @staticmethod
532
- def test_get_card_cross_instance() -> None:
533
- """get_card works with a different object that has the same content."""
534
- card_list = ActionCardList()
535
- original = _make_action(package='numpy', installer='pip')
536
- card_list.populate([original])
537
-
538
- # Create a separate mock with the same content fields
539
- duplicate = _make_action(package='numpy', installer='pip')
540
- assert original is not duplicate
541
-
542
- card = card_list.get_card(duplicate)
543
- assert card is not None
544
- assert card._package_label.text() == 'numpy'
545
-
546
542
  @staticmethod
547
543
  def test_get_card_returns_none_for_unknown() -> None:
548
544
  """get_card returns None for an unknown action."""
@@ -582,7 +578,7 @@ class TestActionCardList:
582
578
 
583
579
  card_list.finalize_all_checking()
584
580
 
585
- assert c1.status_text() == 'Already installed' # unchanged
581
+ assert c1.status_text() == '\u2713 Already installed' # unchanged
586
582
  c2 = card_list.get_card(a2)
587
583
  assert c2 is not None
588
584
  assert c2.status_text() == 'Needed' # resolved
@@ -612,53 +608,6 @@ class TestActionCardList:
612
608
  assert card_list.card_at(-1) is None
613
609
 
614
610
 
615
- # ---------------------------------------------------------------------------
616
- # action_key — stable identity
617
- # ---------------------------------------------------------------------------
618
-
619
-
620
- class TestActionKey:
621
- """Tests for the action_key function."""
622
-
623
- @staticmethod
624
- def test_same_content_same_key() -> None:
625
- """Two actions with identical content produce the same key."""
626
- a = _make_action(package='numpy', installer='pip')
627
- b = _make_action(package='numpy', installer='pip')
628
- assert a is not b
629
- assert action_key(a) == action_key(b)
630
-
631
- @staticmethod
632
- def test_different_package_different_key() -> None:
633
- """Actions with different packages produce different keys."""
634
- a = _make_action(package='numpy')
635
- b = _make_action(package='scipy')
636
- assert action_key(a) != action_key(b)
637
-
638
- @staticmethod
639
- def test_different_installer_different_key() -> None:
640
- """Actions with different installers produce different keys."""
641
- a = _make_action(package='numpy', installer='pip')
642
- b = _make_action(package='numpy', installer='uv')
643
- assert action_key(a) != action_key(b)
644
-
645
- @staticmethod
646
- def test_different_kind_different_key() -> None:
647
- """Actions with different kinds produce different keys."""
648
- a = _make_action(package='ruff', kind=PluginKind.PACKAGE)
649
- b = _make_action(package='ruff', kind=PluginKind.TOOL)
650
- assert action_key(a) != action_key(b)
651
-
652
- @staticmethod
653
- def test_command_action_key() -> None:
654
- """Command actions include the command in the key."""
655
- a = _make_action(command=['echo', 'hello'])
656
- b = _make_action(command=['echo', 'hello'])
657
- c = _make_action(command=['echo', 'world'])
658
- assert action_key(a) == action_key(b)
659
- assert action_key(a) != action_key(c)
660
-
661
-
662
611
  # ---------------------------------------------------------------------------
663
612
  # ActionCard — CLI command label
664
613
  # ---------------------------------------------------------------------------
@@ -677,11 +626,18 @@ class TestActionCardCommandLabel:
677
626
  assert not card._command_row.isHidden()
678
627
 
679
628
  @staticmethod
680
- def test_explicit_cli_command() -> None:
681
- """Actions with cli_command show that instead of the default."""
629
+ def test_explicit_cli_command_from_result() -> None:
630
+ """set_check_result with cli_command updates the command label."""
682
631
  card = ActionCard()
683
- action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
632
+ action = _make_action(package='ruff', installer='pip')
684
633
  card.populate(action)
634
+ assert card._command_label.text() == 'pip install ruff'
635
+
636
+ result = _make_result(
637
+ action=action,
638
+ cli_command=('uv', 'tool', 'install', 'ruff'),
639
+ )
640
+ card.set_check_result(result)
685
641
  assert card._command_label.text() == 'uv tool install ruff'
686
642
 
687
643
  @staticmethod
@@ -694,46 +650,30 @@ class TestActionCardCommandLabel:
694
650
  assert flags & Qt.TextInteractionFlag.TextSelectableByMouse
695
651
 
696
652
  @staticmethod
697
- def test_update_command_updates_text() -> None:
698
- """update_command replaces the command label text."""
653
+ def test_set_check_result_updates_command_label() -> None:
654
+ """set_check_result with result cli_command updates the command label."""
699
655
  card = ActionCard()
700
656
  action = _make_action(package='ruff', installer='pip')
701
657
  card.populate(action)
702
658
  assert card._command_label.text() == 'pip install ruff'
703
659
 
704
- resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
705
- card.update_command(resolved)
660
+ result = _make_result(
661
+ action=action,
662
+ cli_command=('uv', 'tool', 'install', 'ruff'),
663
+ )
664
+ card.set_check_result(result)
706
665
  assert card._command_label.text() == 'uv tool install ruff'
707
666
  assert not card._command_row.isHidden()
708
667
 
709
- @staticmethod
710
- def test_update_command_hides_label_when_empty() -> None:
711
- """update_command hides the row when the resolved action has no command."""
712
- card = ActionCard()
713
- action = _make_action(package='ruff', installer='pip')
714
- card.populate(action)
715
- assert not card._command_row.isHidden()
716
-
717
- empty_action = _make_action(kind=PluginKind.RUNTIME)
718
- empty_action.cli_command = None
719
- empty_action.command = None
720
- empty_action.package = None
721
- card.update_command(empty_action)
722
- assert card._command_row.isHidden()
723
-
724
- @staticmethod
725
- def test_update_command_noop_on_skeleton() -> None:
726
- """update_command does nothing when card is a skeleton."""
727
- card = ActionCard(skeleton=True)
728
- action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
729
- # Should not raise — skeleton simply returns early
730
- card.update_command(action)
731
-
732
668
  @staticmethod
733
669
  def test_copy_button_copies_command(monkeypatch: object) -> None:
734
670
  """Clicking the copy button copies the command text to the clipboard."""
735
671
  card = ActionCard()
736
- action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
672
+ action = _make_action(
673
+ package='ruff',
674
+ installer='uv',
675
+ command=('uv', 'tool', 'install', 'ruff'),
676
+ )
737
677
  card.populate(action)
738
678
 
739
679
  clipboard = QApplication.clipboard()
@@ -746,7 +686,11 @@ class TestActionCardCommandLabel:
746
686
  def test_copy_button_shows_feedback() -> None:
747
687
  """Clicking copy shows a check-mark on the button."""
748
688
  card = ActionCard()
749
- action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
689
+ action = _make_action(
690
+ package='ruff',
691
+ installer='uv',
692
+ command=('uv', 'tool', 'install', 'ruff'),
693
+ )
750
694
  card.populate(action)
751
695
 
752
696
  card._copy_btn.click()
@@ -936,7 +880,7 @@ class TestActionCardAlreadyLatest:
936
880
  skip_reason=SkipReason.ALREADY_LATEST,
937
881
  )
938
882
  )
939
- assert card.status_text() == 'Already latest'
883
+ assert card.status_text() == '\u2713 Already latest'
940
884
  assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet()
941
885
 
942
886
  @staticmethod
@@ -974,8 +918,8 @@ class TestActionCardAlreadyLatest:
974
918
  )
975
919
  )
976
920
 
977
- assert card_installed.status_text() == 'Already installed'
978
- assert card_latest.status_text() == 'Already latest'
921
+ assert card_installed.status_text() == '\u2713 Already installed'
922
+ assert card_latest.status_text() == '\u2713 Already latest'
979
923
  # Both use the satisfied stylesheet
980
924
  assert ACTION_CARD_STATUS_SATISFIED in card_installed._status_label.styleSheet()
981
925
  assert ACTION_CARD_STATUS_SATISFIED in card_latest._status_label.styleSheet()
@@ -49,7 +49,6 @@ class TestInstallPreviewWindow:
49
49
  action.installer = installer
50
50
  action.package = package
51
51
  action.command = None
52
- action.cli_command = None
53
52
  return action
54
53
 
55
54
  @staticmethod
@@ -85,7 +84,6 @@ class TestFormatCliCommand:
85
84
  'description': 'Install test',
86
85
  'installer': 'pip',
87
86
  'package': 'requests',
88
- 'cli_command': None,
89
87
  'command': None,
90
88
  }
91
89
  defaults.update(overrides)
@@ -96,19 +94,20 @@ class TestFormatCliCommand:
96
94
  action.installer = defaults['installer']
97
95
  action.package = defaults['package']
98
96
  action.command = defaults['command']
99
- action.cli_command = defaults['cli_command']
100
97
  return action
101
98
 
102
- def test_prefers_cli_command(self) -> None:
103
- """Verify cli_command takes precedence over command and fallback."""
104
- action = self._make_action(cli_command=['uv', 'pip', 'install', 'requests'])
105
- assert format_cli_command(action) == 'uv pip install requests'
99
+ def test_prefers_cli_command_from_result(self) -> None:
100
+ """Verify result cli_command takes precedence over command and fallback."""
101
+ action = self._make_action()
102
+ result = MagicMock()
103
+ result.cli_command = ('uv', 'pip', 'install', 'requests')
104
+ assert format_cli_command(action, result=result) == 'uv pip install requests'
106
105
 
107
106
  def test_falls_back_to_command(self) -> None:
108
107
  """Verify command is used when cli_command is absent."""
109
108
  action = self._make_action(
110
109
  kind='TOOL',
111
- command=['echo', 'hello'],
110
+ command=('echo', 'hello'),
112
111
  )
113
112
  assert format_cli_command(action) == 'echo hello'
114
113
 
@@ -150,6 +149,7 @@ class TestInstallWorker:
150
149
  kind=ProgressEventKind.ACTION_COMPLETED,
151
150
  action=action,
152
151
  result=result,
152
+ action_index=0,
153
153
  )
154
154
 
155
155
  async def mock_stream(*args, **kwargs):
@@ -389,7 +389,12 @@ class TestPreviewWorkerSignals:
389
389
  # Dry-run stream yields manifest loaded then one completed event
390
390
  manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
391
391
  result = SetupActionResult(action=action, success=True, skipped=False, skip_reason=None)
392
- completed_event = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action, result=result)
392
+ completed_event = ProgressEvent(
393
+ kind=ProgressEventKind.ACTION_COMPLETED,
394
+ action=action,
395
+ result=result,
396
+ action_index=0,
397
+ )
393
398
 
394
399
  async def mock_stream(*args: Any, **kwargs: Any) -> Any:
395
400
  yield manifest_event
@@ -448,7 +453,7 @@ class TestPreviewWorkerSignals:
448
453
 
449
454
  @staticmethod
450
455
  def test_action_checked_maps_correct_rows(tmp_path: Path) -> None:
451
- """Verify on_action_checked receives correct row indices via identity matching."""
456
+ """Verify on_action_checked receives correct row indices via content-based matching."""
452
457
  manifest = tmp_path / 'porringer.json'
453
458
  manifest.write_text('{}')
454
459
 
@@ -466,8 +471,18 @@ class TestPreviewWorkerSignals:
466
471
  result_a = SetupActionResult(action=action_a, success=True, skipped=False, skip_reason=None)
467
472
 
468
473
  # Stream returns in execution order (b before a), not preview order
469
- event_b = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action_b, result=result_b)
470
- event_a = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action_a, result=result_a)
474
+ event_b = ProgressEvent(
475
+ kind=ProgressEventKind.ACTION_COMPLETED,
476
+ action=action_b,
477
+ result=result_b,
478
+ action_index=1,
479
+ )
480
+ event_a = ProgressEvent(
481
+ kind=ProgressEventKind.ACTION_COMPLETED,
482
+ action=action_a,
483
+ result=result_a,
484
+ action_index=0,
485
+ )
471
486
 
472
487
  async def mock_stream(*args: Any, **kwargs: Any) -> Any:
473
488
  yield manifest_event
@@ -815,7 +830,6 @@ class TestSCMPreviewActions:
815
830
  action.installer = 'git'
816
831
  action.package = None
817
832
  action.command = None
818
- action.cli_command = None
819
833
  return action
820
834
 
821
835
  def test_scm_already_installed_emits_correct_result(self, tmp_path: Path) -> None:
@@ -838,6 +852,7 @@ class TestSCMPreviewActions:
838
852
  kind=ProgressEventKind.ACTION_COMPLETED,
839
853
  action=action,
840
854
  result=result,
855
+ action_index=0,
841
856
  )
842
857
 
843
858
  async def mock_stream(*args: Any, **kwargs: Any) -> Any:
@@ -884,6 +899,7 @@ class TestSCMPreviewActions:
884
899
  kind=ProgressEventKind.ACTION_COMPLETED,
885
900
  action=action,
886
901
  result=result,
902
+ action_index=0,
887
903
  )
888
904
 
889
905
  async def mock_stream(*args: Any, **kwargs: Any) -> Any:
@@ -61,7 +61,6 @@ def _make_action(
61
61
  action.package = pkg_mock
62
62
  action.package_description = package_description or description
63
63
  action.command = None
64
- action.cli_command = None
65
64
  action.plugin_target = None
66
65
  return action
67
66
 
@@ -8,7 +8,6 @@ from unittest.mock import MagicMock
8
8
  from porringer.schema import SetupAction
9
9
  from porringer.schema.plugin import PluginKind
10
10
 
11
- from synodic_client.application.screen.action_card import action_key
12
11
  from synodic_client.application.screen.schema import ActionState, PreviewModel, PreviewPhase
13
12
  from synodic_client.application.uri import normalize_manifest_key
14
13
 
@@ -36,7 +35,6 @@ def _make_action(
36
35
  action.package = pkg_mock
37
36
  action.package_description = overrides.get('package_description', description)
38
37
  action.command = overrides.get('command')
39
- action.cli_command = overrides.get('cli_command')
40
38
  action.include_prereleases = overrides.get('include_prereleases', False)
41
39
  action.plugin_target = overrides.get('plugin_target')
42
40
  return action
@@ -105,7 +103,7 @@ class TestPreviewModel:
105
103
  state = ActionState(action=_make_action())
106
104
  state.status = 'Already installed'
107
105
  model.action_states.append(state)
108
- model.upgradable_keys.add(action_key(state.action))
106
+ model.upgradable_keys.add(state.action)
109
107
  assert model.install_enabled is True
110
108
 
111
109
  @staticmethod
@@ -149,7 +147,7 @@ class TestPreviewModel:
149
147
  upgradable = ActionState(action=_make_action(package='c'))
150
148
  upgradable.status = 'Update available'
151
149
  model.action_states = [needed, satisfied, upgradable]
152
- model.upgradable_keys.add(action_key(upgradable.action))
150
+ model.upgradable_keys.add(upgradable.action)
153
151
  expected_actionable = 2 # 1 needed + 1 upgradable
154
152
  assert model.actionable_count == expected_actionable
155
153
 
@@ -48,6 +48,27 @@ class ModelSpy:
48
48
  # ---------------------------------------------------------------------------
49
49
 
50
50
 
51
+ class ModelSpy:
52
+ """Records signal emissions from an :class:`UpdateModel`."""
53
+
54
+ def __init__(self, model: UpdateModel) -> None:
55
+ """Connect to *model* signals and record emissions."""
56
+ self.status: list[tuple[str, str]] = []
57
+ self.check_button_enabled: list[bool] = []
58
+ self.restart_visible: list[bool] = []
59
+ self.last_checked: list[str] = []
60
+
61
+ model.status_text_changed.connect(lambda t, s: self.status.append((t, s)))
62
+ model.check_button_enabled_changed.connect(self.check_button_enabled.append)
63
+ model.restart_visible_changed.connect(self.restart_visible.append)
64
+ model.last_checked_changed.connect(self.last_checked.append)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
51
72
  def _make_config(**overrides: Any) -> ResolvedConfig:
52
73
  """Create a ``ResolvedConfig`` with sensible defaults and optional overrides."""
53
74
  defaults: dict[str, Any] = {