Coverage for vibefpmgui/coverage_panel.py: 95%

263 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The *Coverage* tab: how much of the code the last test run ran (with 

2Build options → Coverage on): the totals for lines, functions and branches, 

3each file's numbers, and the selected file's missed lines and functions. 

4Clicking a file opens it at its first missed line; the editor's gutter 

5shows which lines ran (see `vibefpmgui.coverage`).""" 

6 

7from __future__ import annotations 

8 

9import html 

10import re 

11from pathlib import Path 

12 

13from qtpy.QtCore import Qt, Signal 

14from qtpy.QtGui import QColor, QFont 

15from qtpy.QtWidgets import ( 

16 QAbstractItemView, 

17 QApplication, 

18 QCheckBox, 

19 QHBoxLayout, 

20 QHeaderView, 

21 QLabel, 

22 QSplitter, 

23 QTableWidget, 

24 QTableWidgetItem, 

25 QVBoxLayout, 

26 QWidget, 

27) 

28 

29from . import coverage as cov 

30from .build_monitor import ( 

31 PALETTES, 

32 ProgressLine, 

33 TitleIcon, 

34 app_is_light, 

35 panel_stylesheet, 

36) 

37from .icons import symbol_icon 

38from .widgets import StatusLabel 

39 

40GOOD, FAIR, POOR = "#2f9e44", "#f08c00", "#e03131" 

41MANGLED = re.compile(r"^_*(\w+?)_MOD_(\w+)$") # gfortran: __module_MOD_procedure 

42 

43 

44def function_name(name: str) -> str: 

45 """gfortran's name for a procedure, as written: ``add (module calc)``.""" 

46 found = MANGLED.match(name) 

47 if found: 

48 return f"{found.group(2)} (module {found.group(1)})" 

49 if name == "MAIN__": 49 ↛ 51line 49 didn't jump to line 51 because the condition on line 49 was always true

50 return "(main program)" 

51 return name 

52 

53 

54def shown_functions(file: cov.FileCoverage) -> list[cov.FunctionCoverage]: 

55 """A file's functions without the ``main`` gfortran adds to call a 

56 program (it has no line of its own).""" 

57 has_program = any(f.name == "MAIN__" for f in file.functions) 

58 return [f for f in file.functions if not (has_program and f.name == "main")] 

59 

60 

61def row_tooltip(f: cov.FileCoverage) -> str: 

62 """A file's row's tooltip: its path, and every range of lines the tests 

63 missed (wrapped: a tooltip of one very long line runs off the screen).""" 

64 ranges = f.missed() 

65 if not ranges: 

66 missed = "Every line with code ran." 

67 else: 

68 lines = sum(1 for n in f.lines.values() if n == 0) 

69 missed = (f"<b>Missed lines</b> ({lines:,} in {len(ranges):,} range{'s' * (len(ranges) != 1)}):<br>" 

70 f"{html.escape(cov.ranges_text(ranges, limit=len(ranges)))}") 

71 return f"<p style='white-space:normal'>{html.escape(str(f.path))}<br><br>{missed}</p>" 

72 

73 

74def color_for(percent: float) -> str: 

75 return GOOD if percent >= 80 else FAIR if percent >= 50 else POOR 

76 

77 

78class NumberItem(QTableWidgetItem): 

79 """A cell shown as text, sorted by a number.""" 

80 

81 def __init__(self, text: str, value: float): 

82 super().__init__(text) 

83 self.value = value 

84 

85 def __lt__(self, other): 

86 return self.value < getattr(other, "value", 0) 

87 

88 

89class CoveragePanel(QWidget): 

90 """Totals, a table of files, and the selected file's details.""" 

91 

92 open_requested = Signal(str, int) # file, line 

93 

94 COLUMNS = ("FILE", "COVERAGE", "LINES", "FUNCTIONS") 

95 

96 def __init__(self, parent=None): 

97 super().__init__(parent) 

98 self.files: dict[Path, cov.FileCoverage] = {} 

99 self.project_dir: Path | None = None 

100 self.test_dirs: list[Path] = [] # the project's test folders (fpm.toml's, test/ by default) 

