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