diff --git a/CLAUDE.md b/CLAUDE.md index a4f3100..f725901 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,8 @@ pip install -r requirements-dev.txt `src/pytest_fly/__main__.py` → `main.py` initializes the stdlib-based logger (`logger.py`) and launches the Qt app. ### GUI layer (`src/pytest_fly/gui/`) -- `gui_main.py` — `FlyAppMainWindow`: 7-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), `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/`. +- `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/`. ### 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 9ca92b8..1fd71e3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ python -m pytest_fly ## Features -- Real-time monitoring of test execution in a GUI with seven tabs: +- Real-time monitoring of test execution in a GUI with eight tabs: - **Run** — run controls and live panels: - Controls: **Run**, **Stop** (waits for the running tests to finish and, while pending, becomes **Cancel Stop** so the stop can be called off and the queued tests keep running), @@ -48,6 +48,9 @@ python -m pytest_fly - **Graph** — time-based progress chart showing each test module as a horizontal bar - **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 + 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 triggers, force stops) plus all warnings; a **Verbose** checkbox shows every log line. @@ -55,7 +58,7 @@ python -m pytest_fly count is configurable - **Configuration** — Resume-vs-Check toggle, a reorderable test-ordering aspect list, process count, refresh rate, utilization thresholds, tooltip line limit, system-metrics chart window, - Progress Graph font size, Log tab line limit, target project path (applies on the next run), + Progress Graph font size, Log tab line limit, History run limit, target project path (applies on the next run), test-results DB directory, a Liveness / Recovery group (stall watchdog with optional automatic force-stop), an Admission Gates group (process-count / commit-charge / CPU dispatch throttles), a Resource Guard group (low-resource automatic soft stop with @@ -125,6 +128,10 @@ takes effect on the next run (no relaunch). See [Choosing Which Tests Run](#choo ![Coverage tab](https://raw.githubusercontent.com/jamesabel/pytest-fly/master/docs/images/coverage.png) +### History + +![History tab](https://raw.githubusercontent.com/jamesabel/pytest-fly/master/docs/images/history.png) + ### Log ![Log tab](https://raw.githubusercontent.com/jamesabel/pytest-fly/master/docs/images/log.png) diff --git a/docs/images/about.png b/docs/images/about.png index f655d32..05fcee5 100644 Binary files a/docs/images/about.png and b/docs/images/about.png differ diff --git a/docs/images/configuration.png b/docs/images/configuration.png index d153b23..f7adb5c 100644 Binary files a/docs/images/configuration.png and b/docs/images/configuration.png differ diff --git a/docs/images/coverage.png b/docs/images/coverage.png index d50679c..6fbbe24 100644 Binary files a/docs/images/coverage.png and b/docs/images/coverage.png differ diff --git a/docs/images/graph.png b/docs/images/graph.png index a915641..e266d7a 100644 Binary files a/docs/images/graph.png and b/docs/images/graph.png differ diff --git a/docs/images/history.png b/docs/images/history.png new file mode 100644 index 0000000..939ab66 Binary files /dev/null and b/docs/images/history.png differ diff --git a/docs/images/log.png b/docs/images/log.png index 3a5e106..7ed5356 100644 Binary files a/docs/images/log.png and b/docs/images/log.png differ diff --git a/docs/images/run.png b/docs/images/run.png index 76fbe7e..66a78e4 100644 Binary files a/docs/images/run.png and b/docs/images/run.png differ diff --git a/docs/images/run_animation.gif b/docs/images/run_animation.gif index 607af6c..da57fc7 100644 Binary files a/docs/images/run_animation.gif and b/docs/images/run_animation.gif differ diff --git a/docs/images/table.png b/docs/images/table.png index db16082..daf1b9f 100644 Binary files a/docs/images/table.png and b/docs/images/table.png differ diff --git a/pyproject.toml b/pyproject.toml index ddc183d..9fa21c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pytest-fly" description = "pytest runner and observer" -version = "0.7.4" +version = "0.8.0" readme = "README.md" requires-python = ">=3.12" authors = [ diff --git a/scripts/capture_assets.py b/scripts/capture_assets.py index 5cbb417..0436872 100644 --- a/scripts/capture_assets.py +++ b/scripts/capture_assets.py @@ -15,6 +15,7 @@ docs/images/graph.png docs/images/table.png docs/images/coverage.png + docs/images/history.png docs/images/log.png docs/images/configuration.png docs/images/about.png @@ -48,7 +49,7 @@ POST_RUN_SETTLE_DELAY_MS = 1500 RUN_TRIGGER_DELAY_MS = 800 -TAB_FILENAMES = ["run", "graph", "table", "coverage", "log", "configuration", "about"] +TAB_FILENAMES = ["run", "graph", "table", "coverage", "history", "log", "configuration", "about"] def qpixmap_to_pil(pixmap: QPixmap) -> Image.Image: diff --git a/src/pytest_fly/db/db.py b/src/pytest_fly/db/db.py index 06f0d75..d4f2bc6 100644 --- a/src/pytest_fly/db/db.py +++ b/src/pytest_fly/db/db.py @@ -145,6 +145,20 @@ def _query_last_pass(execute_fn: _ExecuteFn) -> dict[str, tuple[float, float]]: return result +def _query_recent_run_guids(execute_fn: _ExecuteFn, limit: int) -> list[str]: + """Return the *limit* most recent run GUIDs, newest first. + + Recency ordering relies on run GUIDs being UUIDv7 (time-ordered; see + :func:`pytest_fly.guid.generate_uuid`), the same rule ``run_guid=None`` queries use. + """ + statement = f"SELECT DISTINCT run_guid FROM {_TABLE_NAME} ORDER BY run_guid DESC LIMIT ?" + try: + return [row[0] for row in execute_fn(statement, [limit])] + except sqlite3.OperationalError as e: + log.debug(f"query_recent_run_guids failed (table may not exist yet): {e}") + return [] + + def _query_ever_run_names(execute_fn: _ExecuteFn) -> set[str]: """Return the set of test node_ids that have ever been run, across all runs and PUT versions. @@ -374,3 +388,30 @@ def query_last_pass(self) -> dict[str, tuple[float, float]]: def query_ever_run_names(self) -> set[str]: """Return the set of test node_ids that have ever been run, across all runs and PUT versions.""" return _query_ever_run_names(self._execute) + + def query_recent_runs(self, limit: int) -> list[PytestProcessInfo]: + """Return the records of the *limit* most recent runs, with the ``output`` column omitted. + + Used by the History tab; group the result by ``run_guid`` for per-run views. + + :param limit: Maximum number of distinct runs to include. + :return: The runs' records (``output=None``); empty when there are no runs yet. + """ + result: list[PytestProcessInfo] = [] + for run_guid in _query_recent_run_guids(self._execute, limit): + result.extend(_query_records(self._execute, self._columns, run_guid, include_output=False)) + return result + + def query_change_token(self) -> tuple[int, int]: + """Return a cheap ``(row_count, max_rowid)`` token that changes whenever the table's content changes. + + Lets per-tick consumers (the History tab) skip re-querying and rebuilding when + nothing was written since the previous tick. ``COUNT(*)`` runs off an index scan and + ``MAX(rowid)`` is an O(log n) b-tree seek, so this stays cheap even with large output + blobs in the table. + """ + rows = self._execute(f"SELECT COUNT(*), MAX(rowid) FROM {_TABLE_NAME}") + if not rows: + return (0, 0) + count, max_rowid = rows[0] + return (count or 0, max_rowid or 0) diff --git a/src/pytest_fly/gui/configuration_tab/configuration.py b/src/pytest_fly/gui/configuration_tab/configuration.py index c21cdf0..7983d9e 100644 --- a/src/pytest_fly/gui/configuration_tab/configuration.py +++ b/src/pytest_fly/gui/configuration_tab/configuration.py @@ -45,6 +45,7 @@ get_active_put_path, get_pref, graph_font_size_default, + history_run_limit_default, log_tab_line_limit_default, max_descendant_processes_default, process_count_gate_enabled_default, @@ -71,6 +72,7 @@ minimum_chart_window_minutes = 0.5 minimum_graph_font_size = 6 minimum_log_tab_line_limit = 100 +minimum_history_run_limit = 1 def _add_labeled_lineedit( @@ -365,6 +367,19 @@ def __init__(self): layout.addWidget(QLabel("")) # space + history_run_limit_label = f"History Run Limit (min {minimum_history_run_limit}, {history_run_limit_default} default)" + self.history_run_limit_lineedit = _add_labeled_lineedit( + layout, + history_run_limit_label, + str(pref.history_run_limit), + QIntValidator(), + self.update_history_run_limit, + char_width=6, + tooltip="Number of recent test runs summarized in the History tab. Applies on the next refresh tick.", + ) + + layout.addWidget(QLabel("")) # space + # Target project path (PUT). Stored as a preference (independent of where pytest-fly keeps # its own data), so it is freely editable here and takes effect on the next test run. self._active_put_path = str(get_active_put_path()) @@ -864,6 +879,10 @@ def update_log_tab_line_limit(self, value: str): """Persist the Log tab line limit (clamped to *minimum_log_tab_line_limit*).""" self._set_int_pref("log_tab_line_limit", value, minimum=minimum_log_tab_line_limit) + def update_history_run_limit(self, value: str): + """Persist the History tab run limit (clamped to *minimum_history_run_limit*).""" + self._set_int_pref("history_run_limit", value, minimum=minimum_history_run_limit) + def restore_defaults(self): """Ask for confirmation, then reset every Configuration-tab setting to its default.""" response = QMessageBox.question( @@ -914,6 +933,7 @@ def _apply_defaults(self) -> None: ("chart_window_minutes", self.chart_window_minutes_lineedit, chart_window_minutes_default), ("graph_font_size", self.graph_font_size_lineedit, graph_font_size_default), ("log_tab_line_limit", self.log_tab_line_limit_lineedit, log_tab_line_limit_default), + ("history_run_limit", self.history_run_limit_lineedit, history_run_limit_default), ("cpu_active_epsilon", self.cpu_active_epsilon_lineedit, cpu_active_epsilon_default), ("max_descendant_processes", self.max_descendant_processes_lineedit, max_descendant_processes_default), ("commit_gate_threshold", self.commit_gate_threshold_lineedit, commit_gate_threshold_default), diff --git a/src/pytest_fly/gui/gui_main.py b/src/pytest_fly/gui/gui_main.py index d98da53..ec22725 100644 --- a/src/pytest_fly/gui/gui_main.py +++ b/src/pytest_fly/gui/gui_main.py @@ -38,6 +38,7 @@ from .coverage_tracker import CoverageTracker from .graph_tab import GraphTab from .gui_util import PhaseTimer, get_font, get_text_dimensions, qt_state_from_hex, qt_state_to_hex +from .history_tab import HistoryTab from .log_tab import LogTab from .run_tab import RunTab from .table_tab import TableTab @@ -47,7 +48,7 @@ class FlyAppMainWindow(QMainWindow): - """Top-level application window containing the seven main tabs.""" + """Top-level application window containing the eight main tabs.""" def __init__(self, data_dir: Path): self.data_dir = data_dir @@ -101,6 +102,7 @@ def __init__(self, data_dir: Path): self.graph_tab = GraphTab() self.table_tab = TableTab(self.data_dir) self.coverage_tab = CoverageTab(self.data_dir) + self.history_tab = HistoryTab() self.log_tab = LogTab() self.configuration = Configuration() self.about = About(self, self.data_dir) @@ -108,6 +110,7 @@ def __init__(self, data_dir: Path): self.tab_widget.addTab(self.graph_tab, "Graph") self.tab_widget.addTab(self.table_tab, "Table") self.tab_widget.addTab(self.coverage_tab, "Coverage") + self.tab_widget.addTab(self.history_tab, "History") self.tab_widget.addTab(self.log_tab, "Log") self.tab_widget.addTab(self.configuration, "Configuration") self.tab_widget.addTab(self.about, "About") @@ -279,6 +282,9 @@ def _update_tick(self): self._last_pass_cache_run_guid = run_guid self._last_pass_cache_pass_count = pass_count last_pass_data = self._last_pass_cache + with timer.time("db_history"): + # No-op unless the DB content (or the configured run limit) changed since the last tick. + self.history_tab.update_tick(db) control = self.run_tab.control_window with timer.time("build"): diff --git a/src/pytest_fly/gui/history_tab/__init__.py b/src/pytest_fly/gui/history_tab/__init__.py new file mode 100644 index 0000000..e845d25 --- /dev/null +++ b/src/pytest_fly/gui/history_tab/__init__.py @@ -0,0 +1 @@ +from .history_tab import HistoryTab as HistoryTab diff --git a/src/pytest_fly/gui/history_tab/history_tab.py b/src/pytest_fly/gui/history_tab/history_tab.py new file mode 100644 index 0000000..5772803 --- /dev/null +++ b/src/pytest_fly/gui/history_tab/history_tab.py @@ -0,0 +1,105 @@ +""" +History tab — summaries of recent test runs. + +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. +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 ...colors import TABLE_COLORS +from ...db import PytestProcessInfoReader +from ...interfaces import PytestRunnerState +from ...preferences import get_pref +from ...run_history import RunHistorySummary, build_run_history +from ..gui_util import format_runtime + +_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_ROLE = Qt.ItemDataRole.UserRole + + +class HistoryTab(QGroupBox): + """Tab displaying per-run summaries of recent test runs, most recent first.""" + + def __init__(self): + super().__init__() + self.setTitle("Run History (most recent first)") + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + layout = QVBoxLayout() + self.setLayout(layout) + + self._tree = QTreeWidget() + self._tree.setHeaderLabels(list(_COLUMNS)) + 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" + "The number of runs shown is set by the Configuration tab's History Run Limit." + ) + layout.addWidget(self._tree) + + # 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 + + def update_tick(self, db: PytestProcessInfoReader) -> None: + """Refresh the run summaries from the DB; a no-op when nothing changed since the last tick.""" + run_limit = max(get_pref().history_run_limit, 1) + change_token = db.query_change_token() + if change_token == self._change_token and run_limit == self._run_limit: + return + self._change_token = change_token + self._run_limit = run_limit + self._rebuild(build_run_history(db.query_recent_runs(run_limit))) + + def _rebuild(self, summaries: list[RunHistorySummary]) -> None: + """Repopulate the tree, preserving each still-present run's expansion state.""" + expansion_by_guid: dict[str, bool] = {} + for index in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(index) + # 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() + + self._tree.clear() + fail_color = TABLE_COLORS[PytestRunnerState.FAIL] + pass_color = TABLE_COLORS[PytestRunnerState.PASS] + for summary in summaries: + texts = [""] * len(_COLUMNS) + texts[_START_COLUMN] = datetime.fromtimestamp(summary.start_ts).strftime("%Y-%m-%d %H:%M:%S") + texts[_DURATION_COLUMN] = format_runtime(summary.duration) + texts[_STATUS_COLUMN] = "Complete" if summary.is_complete else "In progress" + texts[_PASS_COLUMN] = str(summary.n_pass) + texts[_FAIL_COLUMN] = str(summary.n_fail) + texts[_OTHER_COLUMN] = str(summary.n_other) + texts[_TOTAL_COLUMN] = str(summary.n_total) + texts[_VERSION_COLUMN] = summary.put_version + run_item = QTreeWidgetItem(texts) + run_item.setData(0, _RUN_GUID_ROLE, summary.run_guid) + if summary.n_pass > 0: + run_item.setForeground(_PASS_COLUMN, pass_color) + if summary.n_fail > 0: + run_item.setForeground(_FAIL_COLUMN, fail_color) + for failed_test in summary.failed_tests: + failed_item = QTreeWidgetItem([failed_test]) + failed_item.setForeground(0, fail_color) + run_item.addChild(failed_item) + self._tree.addTopLevelItem(run_item) + # 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) + + for column in range(len(_COLUMNS)): + self._tree.resizeColumnToContents(column) diff --git a/src/pytest_fly/preferences.py b/src/pytest_fly/preferences.py index fe92cfd..a32df66 100644 --- a/src/pytest_fly/preferences.py +++ b/src/pytest_fly/preferences.py @@ -31,6 +31,7 @@ chart_window_minutes_default = 5.0 # width of the system-metrics chart time window on the Run tab, in minutes graph_font_size_default = 10 # point size of the font used in the Progress Graph tab log_tab_line_limit_default = 10_000 # max lines retained/displayed in the Log tab — bounds memory over a long session +history_run_limit_default = 10 # number of recent runs summarized in the History tab # Time-duration units offered for the stall timeouts. Stored as a (value, unit) pair so the # user can express a timeout in whichever unit reads best; converted to seconds for the runner. @@ -158,6 +159,8 @@ class FlyPreferences(Pref): log_tab_follow_tail: bool = attrib(default=True) # keep the Log tab scrolled to the newest line log_tab_line_limit: int = attrib(default=log_tab_line_limit_default) # max lines retained/displayed in the Log tab + history_run_limit: int = attrib(default=history_run_limit_default) # number of recent runs summarized in the History tab + # Wall-clock start of the most recent run; the Progress Graph time-axis origin, restored on # restart so RESUME-carried records still shift onto the run timeline (0.0 = none). last_run_start: float = attrib(default=0.0) diff --git a/src/pytest_fly/run_history.py b/src/pytest_fly/run_history.py new file mode 100644 index 0000000..3eb298d --- /dev/null +++ b/src/pytest_fly/run_history.py @@ -0,0 +1,74 @@ +""" +Run-history summarization — reduces multi-run DB records into per-run summaries. + +Groups :class:`PytestProcessInfo` records by ``run_guid`` and derives one +:class:`RunHistorySummary` per run (start time, duration, pass/fail statistics, and the +list of failed tests) for the History tab. Kept free of Qt so it can be tested headless, +matching :mod:`pytest_fly.pytest_runner.run_state` which it builds on. +""" + +from dataclasses import dataclass + +from .interfaces import PytestProcessInfo, PytestRunnerState +from .pytest_runner.run_state import TERMINAL_STATES, latest_info_per_name, state_of + + +@dataclass(frozen=True) +class RunHistorySummary: + """Aggregate view of one test run, derived from its DB records.""" + + run_guid: str + put_version: str # program-under-test label of the run, or "" if none was recorded + start_ts: float # earliest record timestamp (wall-clock start of the run) + end_ts: float # latest record timestamp (end of the run, or "so far" for an in-progress run) + n_pass: int + n_fail: int + n_other: int # everything else: terminated, stopped, and still queued/running tests + is_complete: bool # True when every test in the run reached a terminal state + failed_tests: tuple[str, ...] # node_ids whose latest state is FAIL, sorted + + @property + def duration(self) -> float: + """Run wall-clock duration in seconds (elapsed-so-far for an in-progress run).""" + return self.end_ts - self.start_ts + + @property + def n_total(self) -> int: + """Total number of tests in the run.""" + return self.n_pass + self.n_fail + self.n_other + + +def build_run_history(infos: list[PytestProcessInfo]) -> list[RunHistorySummary]: + """Group *infos* by run and summarize each run, most recent first. + + Ordering relies on run GUIDs being UUIDv7 (time-ordered; see + :func:`pytest_fly.guid.generate_uuid`), the same rule the DB layer uses to select + the most recent run. + """ + infos_by_run: dict[str, list[PytestProcessInfo]] = {} + for info in infos: + infos_by_run.setdefault(info.run_guid, []).append(info) + + summaries = [] + for run_guid in sorted(infos_by_run, reverse=True): + run_infos = infos_by_run[run_guid] + states = {name: state_of(info) for name, info in latest_info_per_name(run_infos).items()} + n_pass = sum(1 for state in states.values() if state == PytestRunnerState.PASS) + n_fail = sum(1 for state in states.values() if state == PytestRunnerState.FAIL) + # The version label is only stamped on some records (status records carry ""); take it + # from the newest record that has one. + put_version = next((info.put_version for info in sorted(run_infos, key=lambda i: i.time_stamp, reverse=True) if info.put_version), "") + summaries.append( + RunHistorySummary( + run_guid=run_guid, + put_version=put_version, + start_ts=min(info.time_stamp for info in run_infos), + end_ts=max(info.time_stamp for info in run_infos), + n_pass=n_pass, + n_fail=n_fail, + n_other=len(states) - n_pass - n_fail, + is_complete=all(state in TERMINAL_STATES for state in states.values()), + failed_tests=tuple(sorted(name for name, state in states.items() if state == PytestRunnerState.FAIL)), + ) + ) + return summaries diff --git a/tests/test_db_reader.py b/tests/test_db_reader.py index 5abf290..8a2fabd 100644 --- a/tests/test_db_reader.py +++ b/tests/test_db_reader.py @@ -25,6 +25,8 @@ def test_reader_missing_db_fails_open(): assert reader.query_outputs("some-guid", ["tests/test_a.py"]) == {} assert reader.query_last_pass() == {} assert reader.query_ever_run_names() == set() + assert reader.query_recent_runs(5) == [] + assert reader.query_change_token() == (0, 0) def test_reader_query_omits_output_by_default(): @@ -95,6 +97,36 @@ def test_reader_matches_writer_for_last_pass_and_ever_run(): assert writer_ever_run == {"tests/test_a.py"} +def test_reader_query_recent_runs_limit_and_ordering(): + """Only the N most recent runs are returned (UUIDv7-ordered GUIDs), output omitted.""" + data_dir = get_temp_dir("reader_recent_runs") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + for run_index in range(3): + db.write(_record(f"run-{run_index}", "tests/test_a.py", PyTestFlyExitCode.OK, f"output {run_index}", now + run_index)) + + with PytestProcessInfoReader(data_dir) as reader: + infos = reader.query_recent_runs(2) + assert {info.run_guid for info in infos} == {"run-1", "run-2"} + assert all(info.output is None for info in infos) + assert len(reader.query_recent_runs(10)) == 3 # limit larger than the run count is fine + + +def test_reader_change_token_tracks_writes(): + data_dir = get_temp_dir("reader_change_token") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, "out", now)) + with PytestProcessInfoReader(data_dir) as reader: + token_before = reader.query_change_token() + assert reader.query_change_token() == token_before # stable when nothing was written + + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_b.py", PyTestFlyExitCode.OK, "out", now + 1)) + with PytestProcessInfoReader(data_dir) as reader: + assert reader.query_change_token() != token_before + + def test_reader_does_not_create_db_file(): """Opening the reader must not create an empty database file as a side effect.""" data_dir = get_temp_dir("reader_no_create") diff --git a/tests/test_history_tab.py b/tests/test_history_tab.py new file mode 100644 index 0000000..d6d52a3 --- /dev/null +++ b/tests/test_history_tab.py @@ -0,0 +1,164 @@ +"""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. +""" + +import time + +import pytest +from PySide6.QtCore import Qt + +from pytest_fly.db import PytestProcessInfoDB, PytestProcessInfoReader +from pytest_fly.gui.history_tab import HistoryTab +from pytest_fly.interfaces import PyTestFlyExitCode, PytestProcessInfo +from pytest_fly.preferences import get_pref, history_run_limit_default + +from .paths import get_temp_dir + + +@pytest.fixture +def history_run_limit_pref(): + """Restore the History run-limit preference after the test.""" + yield get_pref() + get_pref().history_run_limit = history_run_limit_default + + +def _record(run_guid: str, name: str, exit_code: PyTestFlyExitCode, time_stamp: float, pid: int | None = 1234) -> PytestProcessInfo: + return PytestProcessInfo(run_guid=run_guid, name=name, pid=pid, exit_code=exit_code, output=None, time_stamp=time_stamp, put_version="put 1.0") + + +def _update_from_db(tab: HistoryTab, data_dir) -> None: + with PytestProcessInfoReader(data_dir) as reader: + tab.update_tick(reader) + + +def test_history_tab_rows_and_failed_children(qtbot, history_run_limit_pref): + data_dir = get_temp_dir("history_tab_rows") + 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 + assert tree.topLevelItemCount() == 2 + newest = tree.topLevelItem(0) # most recent run first + assert newest.text(3) == "1" # pass count + assert newest.text(4) == "1" # fail count + assert newest.text(6) == "2" # total + assert newest.text(2) == "Complete" + assert newest.text(7) == "put 1.0" + assert newest.childCount() == 1 + assert newest.child(0).text(0) == "tests/test_b.py" + assert newest.isExpanded() # runs with failures start expanded + + oldest = tree.topLevelItem(1) + assert oldest.text(3) == "1" + assert oldest.text(4) == "0" + assert oldest.childCount() == 0 + + +def test_history_tab_in_progress_status(qtbot, history_run_limit_pref): + data_dir = get_temp_dir("history_tab_in_progress") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now - 10)) + db.write(_record("run-1", "tests/test_b.py", PyTestFlyExitCode.NONE, now)) # still running + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0).text(2) == "In progress" + + +def test_history_tab_run_limit(qtbot, history_run_limit_pref): + """Only the configured number of most recent runs is shown; a limit change applies on the next tick.""" + data_dir = get_temp_dir("history_tab_run_limit") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + for run_index in range(4): + db.write(_record(f"run-{run_index}", "tests/test_a.py", PyTestFlyExitCode.OK, now - 100 + run_index)) + + get_pref().history_run_limit = 2 + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + tree = tab._tree + assert tree.topLevelItemCount() == 2 + assert tree.topLevelItem(0).data(0, Qt.ItemDataRole.UserRole) == "run-3" + assert tree.topLevelItem(1).data(0, Qt.ItemDataRole.UserRole) == "run-2" + + get_pref().history_run_limit = 3 # widen mid-session; applied on the next tick + _update_from_db(tab, data_dir) + assert tree.topLevelItemCount() == 3 + + +def test_history_tab_skips_rebuild_when_unchanged(qtbot, history_run_limit_pref): + """An unchanged DB leaves the tree untouched; a new write triggers a rebuild.""" + data_dir = get_temp_dir("history_tab_unchanged") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now - 10)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + item_before = tab._tree.topLevelItem(0) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0) is item_before # no rebuild — same item object survives + + 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 tab._tree.topLevelItemCount() == 2 + + +def test_history_tab_collapse_survives_rebuild(qtbot, history_run_limit_pref): + """A run the user collapsed stays collapsed when new records force a rebuild.""" + data_dir = get_temp_dir("history_tab_collapse") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.TESTS_FAILED, now - 10)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0).isExpanded() + tab._tree.topLevelItem(0).setExpanded(False) + + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_b.py", PyTestFlyExitCode.OK, now)) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0).isExpanded() is False + + +def test_history_tab_auto_expands_when_first_failure_appears(qtbot, history_run_limit_pref): + """A run first shown without failures still auto-expands once its first failure lands.""" + data_dir = get_temp_dir("history_tab_late_failure") + now = time.time() + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now - 10)) + + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0).isExpanded() is False # nothing to expand yet + + with PytestProcessInfoDB(data_dir) as db: + db.write(_record("run-1", "tests/test_b.py", PyTestFlyExitCode.TESTS_FAILED, now)) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItem(0).isExpanded() is True + + +def test_history_tab_empty_db(qtbot, history_run_limit_pref): + data_dir = get_temp_dir("history_tab_empty") + tab = HistoryTab() + qtbot.addWidget(tab) + _update_from_db(tab, data_dir) + assert tab._tree.topLevelItemCount() == 0 diff --git a/tests/test_run_history.py b/tests/test_run_history.py new file mode 100644 index 0000000..70e735a --- /dev/null +++ b/tests/test_run_history.py @@ -0,0 +1,89 @@ +"""Run-history summarization — grouping DB records by run into per-run summaries. + +Covers pass/fail statistics, failed-test lists, run timing, completion detection, +version-label selection, and most-recent-first ordering. +""" + +from pytest_fly.interfaces import PyTestFlyExitCode, PytestProcessInfo +from pytest_fly.run_history import build_run_history + + +def _record(run_guid: str, name: str, exit_code: PyTestFlyExitCode, time_stamp: float, pid: int | None = 1234, put_version: str | None = "") -> PytestProcessInfo: + return PytestProcessInfo(run_guid=run_guid, name=name, pid=pid, exit_code=exit_code, output=None, time_stamp=time_stamp, put_version=put_version) + + +def test_build_run_history_empty(): + assert build_run_history([]) == [] + + +def test_build_run_history_counts_and_failed_tests(): + infos = [ + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.NONE, 100.0), + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 110.0), + _record("run-1", "tests/test_b.py", PyTestFlyExitCode.TESTS_FAILED, 112.0), + _record("run-1", "tests/test_c.py", PyTestFlyExitCode.USAGE_ERROR, 114.0), + _record("run-1", "tests/test_d.py", PyTestFlyExitCode.STOPPED, 116.0, pid=None), + ] + (summary,) = build_run_history(infos) + assert summary.run_guid == "run-1" + assert summary.n_pass == 1 + assert summary.n_fail == 2 # any non-OK pytest exit code is a failure + assert summary.n_other == 1 # the stopped test + assert summary.n_total == 4 + assert summary.failed_tests == ("tests/test_b.py", "tests/test_c.py") + assert summary.start_ts == 100.0 + assert summary.end_ts == 116.0 + assert summary.duration == 16.0 + assert summary.is_complete is True + + +def test_build_run_history_latest_record_wins(): + """A test's state comes from its most recent record — a failure then a passing rerun counts as a pass.""" + infos = [ + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.TESTS_FAILED, 100.0), + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 110.0), + ] + (summary,) = build_run_history(infos) + assert summary.n_pass == 1 + assert summary.n_fail == 0 + assert summary.failed_tests == () + + +def test_build_run_history_in_progress_run(): + """A run with queued or running tests is not complete.""" + infos = [ + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 100.0), + _record("run-1", "tests/test_b.py", PyTestFlyExitCode.NONE, 101.0), # running (has a pid) + _record("run-1", "tests/test_c.py", PyTestFlyExitCode.NONE, 102.0, pid=None), # queued + ] + (summary,) = build_run_history(infos) + assert summary.is_complete is False + assert summary.n_pass == 1 + assert summary.n_other == 2 + + +def test_build_run_history_most_recent_first(): + """Runs are ordered by run GUID descending (UUIDv7 GUIDs are time-ordered).""" + infos = [ + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 100.0), + _record("run-3", "tests/test_a.py", PyTestFlyExitCode.OK, 300.0), + _record("run-2", "tests/test_a.py", PyTestFlyExitCode.OK, 200.0), + ] + assert [summary.run_guid for summary in build_run_history(infos)] == ["run-3", "run-2", "run-1"] + + +def test_build_run_history_put_version_from_newest_labeled_record(): + """Status records carry an empty version label; the newest non-empty label is used.""" + infos = [ + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.NONE, 100.0, put_version="put 1.0"), + _record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 110.0, put_version="put 1.1"), + _record("run-1", "tests/test_b.py", PyTestFlyExitCode.STOPPED, 120.0, pid=None, put_version=""), + ] + (summary,) = build_run_history(infos) + assert summary.put_version == "put 1.1" + + +def test_build_run_history_no_version_label(): + infos = [_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, 100.0, put_version=None)] + (summary,) = build_run_history(infos) + assert summary.put_version == ""