101 self.icon_for = None # a file's QIcon, as in Project files (set by the window) 

102 self.is_light = True 

103 self.palette_colors = PALETTES["light"] 

104 self.setObjectName("CoveragePanel") 

105 self.setAttribute(Qt.WA_StyledBackground, True) 

106 

107 self.title_icon = TitleIcon("coverage") 

108 self.title = QLabel("Coverage") 

109 self.subtitle = StatusLabel() 

110 self.dependencies_check = QCheckBox("Include dependencies") 

111 self.dependencies_check.setToolTip("Also list the dependencies' files (and count them in the totals)") 

112 self.dependencies_check.toggled.connect(self._fill) 

113 self.tests_check = QCheckBox("Include tests") 

114 self.tests_check.setToolTip("Also list the test programs (usually only the code they test matters)") 

115 self.tests_check.toggled.connect(self._fill) 

116 header = QHBoxLayout() 

117 header.setSpacing(6) 

118 header.addWidget(self.title_icon) 

119 header.addWidget(self.title) 

120 header.addSpacing(4) 

121 header.addWidget(self.subtitle, 1) 

122 header.addWidget(self.tests_check) 

123 header.addSpacing(12) 

124 header.addWidget(self.dependencies_check) 

125 

126 self.progress = ProgressLine(self) 

127 self.totals = StatusLabel() 

128 totals_row = QHBoxLayout() 

129 totals_row.setSpacing(10) 

130 totals_row.addWidget(self.progress, 1) 

131 totals_row.addWidget(self.totals) 

132 

133 self.table = QTableWidget(0, len(self.COLUMNS)) 

134 self.table.setHorizontalHeaderLabels(list(self.COLUMNS)) 

135 self.table.verticalHeader().setVisible(False) 

136 self.table.setShowGrid(False) 

137 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 

138 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 

139 self.table.setSelectionMode(QAbstractItemView.SingleSelection) 

140 self.table.setSortingEnabled(True) 

141 self.table.setWordWrap(False) 

142 hh = self.table.horizontalHeader() 

143 hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 

144 hh.setSectionResizeMode(0, QHeaderView.Stretch) 

145 for column in (1, 2, 3): 

146 hh.setSectionResizeMode(column, QHeaderView.ResizeToContents) 

147 self.table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder) # (by file, at first) 

148 self.table.itemSelectionChanged.connect(self._show_details) 

149 self.table.cellClicked.connect(self._row_clicked) 

150 

151 self.detail_title = QLabel() 

152 self.functions = QTableWidget(0, 3) 

153 self.functions.setHorizontalHeaderLabels(["FUNCTION", "LINE", "CALLS"]) 

154 self.functions.verticalHeader().setVisible(False) 

155 self.functions.setShowGrid(False) 

156 self.functions.setEditTriggers(QAbstractItemView.NoEditTriggers) 

157 self.functions.setSelectionBehavior(QAbstractItemView.SelectRows) 

158 self.functions.setSortingEnabled(True) 

159 fh = self.functions.horizontalHeader() 

160 fh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 

161 fh.setSectionResizeMode(0, QHeaderView.Stretch) 

162 fh.setSectionResizeMode(1, QHeaderView.ResizeToContents) 

163 fh.setSectionResizeMode(2, QHeaderView.ResizeToContents) 

164 self.functions.cellClicked.connect(self._function_clicked) 

165 details = QWidget() 

166 detail_layout = QVBoxLayout(details) 

167 detail_layout.setContentsMargins(0, 4, 0, 0) 

168 detail_layout.setSpacing(4) 

169 detail_layout.addWidget(self.detail_title) 

170 detail_layout.addWidget(self.functions, 1) 

171 self.splitter = QSplitter(Qt.Vertical) 

172 self.splitter.addWidget(self.table) 

173 self.splitter.addWidget(details) 

174 self.splitter.setStretchFactor(0, 3) 

175 self.splitter.setStretchFactor(1, 2) 

176 

177 self.empty = QLabel() 

178 self.empty.setAlignment(Qt.AlignCenter) 

179 self.empty.setWordWrap(True) 

180 

