Coverage for vibefpmgui/window/coverage.py: 82%
64 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"""Test coverage: collecting it after a test run, and showing it.
3In the Coverage panel and the editors' gutters (see `vibefpmgui.coverage`).
4"""
6from __future__ import annotations
8import time
9from pathlib import Path
11from .. import coverage as cov
12from ..background import call_in_background
13from ..editor import EditorTab
14from ..project_tree import fpm_folders
17class CoverageMixin:
18 """Test coverage (a part of `vibefpmgui.main_window.MainWindow`)."""
20 def _collect_coverage(self):
21 """After a test run with coverage: gcov reads the counts the tests
22 wrote (in the background), then the Coverage panel and the editors
23 show them."""
24 compiler = self.effective_compiler()[0]
25 gcov = cov.find_gcov(compiler)
26 if gcov is None: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 self.coverage_panel.show_message(f"Coverage needs gcov (for {compiler}): it isn't on the PATH. "
28 "(It comes with gfortran.)")
29 return
30 if self._coverage_collector is not None: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 return
32 self.coverage_panel.show_message("Collecting coverage…")
33 self._coverage_description = f"{time.strftime('%H:%M')} · {self._build_description()}"
34 collector = self._coverage_collector = cov.Collector(gcov, self.manifest_path.parent / "build")
35 call_in_background(collector.run, lambda result: self._coverage_collected(collector, *result), self)
37 def _stop_coverage(self):
38 """Stop gcov if it's still reading the counts (quitting, or another
39 project); its result is then dropped."""
40 collector, self._coverage_collector = self._coverage_collector, None
41 if collector is not None:
42 collector.stop()
44 def _coverage_collected(self, collector: cov.Collector, files: dict, error: str):
45 if collector is not self._coverage_collector:
46 return
47 self._coverage_collector = None
48 if error: 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true
49 self.coverage_panel.show_message(f"gcov couldn't read the coverage: {error}")
50 return
51 self._coverage_read(files)
53 def _coverage_read(self, files: dict):
54 if not self.manifest_path: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return
56 if not files: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 self.coverage_panel.show_message("The tests wrote no coverage counts: was the project built with "
58 "gfortran? (Coverage works with gfortran's --coverage.)")
59 return
60 self._apply_coverage(files)
61 tests = [Path(folder) for folder, role in fpm_folders(self.manifest_path).items() if role == "test"]
62 self.coverage_panel.show_report(files, self.manifest_path.parent, self._coverage_description, tests)
63 hit, found = cov.totals(self.coverage_panel.shown_files())["lines"]
64 if found: 64 ↛ exitline 64 didn't return from function '_coverage_read' because the condition on line 64 was always true
65 self.statusBar().showMessage(f"Coverage: {100 * hit / found:.1f}% of the lines with code ran "
66 "(see the Coverage panel)", 8000)
68 def _apply_coverage(self, files: dict):
69 """The coverage shown in the editors' gutters."""
70 self.coverage = files
71 for editor in self.editors():
72 self._show_coverage(editor)
74 def _go_to_missed(self, editor: EditorTab, forward: bool):
75 if not editor.editor.go_to_missed(forward):
76 self.statusBar().showMessage("No missed lines in this file" if editor.editor.coverage else
77 "No coverage for this file: turn on Build options → Coverage "
78 "and run the tests", 5000)
80 def _show_coverage(self, editor: EditorTab):
81 found = self._coverage_for(editor)
82 editor.editor.set_coverage(found.lines if found else None, found.line_branches if found else None)
84 def _coverage_for(self, editor: EditorTab) -> cov.FileCoverage | None:
85 """A file's coverage for its gutter, if its text is still what the
86 tests ran: not changed on disk since the run started, and no unsaved
87 changes (its line numbers could differ: the bars would be on the
88 wrong lines). An open file edited later drops its bars itself."""
89 found = self.coverage.get(editor.path)
90 if found is None or editor.is_dirty():
91 return None
92 try:
93 if editor.path.stat().st_mtime > self._coverage_started:
94 return None
95 except OSError:
96 return None
97 return found