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

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): 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 

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): 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()) 

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 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") 

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": 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() 

430 

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() 

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: 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 [])) 

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: 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 

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: 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) 

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: 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) 

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: 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 

592 

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 

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): 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))) 

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: 638 ↛ 639line 638 didn't jump to line 639 because the loop on line 638 never started

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: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

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": 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) 

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): 701 ↛ 702line 701 didn't jump to line 702 because the condition on line 701 was never true

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(): 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) 

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()