181 layout = QVBoxLayout(self) 

182 layout.setContentsMargins(14, 10, 14, 6) 

183 layout.setSpacing(8) 

184 layout.addLayout(header) 

185 layout.addLayout(totals_row) 

186 layout.addWidget(self.splitter, 1) 

187 layout.addWidget(self.empty, 1) 

188 self.refresh_fonts() 

189 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 

190 "ran shows here, and in the editor's gutter.") 

191 

192 # ------------------------------------------------------------ styling 

193 def refresh_palette(self): 

194 self.is_light = app_is_light() 

195 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"] 

196 self.setStyleSheet(panel_stylesheet(self.objectName(), pal)) 

197 self.title_icon.draw(pal["text"], self.title) 

198 for label in (self.subtitle, self.empty, self.detail_title): 

199 label.setStyleSheet(f"color: {pal['muted']};") 

200 self.progress.update() 

201 self._fill() 

202 

203 def refresh_fonts(self): 

204 font = QFont(QApplication.font()) 

205 font.setPointSizeF(font.pointSizeF() + 2) 

206 font.setWeight(QFont.Bold) 

207 self.title.setFont(font) 

208 self.refresh_palette() 

209 

210 # -------------------------------------------------------------- state 

211 def show_message(self, message: str): 

212 """No numbers to show (yet): why, or what's happening.""" 

213 self.empty.setText(message) 

214 self.empty.show() 

215 self.splitter.hide() 

216 

217 def show_report(self, files: dict[Path, cov.FileCoverage], project_dir: Path, description: str = "", 

218 test_dirs: list[Path] | None = None): 

219 self.files = files 

220 self.project_dir = project_dir.resolve() 

221 self.test_dirs = [Path(d).resolve() for d in test_dirs or []] 

222 self.subtitle.setText(description) 

223 self._fill() 

224 

225 def clear(self): 

226 self.files = {} 

227 self.subtitle.clear() 

228 self.totals.clear() 

229 self.progress.set(0, "running") 

230 self.table.setRowCount(0) 

231 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 

232 "ran shows here, and in the editor's gutter.") 

233 

234 def shown_files(self) -> list[cov.FileCoverage]: 

235 """The files listed: the project's (and the dependencies' or the 

236 test programs, if asked), each with code in it.""" 

237 out = [] 

238 for path, f in self.files.items(): 

239 if not f.lines_found: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 continue 

241 inside = self.project_dir is not None and self.project_dir in path.parents 

242 dependency = not inside or "dependencies" in path.relative_to(self.project_dir).parts[:2] 

243 if dependency and not self.dependencies_check.isChecked(): 

244 continue 

245 if not self.tests_check.isChecked() and any(d == path.parent or d in path.parents for d in self.test_dirs): 

246 continue 

247 out.append(f) 

248 return out 

249 

250 def relative(self, path: Path) -> str: 

251 try: 

252 return str(path.relative_to(self.project_dir)) if self.project_dir else path.name 

253 except ValueError: 

254 return str(path) 

255 

256 def _fill(self, *_): 

257 files = self.shown_files() 

258 if not self.files: 

259 return 

260 if not files: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 self.show_message("No coverage for the project's files: did the tests run code from them? " 

262 "(Include tests and Include dependencies list the others.)") 

263 return 

264 self.empty.hide() 

265 self.splitter.show() 

266 muted = self.palette_colors["muted"] 

267 t = cov.totals(files) 

268 functions = [f for file in files for f in shown_functions(file)] 

269 parts = [] 

270 for name, (hit, found) in (("lines", t["lines"]), 

271 ("functions", (sum(f.hits > 0 for f in functions), len(functions))), 

272 ("branches", t["branches"])): 

273 if found: 

274 percent = 100 * hit / found 

275 parts.append(f"<b style='color:{color_for(percent)}'>{percent:.1f}%</b> " 

276 f"<span style='color:{muted}'>{name} ({hit:,} of {found:,})</span>") 

277 self.totals.setText("&nbsp;&nbsp;".join(parts)) 

278 hit, found = t["lines"] 

279 self.progress.set(hit / found if found else 0, "ok" if found and hit == found else "running") 

