Coverage for vibefpmgui/test_monitor.py: 81%
580 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Live table of the tests ``fpm test`` runs, with an estimated progress bar.
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)::
7 @@vibefpm:start:check_fast@@
8 @@vibefpm:end:check_fast:0:0.012@@
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"""
14from __future__ import annotations
16import re
17import statistics
18import time
19from dataclasses import dataclass, field
20from typing import ClassVar
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)
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
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")
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
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): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 return []
78 lines = text.splitlines()
79 for i, line in enumerate(lines): 79 ↛ 88line 79 didn't jump to line 88 because the loop on line 79 didn't complete
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")): 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 break
86 names += rest.split()
87 return names
88 return None
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)
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
114class TestDelegate(QStyledItemDelegate):
115 def __init__(self, monitor: TestMonitor):
116 super().__init__(monitor.table)
117 self.monitor = monitor
119 def sizeHint(self, option, index):
120 return QSize(100, scaled(COMPACT_ROW_HEIGHT))
122 def paint(self, p: QPainter, option, index):
123 mon = self.monitor
124 if index.row() >= len(mon.tests): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
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: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
134 elif index.row() == mon.hover_row: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
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: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
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())
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()
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"]))
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)
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 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
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 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
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")
223STATUS_SORT = {FAILED_T: 0, RUNNING: 1, QUEUED: 2, NOT_RUN: 3, PASSED: 4}
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)."""
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 }
248 def word(self, key: str) -> str:
249 return self.WORDS.get(key, key)
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"]
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
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)
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})
336 self.empty = QLabel()
337 self.empty.setAlignment(Qt.AlignCenter)
338 self.empty.setWordWrap(True)
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)
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()
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()
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)
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()
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()
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()
423 def build_finished(self):
424 """fpm has finished building; the tests are starting."""
425 if self._result == "building": 425 ↛ exitline 425 didn't return from function 'build_finished' because the condition on line 425 was always true
426 self._result = ""
427 self._started = time.monotonic()
428 self.progress.set(0, "running")
429 self.update_header()
431 def finish(self, exit_code: int, stopped: bool = False, no_tests: bool = False):
432 if not self._active: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
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: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 test.status = NOT_RUN
445 if no_tests: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
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: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 self._result = "buildfailed"
456 if self._result == "failed" and not self.tests: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true
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()
465 @property
466 def build_failed(self) -> bool:
467 """The last run stopped because the build failed (nothing ran)."""
468 return self._result == "buildfailed"
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}
474 def output_for(self, name: str) -> str:
475 test = self.by_name.get(name)
476 if not test: 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true
477 return ""
478 return "\n".join(test.output + ([test.partial] if test.partial else []))
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: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true
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
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
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: 514 ↛ exitline 514 didn't return from function 'test_output' because the condition on line 514 was always true
515 lines = (test.partial + text).split("\n")
516 test.partial = lines.pop()
517 test.output.extend(lines)
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: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
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 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true
531 self.test_selected.emit(name)
532 self._changed(test)
534 def _changed(self, test: TestCase):
535 self._apply_filter(test)
536 self.update_header()
537 self.table.viewport().update()
538 self.schedule_sort()
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
556 # SortableTable hooks (the selection is kept by name, so nothing to remap)
557 def rows(self) -> list[TestCase]:
558 return self.tests
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"))
565 def _reapply_filter(self):
566 for t in self.tests:
567 self._apply_filter(t)
569 def _selected_object(self):
570 return None
572 def _restore_selection(self, _obj):
573 pass
575 def _expected(self) -> dict[str, float]:
576 """Expected duration for every test, or {} when nothing is known.
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: 582 ↛ 584line 582 didn't jump to line 584 because the condition on line 582 was always true
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
593 def fraction(self) -> float:
594 if not self.tests: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 return 0.0
596 expected = self._expected()
597 if not expected: # nothing known yet: count tests 597 ↛ 600line 597 didn't jump to line 600 because the condition on line 597 was always true
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
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): 612 ↛ 614line 612 didn't jump to line 614 because the condition on line 612 was always true
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)))
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])
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
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: 638 ↛ 639line 638 didn't jump to line 639 because the loop on line 638 never started
639 self._apply_filter(t)
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)
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: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true
655 self.set_filter(None)
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": 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true
669 text = f"<b>Build failed</b> {muted}· see the Build tab</span>"
670 elif self._result == "none": 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 text = f"<b>{self.word('none_label')}</b>"
672 elif self._result == "ok" and counts[NOT_RUN]: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
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)
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): 701 ↛ 702line 701 didn't jump to line 702 because the condition on line 701 was never true
702 self._timer.stop()
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)
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
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))
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()
732 def eventFilter(self, obj, event):
733 if obj is self.table.viewport(): 733 ↛ 738line 733 didn't jump to line 738 because the condition on line 733 was always true
734 if event.type() == QEvent.Leave: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 self._set_hover(-1, 0)
736 elif event.type() == QEvent.Resize:
737 self._fit_columns()
738 return super().eventFilter(obj, event)
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))
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()