vibefpmgui.test_monitor

Live table of the tests fpm test runs, with an estimated progress bar.

The GUI first runs fpm test --list to learn the test names, then runs the tests with --runner pointing at vibefpmgui.test_runner, which prints a marker before and after each test (fpm runs them one at a time)::

@@vibefpm:start:check_fast@@
@@vibefpm:end:check_fast:0:0.012@@

How long each test took is remembered per project, so later runs can weight the progress bar by expected duration and estimate the time left.

  1"""Live table of the tests ``fpm test`` runs, with an estimated progress bar.
  2
  3The GUI first runs ``fpm test --list`` to learn the test names, then runs the
  4tests with ``--runner`` pointing at `vibefpmgui.test_runner`, which
  5prints a marker before and after each test (fpm runs them one at a time)::
  6
  7    @@vibefpm:start:check_fast@@
  8    @@vibefpm:end:check_fast:0:0.012@@
  9
 10How long each test took is remembered per project, so later runs can weight
 11the progress bar by expected duration and estimate the time left.
 12"""
 13
 14from __future__ import annotations
 15
 16import re
 17import statistics
 18import time
 19from dataclasses import dataclass, field
 20from typing import ClassVar
 21
 22from qtpy.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
 23from qtpy.QtGui import QColor, QFont, QFontDatabase, QFontMetricsF, QPainter, QPen
 24from qtpy.QtWidgets import (
 25    QAbstractItemView,
 26    QApplication,
 27    QHBoxLayout,
 28    QHeaderView,
 29    QLabel,
 30    QMenu,
 31    QPushButton,
 32    QStyledItemDelegate,
 33    QTableWidget,
 34    QTableWidgetItem,
 35    QVBoxLayout,
 36    QWidget,
 37)
 38
 39from .build_monitor import (
 40    ABORTED,
 41    COMPACT_ROW_HEIGHT,
 42    COMPILING,
 43    DONE,
 44    FAILED,
 45    PALETTES,
 46    ProgressLine,
 47    SortableTable,
 48    TitleIcon,
 49    _tint,
 50    app_is_light,
 51    chip_stylesheet,
 52    paint_name_and_detail,
 53    panel_stylesheet,
 54    scaled,
 55)
 56from .widgets import StatusLabel
 57
 58MARKER_RE = re.compile(r"@@vibefpm:(start|end):([^:@]+)(?::(-?\d+):([\d.]+))?@@")
 59# A test's command line, from "test_runner.py --collect" (for a parallel run).
 60EXE_RE = re.compile(r"@@vibefpm:exe:([^:@]+)@@(.*)$")
 61# "fpm test --list" prints " Matched names:" followed by the names.
 62MATCHED_RE = re.compile(r"^\s*Matched names:\s*(.*)$")
 63NO_TESTS_RE = re.compile(r"No tests to run")
 64
 65QUEUED, RUNNING, PASSED, FAILED_T, NOT_RUN = "Queued", "Running", "Passed", "Failed", "Not run"
 66STATUSES = (RUNNING, PASSED, FAILED_T, NOT_RUN, QUEUED)
 67ICONS = {QUEUED: "·", RUNNING: "⟳", PASSED: "✓", FAILED_T: "✕", NOT_RUN: "–"}
 68# Test statuses borrow the build palette's colors.
 69COLOR_KEY = {RUNNING: COMPILING, PASSED: DONE, FAILED_T: FAILED, NOT_RUN: ABORTED, QUEUED: ABORTED}
 70FLASH_SECONDS = 0.6
 71
 72
 73def parse_test_list(text: str) -> list[str] | None:
 74    """Test names from ``fpm test --list`` output ([] if there are none,
 75    None if the output wasn't understood)."""
 76    if NO_TESTS_RE.search(text):
 77        return []
 78    lines = text.splitlines()
 79    for i, line in enumerate(lines):
 80        match = MATCHED_RE.match(line)
 81        if match:
 82            names = match.group(1).split()
 83            for rest in lines[i + 1:]:
 84                if not rest.strip() or rest.lstrip().startswith(("<", "STOP")):
 85                    break
 86                names += rest.split()
 87            return names
 88    return None
 89
 90
 91@dataclass
 92class TestCase:
 93    name: str
 94    row: int
 95    expected: float | None = None  # duration last time, if known
 96    seq: int = 0  # original (listed) order, for "unsorted"
 97    status: str = QUEUED
 98    started: float | None = None
 99    finished: float | None = None