280 selected = self._selected_path() 

281 self.table.setSortingEnabled(False) 

282 self.table.setRowCount(len(files)) 

283 for row, f in enumerate(sorted(files, key=lambda f: self.relative(f.path))): 

284 name = QTableWidgetItem(self.relative(f.path)) # (sorted by its text) 

285 if self.icon_for is not None: 

286 name.setIcon(self.icon_for(f.path)) 

287 name.setData(Qt.UserRole, str(f.path)) 

288 shown = shown_functions(f) 

289 fn_hit = sum(x.hits > 0 for x in shown) 

290 items = [ 

291 name, 

292 NumberItem(f"{f.percent:.1f}%", f.percent), 

293 NumberItem(f"{f.lines_hit} / {f.lines_found}", f.lines_hit), 

294 NumberItem(f"{fn_hit} / {len(shown)}" if shown else "—", fn_hit / len(shown) if shown else -1), 

295 ] 

296 items[1].setForeground(QColor(color_for(f.percent))) 

297 tip = row_tooltip(f) # (the whole row: its path and every missed line) 

298 for column, item in enumerate(items): 

299 item.setToolTip(tip) 

300 self.table.setItem(row, column, item) 

301 self.table.setSortingEnabled(True) # (sorted again, as the header says: the user's choice kept) 

302 if selected is not None: 

303 for row in range(self.table.rowCount()): 303 ↛ 307line 303 didn't jump to line 307 because the loop on line 303 didn't complete

304 if self.table.item(row, 0).data(Qt.UserRole) == selected: 

305 self.table.selectRow(row) 

306 break 

307 self._show_details() 

308 

309 # ---------------------------------------------------------- details 

310 def _selected_path(self) -> str | None: 

311 rows = self.table.selectionModel().selectedRows() if self.table.selectionModel() else [] 

312 return self.table.item(rows[0].row(), 0).data(Qt.UserRole) if rows else None 

313 

314 def _show_details(self): 

315 path = self._selected_path() 

316 f = self.files.get(Path(path)) if path else None 

317 if f is None: 

318 self.detail_title.setText("Select a file for its functions.") 

319 self.functions.setRowCount(0) 

320 return 

321 self.detail_title.setText(f"<b>{html.escape(self.relative(f.path))}</b> · {f.percent:.1f}% of its " 

322 f"{f.lines_found} lines with code ran") 

323 shown = shown_functions(f) 

324 try: # (what each function is, from its first line: the outline's badge for it) 

325 source = f.path.read_text(errors="replace").split("\n") 

326 except OSError: 

327 source = [] 

328 self.functions.setSortingEnabled(False) 

329 self.functions.setRowCount(len(shown)) 

330 for row, fn in enumerate(shown): 

331 name = QTableWidgetItem(function_name(fn.name)) 

332 kind = cov.procedure_kind(source, fn.line) 

333 if kind: 

334 name.setIcon(symbol_icon(kind)) 

335 name.setToolTip(kind) 

336 name.setData(Qt.UserRole, fn.line) 

337 calls = NumberItem(f"{fn.hits:,}" if fn.hits else "never", fn.hits) 

338 calls.setForeground(QColor(GOOD if fn.hits else POOR)) 

339 for column, item in enumerate((name, NumberItem(str(fn.line), fn.line), calls)): 

340 self.functions.setItem(row, column, item) 

341 self.functions.setSortingEnabled(True) 

342 self.functions.sortItems(1) 

343 

344 def _row_clicked(self, row: int, _column: int): 

345 path = self.table.item(row, 0).data(Qt.UserRole) 

346 f = self.files.get(Path(path)) 

347 if f is not None: 347 ↛ exitline 347 didn't return from function '_row_clicked' because the condition on line 347 was always true

348 missed = f.missed() 

349 self.open_requested.emit(path, missed[0][0] if missed else min(f.lines, default=1)) 

350 

351 def _function_clicked(self, row: int, _column: int): 

352 path = self._selected_path() 

353 if path: 

354 self.open_requested.emit(path, self.functions.item(row, 0).data(Qt.UserRole))