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("&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()):
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))
MANGLED = re.compile('^_*(\\w+?)_MOD_(\\w+)$')
def function_name(name: str) -> str:
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).

def shown_functions( file: vibefpmgui.coverage.FileCoverage) -> list[vibefpmgui.coverage.FunctionCoverage]:
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).

def row_tooltip(f: vibefpmgui.coverage.FileCoverage) -> str:
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).

def color_for(percent: float) -> str:
75def color_for(percent: float) -> str:
76    return GOOD if percent >= 80 else FAIR if percent >= 50 else POOR
class NumberItem(PySide6.QtWidgets.QTableWidgetItem):
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.

NumberItem(text: str, value: float)
82    def __init__(self, text: str, value: float):
83        super().__init__(text)
84        self.value = value

__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.

value
class CoveragePanel(PySide6.QtWidgets.QWidget):
 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("&nbsp;&nbsp;".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.

CoveragePanel(parent=None)
 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.

def open_requested(unknown):

The type of the None singleton.

COLUMNS = ('FILE', 'COVERAGE', 'LINES', 'FUNCTIONS')
files: dict[pathlib.Path, vibefpmgui.coverage.FileCoverage]
project_dir: pathlib.Path | None
test_dirs: list[pathlib.Path]
icon_for
is_light
palette_colors
title_icon
title
subtitle
dependencies_check
tests_check
progress
totals
table
detail_title
functions
splitter
empty
def refresh_palette(self):
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()
def refresh_fonts(self):
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()
def show_message(self, message: str):
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.

def show_report( self, files: dict[pathlib.Path, vibefpmgui.coverage.FileCoverage], project_dir: pathlib.Path, description: str = '', test_dirs: list[pathlib.Path] | None = None):
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()
def clear(self):
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.")
def shown_files(self) -> list[vibefpmgui.coverage.FileCoverage]:
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.

def relative(self, path: pathlib.Path) -> str:
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)
staticMetaObject = PySide6.QtCore.QMetaObject("CoveragePanel" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(QString,int), parameters=QString, int )