100    seconds: float | None = None  # as measured by the runner
101    exit_code: int | None = None
102    stopped: bool = False  # was running when Stop was pressed
103    output: list[str] = field(default_factory=list)
104    partial: str = ""  # (a parallel run: the start of a line still being written)
105
106    def elapsed(self) -> float:
107        if self.seconds is not None:
108            return self.seconds
109        if self.started is None:
110            return 0.0
111        return (self.finished or time.monotonic()) - self.started
112
113
114class TestDelegate(QStyledItemDelegate):
115    def __init__(self, monitor: TestMonitor):
116        super().__init__(monitor.table)
117        self.monitor = monitor
118
119    def sizeHint(self, option, index):
120        return QSize(100, scaled(COMPACT_ROW_HEIGHT))
121
122    def paint(self, p: QPainter, option, index):
123        mon = self.monitor
124        if index.row() >= len(mon.tests):
125            return
126        test = mon.tests[index.row()]
127        pal = mon.palette_colors
128        color = QColor(pal["status"][COLOR_KEY[test.status]])
129        r = QRectF(option.rect)
130        p.save()
131        p.setRenderHint(QPainter.Antialiasing)
132        if test.name == mon.selected:
133            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
134        elif index.row() == mon.hover_row:
135            p.fillRect(r, QColor(pal["hover"]))
136        if test.finished is not None and time.monotonic() - test.finished < FLASH_SECONDS:
137            age = time.monotonic() - test.finished
138            p.fillRect(r, _tint(color, int(35 * (1 - age / FLASH_SECONDS))))
139        if test.name == mon.selected and index.column() == 0:
140            p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
141        p.setPen(QPen(QColor(pal["line"]), 1))
142        p.drawLine(r.bottomLeft(), r.bottomRight())
143
144        small = QFont(option.font)
145        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
146        if index.column() == 0:
147            self._paint_name(p, r, test, option.font, small, pal)
148        elif index.column() == 1:
149            self._paint_status(p, r, test, small, color)
150        else:
151            self._paint_time(p, r, test, small, pal, color)
152        p.restore()
153
154    def _paint_name(self, p, r, test, font, small, pal):
155        bold = QFont(font)
156        bold.setWeight(QFont.DemiBold)
157        detail = {RUNNING: "running…", NOT_RUN: "not run"}.get(test.status, "")
158        if test.stopped:
159            detail = f"stopped after {test.elapsed():.1f} s"
160        if test.status == FAILED_T and test.exit_code is not None:
161            detail = f"exit code {test.exit_code}"
162        lines = len(test.output)
163        if lines and test.status != RUNNING:
164            count = f"{lines} line{'s' * (lines != 1)} of output"
165            detail = f"{detail} · {count}" if detail else count
166        paint_name_and_detail(p, r.adjusted(14, 0, -6, 0), test.name, detail, bold, small,
167                              QColor(pal["text"] if test.status != QUEUED else pal["muted"]), QColor(pal["muted"]))
168
169    def _paint_status(self, p, r, test, small, color):
170        font = QFont(small)
171        font.setWeight(QFont.DemiBold)
172        text = "Stopped" if test.stopped else self.monitor.word(test.status)
173        pill = QRectF(r.left() + 8, r.center().y() - 10,
174                      QFontMetricsF(font).horizontalAdvance(text) + 36, 20)
175        p.setPen(Qt.NoPen)
176        p.setBrush(_tint(color, 40 if test.status != QUEUED else 22))
177        p.drawRoundedRect(pill, 10, 10)
178        icon = QRectF(pill.left() + 8, pill.center().y() - 6, 12, 12)
179        if test.status == RUNNING:
180            p.setPen(QPen(_tint(color, 70), 2))
181            p.drawEllipse(icon)
182            p.setPen(QPen(color, 2, Qt.SolidLine, Qt.RoundCap))
183            p.drawArc(icon, int(-self.monitor.spinner_angle * 16), -110 * 16)
184        else:
185            p.setFont(font)
186            p.setPen(color)
187            p.drawText(icon.adjusted(-3, -3, 3, 3), Qt.AlignCenter, ICONS[test.status])
188        p.setFont(font)
189        p.setPen(color.darker(115) if self.monitor.is_light else color)
190        p.drawText(pill.adjusted(27, 0, -6, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
191
192    def _paint_time(self, p, r, test, small, pal, color):
193        scale = self.monitor.time_scale()
194        track = QRectF(r.left() + 10, r.center().y() - 3, max(r.width() - 74, 10), 6)
195        p.setPen(Qt.NoPen)
196        p.setBrush(QColor(pal["track"]))
197        p.drawRoundedRect(track, 3, 3)
198        mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
199        mono.setPointSizeF(small.pointSizeF())
200        p.setFont(mono)
201        if test.status == QUEUED:
202            if test.expected is not None and scale > 0:  # ghost bar: last run's time
203                p.setBrush(_tint(QColor(pal["muted"]), 70))
204                p.drawRoundedRect(QRectF(track.left(), track.top(),
205                                         max(track.width() * test.expected / scale, 3), 6), 3, 3)
206                p.setPen(QColor(pal["muted"]))
207                p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight,
208                           f"~{test.expected:.1f} s")
209            return
210        elapsed = test.elapsed()
211        if test.status == RUNNING and test.expected:  # where it finished last time
212            x = track.left() + track.width() * min(test.expected / scale, 1)
213            p.setPen(QPen(QColor(pal["muted"]), 1, Qt.DotLine))
214            p.drawLine(x, track.top() - 3, x, track.bottom() + 3)
215            p.setPen(Qt.NoPen)
216        p.setBrush(color if test.status == RUNNING else QColor(pal["muted"]))
217        fraction = elapsed / scale if scale > 0 else 0
218        p.drawRoundedRect(QRectF(track.left(), track.top(), max(track.width() * min(fraction, 1), 3), 6), 3, 3)
219        p.setPen(QColor(pal["text"] if test.status == RUNNING else pal["muted"]))
220        p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight, f"{elapsed:.1f} s")
221
222
223STATUS_SORT = {FAILED_T: 0, RUNNING: 1, QUEUED: 2, NOT_RUN: 3, PASSED: 4}
224
225
226class TestMonitor(SortableTable, QWidget):
227    """Header with filter chips, progress bar and a table of tests. Clicking a
228    test emits `test_selected` with its name ("" to show all output)."""
229
230    COLUMNS: ClassVar[list[str]] = ["TEST", "STATUS", "TIME"]
231    test_selected = Signal(str)
232    debug_requested = Signal(str)  # right-click → Debug: a test's (or program's) name
233    ICON = "test"  # (the toolbar's icon for the command: see TitleIcon)
234    # Everything the panel says, so a "Run" version can reuse it (see run_monitor).
235    WORDS: ClassVar[dict[str, str]] = {
236        "title": "Tests", "item": "test", "items": "tests",
237        PASSED: "Passed", "passed": "passed",
238        "idle": "Run Test to see each test's progress here.",
239        "idle_label": "Run Test to see progress",
240        "collecting": "Building and collecting tests… (per-file progress is in the Build tab)",
241        "collecting_label": "collecting tests",
242        "after_build": "tests start after the build",
243        "none": "This project has no tests.", "none_label": "No tests",
244        "unlisted": "The tests could not be built or listed — see the build panel and output.",
245        "row_tip": "Click to show only this test's output",
246    }
247
248    def word(self, key: str) -> str:
249        return self.WORDS.get(key, key)
250
251    def __init__(self, parent=None):
252        super().__init__(parent)
253        self.tests: list[TestCase] = []
254        self.by_name: dict[str, TestCase] = {}
255        self.current: TestCase | None = None
256        self.selected = ""
257        self.hover_row = -1
258        self.spinner_angle = 0.0
259        self.filter_status: str | None = None
260        self.description = ""
261        self.build_percent = None  # callable giving the build's % (set by the main window)
262        self.jobs = 1  # how many run at once (Build options → Parallel tests)
263        self._started = 0.0
264        self._finished_at: float | None = None
265        self._result = ""  # "", "listing", "ok", "failed", "stopped", "none"
266        self._active = False
267        self.is_light = True
268        self.palette_colors = PALETTES["light"]
269
270        self.setObjectName("TestMonitor")
271        self.setAttribute(Qt.WA_StyledBackground, True)
272        self.title_icon = TitleIcon(self.ICON)
273        self.title = QLabel(self.word("title"))
274        title_font = self.title.font()
275        title_font.setPointSizeF(title_font.pointSizeF() + 2)
276        title_font.setWeight(QFont.Bold)
277        self.title.setFont(title_font)
278        self.subtitle = StatusLabel()
279        header = QHBoxLayout()
280        header.setSpacing(6)
281        header.addWidget(self.title_icon)
282        header.addWidget(self.title)
283        header.addSpacing(4)
284        header.addWidget(self.subtitle)
285        header.addStretch(1)
286        self.chips: dict[str, QPushButton] = {}
287        for status in (RUNNING, PASSED, FAILED_T, NOT_RUN):
288            chip = QPushButton()
289            chip.setCheckable(True)
290            chip.setCursor(Qt.PointingHandCursor)
291            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
292                            "(click again to show all)")
293            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
294            chip.hide()
295            header.addWidget(chip)
296            self.chips[status] = chip
297
298        self.progress = ProgressLine(self)
299        self.progress_label = StatusLabel()
300        progress_row = QHBoxLayout()
301        progress_row.setSpacing(10)
302        progress_row.addWidget(self.progress, 1)
303        progress_row.addWidget(self.progress_label)
304
305        self.table = QTableWidget(0, len(self.COLUMNS))
306        self.table.setHorizontalHeaderLabels(self.COLUMNS)
307        self.table.setItemDelegate(TestDelegate(self))
308        self.table.verticalHeader().setVisible(False)
309        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
310        self.table.setShowGrid(False)
311        self.table.setFrameShape(QTableWidget.NoFrame)
312        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
313        self.table.setSelectionMode(QAbstractItemView.NoSelection)
314        self.table.setFocusPolicy(Qt.NoFocus)
315        self.table.setMouseTracking(True)
316        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
317        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
318        self.table.cellEntered.connect(self._set_hover)
319        self.table.cellClicked.connect(self._row_clicked)
320        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
321        self.table.customContextMenuRequested.connect(self._row_menu)
322        self.table.viewport().installEventFilter(self)
323        hh = self.table.horizontalHeader()
324        hh.setHighlightSections(False)
325        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
326        hh.setSectionResizeMode(0, QHeaderView.Stretch)
327        for column in (1, 2):
328            hh.setSectionResizeMode(column, QHeaderView.Fixed)
329        self._fit_columns()
330        self._init_sorting({
331            0: lambda t: t.name.lower(),
332            1: lambda t: STATUS_SORT.get(t.status, 9),
333            2: lambda t: t.elapsed() if t.status != QUEUED else (t.expected or 0.0),
334        }, descending_first={2})
335
336        self.empty = QLabel()
337        self.empty.setAlignment(Qt.AlignCenter)
338        self.empty.setWordWrap(True)
339
340        layout = QVBoxLayout(self)
341        layout.setContentsMargins(14, 10, 14, 6)
342        layout.setSpacing(8)
343        layout.addLayout(header)
344        layout.addLayout(progress_row)
345        layout.addWidget(self.table, 1)
346        layout.addWidget(self.empty, 1)
347
348        self._timer = QTimer(self)
349        self._timer.setInterval(50)
350        self._timer.timeout.connect(self._tick)
351        self.refresh_palette()
352        self._show_empty(self.word("idle"))
353        self.update_header()
354
355    # ------------------------------------------------------------- styling
356    def refresh_palette(self):
357        self.is_light = app_is_light()
358        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
359        self.setStyleSheet(panel_stylesheet(self.objectName(), pal))
360        self.title_icon.draw(pal["text"], self.title)
361        for status, chip in self.chips.items():
362            chip.setStyleSheet(chip_stylesheet(pal, pal["status"][COLOR_KEY[status]]))
363        self.empty.setStyleSheet(f"color: {pal['muted']};")
364        self.update_header()
365        self.table.viewport().update()
366        self.progress.update()
367
368    def _show_empty(self, message: str | None):
369        self.empty.setText(message or "")
370        self.empty.setVisible(bool(message))
371        self.table.setVisible(not message)
372
373    # ----------------------------------------------------------- lifecycle
374    def reset(self):
375        """Back to the empty state (e.g. when another project is opened)."""
376        self._timer.stop()
377        self.tests, self.by_name, self.current = [], {}, None
378        self.table.setRowCount(0)
379        self.selected = ""
380        self.description = ""
381        self._started = 0.0
382        self._finished_at = None
383        self._result = ""
384        self._active = False
385        self.set_filter(None)
386        self.progress.set(0, "running")
387        self._show_empty(self.word("idle"))
388        self.update_header()
389
390    def begin_listing(self, description: str = ""):
391        """Phase 1: fpm is building and listing the tests."""
392        self.tests, self.by_name, self.current = [], {}, None
393        self.table.setRowCount(0)
394        self.selected = ""
395        self.description = description
396        self._result = "listing"
397        self._started = time.monotonic()
398        self._finished_at = None
399        self._active = True
400        self.set_filter(None)
401        self.progress.set_busy(True)  # the build's length is unknown here
402        self._show_empty(self.word("collecting"))
403        self._timer.start()
404        self.update_header()
405
406    def start(self, names: list[str], history: dict[str, float], building: bool = False):
407        """Phase 2: run the tests (after fpm has built them, if ``building``)."""
408        self.tests, self.by_name, self.current = [], {}, None
409        self.table.setRowCount(0)
410        for name in names:
411            self._add(name, history.get(name))
412        self._result = "building" if building else ""
413        self._started = time.monotonic()
414        self._active = True
415        if building:
416            self.progress.set_busy(True)
417        else:
418            self.progress.set(0, "running")
419        self._show_empty(None if names else self.word("none"))
420        self._timer.start()
421        self.update_header()
422
423    def build_finished(self):
424        """fpm has finished building; the tests are starting."""
425        if self._result == "building":
426            self._result = ""
427            self._started = time.monotonic()
428            self.progress.set(0, "running")
429            self.update_header()
430
431    def finish(self, exit_code: int, stopped: bool = False, no_tests: bool = False):
432        if not self._active:
433            return
434        build_failed = self._result == "building" and exit_code != 0 and not stopped
435        self._active = False
436        self._finished_at = time.monotonic()
437        now = self._finished_at
438        for test in self.tests:
439            if test.status == RUNNING:
440                test.status = FAILED_T if not stopped else NOT_RUN
441                test.stopped = stopped
442                test.finished = now
443            elif test.status == QUEUED:
444                test.status = NOT_RUN
445        if no_tests:
446            self._result = "none"
447            self._show_empty(self.word("none"))
448        elif stopped:
449            self._result = "stopped"
450        elif exit_code != 0 or any(t.status == FAILED_T for t in self.tests):
451            self._result = "failed"
452        else:
453            self._result = "ok"
454        if build_failed:
455            self._result = "buildfailed"
456        if self._result == "failed" and not self.tests:
457            self._show_empty(self.word("unlisted"))
458        state = {"ok": "ok", "failed": "failed", "stopped": "stopped", "none": "ok",
459                 "buildfailed": "failed"}[self._result]
460        self.progress.set(1.0 if self._result in ("ok", "none") else self.fraction(), state)
461        self.update_header()
462        self.table.viewport().update()
463        self.schedule_sort()
464
465    @property
466    def build_failed(self) -> bool:
467        """The last run stopped because the build failed (nothing ran)."""
468        return self._result == "buildfailed"
469
470    def durations(self) -> dict[str, float]:
471        """Measured durations of the tests that ran to completion."""
472        return {t.name: t.seconds for t in self.tests if t.seconds is not None}
473
474    def output_for(self, name: str) -> str:
475        test = self.by_name.get(name)
476        if not test:
477            return ""
478        return "\n".join(test.output + ([test.partial] if test.partial else []))
479
480    # ------------------------------------------------------------- parsing
481    def feed_line(self, line: str) -> str | None:
482        """Process one line of ``fpm test`` output. Returns the text to show
483        in the console (markers removed), or None to drop the line."""
484        match = MARKER_RE.search(line)
485        if not match:
486            if self.current is not None:
487                self.current.output.append(line)
488            return line
489        before = line[:match.start()]
490        if before.strip() and self.current is not None:
491            self.current.output.append(before)
492        kind, name = match.group(1), match.group(2)
493        if kind == "start":
494            self.current = self.test_started(name)
495        else:
496            self.current = None
497            self.test_finished(name, int(match.group(3)), float(match.group(4)))
498        return before if before.strip() else None
499
500    # ---------------------------------- each test (also for a parallel run)
501    def test_started(self, name: str) -> TestCase:
502        test = self.by_name.get(name) or self._add(name, None)
503        test.status = RUNNING
504        test.started = time.monotonic()
505        test.output, test.partial = [], ""
506        if self.filter_status is None and self.jobs == 1:  # (in parallel the view would jump about)
507            self.table.scrollToItem(self.table.item(test.row, 0))
508        self._changed(test)
509        return test
510
511    def test_output(self, name: str, text: str):
512        """Output of one test, as it comes (parts of lines too)."""
513        test = self.by_name.get(name)
514        if test is not None:
515            lines = (test.partial + text).split("\n")
516            test.partial = lines.pop()
517            test.output.extend(lines)
518
519    def test_finished(self, name: str, exit_code: int, seconds: float, stopped: bool = False):
520        test = self.by_name.get(name) or self._add(name, None)
521        if test.partial:
522            test.output.append(test.partial)
523            test.partial = ""
524        test.finished = time.monotonic()
525        if stopped:
526            test.status, test.stopped = NOT_RUN, True
527        else:
528            test.exit_code, test.seconds = exit_code, seconds
529            test.status = PASSED if exit_code == 0 else FAILED_T
530        if self.selected == name:  # refresh the filtered output view
531            self.test_selected.emit(name)
532        self._changed(test)
533
534    def _changed(self, test: TestCase):
535        self._apply_filter(test)
536        self.update_header()
537        self.table.viewport().update()
538        self.schedule_sort()
539
540    # ------------------------------------------------------------- helpers
541    def _add(self, name: str, expected: float | None) -> TestCase:
542        row = self.table.rowCount()
543        self.table.insertRow(row)
544        for column in range(len(self.COLUMNS)):
545            self.table.setItem(row, column, QTableWidgetItem())
546        self.table.item(row, 0).setText(name)
547        self.table.item(row, 0).setToolTip(self.word("row_tip"))
548        test = TestCase(name, row, expected, seq=len(self.tests))
549        self.tests.append(test)
550        self.by_name[name] = test
551        self._apply_filter(test)
552        self._show_empty(None)
553        self.schedule_sort()
554        return test
555
556    # SortableTable hooks (the selection is kept by name, so nothing to remap)
557    def rows(self) -> list[TestCase]:
558        return self.tests
559
560    def _sync_row(self, row: int):
561        item = self.table.item(row, 0)
562        item.setText(self.tests[row].name)
563        item.setToolTip(self.word("row_tip"))
564
565    def _reapply_filter(self):
566        for t in self.tests:
567            self._apply_filter(t)
568
569    def _selected_object(self):
570        return None
571
572    def _restore_selection(self, _obj):
573        pass
574
575    def _expected(self) -> dict[str, float]:
576        """Expected duration for every test, or {} when nothing is known.
577
578        Tests without a recorded time get the median of the known ones, and a
579        running test that has already taken longer than expected is assumed
580        to need a bit longer still, so the bar never sits at ~100%."""
581        known = [t.expected for t in self.tests if t.expected is not None]
582        if not known:
583            return {}
584        guess = statistics.median(known)
585        expected = {}
586        for t in self.tests:
587            e = t.expected if t.expected is not None else guess
588            if t.status == RUNNING:
589                e = max(e, t.elapsed() * 1.25)
590            expected[t.name] = e
591        return expected
592
593    def fraction(self) -> float:
594        if not self.tests:
595            return 0.0
596        expected = self._expected()
597        if not expected:  # nothing known yet: count tests
598            done = sum(t.status in (PASSED, FAILED_T, NOT_RUN) for t in self.tests)
599            return done / len(self.tests)
600        total = sum(max(v, 1e-3) for v in expected.values())
601        progress = 0.0
602        for t in self.tests:
603            e = max(expected[t.name], 1e-3)
604            if t.status in (PASSED, FAILED_T, NOT_RUN):
605                progress += e
606            elif t.status == RUNNING:
607                progress += min(t.elapsed(), 0.95 * e)
608        return progress / total
609
610    def remaining(self) -> float | None:
611        """Estimated time left, only when every test has a recorded time."""
612        if any(t.expected is None for t in self.tests):
613            return None
614        expected = self._expected()
615        if not expected:
616            return None
617        left = 0.0
618        for t in self.tests:
619            if t.status == QUEUED:
620                left += expected[t.name]
621            elif t.status == RUNNING:
622                left += max(expected[t.name] - t.elapsed(), 0.0)
623        return left / min(self.jobs, max(1, sum(t.status in (QUEUED, RUNNING) for t in self.tests)))
624
625    def time_scale(self) -> float:
626        return max([t.elapsed() for t in self.tests] + [t.expected or 0 for t in self.tests] + [0.1])
627
628    def counts(self) -> dict[str, int]:
629        counts = {s: 0 for s in STATUSES}
630        for t in self.tests:
631            counts[t.status] += 1
632        return counts
633
634    def set_filter(self, status: str | None):
635        self.filter_status = status
636        for s, chip in self.chips.items():
637            chip.setChecked(s == status)
638        for t in self.tests:
639            self._apply_filter(t)
640
641    def _apply_filter(self, test: TestCase):
642        hide = self.filter_status is not None and test.status != self.filter_status
643        self.table.setRowHidden(test.row, hide)
644
645    def update_header(self):
646        pal = self.palette_colors
647        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{self.description}</span>")
648        counts = self.counts()
649        for status, chip in self.chips.items():
650            chip.setText(f"{ICONS[status]} {counts[status]}")
651            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
652                            "(click again to show all)")
653            chip.setVisible(counts[status] > 0)
654        if self.filter_status and counts[self.filter_status] == 0:
655            self.set_filter(None)
656
657        muted = f"<span style='color:{pal['muted']}'>"
658        end = self._finished_at or time.monotonic()
659        seconds = f"{end - self._started:.1f} s" if self._started else ""
660        n = len(self.tests)
661        finished = counts[PASSED] + counts[FAILED_T]
662        failed = f" · {counts[FAILED_T]} failed" if counts[FAILED_T] else ""
663        if self._result in ("listing", "building"):
664            pct = self.build_percent() if self.build_percent else None
665            done = f" {pct}%" if pct else ""
666            what = self.word("collecting_label" if self._result == "listing" else "after_build")
667            text = f"<b>Building…{done}</b> {muted}· {what} · {seconds}</span>"
668        elif self._result == "buildfailed":
669            text = f"<b>Build failed</b> {muted}· see the Build tab</span>"
670        elif self._result == "none":
671            text = f"<b>{self.word('none_label')}</b>"
672        elif self._result == "ok" and counts[NOT_RUN]:
673            text = (f"<b>{counts[PASSED]} {self.word('passed')}</b> {muted}· {counts[NOT_RUN]} not run"
674                    f" · {seconds}</span>")
675        elif self._result == "ok":
676            done = self.word(PASSED) if n == 1 else f"All {n} {self.word('passed')}"
677            text = f"<b>{done}</b> {muted}· {seconds}</span>"
678        elif self._result == "failed":
679            text = f"<b>{counts[FAILED_T]} of {n} failed</b> {muted}· {seconds}</span>" if n \
680                else "<b>Failed</b>"
681        elif self._result == "stopped":
682            text = f"<b>Stopped</b> {muted}· {finished} of {n} run{failed}</span>"
683        elif self._active:
684            running = counts[RUNNING]
685            failed += f" · {running} running at once" if self.jobs > 1 and running > 1 else ""
686            left = self.remaining()
687            eta = f" · ~{left:.0f} s left" if left is not None and left >= 1 else ""
688            text = f"<b>{finished} of {n}</b> {muted}{self.word('items')}{failed} · {seconds}{eta}</span>"
689        else:
690            text = f"{muted}{self.word('idle_label')}</span>"
691        self.progress_label.setText(text)
692
693    def _tick(self):
694        self.spinner_angle = (self.spinner_angle + 18) % 360
695        now = time.monotonic()
696        flashing = any(t.finished is not None and now - t.finished < FLASH_SECONDS for t in self.tests)
697        if self._active and self._result == "":
698            self.progress.set(self.fraction())
699        self.table.viewport().update()
700        self.update_header()
701        if not (self._active or flashing):
702            self._timer.stop()
703
704    def _row_clicked(self, row: int, _column: int):
705        if row >= len(self.tests):
706            return
707        name = self.tests[row].name
708        self.selected = "" if self.selected == name else name  # click again: show all
709        self.table.viewport().update()
710        self.test_selected.emit(self.selected)
711
712    def row_menu(self, row: int) -> QMenu | None:
713        """A row's right-click menu: Debug it."""
714        if not 0 <= row < len(self.tests):
715            return None
716        name = self.tests[row].name
717        menu = QMenu(self)
718        menu.addAction(f"Debug “{name}”", lambda n=name: self.debug_requested.emit(n))
719        return menu
720
721    def _row_menu(self, pos):
722        menu = self.row_menu(self.table.rowAt(pos.y()))
723        if menu is not None:
724            menu.aboutToHide.connect(menu.deleteLater)
725            menu.popup(self.table.viewport().mapToGlobal(pos))
726
727    def _set_hover(self, row: int, _column: int):
728        if row != self.hover_row:
729            self.hover_row = row
730            self.table.viewport().update()
731
732    def eventFilter(self, obj, event):
733        if obj is self.table.viewport():
734            if event.type() == QEvent.Leave:
735                self._set_hover(-1, 0)
736            elif event.type() == QEvent.Resize:
737                self._fit_columns()
738        return super().eventFilter(obj, event)
739
740    def _fit_columns(self):
741        width = self.table.viewport().width()
742        self.table.setColumnWidth(1, scaled(140 if width >= scaled(520) else 120))
743        self.table.setColumnWidth(2, scaled(170 if width >= scaled(640) else 120))
744
745    def refresh_fonts(self):
746        """Re-apply sizes after the application font changed."""
747        title_font = QFont(QApplication.font())
748        title_font.setPointSizeF(title_font.pointSizeF() + 2)
749        title_font.setWeight(QFont.Bold)
750        self.title.setFont(title_font)
751        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
752        self.refresh_palette()
753        self._fit_columns()
MARKER_RE = re.compile('@@vibefpm:(start|end):([^:@]+)(?::(-?\\d+):([\\d.]+))?@@')
EXE_RE = re.compile('@@vibefpm:exe:([^:@]+)@@(.*)$')
MATCHED_RE = re.compile('^\\s*Matched names:\\s*(.*)$')
NO_TESTS_RE = re.compile('No tests to run')
STATUSES = ('Running', 'Passed', 'Failed', 'Not run', 'Queued')
ICONS = {'Queued': '·', 'Running': '⟳', 'Passed': '✓', 'Failed': '✕', 'Not run': '–'}
COLOR_KEY = {'Running': 'Compiling', 'Passed': 'Done', 'Failed': 'Failed', 'Not run': 'Aborted', 'Queued': 'Aborted'}
FLASH_SECONDS = 0.6
def parse_test_list(text: str) -> list[str] | None:
74def parse_test_list(text: str) -> list[str] | None:
75    """Test names from ``fpm test --list`` output ([] if there are none,
76    None if the output wasn't understood)."""
77    if NO_TESTS_RE.search(text):
78        return []
79    lines = text.splitlines()
80    for i, line in enumerate(lines):
81        match = MATCHED_RE.match(line)
82        if match:
83            names = match.group(1).split()
84            for rest in lines[i + 1:]:
85                if not rest.strip() or rest.lstrip().startswith(("<", "STOP")):
86                    break
87                names += rest.split()
88            return names
89    return None

