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
« 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`)."""
7from __future__ import annotations
9import html
10import re
11from pathlib import Path
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)
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
40GOOD, FAIR, POOR = "#2f9e44", "#f08c00", "#e03131"
41MANGLED = re.compile(r"^_*(\w+?)_MOD_(\w+)$") # gfortran: __module_MOD_procedure
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
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")]
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>"
74def color_for(percent: float) -> str:
75 return GOOD if percent >= 80 else FAIR if percent >= 50 else POOR
78class NumberItem(QTableWidgetItem):
79 """A cell shown as text, sorted by a number."""
81 def __init__(self, text: str, value: float):
82 super().__init__(text)
83 self.value = value
85 def __lt__(self, other):
86 return self.value < getattr(other, "value", 0)
89class CoveragePanel(QWidget):
90 """Totals, a table of files, and the selected file's details."""
92 open_requested = Signal(str, int) # file, line
94 COLUMNS = ("FILE", "COVERAGE", "LINES", "FUNCTIONS")
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)
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)
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)
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)
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)
177 self.empty = QLabel()
178 self.empty.setAlignment(Qt.AlignCenter)
179 self.empty.setWordWrap(True)
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.")
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()
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()
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()
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()
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.")
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
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)
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(" ".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()
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
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)
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))
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))