From b5a197b2b09683e8a139bc10af93f95434578731 Mon Sep 17 00:00:00 2001 From: JamesAbel Date: Thu, 20 Aug 2026 09:53:13 -0700 Subject: [PATCH] History tab: multi-select rows and copy to clipboard - Extended selection on the history tree: Ctrl/Shift-click selects any mix of run rows and failed-test rows - Ctrl+C (WidgetWithChildrenShortcut, so it fires with viewport focus) and a right-click Copy menu put the highlighted rows on the clipboard, in visual order: run rows as tab-separated column values, failed-test rows as the test node_id - Selection survives the per-tick rebuilds during an active run, keyed by run GUID (and test name for failed-test rows), like expansion state Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- README.md | 3 +- pyproject.toml | 2 +- src/pytest_fly/gui/history_tab/history_tab.py | 78 +++++++++++++++++-- tests/test_history_tab.py | 76 +++++++++++++++++- 5 files changed, 148 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f725901..2dce59d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ pip install -r requirements-dev.txt ### GUI layer (`src/pytest_fly/gui/`) - `gui_main.py` — `FlyAppMainWindow`: 8-tab Qt window with a periodic timer (default 3 s) that pulls updates from the runner and refreshes all tabs. -- Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`. +- Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; multi-select rows copy to the clipboard; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`. ### Core runner (`src/pytest_fly/pytest_runner/`) - `pytest_runner.py` — `PytestRunner` (thread): orchestrates worker threads, schedules tests, handles run modes. diff --git a/README.md b/README.md index 1fd71e3..52f3f6d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ python -m pytest_fly - **Table** — per-test status grid with elapsed time, peak CPU, memory usage, and individual coverage - **Coverage** — line chart of combined code coverage over time with covered/total line counts - **History** — summaries of recent runs, most recent first: start time, duration, completion - status, pass/fail statistics, and each run's failed tests as expandable rows. The number of + status, pass/fail statistics, and each run's failed tests as expandable rows. Rows can be + multi-selected and copied to the clipboard (Ctrl+C or right-click → Copy). The number of runs shown is configurable - **Log** — live application event log, each line date/time-prefixed. The default view shows only notable run events (admission-gate deferrals, resource-guard and stall-watchdog diff --git a/pyproject.toml b/pyproject.toml index 9fa21c7..13c426c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pytest-fly" description = "pytest runner and observer" -version = "0.8.0" +version = "0.8.1" readme = "README.md" requires-python = ">=3.12" authors = [ diff --git a/src/pytest_fly/gui/history_tab/history_tab.py b/src/pytest_fly/gui/history_tab/history_tab.py index 5772803..b39554b 100644 --- a/src/pytest_fly/gui/history_tab/history_tab.py +++ b/src/pytest_fly/gui/history_tab/history_tab.py @@ -3,13 +3,15 @@ Shows one row per run (start time, duration, completion status, pass/fail statistics, and the program-under-test version), with the run's failed tests as expandable child rows. +Rows can be multi-selected and copied to the clipboard (Ctrl+C or right-click → Copy). The number of runs shown is the Configuration tab's "History Run Limit" preference. """ from datetime import datetime -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QGroupBox, QSizePolicy, QTreeWidget, QTreeWidgetItem, QVBoxLayout +from PySide6.QtCore import QPoint, Qt +from PySide6.QtGui import QGuiApplication, QKeySequence, QShortcut +from PySide6.QtWidgets import QAbstractItemView, QGroupBox, QMenu, QSizePolicy, QTreeWidget, QTreeWidgetItem, QVBoxLayout from ...colors import TABLE_COLORS from ...db import PytestProcessInfoReader @@ -21,7 +23,7 @@ _COLUMNS = ("Start", "Duration", "Status", "Pass", "Fail", "Other", "Total", "Version") _START_COLUMN, _DURATION_COLUMN, _STATUS_COLUMN, _PASS_COLUMN, _FAIL_COLUMN, _OTHER_COLUMN, _TOTAL_COLUMN, _VERSION_COLUMN = range(len(_COLUMNS)) -# Run GUID stored on each top-level item so expansion state survives rebuilds. +# Run GUID stored on each top-level item so expansion and selection state survive rebuilds. _RUN_GUID_ROLE = Qt.ItemDataRole.UserRole @@ -38,13 +40,23 @@ def __init__(self): self._tree = QTreeWidget() self._tree.setHeaderLabels(list(_COLUMNS)) + self._tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self._tree.setToolTip( "One row per recent test run; expand a run to see its failed tests.\n" "Other = terminated, stopped, or still queued/running tests.\n" + "Select rows (Ctrl/Shift-click for several) and copy them with Ctrl+C or right-click → Copy.\n" "The number of runs shown is set by the Configuration tab's History Run Limit." ) + self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._show_context_menu) layout.addWidget(self._tree) + # WidgetWithChildrenShortcut: the tree's viewport has focus during interaction, so a + # plain widget-context shortcut on the tree itself would never fire. + copy_shortcut = QShortcut(QKeySequence(QKeySequence.StandardKey.Copy), self._tree) + copy_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + copy_shortcut.activated.connect(self.copy_selection_to_clipboard) + # Change-detection state: rebuild only when the DB content or the run limit changed. self._change_token: tuple[int, int] | None = None self._run_limit: int | None = None @@ -59,16 +71,61 @@ def update_tick(self, db: PytestProcessInfoReader) -> None: self._run_limit = run_limit self._rebuild(build_run_history(db.query_recent_runs(run_limit))) + def selection_as_text(self) -> str: + """Return the highlighted rows as clipboard text, in visual (tree) order. + + A run row becomes one tab-separated line of its column values; a failed-test row + becomes the test's node_id. Empty when nothing is selected. + """ + lines: list[str] = [] + for index in range(self._tree.topLevelItemCount()): + run_item = self._tree.topLevelItem(index) + if run_item is None: + continue + if run_item.isSelected(): + lines.append("\t".join(run_item.text(column) for column in range(len(_COLUMNS)))) + for child_index in range(run_item.childCount()): + child = run_item.child(child_index) + if child is not None and child.isSelected(): + lines.append(child.text(0)) + return "\n".join(lines) + + def copy_selection_to_clipboard(self) -> None: + """Copy the highlighted rows to the system clipboard; a no-op when nothing is selected.""" + text = self.selection_as_text() + if text: + QGuiApplication.clipboard().setText(text) + + def _show_context_menu(self, position: QPoint) -> None: + """Right-click menu: Copy the highlighted rows to the clipboard.""" + menu = QMenu(self._tree) + copy_action = menu.addAction("Copy") + copy_action.setEnabled(bool(self._tree.selectedItems())) + selected_action = menu.exec_(self._tree.viewport().mapToGlobal(position)) + if selected_action == copy_action: + self.copy_selection_to_clipboard() + def _rebuild(self, summaries: list[RunHistorySummary]) -> None: - """Repopulate the tree, preserving each still-present run's expansion state.""" + """Repopulate the tree, preserving each still-present run's expansion and selection state.""" expansion_by_guid: dict[str, bool] = {} + selected_run_guids: set[str] = set() + selected_failed_tests: set[tuple[str, str]] = set() # (run_guid, test node_id) for index in range(self._tree.topLevelItemCount()): item = self._tree.topLevelItem(index) + if item is None: + continue + run_guid = item.data(0, _RUN_GUID_ROLE) # Only a row with failed-test children has a meaningful expansion state to keep. # Recording childless rows would freeze an in-progress run in its initial collapsed # state and defeat the auto-expand when its first failure appears. - if item is not None and item.childCount() > 0: - expansion_by_guid[item.data(0, _RUN_GUID_ROLE)] = item.isExpanded() + if item.childCount() > 0: + expansion_by_guid[run_guid] = item.isExpanded() + if item.isSelected(): + selected_run_guids.add(run_guid) + for child_index in range(item.childCount()): + child = item.child(child_index) + if child is not None and child.isSelected(): + selected_failed_tests.add((run_guid, child.text(0))) self._tree.clear() fail_color = TABLE_COLORS[PytestRunnerState.FAIL] @@ -94,12 +151,17 @@ def _rebuild(self, summaries: list[RunHistorySummary]) -> None: failed_item.setForeground(0, fail_color) run_item.addChild(failed_item) self._tree.addTopLevelItem(run_item) + # Selection (like the column span below) only takes effect once the item is in the tree. + run_item.setSelected(summary.run_guid in selected_run_guids) # Runs with failures start expanded so the failed tests are immediately visible; # a run the user explicitly collapsed (or expanded) stays that way across rebuilds. run_item.setExpanded(expansion_by_guid.get(summary.run_guid, summary.n_fail > 0)) - # A failed-test child row is a single name, not tabular data — span it across all columns. for child_index in range(run_item.childCount()): - run_item.child(child_index).setFirstColumnSpanned(True) + child = run_item.child(child_index) + if child is not None: + child.setSelected((summary.run_guid, child.text(0)) in selected_failed_tests) + # A failed-test child row is a single name, not tabular data — span it across all columns. + child.setFirstColumnSpanned(True) for column in range(len(_COLUMNS)): self._tree.resizeColumnToContents(column) diff --git a/tests/test_history_tab.py b/tests/test_history_tab.py index d6d52a3..6db4dde 100644 --- a/tests/test_history_tab.py +++ b/tests/test_history_tab.py @@ -1,14 +1,15 @@ """History tab — recent-run summaries rendered from the DB. Covers the run rows (times, pass/fail statistics, status), the failed-test child rows, -the configurable run limit, and the change-token gating that skips rebuilds when the DB -has not changed. +the configurable run limit, the change-token gating that skips rebuilds when the DB +has not changed, and multi-select copy-to-clipboard. """ import time import pytest from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication from pytest_fly.db import PytestProcessInfoDB, PytestProcessInfoReader from pytest_fly.gui.history_tab import HistoryTab @@ -162,3 +163,74 @@ def test_history_tab_empty_db(qtbot, history_run_limit_pref): qtbot.addWidget(tab) _update_from_db(tab, data_dir) assert tab._tree.topLevelItemCount() == 0 + + +def test_history_tab_copy_selection(qtbot, history_run_limit_pref): + """Selected run rows copy as tab-separated columns and failed-test rows as names, in visual order.""" + data_dir = get_temp_dir("history_tab_copy") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now - 100)) + db.write(_record("run-2", "tests/test_a.py", PyTestFlyExitCode.OK, now - 50)) + db.write(_record("run-2", "tests/test_b.py", PyTestFlyExitCode.TESTS_FAILED, now - 40)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + tree = tab._tree + + # Select (in reverse click order, to prove visual ordering wins): the older run row, + # then the newest run's failed-test child, then the newest run row. + tree.topLevelItem(1).setSelected(True) + tree.topLevelItem(0).child(0).setSelected(True) + tree.topLevelItem(0).setSelected(True) + + tab.copy_selection_to_clipboard() + lines = QGuiApplication.clipboard().text().splitlines() + assert len(lines) == 3 + newest_run_line, failed_test_line, oldest_run_line = lines + newest_columns = newest_run_line.split("\t") + assert newest_columns[2:8] == ["Complete", "1", "1", "0", "2", "put 1.0"] # Status through Version + assert failed_test_line == "tests/test_b.py" + assert oldest_run_line.split("\t")[3] == "1" # older run's pass count + + +def test_history_tab_copy_empty_selection_is_noop(qtbot, history_run_limit_pref): + """Copy with nothing selected leaves the clipboard untouched.""" + data_dir = get_temp_dir("history_tab_copy_empty") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + + QGuiApplication.clipboard().setText("sentinel") + assert tab.selection_as_text() == "" + tab.copy_selection_to_clipboard() + assert QGuiApplication.clipboard().text() == "sentinel" + + +def test_history_tab_selection_survives_rebuild(qtbot, history_run_limit_pref): + """Run-row and failed-test selections persist when new records force a rebuild.""" + data_dir = get_temp_dir("history_tab_selection_rebuild") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.TESTS_FAILED, now - 100)) + db.write(_record("run-2", "tests/test_a.py", PyTestFlyExitCode.OK, now - 50)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + tree = tab._tree + tree.topLevelItem(0).setSelected(True) # run-2's row + tree.topLevelItem(1).child(0).setSelected(True) # run-1's failed test + + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-2", "tests/test_b.py", PyTestFlyExitCode.OK, now)) + _update_from_db(tab, data_dir) + + assert tree.topLevelItem(0).isSelected() is True + assert tree.topLevelItem(1).isSelected() is False + assert tree.topLevelItem(1).child(0).isSelected() is True