From b917f25d86ddf8323ba817d7b9344094c9339358 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 23 Feb 2026 15:19:48 -0800 Subject: [PATCH 1/4] Better Channel 404 --- synodic_client/application/screen/tray.py | 11 +++++---- synodic_client/updater.py | 14 ++++++++++++ tests/unit/test_updater.py | 28 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 3c31746..debb292 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -485,11 +485,14 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = if result.error: if not silent: - self.tray.showMessage( - 'Update Check Failed', - f'Failed to check for updates: {result.error}', - QSystemTrayIcon.MessageIcon.Warning, + # Distinguish informational messages (no releases for channel) + # from genuine failures. + is_no_releases = 'No releases found' in result.error + title = 'No Updates Available' if is_no_releases else 'Update Check Failed' + icon = ( + QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning ) + self.tray.showMessage(title, result.error, icon) else: logger.warning('Automatic update check failed: %s', result.error) return diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 59d4fda..549a130 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -193,6 +193,20 @@ def check_for_update(self) -> UpdateInfo: return self._update_info except Exception as e: + if '404' in str(e): + channel = self._config.channel_name + msg = ( + f"No releases found for the '{channel}' channel. " + "Try switching to the 'Development' channel in Settings \u2192 Channel." + ) + logger.debug('No releases for channel %s: %s', channel, e) + self._state = UpdateState.NO_UPDATE + return UpdateInfo( + available=False, + current_version=self._current_version, + error=msg, + ) + logger.exception('Failed to check for updates') self._state = UpdateState.FAILED return UpdateInfo( diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 2d8f231..90c1af5 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -150,6 +150,34 @@ def test_check_error(updater: Updater) -> None: assert info.error == 'Network error' assert updater.state == UpdateState.FAILED + @staticmethod + def test_check_404_returns_friendly_message(updater: Updater) -> None: + """Verify a 404 from GitHub returns a friendly no-releases message.""" + mock_manager = MagicMock() + mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404') + + with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): + info = updater.check_for_update() + + assert info.available is False + assert info.error is not None + assert 'No releases found' in info.error + assert updater._config.channel_name in info.error + # A missing channel is informational, not a hard failure + assert updater.state == UpdateState.NO_UPDATE + + @staticmethod + def test_check_non_404_http_error_is_failed(updater: Updater) -> None: + """Verify non-404 HTTP errors still produce FAILED state.""" + mock_manager = MagicMock() + mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500') + + with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): + info = updater.check_for_update() + + assert info.available is False + assert updater.state == UpdateState.FAILED + class TestUpdaterDownloadUpdate: """Tests for download_update method.""" From eade6afd851f1b54610acf6b72dc38147c2ea1ee Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 23 Feb 2026 15:23:36 -0800 Subject: [PATCH 2/4] Command Copy Button --- .../application/screen/action_card.py | 58 ++++++++++++++++--- tests/unit/qt/test_action_card.py | 33 +++++++++-- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index e54e4ad..b1ffc3b 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -19,6 +19,7 @@ from PySide6.QtCore import QRect, Qt, QTimer, Signal from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor from PySide6.QtWidgets import ( + QApplication, QCheckBox, QFrame, QHBoxLayout, @@ -26,6 +27,7 @@ QScrollArea, QSizePolicy, QTextEdit, + QToolButton, QVBoxLayout, QWidget, ) @@ -53,6 +55,10 @@ ACTION_CARD_STYLE, ACTION_CARD_TYPE_BADGE_STYLE, ACTION_CARD_VERSION_STYLE, + COPY_BTN_SIZE, + COPY_BTN_STYLE, + COPY_FEEDBACK_MS, + COPY_ICON, LOG_COLOR_ERROR, LOG_COLOR_PHASE, LOG_COLOR_STDERR, @@ -326,13 +332,31 @@ def _init_real_ui(self) -> None: outer.addWidget(self._desc_label) # --- CLI command row (always visible, muted monospace) --- + self._command_row = QWidget() + cmd_layout = QHBoxLayout(self._command_row) + cmd_layout.setContentsMargins(0, 0, 0, 0) + cmd_layout.setSpacing(4) + self._command_label = QLabel() self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE) self._command_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse, ) - self._command_label.hide() - outer.addWidget(self._command_label) + cmd_layout.addWidget(self._command_label) + + self._copy_btn = QToolButton() + self._copy_btn.setText(COPY_ICON) + self._copy_btn.setToolTip('Copy to clipboard') + self._copy_btn.setFixedSize(*COPY_BTN_SIZE) + self._copy_btn.setStyleSheet(COPY_BTN_STYLE) + self._copy_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._copy_btn.clicked.connect(self._copy_command) + cmd_layout.addWidget(self._copy_btn) + + cmd_layout.addStretch() + + self._command_row.hide() + outer.addWidget(self._command_row) # --- Inline log body (hidden by default) --- self._log_output = QTextEdit() @@ -349,10 +373,13 @@ def _init_real_ui(self) -> None: # Mouse events (toggle log) # ------------------------------------------------------------------ - def mousePressEvent(self, _event: object) -> None: # noqa: N802 + def mousePressEvent(self, event: object) -> None: # noqa: N802 """Toggle the inline log body on click.""" if self._is_skeleton or not hasattr(self, '_log_output'): return + # Don't toggle the log when clicking the copy button + if hasattr(self, '_copy_btn') and self._copy_btn.underMouse(): + return self._toggle_log() def _toggle_log(self) -> None: @@ -360,6 +387,23 @@ def _toggle_log(self) -> None: self._log_expanded = not self._log_expanded self._log_output.setVisible(self._log_expanded) + def _copy_command(self) -> None: + """Copy the command label text to the clipboard with brief feedback.""" + clipboard = QApplication.clipboard() + if clipboard: + clipboard.setText(self._command_label.text()) + self._copy_btn.setText('\u2713') + self._copy_btn.setToolTip('Copied!') + + def _restore() -> None: + try: + self._copy_btn.setText(COPY_ICON) + self._copy_btn.setToolTip('Copy to clipboard') + except RuntimeError: + pass + + QTimer.singleShot(COPY_FEEDBACK_MS, _restore) + # ------------------------------------------------------------------ # Public API — populate from action data # ------------------------------------------------------------------ @@ -409,9 +453,9 @@ def populate( cmd_text = _format_command(action) if cmd_text: self._command_label.setText(cmd_text) - self._command_label.show() + self._command_row.show() else: - self._command_label.hide() + self._command_row.hide() # Version — populated later by set_check_result() @@ -467,9 +511,9 @@ def update_command(self, action: SetupAction) -> None: cmd_text = _format_command(action) if cmd_text: self._command_label.setText(cmd_text) - self._command_label.show() + self._command_row.show() else: - self._command_label.hide() + self._command_row.hide() def initial_status(self) -> str: """Return the initial status text set during :meth:`populate`.""" diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index 1c27af3..ae55dbf 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -664,7 +664,7 @@ def test_default_package_command() -> None: action = _make_action(package='ruff', installer='pip') card.populate(action) assert card._command_label.text() == 'pip install ruff' - assert not card._command_label.isHidden() + assert not card._command_row.isHidden() @staticmethod def test_explicit_cli_command() -> None: @@ -694,22 +694,22 @@ def test_update_command_updates_text() -> None: resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) card.update_command(resolved) assert card._command_label.text() == 'uv tool install ruff' - assert not card._command_label.isHidden() + assert not card._command_row.isHidden() @staticmethod def test_update_command_hides_label_when_empty() -> None: - """update_command hides the label when the resolved action has no command.""" + """update_command hides the row when the resolved action has no command.""" card = ActionCard() action = _make_action(package='ruff', installer='pip') card.populate(action) - assert not card._command_label.isHidden() + assert not card._command_row.isHidden() empty_action = _make_action(kind=PluginKind.RUNTIME) empty_action.cli_command = None empty_action.command = None empty_action.package = None card.update_command(empty_action) - assert card._command_label.isHidden() + assert card._command_row.isHidden() @staticmethod def test_update_command_noop_on_skeleton() -> None: @@ -719,6 +719,29 @@ def test_update_command_noop_on_skeleton() -> None: # Should not raise — skeleton simply returns early card.update_command(action) + @staticmethod + def test_copy_button_copies_command(monkeypatch: object) -> None: + """Clicking the copy button copies the command text to the clipboard.""" + card = ActionCard() + action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) + card.populate(action) + + clipboard = QApplication.clipboard() + assert clipboard is not None + clipboard.clear() + card._copy_btn.click() + assert clipboard.text() == 'uv tool install ruff' + + @staticmethod + def test_copy_button_shows_feedback() -> None: + """Clicking copy shows a check-mark on the button.""" + card = ActionCard() + action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) + card.populate(action) + + card._copy_btn.click() + assert card._copy_btn.text() == '\u2713' + # --------------------------------------------------------------------------- # ActionCard — per-card spinner From 6600cf3b58a7baae0a3062649857cb269bfdb24b Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 23 Feb 2026 16:55:00 -0800 Subject: [PATCH 3/4] Fix SCM Dir + Channel --- .../application/screen/action_card.py | 25 ++- synodic_client/application/screen/screen.py | 8 +- tests/unit/qt/test_install_preview.py | 152 ++++++++++++++++++ tool/scripts/package.py | 8 +- 4 files changed, 179 insertions(+), 14 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index b1ffc3b..0ce2a3e 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -290,7 +290,13 @@ def _init_real_ui(self) -> None: outer.setContentsMargins(6, 6, 6, 6) outer.setSpacing(2) - # --- Top row: type badge | package name ... version | status/spinner | prerelease --- + outer.addLayout(self._build_top_row()) + outer.addWidget(self._build_description_row()) + outer.addWidget(self._build_command_row()) + outer.addWidget(self._build_log_output()) + + def _build_top_row(self) -> QHBoxLayout: + """Build the top row: type badge | package name ... version | status/spinner | prerelease.""" top = QHBoxLayout() top.setSpacing(8) @@ -323,15 +329,17 @@ def _init_real_ui(self) -> None: self._prerelease_cb.hide() top.addWidget(self._prerelease_cb) - outer.addLayout(top) + return top - # --- Description row --- + def _build_description_row(self) -> QLabel: + """Build the description label.""" self._desc_label = QLabel() self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE) self._desc_label.setWordWrap(True) - outer.addWidget(self._desc_label) + return self._desc_label - # --- CLI command row (always visible, muted monospace) --- + def _build_command_row(self) -> QWidget: + """Build the CLI command row with copy button.""" self._command_row = QWidget() cmd_layout = QHBoxLayout(self._command_row) cmd_layout.setContentsMargins(0, 0, 0, 0) @@ -356,9 +364,10 @@ def _init_real_ui(self) -> None: cmd_layout.addStretch() self._command_row.hide() - outer.addWidget(self._command_row) + return self._command_row - # --- Inline log body (hidden by default) --- + def _build_log_output(self) -> QTextEdit: + """Build the inline log body (hidden by default).""" self._log_output = QTextEdit() self._log_output.setReadOnly(True) self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE)) @@ -367,7 +376,7 @@ def _init_real_ui(self) -> None: self._log_output.setMaximumHeight(250) self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self._log_output.hide() - outer.addWidget(self._log_output) + return self._log_output # ------------------------------------------------------------------ # Mouse events (toggle log) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index eee7a85..2cf1680 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -753,12 +753,14 @@ def _load_preview(self) -> None: manifest_key = normalize_manifest_key(str(selected_path)) overrides = set((self._config.prerelease_packages or {}).get(manifest_key, [])) - # Defer project directory assignment until the preview result - # provides root_directory — handles both file and directory inputs. + # For file paths, use the parent directory so the dry-run + # can detect already-cloned repositories on disk. The final + # project directory may still be overridden once porringer + # returns ``root_directory`` in the preview result. preview_worker = PreviewWorker( self._porringer, str(selected_path), - project_directory=selected_path if selected_path.is_dir() else None, + project_directory=selected_path if selected_path.is_dir() else selected_path.parent, detect_updates=self._config.detect_updates, prerelease_packages=overrides or None, ) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 0fb51bb..4df3743 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -842,6 +842,158 @@ def test_relative_path_resolved() -> None: assert Path(result).is_absolute() +class TestPreviewWorkerProjectDirectory: + """Tests for project_directory forwarding to the dry-run.""" + + @staticmethod + def test_file_path_forwards_parent_as_project_directory(tmp_path: Path) -> None: + """When a manifest *file* is selected, its parent dir is forwarded.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker( + porringer, + str(manifest), + project_directory=manifest.parent, + ) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].project_directory == tmp_path + + @staticmethod + def test_directory_path_forwarded_directly(tmp_path: Path) -> None: + """When a directory is selected its path is forwarded as-is.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker( + porringer, + str(tmp_path), + project_directory=tmp_path, + ) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].project_directory == tmp_path + + +class TestSCMPreviewActions: + """Tests for SCM (git clone) actions in the preview dry-run flow.""" + + @staticmethod + def _make_scm_action(description: str = 'Clone repo') -> MagicMock: + action = MagicMock() + action.kind = PluginKind.SCM + action.description = description + action.installer = 'git' + action.package = None + action.command = None + action.cli_command = None + return action + + def test_scm_already_installed_emits_correct_result(self, tmp_path: Path) -> None: + """An SCM action with ALREADY_INSTALLED is surfaced via action_checked.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + action = self._make_scm_action() + preview = SetupResults(actions=[action]) + + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + result = SetupActionResult( + action=action, + success=True, + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + ) + completed_event = ProgressEvent( + kind=ProgressEventKind.ACTION_COMPLETED, + action=action, + result=result, + ) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + yield completed_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path) + + checked: list[tuple[int, SetupActionResult]] = [] + worker.action_checked.connect(lambda row, r: checked.append((row, r))) + worker.run() + + assert len(checked) == 1 + assert checked[0] == (0, result) + assert checked[0][1].skipped is True + assert checked[0][1].skip_reason == SkipReason.ALREADY_INSTALLED + + def test_scm_needed_emits_correct_result(self, tmp_path: Path) -> None: + """An SCM action that is *not* installed is surfaced as Needed.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + action = self._make_scm_action() + preview = SetupResults(actions=[action]) + + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + result = SetupActionResult( + action=action, + success=True, + skipped=False, + skip_reason=None, + ) + completed_event = ProgressEvent( + kind=ProgressEventKind.ACTION_COMPLETED, + action=action, + result=result, + ) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + yield completed_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path) + + checked: list[tuple[int, SetupActionResult]] = [] + worker.action_checked.connect(lambda row, r: checked.append((row, r))) + worker.run() + + assert len(checked) == 1 + assert checked[0][1].skipped is False + assert checked[0][1].skip_reason is None + + class TestPrereleaseCheckboxLock: """Tests for the pre-release checkbox lock/unlock decision logic. diff --git a/tool/scripts/package.py b/tool/scripts/package.py index 5282bf2..805c61b 100644 --- a/tool/scripts/package.py +++ b/tool/scripts/package.py @@ -19,6 +19,7 @@ import typer from synodic_client import __version__ +from synodic_client.updater import platform_suffix from tool.scripts.common import ICON_FILE, MAIN_EXE, OUTPUT_DIR, PACK_DIR, PACK_ID, build, kill_running_instances, run app = typer.Typer(help='Package Synodic Client with PyInstaller and Velopack.') @@ -43,7 +44,8 @@ def main( ] = False, ) -> None: """Entry point for the packaging script.""" - print(f'Packaging Synodic Client v{__version__} (channel: {channel.value})') + velopack_channel = f'{channel.value}-{platform_suffix()}' + print(f'Packaging Synodic Client v{__version__} (channel: {velopack_channel})') # Step 1: PyInstaller if not skip_pyinstaller: @@ -89,7 +91,7 @@ def main( '--icon', str(ICON_FILE), '--channel', - channel.value, + velopack_channel, '-o', str(OUTPUT_DIR), ], @@ -111,7 +113,7 @@ def main( '-o', str(OUTPUT_DIR), '--channel', - channel.value, + velopack_channel, ], description=f'Uploading to local source: {local_path}', ) From 17792c2d9e81bffe40fd5024ff6d4afb16d78297 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 23 Feb 2026 17:06:46 -0800 Subject: [PATCH 4/4] Install Display Ordering Fixes --- .../application/screen/action_card.py | 35 +++++++------------ synodic_client/application/screen/install.py | 10 ++++-- tests/unit/qt/test_action_card.py | 25 ++++++------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 0ce2a3e..100f336 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -14,6 +14,7 @@ import html as html_mod import logging +from porringer.backend.command.core.action_builder import PHASE_ORDER from porringer.schema import SetupAction, SetupActionResult, SkipReason from porringer.schema.plugin import PluginKind from PySide6.QtCore import QRect, Qt, QTimer, Signal @@ -80,19 +81,9 @@ _SPINNER_ARC = 90 -#: Sort priority for each :class:`PluginKind`. -#: Lower numbers appear first. Matches the execution phase order -#: defined in ``porringer.backend.command.core.action_builder.PHASE_ORDER`` -#: so that cards are displayed in the same order they execute: -#: runtime → package → tool → project → SCM. -_KIND_ORDER: dict[PluginKind | None, int] = { - PluginKind.RUNTIME: 0, - PluginKind.PACKAGE: 1, - PluginKind.TOOL: 2, - PluginKind.PROJECT: 3, - PluginKind.SCM: 4, - None: 99, # bare commands are excluded from ActionCardList anyway -} +#: Sort priority derived from porringer's execution phase order so the +#: display order always matches the order actions actually execute. +_KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)} def action_key(action: SetupAction) -> tuple[object, ...]: @@ -108,17 +99,17 @@ def action_key(action: SetupAction) -> tuple[object, ...]: return (action.kind, action.installer, pkg_name, pt_name, cmd) -def action_sort_key(action: SetupAction) -> tuple[int, str]: - """Return a sort key so cards are grouped by kind then alphabetical. +def action_sort_key(action: SetupAction) -> int: + """Return a sort key that groups cards by execution phase. - The ordering matches the execution phase order - (runtime → package → tool → project → SCM) so that displayed - cards appear in the same sequence as they execute. Within a - group, actions are sorted case-insensitively by package name. + The ordering is derived from :data:`porringer.backend.command.core. + action_builder.PHASE_ORDER` so that displayed cards appear in the + same sequence as they execute. Within a phase group the original + order from porringer is preserved (Python sort is stable), which + respects dependency ordering (e.g. a tool must be installed before + its plugins). """ - kind_order = _KIND_ORDER.get(action.kind, 50) - pkg_name = str(action.package.name).lower() if action.package else '' - return (kind_order, pkg_name) + return _KIND_ORDER.get(action.kind, len(PHASE_ORDER)) def _format_command(action: SetupAction) -> str: diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index d1f12a1..e38e744 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -400,9 +400,9 @@ def _init_ui(self) -> None: self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled) outer.addWidget(self._card_list, stretch=1) - # Post-install section lives below the card list but still scrolls + # Post-install section lives below the card list but still scrolls. + # It starts hidden and is inserted into the layout after populate(). self._post_install_section = PostInstallSection() - self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section) # --- Button bar (fixed at bottom) --- button_bar = self._init_button_bar() @@ -653,8 +653,12 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p if installer_missing: self._action_statuses[i] = 'Not installed' - # Populate the post-install commands section + # Populate post-install commands and place them after all cards. self._post_install_section.populate(preview.actions) + self._card_list._layout.insertWidget( + self._card_list._layout.count() - 1, + self._post_install_section, + ) self._install_btn.setEnabled(True) diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index ae55dbf..976dc88 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -829,18 +829,11 @@ def test_package_before_tool() -> None: assert action_sort_key(package) < action_sort_key(tool) @staticmethod - def test_alphabetical_within_kind() -> None: - """Same-kind actions are sorted alphabetically by package name.""" + def test_same_kind_returns_equal_key() -> None: + """Same-kind actions get equal sort keys so stable sort preserves order.""" alpha = _make_action(package='alpha') beta = _make_action(package='beta') - assert action_sort_key(alpha) < action_sort_key(beta) - - @staticmethod - def test_case_insensitive() -> None: - """Package name comparison is case-insensitive.""" - upper = _make_action(package='Alpha') - lower = _make_action(package='alpha') - assert action_sort_key(upper) == action_sort_key(lower) + assert action_sort_key(alpha) == action_sort_key(beta) # --------------------------------------------------------------------------- @@ -852,19 +845,21 @@ class TestActionCardListOrdering: """Tests for card ordering in ActionCardList.""" @staticmethod - def test_cards_sorted_by_kind_then_name() -> None: - """Cards are sorted by kind priority, then alphabetically.""" + def test_cards_grouped_by_kind_preserving_order() -> None: + """Cards are grouped by execution phase, preserving porringer order within.""" card_list = ActionCardList() a_pkg_b = _make_action(kind=PluginKind.PACKAGE, package='beta') a_tool = _make_action(kind=PluginKind.TOOL, package='ruff') a_pkg_a = _make_action(kind=PluginKind.PACKAGE, package='alpha') a_runtime = _make_action(kind=PluginKind.RUNTIME, package='python') - # Populate in unsorted order + # Populate in porringer's execution order card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime]) - # Execution-phase order: RUNTIME(0) → PACKAGE(1) → TOOL(2) - expected = ['python', 'alpha', 'beta', 'ruff'] + # Grouped by phase: RUNTIME(0) → PACKAGE(1) → TOOL(2) + # Within PACKAGE group, original (porringer) order is preserved: + # beta came before alpha in the input list. + expected = ['python', 'beta', 'alpha', 'ruff'] assert card_list.card_count() == len(expected) for i, name in enumerate(expected): card = card_list.card_at(i)