vibefpmgui.coverage_panel
The Coverage tab: how much of the code the last test run ran (with
Build options → Coverage on): the totals for lines, functions and branches,
each file's numbers, and the selected file's missed lines and functions.
Clicking a file opens it at its first missed line; the editor's gutter
shows which lines ran (see vibefpmgui.coverage).
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__": 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: 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: 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()): 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: 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))
45def function_name(name: str) -> str: 46 """gfortran's name for a procedure, as written: ``add (module calc)``.""" 47 found = MANGLED.match(name) 48 if found: 49 return f"{found.group(2)} (module {found.group(1)})" 50 if name == "MAIN__": 51 return "(main program)" 52 return name
gfortran's name for a procedure, as written: add (module calc).
55def shown_functions(file: cov.FileCoverage) -> list[cov.FunctionCoverage]: 56 """A file's functions without the ``main`` gfortran adds to call a 57 program (it has no line of its own).""" 58 has_program = any(f.name == "MAIN__" for f in file.functions) 59 return [f for f in file.functions if not (has_program and f.name == "main")]
A file's functions without the main gfortran adds to call a
program (it has no line of its own).
62def row_tooltip(f: cov.FileCoverage) -> str: 63 """A file's row's tooltip: its path, and every range of lines the tests 64 missed (wrapped: a tooltip of one very long line runs off the screen).""" 65 ranges = f.missed() 66 if not ranges: 67 missed = "Every line with code ran." 68 else: 69 lines = sum(1 for n in f.lines.values() if n == 0) 70 missed = (f"<b>Missed lines</b> ({lines:,} in {len(ranges):,} range{'s' * (len(ranges) != 1)}):<br>" 71 f"{html.escape(cov.ranges_text(ranges, limit=len(ranges)))}") 72 return f"<p style='white-space:normal'>{html.escape(str(f.path))}<br><br>{missed}</p>"
A file's row's tooltip: its path, and every range of lines the tests missed (wrapped: a tooltip of one very long line runs off the screen).
79class NumberItem(QTableWidgetItem): 80 """A cell shown as text, sorted by a number.""" 81 82 def __init__(self, text: str, value: float): 83 super().__init__(text) 84 self.value = value 85 86 def __lt__(self, other): 87 return self.value < getattr(other, "value", 0)
A cell shown as text, sorted by a number.
__init__(self, other: PySide6.QtWidgets.QTableWidgetItem, /) -> None __init__(self, text: str, /, type: int = <ItemType.Type: 0>) -> None __init__(self, icon: PySide6.QtGui.QIcon | PySide6.QtGui.QPixmap, text: str, /, type: int = <ItemType.Type: 0>) -> None __init__(self, /, type: int = <ItemType.Type: 0>) -> None
Initialize self. See help(type(self)) for accurate signature.
90class CoveragePanel(QWidget): 91 """Totals, a table of files, and the selected file's details.""" 92 93 open_requested = Signal(str, int) # file, line 94 95 COLUMNS = ("FILE", "COVERAGE", "LINES", "FUNCTIONS") 96 97 def __init__(self, parent=None): 98 super().__init__(parent) 99 self.files: dict[Path, cov.FileCoverage] = {} 100 self.project_dir: Path | None = None 101 self.test_dirs: list[Path] = [] # the project's test folders (fpm.toml's, test/ by default) 102 self.icon_for = None # a file's QIcon, as in Project files (set by the window) 103 self.is_light = True 104 self.palette_colors = PALETTES["light"] 105 self.setObjectName("CoveragePanel") 106 self.setAttribute(Qt.WA_StyledBackground, True) 107 108 self.title_icon = TitleIcon("coverage") 109 self.title = QLabel("Coverage") 110 self.subtitle = StatusLabel() 111 self.dependencies_check = QCheckBox("Include dependencies") 112 self.dependencies_check.setToolTip("Also list the dependencies' files (and count them in the totals)") 113 self.dependencies_check.toggled.connect(self._fill) 114 self.tests_check = QCheckBox("Include tests") 115 self.tests_check.setToolTip("Also list the test programs (usually only the code they test matters)") 116 self.tests_check.toggled.connect(self._fill) 117 header = QHBoxLayout() 118 header.setSpacing(6) 119 header.addWidget(self.title_icon) 120 header.addWidget(self.title) 121 header.addSpacing(4) 122 header.addWidget(self.subtitle, 1) 123 header.addWidget(self.tests_check) 124 header.addSpacing(12) 125 header.addWidget(self.dependencies_check) 126 127 self.progress = ProgressLine(self) 128 self.totals = StatusLabel() 129 totals_row = QHBoxLayout() 130 totals_row.setSpacing(10) 131 totals_row.addWidget(self.progress, 1) 132 totals_row.addWidget(self.totals) 133 134 self.table = QTableWidget(0, len(self.COLUMNS)) 135 self.table.setHorizontalHeaderLabels(list(self.COLUMNS)) 136 self.table.verticalHeader().setVisible(False) 137 self.table.setShowGrid(False) 138 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 139 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 140 self.table.setSelectionMode(QAbstractItemView.SingleSelection) 141 self.table.setSortingEnabled(True) 142 self.table.setWordWrap(False) 143 hh = self.table.horizontalHeader() 144 hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 145 hh.setSectionResizeMode(0, QHeaderView.Stretch) 146 for column in (1, 2, 3): 147 hh.setSectionResizeMode(column, QHeaderView.ResizeToContents) 148 self.table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder) # (by file, at first) 149 self.table.itemSelectionChanged.connect(self._show_details) 150 self.table.cellClicked.connect(self._row_clicked) 151 152 self.detail_title = QLabel() 153 self.functions = QTableWidget(0, 3) 154 self.functions.setHorizontalHeaderLabels(["FUNCTION", "LINE", "CALLS"]) 155 self.functions.verticalHeader().setVisible(False) 156 self.functions.setShowGrid(False) 157 self.functions.setEditTriggers(QAbstractItemView.NoEditTriggers) 158 self.functions.setSelectionBehavior(QAbstractItemView.SelectRows) 159 self.functions.setSortingEnabled(True) 160 fh = self.functions.horizontalHeader() 161 fh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 162 fh.setSectionResizeMode(0, QHeaderView.Stretch) 163 fh.setSectionResizeMode(1, QHeaderView.ResizeToContents) 164 fh.setSectionResizeMode(2, QHeaderView.ResizeToContents) 165 self.functions.cellClicked.connect(self._function_clicked) 166 details = QWidget() 167 detail_layout = QVBoxLayout(details) 168 detail_layout.setContentsMargins(0, 4, 0, 0) 169 detail_layout.setSpacing(4) 170 detail_layout.addWidget(self.detail_title) 171 detail_layout.addWidget(self.functions, 1) 172 self.splitter = QSplitter(Qt.Vertical) 173 self.splitter.addWidget(self.table) 174 self.splitter.addWidget(details) 175 self.splitter.setStretchFactor(0, 3) 176 self.splitter.setStretchFactor(1, 2) 177 178 self.empty = QLabel() 179 self.empty.setAlignment(Qt.AlignCenter) 180 self.empty.setWordWrap(True) 181 182 layout = QVBoxLayout(self) 183 layout.setContentsMargins(14, 10, 14, 6) 184 layout.setSpacing(8) 185 layout.addLayout(header) 186 layout.addLayout(totals_row) 187 layout.addWidget(self.splitter, 1) 188 layout.addWidget(self.empty, 1) 189 self.refresh_fonts() 190 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 191 "ran shows here, and in the editor's gutter.") 192 193 # ------------------------------------------------------------ styling 194 def refresh_palette(self): 195 self.is_light = app_is_light() 196 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"] 197 self.setStyleSheet(panel_stylesheet(self.objectName(), pal)) 198 self.title_icon.draw(pal["text"], self.title) 199 for label in (self.subtitle, self.empty, self.detail_title): 200 label.setStyleSheet(f"color: {pal['muted']};") 201 self.progress.update() 202 self._fill() 203 204 def refresh_fonts(self): 205 font = QFont(QApplication.font()) 206 font.setPointSizeF(font.pointSizeF() + 2) 207 font.setWeight(QFont.Bold) 208 self.title.setFont(font) 209 self.refresh_palette() 210 211 # -------------------------------------------------------------- state 212 def show_message(self, message: str): 213 """No numbers to show (yet): why, or what's happening.""" 214 self.empty.setText(message) 215 self.empty.show() 216 self.splitter.hide() 217 218 def show_report(self, files: dict[Path, cov.FileCoverage], project_dir: Path, description: str = "", 219 test_dirs: list[Path] | None = None): 220 self.files = files 221 self.project_dir = project_dir.resolve() 222 self.test_dirs = [Path(d).resolve() for d in test_dirs or []] 223 self.subtitle.setText(description) 224 self._fill() 225 226 def clear(self): 227 self.files = {} 228 self.subtitle.clear() 229 self.totals.clear() 230 self.progress.set(0, "running") 231 self.table.setRowCount(0) 232 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 233 "ran shows here, and in the editor's gutter.") 234 235 def shown_files(self) -> list[cov.FileCoverage]: 236 """The files listed: the project's (and the dependencies' or the 237 test programs, if asked), each with code in it.""" 238 out = [] 239 for path, f in self.files.items(): 240 if not f.lines_found: 241 continue 242 inside = self.project_dir is not None and self.project_dir in path.parents 243 dependency = not inside or "dependencies" in path.relative_to(self.project_dir).parts[:2] 244 if dependency and not self.dependencies_check.isChecked(): 245 continue 246 if not self.tests_check.isChecked() and any(d == path.parent or d in path.parents for d in self.test_dirs): 247 continue 248 out.append(f) 249 return out 250 251 def relative(self, path: Path) -> str: 252 try: 253 return str(path.relative_to(self.project_dir)) if self.project_dir else path.name 254 except ValueError: 255 return str(path) 256 257 def _fill(self, *_): 258 files = self.shown_files() 259 if not self.files: 260 return 261 if not files: 262 self.show_message("No coverage for the project's files: did the tests run code from them? " 263 "(Include tests and Include dependencies list the others.)") 264 return 265 self.empty.hide() 266 self.splitter.show() 267 muted = self.palette_colors["muted"] 268 t = cov.totals(files) 269 functions = [f for file in files for f in shown_functions(file)] 270 parts = [] 271 for name, (hit, found) in (("lines", t["lines"]), 272 ("functions", (sum(f.hits > 0 for f in functions), len(functions))), 273 ("branches", t["branches"])): 274 if found: 275 percent = 100 * hit / found 276 parts.append(f"<b style='color:{color_for(percent)}'>{percent:.1f}%</b> " 277 f"<span style='color:{muted}'>{name} ({hit:,} of {found:,})</span>") 278 self.totals.setText(" ".join(parts)) 279 hit, found = t["lines"] 280 self.progress.set(hit / found if found else 0, "ok" if found and hit == found else "running") 281 selected = self._selected_path() 282 self.table.setSortingEnabled(False) 283 self.table.setRowCount(len(files)) 284 for row, f in enumerate(sorted(files, key=lambda f: self.relative(f.path))): 285 name = QTableWidgetItem(self.relative(f.path)) # (sorted by its text) 286 if self.icon_for is not None: 287 name.setIcon(self.icon_for(f.path)) 288 name.setData(Qt.UserRole, str(f.path)) 289 shown = shown_functions(f) 290 fn_hit = sum(x.hits > 0 for x in shown) 291 items = [ 292 name, 293 NumberItem(f"{f.percent:.1f}%", f.percent), 294 NumberItem(f"{f.lines_hit} / {f.lines_found}", f.lines_hit), 295 NumberItem(f"{fn_hit} / {len(shown)}" if shown else "—", fn_hit / len(shown) if shown else -1), 296 ] 297 items[1].setForeground(QColor(color_for(f.percent))) 298 tip = row_tooltip(f) # (the whole row: its path and every missed line) 299 for column, item in enumerate(items): 300 item.setToolTip(tip) 301 self.table.setItem(row, column, item) 302 self.table.setSortingEnabled(True) # (sorted again, as the header says: the user's choice kept) 303 if selected is not None: 304 for row in range(self.table.rowCount()): 305 if self.table.item(row, 0).data(Qt.UserRole) == selected: 306 self.table.selectRow(row) 307 break 308 self._show_details() 309 310 # ---------------------------------------------------------- details 311 def _selected_path(self) -> str | None: 312 rows = self.table.selectionModel().selectedRows() if self.table.selectionModel() else [] 313 return self.table.item(rows[0].row(), 0).data(Qt.UserRole) if rows else None 314 315 def _show_details(self): 316 path = self._selected_path() 317 f = self.files.get(Path(path)) if path else None 318 if f is None: 319 self.detail_title.setText("Select a file for its functions.") 320 self.functions.setRowCount(0) 321 return 322 self.detail_title.setText(f"<b>{html.escape(self.relative(f.path))}</b> · {f.percent:.1f}% of its " 323 f"{f.lines_found} lines with code ran") 324 shown = shown_functions(f) 325 try: # (what each function is, from its first line: the outline's badge for it) 326 source = f.path.read_text(errors="replace").split("\n") 327 except OSError: 328 source = [] 329 self.functions.setSortingEnabled(False) 330 self.functions.setRowCount(len(shown)) 331 for row, fn in enumerate(shown): 332 name = QTableWidgetItem(function_name(fn.name)) 333 kind = cov.procedure_kind(source, fn.line) 334 if kind: 335 name.setIcon(symbol_icon(kind)) 336 name.setToolTip(kind) 337 name.setData(Qt.UserRole, fn.line) 338 calls = NumberItem(f"{fn.hits:,}" if fn.hits else "never", fn.hits) 339 calls.setForeground(QColor(GOOD if fn.hits else POOR)) 340 for column, item in enumerate((name, NumberItem(str(fn.line), fn.line), calls)): 341 self.functions.setItem(row, column, item) 342 self.functions.setSortingEnabled(True) 343 self.functions.sortItems(1) 344 345 def _row_clicked(self, row: int, _column: int): 346 path = self.table.item(row, 0).data(Qt.UserRole) 347 f = self.files.get(Path(path)) 348 if f is not None: 349 missed = f.missed() 350 self.open_requested.emit(path, missed[0][0] if missed else min(f.lines, default=1)) 351 352 def _function_clicked(self, row: int, _column: int): 353 path = self._selected_path() 354 if path: 355 self.open_requested.emit(path, self.functions.item(row, 0).data(Qt.UserRole))
Totals, a table of files, and the selected file's details.
97 def __init__(self, parent=None): 98 super().__init__(parent) 99 self.files: dict[Path, cov.FileCoverage] = {} 100 self.project_dir: Path | None = None 101 self.test_dirs: list[Path] = [] # the project's test folders (fpm.toml's, test/ by default) 102 self.icon_for = None # a file's QIcon, as in Project files (set by the window) 103 self.is_light = True 104 self.palette_colors = PALETTES["light"] 105 self.setObjectName("CoveragePanel") 106 self.setAttribute(Qt.WA_StyledBackground, True) 107 108 self.title_icon = TitleIcon("coverage") 109 self.title = QLabel("Coverage") 110 self.subtitle = StatusLabel() 111 self.dependencies_check = QCheckBox("Include dependencies") 112 self.dependencies_check.setToolTip("Also list the dependencies' files (and count them in the totals)") 113 self.dependencies_check.toggled.connect(self._fill) 114 self.tests_check = QCheckBox("Include tests") 115 self.tests_check.setToolTip("Also list the test programs (usually only the code they test matters)") 116 self.tests_check.toggled.connect(self._fill) 117 header = QHBoxLayout() 118 header.setSpacing(6) 119 header.addWidget(self.title_icon) 120 header.addWidget(self.title) 121 header.addSpacing(4) 122 header.addWidget(self.subtitle, 1) 123 header.addWidget(self.tests_check) 124 header.addSpacing(12) 125 header.addWidget(self.dependencies_check) 126 127 self.progress = ProgressLine(self) 128 self.totals = StatusLabel() 129 totals_row = QHBoxLayout() 130 totals_row.setSpacing(10) 131 totals_row.addWidget(self.progress, 1) 132 totals_row.addWidget(self.totals) 133 134 self.table = QTableWidget(0, len(self.COLUMNS)) 135 self.table.setHorizontalHeaderLabels(list(self.COLUMNS)) 136 self.table.verticalHeader().setVisible(False) 137 self.table.setShowGrid(False) 138 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 139 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 140 self.table.setSelectionMode(QAbstractItemView.SingleSelection) 141 self.table.setSortingEnabled(True) 142 self.table.setWordWrap(False) 143 hh = self.table.horizontalHeader() 144 hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 145 hh.setSectionResizeMode(0, QHeaderView.Stretch) 146 for column in (1, 2, 3): 147 hh.setSectionResizeMode(column, QHeaderView.ResizeToContents) 148 self.table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder) # (by file, at first) 149 self.table.itemSelectionChanged.connect(self._show_details) 150 self.table.cellClicked.connect(self._row_clicked) 151 152 self.detail_title = QLabel() 153 self.functions = QTableWidget(0, 3) 154 self.functions.setHorizontalHeaderLabels(["FUNCTION", "LINE", "CALLS"]) 155 self.functions.verticalHeader().setVisible(False) 156 self.functions.setShowGrid(False) 157 self.functions.setEditTriggers(QAbstractItemView.NoEditTriggers) 158 self.functions.setSelectionBehavior(QAbstractItemView.SelectRows) 159 self.functions.setSortingEnabled(True) 160 fh = self.functions.horizontalHeader() 161 fh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) 162 fh.setSectionResizeMode(0, QHeaderView.Stretch) 163 fh.setSectionResizeMode(1, QHeaderView.ResizeToContents) 164 fh.setSectionResizeMode(2, QHeaderView.ResizeToContents) 165 self.functions.cellClicked.connect(self._function_clicked) 166 details = QWidget() 167 detail_layout = QVBoxLayout(details) 168 detail_layout.setContentsMargins(0, 4, 0, 0) 169 detail_layout.setSpacing(4) 170 detail_layout.addWidget(self.detail_title) 171 detail_layout.addWidget(self.functions, 1) 172 self.splitter = QSplitter(Qt.Vertical) 173 self.splitter.addWidget(self.table) 174 self.splitter.addWidget(details) 175 self.splitter.setStretchFactor(0, 3) 176 self.splitter.setStretchFactor(1, 2) 177 178 self.empty = QLabel() 179 self.empty.setAlignment(Qt.AlignCenter) 180 self.empty.setWordWrap(True) 181 182 layout = QVBoxLayout(self) 183 layout.setContentsMargins(14, 10, 14, 6) 184 layout.setSpacing(8) 185 layout.addLayout(header) 186 layout.addLayout(totals_row) 187 layout.addWidget(self.splitter, 1) 188 layout.addWidget(self.empty, 1) 189 self.refresh_fonts() 190 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 191 "ran shows here, and in the editor's gutter.")
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
194 def refresh_palette(self): 195 self.is_light = app_is_light() 196 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"] 197 self.setStyleSheet(panel_stylesheet(self.objectName(), pal)) 198 self.title_icon.draw(pal["text"], self.title) 199 for label in (self.subtitle, self.empty, self.detail_title): 200 label.setStyleSheet(f"color: {pal['muted']};") 201 self.progress.update() 202 self._fill()
212 def show_message(self, message: str): 213 """No numbers to show (yet): why, or what's happening.""" 214 self.empty.setText(message) 215 self.empty.show() 216 self.splitter.hide()
No numbers to show (yet): why, or what's happening.
218 def show_report(self, files: dict[Path, cov.FileCoverage], project_dir: Path, description: str = "", 219 test_dirs: list[Path] | None = None): 220 self.files = files 221 self.project_dir = project_dir.resolve() 222 self.test_dirs = [Path(d).resolve() for d in test_dirs or []] 223 self.subtitle.setText(description) 224 self._fill()
226 def clear(self): 227 self.files = {} 228 self.subtitle.clear() 229 self.totals.clear() 230 self.progress.set(0, "running") 231 self.table.setRowCount(0) 232 self.show_message("Turn on Build options → Coverage (gfortran) and run Test: which lines the tests " 233 "ran shows here, and in the editor's gutter.")
235 def shown_files(self) -> list[cov.FileCoverage]: 236 """The files listed: the project's (and the dependencies' or the 237 test programs, if asked), each with code in it.""" 238 out = [] 239 for path, f in self.files.items(): 240 if not f.lines_found: 241 continue 242 inside = self.project_dir is not None and self.project_dir in path.parents 243 dependency = not inside or "dependencies" in path.relative_to(self.project_dir).parts[:2] 244 if dependency and not self.dependencies_check.isChecked(): 245 continue 246 if not self.tests_check.isChecked() and any(d == path.parent or d in path.parents for d in self.test_dirs): 247 continue 248 out.append(f) 249 return out
The files listed: the project's (and the dependencies' or the test programs, if asked), each with code in it.