Test names from fpm test --list output ([] if there are none, None if the output wasn't understood).

@dataclass
class TestCase:
 92@dataclass
 93class TestCase:
 94    name: str
 95    row: int
 96    expected: float | None = None  # duration last time, if known
 97    seq: int = 0  # original (listed) order, for "unsorted"
 98    status: str = QUEUED
 99    started: float | None = None
100    finished: float | None = None
101    seconds: float | None = None  # as measured by the runner
102    exit_code: int | None = None
103    stopped: bool = False  # was running when Stop was pressed
104    output: list[str] = field(default_factory=list)
105    partial: str = ""  # (a parallel run: the start of a line still being written)
106
107    def elapsed(self) -> float:
108        if self.seconds is not None:
109            return self.seconds
110        if self.started is None:
111            return 0.0
112        return (self.finished or time.monotonic()) - self.started
TestCase( name: str, row: int, expected: float | None = None, seq: int = 0, status: str = 'Queued', started: float | None = None, finished: float | None = None, seconds: float | None = None, exit_code: int | None = None, stopped: bool = False, output: list[str] = <factory>, partial: str = '')
name: str
row: int
expected: float | None = None
seq: int = 0
status: str = 'Queued'
started: float | None = None
finished: float | None = None
seconds: float | None = None
exit_code: int | None = None
stopped: bool = False
output: list[str]
partial: str = ''
def elapsed(self) -> float:
107    def elapsed(self) -> float:
108        if self.seconds is not None:
109            return self.seconds
110        if self.started is None:
111            return 0.0
112        return (self.finished or time.monotonic()) - self.started
class TestDelegate(PySide6.QtWidgets.QStyledItemDelegate):
115class TestDelegate(QStyledItemDelegate):
116    def __init__(self, monitor: TestMonitor):
117        super().__init__(monitor.table)
118        self.monitor = monitor
119
120    def sizeHint(self, option, index):
121        return QSize(100, scaled(COMPACT_ROW_HEIGHT))
122
123    def paint(self, p: QPainter, option, index):
124        mon = self.monitor
125        if index.row() >= len(mon.tests):
126            return
127        test = mon.tests[index.row()]
128        pal = mon.palette_colors
129        color = QColor(pal["status"][COLOR_KEY[test.status]])
130        r = QRectF(option.rect)
131        p.save()
132        p.setRenderHint(QPainter.Antialiasing)
133        if test.name == mon.selected:
134            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
135        elif index.row() == mon.hover_row:
136            p.fillRect(r, QColor(pal["hover"]))
137        if test.finished is not None and time.monotonic() - test.finished < FLASH_SECONDS:
138            age = time.monotonic() - test.finished
139            p.fillRect(r, _tint(color, int(35 * (1 - age / FLASH_SECONDS))))
140        if test.name == mon.selected and index.column() == 0:
141            p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
142        p.setPen(QPen(QColor(pal["line"]), 1))
143        p.drawLine(r.bottomLeft(), r.bottomRight())
144
145        small = QFont(option.font)
146        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
147        if index.column() == 0:
148            self._paint_name(p, r, test, option.font, small, pal)
149        elif index.column() == 1:
150            self._paint_status(p, r, test, small, color)
151        else:
152            self._paint_time(p, r, test, small, pal, color)
153        p.restore()
154
155    def _paint_name(self, p, r, test, font, small, pal):
156        bold = QFont(font)
157        bold.setWeight(QFont.DemiBold)
158        detail = {RUNNING: "running…", NOT_RUN: "not run"}.get(test.status, "")
159        if test.stopped:
160            detail = f"stopped after {test.elapsed():.1f} s"
161        if test.status == FAILED_T and test.exit_code is not None:
162            detail = f"exit code {test.exit_code}"
163        lines = len(test.output)
164        if lines and test.status != RUNNING:
165            count = f"{lines} line{'s' * (lines != 1)} of output"
166            detail = f"{detail} · {count}" if detail else count
167        paint_name_and_detail(p, r.adjusted(14, 0, -6, 0), test.name, detail, bold, small,
168                              QColor(pal["text"] if test.status != QUEUED else pal["muted"]), QColor(pal["muted"]))
169
170    def _paint_status(self, p, r, test, small, color):
171        font = QFont(small)
172        font.setWeight(QFont.DemiBold)
173        text = "Stopped" if test.stopped else self.monitor.word(test.status)
174        pill = QRectF(r.left() + 8, r.center().y() - 10,
175                      QFontMetricsF(font).horizontalAdvance(text) + 36, 20)
176        p.setPen(Qt.NoPen)
177        p.setBrush(_tint(color, 40 if test.status != QUEUED else 22))
178        p.drawRoundedRect(pill, 10, 10)
179        icon = QRectF(pill.left() + 8, pill.center().y() - 6, 12, 12)
180        if test.status == RUNNING:
181            p.setPen(QPen(_tint(color, 70), 2))
182            p.drawEllipse(icon)
183            p.setPen(QPen(color, 2, Qt.SolidLine, Qt.RoundCap))
184            p.drawArc(icon, int(-self.monitor.spinner_angle * 16), -110 * 16)
185        else:
186            p.setFont(font)
187            p.setPen(color)
188            p.drawText(icon.adjusted(-3, -3, 3, 3), Qt.AlignCenter, ICONS[test.status])
189        p.setFont(font)
190        p.setPen(color.darker(115) if self.monitor.is_light else color)
191        p.drawText(pill.adjusted(27, 0, -6, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
192
193    def _paint_time(self, p, r, test, small, pal, color):
194        scale = self.monitor.time_scale()
195        track = QRectF(r.left() + 10, r.center().y() - 3, max(r.width() - 74, 10), 6)
196        p.setPen(Qt.NoPen)
197        p.setBrush(QColor(pal["track"]))
198        p.drawRoundedRect(track, 3, 3)
199        mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
200        mono.setPointSizeF(small.pointSizeF())
201        p.setFont(mono)
202        if test.status == QUEUED:
203            if test.expected is not None and scale > 0:  # ghost bar: last run's time
204                p.setBrush(_tint(QColor(pal["muted"]), 70))
205                p.drawRoundedRect(QRectF(track.left(), track.top(),
206                                         max(track.width() * test.expected / scale, 3), 6), 3, 3)
207                p.setPen(QColor(pal["muted"]))
208                p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight,
209                           f"~{test.expected:.1f} s")
210            return
211        elapsed = test.elapsed()
212        if test.status == RUNNING and test.expected:  # where it finished last time
213            x = track.left() + track.width() * min(test.expected / scale, 1)
214            p.setPen(QPen(QColor(pal["muted"]), 1, Qt.DotLine))
215            p.drawLine(x, track.top() - 3, x, track.bottom() + 3)
216            p.setPen(Qt.NoPen)
217        p.setBrush(color if test.status == RUNNING else QColor(pal["muted"]))
218        fraction = elapsed / scale if scale > 0 else 0
219        p.drawRoundedRect(QRectF(track.left(), track.top(), max(track.width() * min(fraction, 1), 3), 6), 3, 3)
220        p.setPen(QColor(pal["text"] if test.status == RUNNING else pal["muted"]))
221        p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight, f"{elapsed:.1f} s")

QStyledItemDelegate(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

TestDelegate(monitor: TestMonitor)
116    def __init__(self, monitor: TestMonitor):
117        super().__init__(monitor.table)
118        self.monitor = monitor

__init__(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

monitor
def sizeHint(self, option, index):
120    def sizeHint(self, option, index):
121        return QSize(100, scaled(COMPACT_ROW_HEIGHT))

sizeHint(self, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> PySide6.QtCore.QSize

def paint(self, p: PySide6.QtGui.QPainter, option, index):
123    def paint(self, p: QPainter, option, index):
124        mon = self.monitor
125        if index.row() >= len(mon.tests):
126            return
127        test = mon.tests[index.row()]
128        pal = mon.palette_colors
129        color = QColor(pal["status"][COLOR_KEY[test.status]])
130        r = QRectF(option.rect)
131        p.save()
132        p.setRenderHint(QPainter.Antialiasing)
133        if test.name == mon.selected:
134            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
135        elif index.row() == mon.hover_row:
136            p.fillRect(r, QColor(pal["hover"]))
137        if test.finished is not None and time.monotonic() - test.finished < FLASH_SECONDS:
138            age = time.monotonic() - test.finished
139            p.fillRect(r, _tint(color, int(35 * (1 - age / FLASH_SECONDS))))
140        if test.name == mon.selected and index.column() == 0:
141            p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
142        p.setPen(QPen(QColor(pal["line"]), 1))
143        p.drawLine(r.bottomLeft(), r.bottomRight())
144
145        small = QFont(option.font)
146        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
147        if index.column() == 0:
148            self._paint_name(p, r, test, option.font, small, pal)
149        elif index.column() == 1:
150            self._paint_status(p, r, test, small, color)
151        else:
152            self._paint_time(p, r, test, small, pal, color)
153        p.restore()

paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("TestDelegate" inherits "QStyledItemDelegate": )
STATUS_SORT = {'Failed': 0, 'Running': 1, 'Queued': 2, 'Not run': 3, 'Passed': 4}
class TestMonitor(vibefpmgui.build_monitor.SortableTable, PySide6.QtWidgets.QWidget):
227class TestMonitor(SortableTable, QWidget):
228    """Header with filter chips, progress bar and a table of tests. Clicking a
229    test emits `test_selected` with its name ("" to show all output)."""
230
231    COLUMNS: ClassVar[list[str]] = ["TEST", "STATUS", "TIME"]
232    test_selected = Signal(str)
233    debug_requested = Signal(str)  # right-click → Debug: a test's (or program's) name
234    ICON = "test"  # (the toolbar's icon for the command: see TitleIcon)
235    # Everything the panel says, so a "Run" version can reuse it (see run_monitor).
236    WORDS: ClassVar[dict[str, str]] = {
237        "title": "Tests", "item": "test", "items": "tests",
238        PASSED: "Passed", "passed": "passed",
239        "idle": "Run Test to see each test's progress here.",
240        "idle_label": "Run Test to see progress",
241        "collecting": "Building and collecting tests… (per-file progress is in the Build tab)",
242        "collecting_label": "collecting tests",
243        "after_build": "tests start after the build",
244        "none": "This project has no tests.", "none_label": "No tests",
245        "unlisted": "The tests could not be built or listed — see the build panel and output.",
246        "row_tip": "Click to show only this test's output",
247    }
248
249    def word(self, key: str) -> str:
250        return self.WORDS.get(key, key)
251
252    def __init__(self, parent=None):
253        super().__init__(parent)
254        self.tests: list[TestCase] = []
255        self.by_name: dict[str, TestCase] = {}
256        self.current: TestCase | None = None
257        self.selected = ""
258        self.hover_row = -1
259        self.spinner_angle = 0.0
260        self.filter_status: str | None = None
261        self.description = ""
262        self.build_percent = None  # callable giving the build's % (set by the main window)
263        self.jobs = 1  # how many run at once (Build options → Parallel tests)
264        self._started = 0.0
265        self._finished_at: float | None = None
266        self._result = ""  # "", "listing", "ok", "failed", "stopped", "none"
267        self._active = False
268        self.is_light = True
269        self.palette_colors = PALETTES["light"]
270
271        self.setObjectName("TestMonitor")
272        self.setAttribute(Qt.WA_StyledBackground, True)
273        self.title_icon = TitleIcon(self.ICON)
274        self.title = QLabel(self.word("title"))
275        title_font = self.title.font()
276        title_font.setPointSizeF(title_font.pointSizeF() + 2)
277        title_font.setWeight(QFont.Bold)
278        self.title.setFont(title_font)
279        self.subtitle = StatusLabel()
280        header = QHBoxLayout()
281        header.setSpacing(6)
282        header.addWidget(self.title_icon)
283        header.addWidget(self.title)
284        header.addSpacing(4)
285        header.addWidget(self.subtitle)
286        header.addStretch(1)
287        self.chips: dict[str, QPushButton] = {}
288        for status in (RUNNING, PASSED, FAILED_T, NOT_RUN):
289            chip = QPushButton()
290            chip.setCheckable(True)
291            chip.setCursor(Qt.PointingHandCursor)
292            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
293                            "(click again to show all)")
294            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
295            chip.hide()
296            header.addWidget(chip)
297            self.chips[status] = chip
298
299        self.progress = ProgressLine(self)
300        self.progress_label = StatusLabel()
301        progress_row = QHBoxLayout()
302        progress_row.setSpacing(10)
303        progress_row.addWidget(self.progress, 1)
304        progress_row.addWidget(self.progress_label)
305
306        self.table = QTableWidget(0, len(self.COLUMNS))
307        self.table.setHorizontalHeaderLabels(self.COLUMNS)
308        self.table.setItemDelegate(TestDelegate(self))
309        self.table.verticalHeader().setVisible(False)
310        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
311        self.table.setShowGrid(False)
312        self.table.setFrameShape(QTableWidget.NoFrame)
313        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
314        self.table.setSelectionMode(QAbstractItemView.NoSelection)
315        self.table.setFocusPolicy(Qt.NoFocus)
316        self.table.setMouseTracking(True)
317        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
318        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
319        self.table.cellEntered.connect(self._set_hover)
320        self.table.cellClicked.connect(self._row_clicked)
321        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
322        self.table.customContextMenuRequested.connect(self._row_menu)
323        self.table.viewport().installEventFilter(self)
324        hh = self.table.horizontalHeader()
325        hh.setHighlightSections(False)
326        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
327        hh.setSectionResizeMode(0, QHeaderView.Stretch)
328        for column in (1, 2):
329            hh.setSectionResizeMode(column, QHeaderView.Fixed)
330        self._fit_columns()
331        self._init_sorting({
332            0: lambda t: t.name.lower(),
333            1: lambda t: STATUS_SORT.get(t.status, 9),
334            2: lambda t: t.elapsed() if t.status != QUEUED else (t.expected or 0.0),
335        }, descending_first={2})
336
337        self.empty = QLabel()
338        self.empty.setAlignment(Qt.AlignCenter)
339        self.empty.setWordWrap(True)
340
341        layout = QVBoxLayout(self)
342        layout.setContentsMargins(14, 10, 14, 6)
343        layout.setSpacing(8)
344        layout.addLayout(header)
345        layout.addLayout(progress_row)
346        layout.addWidget(self.table, 1)
347        layout.addWidget(self.empty, 1)
348
349        self._timer = QTimer(self)
350        self._timer.setInterval(50)
351        self._timer.timeout.connect(self._tick)
352        self.refresh_palette()
353        self._show_empty(self.word("idle"))
354        self.update_header()
355
356    # ------------------------------------------------------------- styling
357    def refresh_palette(self):
358        self.is_light = app_is_light()
359        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
360        self.setStyleSheet(panel_stylesheet(self.objectName(), pal))
361        self.title_icon.draw(pal["text"], self.title)
362        for status, chip in self.chips.items():
363            chip.setStyleSheet(chip_stylesheet(pal, pal["status"][COLOR_KEY[status]]))
364        self.empty.setStyleSheet(f"color: {pal['muted']};")
365        self.update_header()
366        self.table.viewport().update()
367        self.progress.update()
368
369    def _show_empty(self, message: str | None):
370        self.empty.setText(message or "")
371        self.empty.setVisible(bool(message))
372        self.table.setVisible(not message)
373
374    # ----------------------------------------------------------- lifecycle
375    def reset(self):
376        """Back to the empty state (e.g. when another project is opened)."""
377        self._timer.stop()
378        self.tests, self.by_name, self.current = [], {}, None
379        self.table.setRowCount(0)
380        self.selected = ""
381        self.description = ""
382        self._started = 0.0
383        self._finished_at = None
384        self._result = ""
385        self._active = False
386        self.set_filter(None)
387        self.progress.set(0, "running")
388        self._show_empty(self.word("idle"))
389        self.update_header()
390
391    def begin_listing(self, description: str = ""):
392        """Phase 1: fpm is building and listing the tests."""
393        self.tests, self.by_name, self.current = [], {}, None
394        self.table.setRowCount(0)
395        self.selected = ""
396        self.description = description
397        self._result = "listing"
398        self._started = time.monotonic()
399        self._finished_at = None
400        self._active = True
401        self.set_filter(None)
402        self.progress.set_busy(True)  # the build's length is unknown here
403        self._show_empty(self.word("collecting"))
404        self._timer.start()
405        self.update_header()
406
407    def start(self, names: list[str], history: dict[str, float], building: bool = False):
408        """Phase 2: run the tests (after fpm has built them, if ``building``)."""
409        self.tests, self.by_name, self.current = [], {}, None
410        self.table.setRowCount(0)
411        for name in names:
412            self._add(name, history.get(name))
413        self._result = "building" if building else ""
414        self._started = time.monotonic()
415        self._active = True
416        if building:
417            self.progress.set_busy(True)
418        else:
419            self.progress.set(0, "running")
420        self._show_empty(None if names else self.word("none"))
421        self._timer.start()
422        self.update_header()
423
424    def build_finished(self):
425        """fpm has finished building; the tests are starting."""
426        if self._result == "building":
427            self._result = ""
428            self._started = time.monotonic()
429            self.progress.set(0, "running")
430            self.update_header()
431
432    def finish(self, exit_code: int, stopped: bool = False, no_tests: bool = False):
433        if not self._active:
434            return
435        build_failed = self._result == "building" and exit_code != 0 and not stopped
436        self._active = False
437        self._finished_at = time.monotonic()
438        now = self._finished_at
439        for test in self.tests:
440            if test.status == RUNNING:
441                test.status = FAILED_T if not stopped else NOT_RUN
442                test.stopped = stopped
443                test.finished = now
444            elif test.status == QUEUED:
445                test.status = NOT_RUN
446        if no_tests:
447            self._result = "none"
448            self._show_empty(self.word("none"))
449        elif stopped:
450            self._result = "stopped"
451        elif exit_code != 0 or any(t.status == FAILED_T for t in self.tests):
452            self._result = "failed"
453        else:
454            self._result = "ok"
455        if build_failed:
456            self._result = "buildfailed"
457        if self._result == "failed" and not self.tests:
458            self._show_empty(self.word("unlisted"))
459        state = {"ok": "ok", "failed": "failed", "stopped": "stopped", "none": "ok",
460                 "buildfailed": "failed"}[self._result]
461        self.progress.set(1.0 if self._result in ("ok", "none") else self.fraction(), state)
462        self.update_header()
463        self.table.viewport().update()
464        self.schedule_sort()
465
466    @property
467    def build_failed(self) -> bool:
468        """The last run stopped because the build failed (nothing ran)."""
469        return self._result == "buildfailed"
470
471    def durations(self) -> dict[str, float]:
472        """Measured durations of the tests that ran to completion."""
473        return {t.name: t.seconds for t in self.tests if t.seconds is not None}
474
475    def output_for(self, name: str) -> str:
476        test = self.by_name.get(name)
477        if not test:
478            return ""
479        return "\n".join(test.output + ([test.partial] if test.partial else []))
480
481    # ------------------------------------------------------------- parsing
482    def feed_line(self, line: str) -> str | None:
483        """Process one line of ``fpm test`` output. Returns the text to show
484        in the console (markers removed), or None to drop the line."""
485        match = MARKER_RE.search(line)
486        if not match:
487            if self.current is not None:
488                self.current.output.append(line)
489            return line
490        before = line[:match.start()]
491        if before.strip() and self.current is not None:
492            self.current.output.append(before)
493        kind, name = match.group(1), match.group(2)
494        if kind == "start":
495            self.current = self.test_started(name)
496        else:
497            self.current = None
498            self.test_finished(name, int(match.group(3)), float(match.group(4)))
499        return before if before.strip() else None
500
501    # ---------------------------------- each test (also for a parallel run)
502    def test_started(self, name: str) -> TestCase:
503        test = self.by_name.get(name) or self._add(name, None)
504        test.status = RUNNING
505        test.started = time.monotonic()
506        test.output, test.partial = [], ""
507        if self.filter_status is None and self.jobs == 1:  # (in parallel the view would jump about)
508            self.table.scrollToItem(self.table.item(test.row, 0))
509        self._changed(test)
510        return test
511
512    def test_output(self, name: str, text: str):
513        """Output of one test, as it comes (parts of lines too)."""
514        test = self.by_name.get(name)
515        if test is not None:
516            lines = (test.partial + text).split("\n")
517            test.partial = lines.pop()
518            test.output.extend(lines)
519
520    def test_finished(self, name: str, exit_code: int, seconds: float, stopped: bool = False):
521        test = self.by_name.get(name) or self._add(name, None)
522        if test.partial:
523            test.output.append(test.partial)
524            test.partial = ""
525        test.finished = time.monotonic()
526        if stopped:
527            test.status, test.stopped = NOT_RUN, True
528        else:
529            test.exit_code, test.seconds = exit_code, seconds
530            test.status = PASSED if exit_code == 0 else FAILED_T
531        if self.selected == name:  # refresh the filtered output view
532            self.test_selected.emit(name)
533        self._changed(test)
534
535    def _changed(self, test: TestCase):
536        self._apply_filter(test)
537        self.update_header()
538        self.table.viewport().update()
539        self.schedule_sort()
540
541    # ------------------------------------------------------------- helpers
542    def _add(self, name: str, expected: float | None) -> TestCase:
543        row = self.table.rowCount()
544        self.table.insertRow(row)
545        for column in range(len(self.COLUMNS)):
546            self.table.setItem(row, column, QTableWidgetItem())
547        self.table.item(row, 0).setText(name)
548        self.table.item(row, 0).setToolTip(self.word("row_tip"))
549        test = TestCase(name, row, expected, seq=len(self.tests))
550        self.tests.append(test)
551        self.by_name[name] = test
552        self._apply_filter(test)
553        self._show_empty(None)
554        self.schedule_sort()
555        return test
556
557    # SortableTable hooks (the selection is kept by name, so nothing to remap)
558    def rows(self) -> list[TestCase]:
559        return self.tests
560
561    def _sync_row(self, row: int):
562        item = self.table.item(row, 0)
563        item.setText(self.tests[row].name)
564        item.setToolTip(self.word("row_tip"))
565
566    def _reapply_filter(self):
567        for t in self.tests:
568            self._apply_filter(t)
569
570    def _selected_object(self):
571        return None
572
573    def _restore_selection(self, _obj):
574        pass
575
576    def _expected(self) -> dict[str, float]:
577        """Expected duration for every test, or {} when nothing is known.
578
579        Tests without a recorded time get the median of the known ones, and a
580        running test that has already taken longer than expected is assumed
581        to need a bit longer still, so the bar never sits at ~100%."""
582        known = [t.expected for t in self.tests if t.expected is not None]
583        if not known:
584            return {}
585        guess = statistics.median(known)
586        expected = {}
587        for t in self.tests:
588            e = t.expected if t.expected is not None else guess
589            if t.status == RUNNING:
590                e = max(e, t.elapsed() * 1.25)
591            expected[t.name] = e
592        return expected
593
594    def fraction(self) -> float:
595        if not self.tests:
596            return 0.0
597        expected = self._expected()
598        if not expected:  # nothing known yet: count tests
599            done = sum(t.status in (PASSED, FAILED_T, NOT_RUN) for t in self.tests)
600            return done / len(self.tests)
601        total = sum(max(v, 1e-3) for v in expected.values())
602        progress = 0.0
603        for t in self.tests:
604            e = max(expected[t.name], 1e-3)
605            if t.status in (PASSED, FAILED_T, NOT_RUN):
606                progress += e
607            elif t.status == RUNNING:
608                progress += min(t.elapsed(), 0.95 * e)
609        return progress / total
610
611    def remaining(self) -> float | None:
612        """Estimated time left, only when every test has a recorded time."""
613        if any(t.expected is None for t in self.tests):
614            return None
615        expected = self._expected()
616        if not expected:
617            return None
618        left = 0.0
619        for t in self.tests:
620            if t.status == QUEUED:
621                left += expected[t.name]
622            elif t.status == RUNNING:
623                left += max(expected[t.name] - t.elapsed(), 0.0)
624        return left / min(self.jobs, max(1, sum(t.status in (QUEUED, RUNNING) for t in self.tests)))
625
626    def time_scale(self) -> float:
627        return max([t.elapsed() for t in self.tests] + [t.expected or 0 for t in self.tests] + [0.1])
628
629    def counts(self) -> dict[str, int]:
630        counts = {s: 0 for s in STATUSES}
631        for t in self.tests:
632            counts[t.status] += 1
633        return counts
634
635    def set_filter(self, status: str | None):
636        self.filter_status = status
637        for s, chip in self.chips.items():
638            chip.setChecked(s == status)
639        for t in self.tests:
640            self._apply_filter(t)
641
642    def _apply_filter(self, test: TestCase):
643        hide = self.filter_status is not None and test.status != self.filter_status
644        self.table.setRowHidden(test.row, hide)
645
646    def update_header(self):
647        pal = self.palette_colors
648        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{self.description}</span>")
649        counts = self.counts()
650        for status, chip in self.chips.items():
651            chip.setText(f"{ICONS[status]} {counts[status]}")
652            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
653                            "(click again to show all)")
654            chip.setVisible(counts[status] > 0)
655        if self.filter_status and counts[self.filter_status] == 0:
656            self.set_filter(None)
657
658        muted = f"<span style='color:{pal['muted']}'>"
659        end = self._finished_at or time.monotonic()
660        seconds = f"{end - self._started:.1f} s" if self._started else ""
661        n = len(self.tests)
662        finished = counts[PASSED] + counts[FAILED_T]
663        failed = f" · {counts[FAILED_T]} failed" if counts[FAILED_T] else ""
664        if self._result in ("listing", "building"):
665            pct = self.build_percent() if self.build_percent else None
666            done = f" {pct}%" if pct else ""
667            what = self.word("collecting_label" if self._result == "listing" else "after_build")
668            text = f"<b>Building…{done}</b> {muted}· {what} · {seconds}</span>"
669        elif self._result == "buildfailed":
670            text = f"<b>Build failed</b> {muted}· see the Build tab</span>"
671        elif self._result == "none":
672            text = f"<b>{self.word('none_label')}</b>"
673        elif self._result == "ok" and counts[NOT_RUN]:
674            text = (f"<b>{counts[PASSED]} {self.word('passed')}</b> {muted}· {counts[NOT_RUN]} not run"
675                    f" · {seconds}</span>")
676        elif self._result == "ok":
677            done = self.word(PASSED) if n == 1 else f"All {n} {self.word('passed')}"
678            text = f"<b>{done}</b> {muted}· {seconds}</span>"
679        elif self._result == "failed":
680            text = f"<b>{counts[FAILED_T]} of {n} failed</b> {muted}· {seconds}</span>" if n \
681                else "<b>Failed</b>"
682        elif self._result == "stopped":
683            text = f"<b>Stopped</b> {muted}· {finished} of {n} run{failed}</span>"
684        elif self._active:
685            running = counts[RUNNING]
686            failed += f" · {running} running at once" if self.jobs > 1 and running > 1 else ""
687            left = self.remaining()
688            eta = f" · ~{left:.0f} s left" if left is not None and left >= 1 else ""
689            text = f"<b>{finished} of {n}</b> {muted}{self.word('items')}{failed} · {seconds}{eta}</span>"
690        else:
691            text = f"{muted}{self.word('idle_label')}</span>"
692        self.progress_label.setText(text)
693
694    def _tick(self):
695        self.spinner_angle = (self.spinner_angle + 18) % 360
696        now = time.monotonic()
697        flashing = any(t.finished is not None and now - t.finished < FLASH_SECONDS for t in self.tests)
698        if self._active and self._result == "":
699            self.progress.set(self.fraction())
700        self.table.viewport().update()
701        self.update_header()
702        if not (self._active or flashing):
703            self._timer.stop()
704
705    def _row_clicked(self, row: int, _column: int):
706        if row >= len(self.tests):
707            return
708        name = self.tests[row].name
709        self.selected = "" if self.selected == name else name  # click again: show all
710        self.table.viewport().update()
711        self.test_selected.emit(self.selected)
712
713    def row_menu(self, row: int) -> QMenu | None:
714        """A row's right-click menu: Debug it."""
715        if not 0 <= row < len(self.tests):
716            return None
717        name = self.tests[row].name
718        menu = QMenu(self)
719        menu.addAction(f"Debug “{name}”", lambda n=name: self.debug_requested.emit(n))
720        return menu
721
722    def _row_menu(self, pos):
723        menu = self.row_menu(self.table.rowAt(pos.y()))
724        if menu is not None:
725            menu.aboutToHide.connect(menu.deleteLater)
726            menu.popup(self.table.viewport().mapToGlobal(pos))
727
728    def _set_hover(self, row: int, _column: int):
729        if row != self.hover_row:
730            self.hover_row = row
731            self.table.viewport().update()
732
733    def eventFilter(self, obj, event):
734        if obj is self.table.viewport():
735            if event.type() == QEvent.Leave:
736                self._set_hover(-1, 0)
737            elif event.type() == QEvent.Resize:
738                self._fit_columns()
739        return super().eventFilter(obj, event)
740
741    def _fit_columns(self):
742        width = self.table.viewport().width()
743        self.table.setColumnWidth(1, scaled(140 if width >= scaled(520) else 120))
744        self.table.setColumnWidth(2, scaled(170 if width >= scaled(640) else 120))
745
746    def refresh_fonts(self):
747        """Re-apply sizes after the application font changed."""
748        title_font = QFont(QApplication.font())
749        title_font.setPointSizeF(title_font.pointSizeF() + 2)
750        title_font.setWeight(QFont.Bold)
751        self.title.setFont(title_font)
752        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
753        self.refresh_palette()
754        self._fit_columns()

Header with filter chips, progress bar and a table of tests. Clicking a test emits test_selected with its name ("" to show all output).

TestMonitor(parent=None)
252    def __init__(self, parent=None):
253        super().__init__(parent)
254        self.tests: list[TestCase] = []
255        self.by_name: dict[str, TestCase] = {}
256        self.current: TestCase | None = None
257        self.selected = ""
258        self.hover_row = -1
259        self.spinner_angle = 0.0
260        self.filter_status: str | None = None
261        self.description = ""
262        self.build_percent = None  # callable giving the build's % (set by the main window)
263        self.jobs = 1  # how many run at once (Build options → Parallel tests)
264        self._started = 0.0
265        self._finished_at: float | None = None
266        self._result = ""  # "", "listing", "ok", "failed", "stopped", "none"
267        self._active = False
268        self.is_light = True
269        self.palette_colors = PALETTES["light"]
270
271        self.setObjectName("TestMonitor")
272        self.setAttribute(Qt.WA_StyledBackground, True)
273        self.title_icon = TitleIcon(self.ICON)
274        self.title = QLabel(self.word("title"))
275        title_font = self.title.font()
276        title_font.setPointSizeF(title_font.pointSizeF() + 2)
277        title_font.setWeight(QFont.Bold)
278        self.title.setFont(title_font)
279        self.subtitle = StatusLabel()
280        header = QHBoxLayout()
281        header.setSpacing(6)
282        header.addWidget(self.title_icon)
283        header.addWidget(self.title)
284        header.addSpacing(4)
285        header.addWidget(self.subtitle)
286        header.addStretch(1)
287        self.chips: dict[str, QPushButton] = {}
288        for status in (RUNNING, PASSED, FAILED_T, NOT_RUN):
289            chip = QPushButton()
290            chip.setCheckable(True)
291            chip.setCursor(Qt.PointingHandCursor)
292            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
293                            "(click again to show all)")
294            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
295            chip.hide()
296            header.addWidget(chip)
297            self.chips[status] = chip
298
299        self.progress = ProgressLine(self)
300        self.progress_label = StatusLabel()
301        progress_row = QHBoxLayout()
302        progress_row.setSpacing(10)
303        progress_row.addWidget(self.progress, 1)
304        progress_row.addWidget(self.progress_label)
305
306        self.table = QTableWidget(0, len(self.COLUMNS))
307        self.table.setHorizontalHeaderLabels(self.COLUMNS)
308        self.table.setItemDelegate(TestDelegate(self))
309        self.table.verticalHeader().setVisible(False)
310        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
311        self.table.setShowGrid(False)
312        self.table.setFrameShape(QTableWidget.NoFrame)
313        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
314        self.table.setSelectionMode(QAbstractItemView.NoSelection)
315        self.table.setFocusPolicy(Qt.NoFocus)
316        self.table.setMouseTracking(True)
317        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
318        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
319        self.table.cellEntered.connect(self._set_hover)
320        self.table.cellClicked.connect(self._row_clicked)
321        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
322        self.table.customContextMenuRequested.connect(self._row_menu)
323        self.table.viewport().installEventFilter(self)
324        hh = self.table.horizontalHeader()
325        hh.setHighlightSections(False)
326        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
327        hh.setSectionResizeMode(0, QHeaderView.Stretch)
328        for column in (1, 2):
329            hh.setSectionResizeMode(column, QHeaderView.Fixed)
330        self._fit_columns()
331        self._init_sorting({
332            0: lambda t: t.name.lower(),
333            1: lambda t: STATUS_SORT.get(t.status, 9),
334            2: lambda t: t.elapsed() if t.status != QUEUED else (t.expected or 0.0),
335        }, descending_first={2})
336
337        self.empty = QLabel()
338        self.empty.setAlignment(Qt.AlignCenter)
339        self.empty.setWordWrap(True)
340
341        layout = QVBoxLayout(self)
342        layout.setContentsMargins(14, 10, 14, 6)
343        layout.setSpacing(8)
344        layout.addLayout(header)
345        layout.addLayout(progress_row)
346        layout.addWidget(self.table, 1)
347        layout.addWidget(self.empty, 1)
348
349        self._timer = QTimer(self)
350        self._timer.setInterval(50)
351        self._timer.timeout.connect(self._tick)
352        self.refresh_palette()
353        self._show_empty(self.word("idle"))
354        self.update_header()
COLUMNS: ClassVar[list[str]] = ['TEST', 'STATUS', 'TIME']
def test_selected(unknown):

The type of the None singleton.

def debug_requested(unknown):

The type of the None singleton.

ICON = 'test'
WORDS: ClassVar[dict[str, str]] = {'title': 'Tests', 'item': 'test', 'items': 'tests', 'Passed': 'Passed', 'passed': 'passed', 'idle': "Run Test to see each test's progress here.", 'idle_label': 'Run Test to see progress', 'collecting': 'Building and collecting tests… (per-file progress is in the Build tab)', 'collecting_label': 'collecting tests', 'after_build': 'tests start after the build', 'none': 'This project has no tests.', 'none_label': 'No tests', 'unlisted': 'The tests could not be built or listed — see the build panel and output.', 'row_tip': "Click to show only this test's output"}
def word(self, key: str) -> str:
249    def word(self, key: str) -> str:
250        return self.WORDS.get(key, key)
tests: list[TestCase]
by_name: dict[str, TestCase]
current: TestCase | None
selected
hover_row
spinner_angle
filter_status: str | None
description
build_percent
jobs
is_light
palette_colors
title_icon
title
subtitle
chips: dict[str, PySide6.QtWidgets.QPushButton]
progress
progress_label
table
empty
def refresh_palette(self):
357    def refresh_palette(self):
358        self.is_light = app_is_light()
359        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
360        self.setStyleSheet(panel_stylesheet(self.objectName(), pal))
361        self.title_icon.draw(pal["text"], self.title)
362        for status, chip in self.chips.items():
363            chip.setStyleSheet(chip_stylesheet(pal, pal["status"][COLOR_KEY[status]]))
364        self.empty.setStyleSheet(f"color: {pal['muted']};")
365        self.update_header()
366        self.table.viewport().update()
367        self.progress.update()
def reset(self):
375    def reset(self):
376        """Back to the empty state (e.g. when another project is opened)."""
377        self._timer.stop()
378        self.tests, self.by_name, self.current = [], {}, None
379        self.table.setRowCount(0)
380        self.selected = ""
381        self.description = ""
382        self._started = 0.0
383        self._finished_at = None
384        self._result = ""
385        self._active = False
386        self.set_filter(None)
387        self.progress.set(0, "running")
388        self._show_empty(self.word("idle"))
389        self.update_header()

Back to the empty state (e.g. when another project is opened).

def begin_listing(self, description: str = ''):
391    def begin_listing(self, description: str = ""):
392        """Phase 1: fpm is building and listing the tests."""
393        self.tests, self.by_name, self.current = [], {}, None
394        self.table.setRowCount(0)
395        self.selected = ""
396        self.description = description
397        self._result = "listing"
398        self._started = time.monotonic()
399        self._finished_at = None
400        self._active = True
401        self.set_filter(None)
402        self.progress.set_busy(True)  # the build's length is unknown here
403        self._show_empty(self.word("collecting"))
404        self._timer.start()
405        self.update_header()

Phase 1: fpm is building and listing the tests.

def start( self, names: list[str], history: dict[str, float], building: bool = False):
407    def start(self, names: list[str], history: dict[str, float], building: bool = False):
408        """Phase 2: run the tests (after fpm has built them, if ``building``)."""
409        self.tests, self.by_name, self.current = [], {}, None
410        self.table.setRowCount(0)
411        for name in names:
412            self._add(name, history.get(name))
413        self._result = "building" if building else ""
414        self._started = time.monotonic()
415        self._active = True
416        if building:
417            self.progress.set_busy(True)
418        else:
419            self.progress.set(0, "running")
420        self._show_empty(None if names else self.word("none"))
421        self._timer.start()
422        self.update_header()

Phase 2: run the tests (after fpm has built them, if building).

def build_finished(self):
424    def build_finished(self):
425        """fpm has finished building; the tests are starting."""
426        if self._result == "building":
427            self._result = ""
428            self._started = time.monotonic()
429            self.progress.set(0, "running")
430            self.update_header()

fpm has finished building; the tests are starting.

def finish(self, exit_code: int, stopped: bool = False, no_tests: bool = False):
432    def finish(self, exit_code: int, stopped: bool = False, no_tests: bool = False):
433        if not self._active:
434            return
435        build_failed = self._result == "building" and exit_code != 0 and not stopped
436        self._active = False
437        self._finished_at = time.monotonic()
438        now = self._finished_at
439        for test in self.tests:
440            if test.status == RUNNING:
441                test.status = FAILED_T if not stopped else NOT_RUN
442                test.stopped = stopped
443                test.finished = now
444            elif test.status == QUEUED:
445                test.status = NOT_RUN
446        if no_tests:
447            self._result = "none"
448            self._show_empty(self.word("none"))
449        elif stopped:
450            self._result = "stopped"
451        elif exit_code != 0 or any(t.status == FAILED_T for t in self.tests):
452            self._result = "failed"
453        else:
454            self._result = "ok"
455        if build_failed:
456            self._result = "buildfailed"
457        if self._result == "failed" and not self.tests:
458            self._show_empty(self.word("unlisted"))
459        state = {"ok": "ok", "failed": "failed", "stopped": "stopped", "none": "ok",
460                 "buildfailed": "failed"}[self._result]
461        self.progress.set(1.0 if self._result in ("ok", "none") else self.fraction(), state)
462        self.update_header()
463        self.table.viewport().update()
464        self.schedule_sort()
build_failed: bool
466    @property
467    def build_failed(self) -> bool:
468        """The last run stopped because the build failed (nothing ran)."""
469        return self._result == "buildfailed"

The last run stopped because the build failed (nothing ran).

def durations(self) -> dict[str, float]:
471    def durations(self) -> dict[str, float]:
472        """Measured durations of the tests that ran to completion."""
473        return {t.name: t.seconds for t in self.tests if t.seconds is not None}

Measured durations of the tests that ran to completion.

def output_for(self, name: str) -> str:
475    def output_for(self, name: str) -> str:
476        test = self.by_name.get(name)
477        if not test:
478            return ""
479        return "\n".join(test.output + ([test.partial] if test.partial else []))
def feed_line(self, line: str) -> str | None:
482    def feed_line(self, line: str) -> str | None:
483        """Process one line of ``fpm test`` output. Returns the text to show
484        in the console (markers removed), or None to drop the line."""
485        match = MARKER_RE.search(line)
486        if not match:
487            if self.current is not None:
488                self.current.output.append(line)
489            return line
490        before = line[:match.start()]
491        if before.strip() and self.current is not None:
492            self.current.output.append(before)
493        kind, name = match.group(1), match.group(2)
494        if kind == "start":
495            self.current = self.test_started(name)
496        else:
497            self.current = None
498            self.test_finished(name, int(match.group(3)), float(match.group(4)))
499        return before if before.strip() else None

Process one line of fpm test output. Returns the text to show in the console (markers removed), or None to drop the line.

def test_started(self, name: str) -> TestCase:
502    def test_started(self, name: str) -> TestCase:
503        test = self.by_name.get(name) or self._add(name, None)
504        test.status = RUNNING
505        test.started = time.monotonic()
506        test.output, test.partial = [], ""
507        if self.filter_status is None and self.jobs == 1:  # (in parallel the view would jump about)
508            self.table.scrollToItem(self.table.item(test.row, 0))
509        self._changed(test)
510        return test
def test_output(self, name: str, text: str):
512    def test_output(self, name: str, text: str):
513        """Output of one test, as it comes (parts of lines too)."""
514        test = self.by_name.get(name)
515        if test is not None:
516            lines = (test.partial + text).split("\n")
517            test.partial = lines.pop()
518            test.output.extend(lines)

Output of one test, as it comes (parts of lines too).

def test_finished( self, name: str, exit_code: int, seconds: float, stopped: bool = False):
520    def test_finished(self, name: str, exit_code: int, seconds: float, stopped: bool = False):
521        test = self.by_name.get(name) or self._add(name, None)
522        if test.partial:
523            test.output.append(test.partial)
524            test.partial = ""
525        test.finished = time.monotonic()
526        if stopped:
527            test.status, test.stopped = NOT_RUN, True
528        else:
529            test.exit_code, test.seconds = exit_code, seconds
530            test.status = PASSED if exit_code == 0 else FAILED_T
531        if self.selected == name:  # refresh the filtered output view
532            self.test_selected.emit(name)
533        self._changed(test)
def rows(self) -> list[TestCase]:
558    def rows(self) -> list[TestCase]:
559        return self.tests
def fraction(self) -> float:
594    def fraction(self) -> float:
595        if not self.tests:
596            return 0.0
597        expected = self._expected()
598        if not expected:  # nothing known yet: count tests
599            done = sum(t.status in (PASSED, FAILED_T, NOT_RUN) for t in self.tests)
600            return done / len(self.tests)
601        total = sum(max(v, 1e-3) for v in expected.values())
602        progress = 0.0
603        for t in self.tests:
604            e = max(expected[t.name], 1e-3)
605            if t.status in (PASSED, FAILED_T, NOT_RUN):
606                progress += e
607            elif t.status == RUNNING:
608                progress += min(t.elapsed(), 0.95 * e)
609        return progress / total
def remaining(self) -> float | None:
611    def remaining(self) -> float | None:
612        """Estimated time left, only when every test has a recorded time."""
613        if any(t.expected is None for t in self.tests):
614            return None
615        expected = self._expected()
616        if not expected:
617            return None
618        left = 0.0
619        for t in self.tests:
620            if t.status == QUEUED:
621                left += expected[t.name]
622            elif t.status == RUNNING:
623                left += max(expected[t.name] - t.elapsed(), 0.0)
624        return left / min(self.jobs, max(1, sum(t.status in (QUEUED, RUNNING) for t in self.tests)))

Estimated time left, only when every test has a recorded time.

def time_scale(self) -> float:
626    def time_scale(self) -> float:
627        return max([t.elapsed() for t in self.tests] + [t.expected or 0 for t in self.tests] + [0.1])
def counts(self) -> dict[str, int]:
629    def counts(self) -> dict[str, int]:
630        counts = {s: 0 for s in STATUSES}
631        for t in self.tests:
632            counts[t.status] += 1
633        return counts
def set_filter(self, status: str | None):
635    def set_filter(self, status: str | None):
636        self.filter_status = status
637        for s, chip in self.chips.items():
638            chip.setChecked(s == status)
639        for t in self.tests:
640            self._apply_filter(t)
def update_header(self):
646    def update_header(self):
647        pal = self.palette_colors
648        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{self.description}</span>")
649        counts = self.counts()
650        for status, chip in self.chips.items():
651            chip.setText(f"{ICONS[status]} {counts[status]}")
652            chip.setToolTip(f"Show only “{self.word(status)}” {self.word('items')} "
653                            "(click again to show all)")
654            chip.setVisible(counts[status] > 0)
655        if self.filter_status and counts[self.filter_status] == 0:
656            self.set_filter(None)
657
658        muted = f"<span style='color:{pal['muted']}'>"
659        end = self._finished_at or time.monotonic()
660        seconds = f"{end - self._started:.1f} s" if self._started else ""
661        n = len(self.tests)
662        finished = counts[PASSED] + counts[FAILED_T]
663        failed = f" · {counts[FAILED_T]} failed" if counts[FAILED_T] else ""
664        if self._result in ("listing", "building"):
665            pct = self.build_percent() if self.build_percent else None
666            done = f" {pct}%" if pct else ""
667            what = self.word("collecting_label" if self._result == "listing" else "after_build")
668            text = f"<b>Building…{done}</b> {muted}· {what} · {seconds}</span>"
669        elif self._result == "buildfailed":
670            text = f"<b>Build failed</b> {muted}· see the Build tab</span>"
671        elif self._result == "none":
672            text = f"<b>{self.word('none_label')}</b>"
673        elif self._result == "ok" and counts[NOT_RUN]:
674            text = (f"<b>{counts[PASSED]} {self.word('passed')}</b> {muted}· {counts[NOT_RUN]} not run"
675                    f" · {seconds}</span>")
676        elif self._result == "ok":
677            done = self.word(PASSED) if n == 1 else f"All {n} {self.word('passed')}"
678            text = f"<b>{done}</b> {muted}· {seconds}</span>"
679        elif self._result == "failed":
680            text = f"<b>{counts[FAILED_T]} of {n} failed</b> {muted}· {seconds}</span>" if n \
681                else "<b>Failed</b>"
682        elif self._result == "stopped":
683            text = f"<b>Stopped</b> {muted}· {finished} of {n} run{failed}</span>"
684        elif self._active:
685            running = counts[RUNNING]
686            failed += f" · {running} running at once" if self.jobs > 1 and running > 1 else ""
687            left = self.remaining()
688            eta = f" · ~{left:.0f} s left" if left is not None and left >= 1 else ""
689            text = f"<b>{finished} of {n}</b> {muted}{self.word('items')}{failed} · {seconds}{eta}</span>"
690        else:
691            text = f"{muted}{self.word('idle_label')}</span>"
692        self.progress_label.setText(text)
def row_menu(self, row: int) -> PySide6.QtWidgets.QMenu | None:
713    def row_menu(self, row: int) -> QMenu | None:
714        """A row's right-click menu: Debug it."""
715        if not 0 <= row < len(self.tests):
716            return None
717        name = self.tests[row].name
718        menu = QMenu(self)
719        menu.addAction(f"Debug “{name}”", lambda n=name: self.debug_requested.emit(n))
720        return menu

A row's right-click menu: Debug it.

def eventFilter(self, obj, event):
733    def eventFilter(self, obj, event):
734        if obj is self.table.viewport():
735            if event.type() == QEvent.Leave:
736                self._set_hover(-1, 0)
737            elif event.type() == QEvent.Resize:
738                self._fit_columns()
739        return super().eventFilter(obj, event)

eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool

def refresh_fonts(self):
746    def refresh_fonts(self):
747        """Re-apply sizes after the application font changed."""
748        title_font = QFont(QApplication.font())
749        title_font.setPointSizeF(title_font.pointSizeF() + 2)
750        title_font.setWeight(QFont.Bold)
751        self.title.setFont(title_font)
752        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
753        self.refresh_palette()
754        self._fit_columns()

Re-apply sizes after the application font changed.

staticMetaObject = PySide6.QtCore.QMetaObject("TestMonitor" inherits "QWidget": Methods: #33 type=Signal, signature=test_selected(QString), parameters=QString #34 type=Signal, signature=debug_requested(QString), parameters=QString )