coverage-flashlight 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coverage_flashlight/__init__.py +1 -0
- coverage_flashlight/__main__.py +3 -0
- coverage_flashlight/bootstrap/sitecustomize.py +20 -0
- coverage_flashlight/cli.py +107 -0
- coverage_flashlight/config.py +98 -0
- coverage_flashlight/coverage.py +82 -0
- coverage_flashlight/coverage_flashlight.html +158 -0
- coverage_flashlight/execution_flashlight.html +256 -0
- coverage_flashlight/flashlight.css +52 -0
- coverage_flashlight/pytest_plugin.py +28 -0
- coverage_flashlight/runner.py +226 -0
- coverage_flashlight/trace.py +353 -0
- coverage_flashlight-1.0.0.dist-info/METADATA +150 -0
- coverage_flashlight-1.0.0.dist-info/RECORD +17 -0
- coverage_flashlight-1.0.0.dist-info/WHEEL +4 -0
- coverage_flashlight-1.0.0.dist-info/entry_points.txt +2 -0
- coverage_flashlight-1.0.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Coverage reports and opt-in ordered Python execution recording."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Opt-in child-process bootstrap; used only by the execution flashlight runner."""
|
|
2
|
+
|
|
3
|
+
import importlib.machinery
|
|
4
|
+
import importlib.util
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Preserve the environment's own startup customization.
|
|
10
|
+
_here = Path(__file__).parent.resolve()
|
|
11
|
+
_existing = importlib.machinery.PathFinder.find_spec(
|
|
12
|
+
"sitecustomize", [path for path in sys.path if Path(path).resolve() != _here]
|
|
13
|
+
)
|
|
14
|
+
if _existing is not None and _existing.loader is not None:
|
|
15
|
+
_existing.loader.exec_module(importlib.util.module_from_spec(_existing))
|
|
16
|
+
|
|
17
|
+
if os.environ.get("COVERAGE_FLASHLIGHT_DIR"):
|
|
18
|
+
from coverage_flashlight.trace import install
|
|
19
|
+
|
|
20
|
+
install()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The flashlight command; target arguments are passed through after --."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from coverage.exceptions import CoverageException
|
|
9
|
+
|
|
10
|
+
from coverage_flashlight.config import project_sources
|
|
11
|
+
from coverage_flashlight.runner import measure, render_coverage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv=None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="flashlight",
|
|
17
|
+
description="Explore Python coverage or replay executed lines.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--version", action="version", version=version("coverage-flashlight")
|
|
21
|
+
)
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
for name, help_text in [
|
|
24
|
+
("pytest", "Measure each selected pytest case"),
|
|
25
|
+
("run", "Execute a Python script or module once"),
|
|
26
|
+
]:
|
|
27
|
+
command = commands.add_parser(name, help=help_text)
|
|
28
|
+
command.add_argument(
|
|
29
|
+
"--trace",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="record ordered execution instead of line/branch coverage",
|
|
32
|
+
)
|
|
33
|
+
command.add_argument(
|
|
34
|
+
"--source",
|
|
35
|
+
action="append",
|
|
36
|
+
help="project-relative source file or directory; repeat for multiple paths",
|
|
37
|
+
)
|
|
38
|
+
command.add_argument(
|
|
39
|
+
"--output",
|
|
40
|
+
type=Path,
|
|
41
|
+
help="HTML report path (default: htmlcov/flashlight.html or execution.html)",
|
|
42
|
+
)
|
|
43
|
+
command.add_argument(
|
|
44
|
+
"--data-file",
|
|
45
|
+
type=Path,
|
|
46
|
+
default=Path(".coverage"),
|
|
47
|
+
help="coverage database to replace (coverage mode only)",
|
|
48
|
+
)
|
|
49
|
+
command.add_argument(
|
|
50
|
+
"--max-events",
|
|
51
|
+
type=int,
|
|
52
|
+
default=100_000,
|
|
53
|
+
help="maximum trace events per process",
|
|
54
|
+
)
|
|
55
|
+
if name == "run":
|
|
56
|
+
command.add_argument(
|
|
57
|
+
"-m",
|
|
58
|
+
"--module",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="run the target as a Python module",
|
|
61
|
+
)
|
|
62
|
+
command.add_argument(
|
|
63
|
+
"arguments", nargs=argparse.REMAINDER, help="target arguments, after --"
|
|
64
|
+
)
|
|
65
|
+
report = commands.add_parser(
|
|
66
|
+
"report", help="Render existing branch coverage without executing code"
|
|
67
|
+
)
|
|
68
|
+
report.add_argument("--data-file", type=Path, default=Path(".coverage"))
|
|
69
|
+
report.add_argument("--output", type=Path, default=Path("htmlcov/flashlight.html"))
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
try:
|
|
72
|
+
if args.command == "report":
|
|
73
|
+
if not args.data_file.is_file():
|
|
74
|
+
parser.error(f"Coverage database does not exist: {args.data_file}")
|
|
75
|
+
render_coverage(args.data_file, args.output, Path.cwd())
|
|
76
|
+
return 0
|
|
77
|
+
arguments = (
|
|
78
|
+
args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
|
|
79
|
+
)
|
|
80
|
+
if args.command == "run" and not arguments:
|
|
81
|
+
parser.error("run requires a script path or module name")
|
|
82
|
+
if args.max_events <= 0:
|
|
83
|
+
parser.error("--max-events must be positive")
|
|
84
|
+
sources = project_sources(Path.cwd(), trace=args.trace, override=args.source)
|
|
85
|
+
return measure(
|
|
86
|
+
arguments,
|
|
87
|
+
args.output
|
|
88
|
+
or Path(
|
|
89
|
+
"htmlcov/execution.html" if args.trace else "htmlcov/flashlight.html"
|
|
90
|
+
),
|
|
91
|
+
sources,
|
|
92
|
+
trace=args.trace,
|
|
93
|
+
pytest=args.command == "pytest",
|
|
94
|
+
module=getattr(args, "module", False),
|
|
95
|
+
max_events=args.max_events,
|
|
96
|
+
data_file=args.data_file,
|
|
97
|
+
)
|
|
98
|
+
except subprocess.CalledProcessError as error:
|
|
99
|
+
return error.returncode
|
|
100
|
+
except (ValueError, OSError, CoverageException) as error:
|
|
101
|
+
parser.error(str(error))
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
return 130
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Project-local source selection shared by both measurement modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
EXCLUDED_DIRS = {
|
|
11
|
+
".venv",
|
|
12
|
+
"venv",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"build",
|
|
15
|
+
"dist",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
"site-packages",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Sources:
|
|
22
|
+
def __init__(self, root: Path, paths: list[str], omit: list[str] | None = None):
|
|
23
|
+
self.root = root.resolve()
|
|
24
|
+
self.paths = [(self.root / path).resolve() for path in paths]
|
|
25
|
+
self.omit = omit or []
|
|
26
|
+
if not self.paths:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"source must contain at least one project-relative file or directory"
|
|
29
|
+
)
|
|
30
|
+
for path in self.paths:
|
|
31
|
+
if not path.is_relative_to(self.root) or not path.exists():
|
|
32
|
+
raise ValueError(f"Source must exist inside the project root: {path}")
|
|
33
|
+
|
|
34
|
+
def name(self, filename: str) -> str | None:
|
|
35
|
+
# Use the real platform's concrete path type, even when tests mock os.name.
|
|
36
|
+
path = type(self.root)(filename)
|
|
37
|
+
if not path.is_absolute() or not filename.endswith(".py"):
|
|
38
|
+
return None
|
|
39
|
+
if not any(
|
|
40
|
+
path == source or path.is_relative_to(source) for source in self.paths
|
|
41
|
+
):
|
|
42
|
+
return None
|
|
43
|
+
relative = path.relative_to(self.root)
|
|
44
|
+
if any(
|
|
45
|
+
part.startswith(".") or part in EXCLUDED_DIRS for part in relative.parts
|
|
46
|
+
):
|
|
47
|
+
return None
|
|
48
|
+
name = relative.as_posix()
|
|
49
|
+
if any(
|
|
50
|
+
fnmatch.fnmatchcase(name, pattern)
|
|
51
|
+
or fnmatch.fnmatchcase(path.as_posix(), pattern)
|
|
52
|
+
for pattern in self.omit
|
|
53
|
+
):
|
|
54
|
+
return None
|
|
55
|
+
return name
|
|
56
|
+
|
|
57
|
+
def snapshot(self) -> dict[str, list[str]]:
|
|
58
|
+
result = {}
|
|
59
|
+
for source in self.paths:
|
|
60
|
+
if source.is_file():
|
|
61
|
+
if name := self.name(str(source)):
|
|
62
|
+
result[name] = source.read_text(encoding="utf-8").splitlines()
|
|
63
|
+
continue
|
|
64
|
+
for directory, subdirs, filenames in os.walk(source):
|
|
65
|
+
subdirs[:] = [
|
|
66
|
+
name
|
|
67
|
+
for name in subdirs
|
|
68
|
+
if not name.startswith(".")
|
|
69
|
+
and name not in EXCLUDED_DIRS
|
|
70
|
+
and not (Path(directory) / name).is_symlink()
|
|
71
|
+
]
|
|
72
|
+
for filename in filenames:
|
|
73
|
+
path = Path(directory) / filename
|
|
74
|
+
if path.is_symlink():
|
|
75
|
+
continue
|
|
76
|
+
if name := self.name(str(path)):
|
|
77
|
+
result[name] = path.read_text(encoding="utf-8").splitlines()
|
|
78
|
+
return dict(sorted(result.items()))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def project_sources(
|
|
82
|
+
root: Path, *, trace: bool = False, override: list[str] | None = None
|
|
83
|
+
) -> Sources:
|
|
84
|
+
path = root / "pyproject.toml"
|
|
85
|
+
document = tomllib.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
86
|
+
settings = document.get("tool", {}).get("coverage-flashlight", {})
|
|
87
|
+
paths = override or settings.get(
|
|
88
|
+
"trace-source" if trace else "source", settings.get("source", ["."])
|
|
89
|
+
)
|
|
90
|
+
omit = settings.get("omit", [])
|
|
91
|
+
for name, value in [("source", paths), ("omit", omit)]:
|
|
92
|
+
if not isinstance(value, list) or not all(
|
|
93
|
+
isinstance(item, str) for item in value
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"tool.coverage-flashlight.{name} must be a list of strings"
|
|
97
|
+
)
|
|
98
|
+
return Sources(root, paths, omit)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Render measured line/branch coverage as a self-contained scenario explorer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from coverage import Coverage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_payload(cov: Coverage, root: Path) -> dict:
|
|
15
|
+
"""Use coverage.py's analysis rather than inferring executable code ourselves."""
|
|
16
|
+
root = root.resolve()
|
|
17
|
+
measured_files = [
|
|
18
|
+
name
|
|
19
|
+
for name in cov.get_data().measured_files()
|
|
20
|
+
if Path(name).resolve().is_relative_to(root)
|
|
21
|
+
]
|
|
22
|
+
if not measured_files:
|
|
23
|
+
raise ValueError("No measured source files inside the project root")
|
|
24
|
+
labels = sorted(cov.get_data().measured_contexts())
|
|
25
|
+
scopes = [("All recorded runs", None)]
|
|
26
|
+
scopes.extend(
|
|
27
|
+
(label or "Unlabelled run", [f"^{re.escape(label)}$"]) for label in labels
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
payload = {
|
|
31
|
+
"created": datetime.now(UTC).isoformat(),
|
|
32
|
+
"files": {},
|
|
33
|
+
"scopes": [],
|
|
34
|
+
"contexts": labels,
|
|
35
|
+
}
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="flashlight-report-") as directory:
|
|
37
|
+
report_path = Path(directory) / "report.json"
|
|
38
|
+
for index, (label, contexts) in enumerate(scopes):
|
|
39
|
+
cov.json_report(
|
|
40
|
+
morfs=measured_files,
|
|
41
|
+
outfile=str(report_path),
|
|
42
|
+
contexts=contexts,
|
|
43
|
+
show_contexts=True,
|
|
44
|
+
)
|
|
45
|
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
46
|
+
scope = {"label": label, "totals": report["totals"], "files": {}}
|
|
47
|
+
for filename, measured in report["files"].items():
|
|
48
|
+
path = Path(filename)
|
|
49
|
+
if not path.is_absolute():
|
|
50
|
+
path = root / path
|
|
51
|
+
name = path.relative_to(root).as_posix()
|
|
52
|
+
scope["files"][name] = {
|
|
53
|
+
"summary": measured["summary"],
|
|
54
|
+
"executed": measured["executed_lines"],
|
|
55
|
+
"missing_branches": measured.get("missing_branches", []),
|
|
56
|
+
}
|
|
57
|
+
if index == 0:
|
|
58
|
+
payload["files"][name] = {
|
|
59
|
+
"source": path.read_text(encoding="utf-8").splitlines(),
|
|
60
|
+
"statements": sorted(
|
|
61
|
+
measured["executed_lines"] + measured["missing_lines"]
|
|
62
|
+
),
|
|
63
|
+
"excluded": measured["excluded_lines"],
|
|
64
|
+
"contexts": measured.get("contexts", {}),
|
|
65
|
+
}
|
|
66
|
+
payload["scopes"].append(scope)
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render(payload: dict) -> str:
|
|
71
|
+
template = (
|
|
72
|
+
Path(__file__).with_name("coverage_flashlight.html").read_text(encoding="utf-8")
|
|
73
|
+
)
|
|
74
|
+
template = template.replace(
|
|
75
|
+
"__FLASHLIGHT_STYLE__",
|
|
76
|
+
Path(__file__).with_name("flashlight.css").read_text(encoding="utf-8"),
|
|
77
|
+
)
|
|
78
|
+
# Source files can contain literal </script> tags (notably the dashboard).
|
|
79
|
+
encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).replace(
|
|
80
|
+
"<", "\\u003c"
|
|
81
|
+
)
|
|
82
|
+
return template.replace("__COVERAGE_DATA__", encoded)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>Coverage flashlight</title>
|
|
6
|
+
<style>
|
|
7
|
+
__FLASHLIGHT_STYLE__
|
|
8
|
+
.legend { display:flex; gap:12px; flex-wrap:wrap; font-size:11px; margin-top:5px; color:var(--muted); }
|
|
9
|
+
.legend span::before { content:""; display:inline-block; width:9px; height:9px; border:1px solid var(--border); margin-right:4px; }
|
|
10
|
+
.legend .lit::before { background:var(--hit); }
|
|
11
|
+
.legend .partial::before { background:var(--partial); }
|
|
12
|
+
.legend .miss::before { background:var(--miss); }
|
|
13
|
+
.legend .elsewhere::before { background:var(--elsewhere); }
|
|
14
|
+
main { grid-template-columns:minmax(260px,310px) minmax(0,1fr); }
|
|
15
|
+
aside { min-height:0; padding:6px 10px; border-right:1px solid var(--border); overflow:auto; overscroll-behavior:contain; }
|
|
16
|
+
table { border-collapse:collapse; width:100%; }
|
|
17
|
+
th { position:sticky; top:-6px; background:var(--bg); font-weight:500; color:var(--muted); text-align:right; font-size:11px; }
|
|
18
|
+
th:first-child { text-align:left; }
|
|
19
|
+
td { padding:2px 0; border-bottom:1px solid var(--border); font-variant-numeric:tabular-nums; }
|
|
20
|
+
td button { width:100%; border:0; text-align:left; background:none; font-size:12px; padding:5px 3px; overflow-wrap:anywhere; }
|
|
21
|
+
tr[aria-selected=true] { background:var(--elsewhere); }
|
|
22
|
+
.percent { min-width:53px; font-size:11px; text-align:right; }
|
|
23
|
+
.bar { height:3px; width:45px; margin:2px 0 2px auto; background:var(--border); }
|
|
24
|
+
.bar i { display:block; height:100%; background:var(--accent); }
|
|
25
|
+
#file-summary { margin:0; font-size:12px; }
|
|
26
|
+
.line.hit { background:var(--hit); }
|
|
27
|
+
.line.partial { background:var(--partial); }
|
|
28
|
+
.line.miss { background:var(--miss); }
|
|
29
|
+
.line.elsewhere { background:var(--elsewhere); }
|
|
30
|
+
.line.selected { outline:2px solid var(--accent); outline-offset:-2px; }
|
|
31
|
+
.line button { position:sticky; left:0; flex:0 0 56px; width:56px; border:0; border-radius:0; padding:0 8px; font:inherit; text-align:right; color:var(--muted); background:var(--panel); }
|
|
32
|
+
#line-detail { flex-shrink:0; overflow:auto; max-height:32px; font-size:11px; overflow-wrap:anywhere; color:var(--muted); }
|
|
33
|
+
#scope { flex:1; }
|
|
34
|
+
@media(max-width:850px) { aside { border-right:0; border-bottom:1px solid var(--border); } #totals { font-size:11px; } }
|
|
35
|
+
</style>
|
|
36
|
+
<header>
|
|
37
|
+
<h1>Coverage flashlight</h1>
|
|
38
|
+
<div class="controls">
|
|
39
|
+
<label for="scope">Measured scope</label><select id="scope"></select>
|
|
40
|
+
<span class="metrics" id="totals" aria-live="polite"></span>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="legend" aria-label="Line status legend">
|
|
43
|
+
<span class="lit" title="Executed in the selected scope">Executed</span><span class="partial" title="Executed line with an untested branch alternative">Branch gap</span>
|
|
44
|
+
<span class="elsewhere" title="Executed in another recorded scope">Other scope</span><span class="miss" title="Never executed in these runs">Unexecuted</span>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
<main>
|
|
48
|
+
<aside aria-label="Modules">
|
|
49
|
+
<table><thead><tr><th>Module</th><th>Lines</th><th>Branches</th></tr></thead><tbody id="modules"></tbody></table>
|
|
50
|
+
</aside>
|
|
51
|
+
<section class="source-panel" aria-label="Source coverage">
|
|
52
|
+
<div class="source-heading"><h2 id="filename"></h2><button id="next-gap" type="button" aria-label="Next uncovered line or branch">Next gap</button></div>
|
|
53
|
+
<p id="file-summary" class="metrics"></p>
|
|
54
|
+
<div id="code" aria-label="Source lines"></div>
|
|
55
|
+
<div id="line-detail" aria-live="polite">Select a line number to inspect its recorded scenarios and missing branch destinations.</div>
|
|
56
|
+
</section>
|
|
57
|
+
</main>
|
|
58
|
+
<footer><span id="generated"></span>
|
|
59
|
+
<details><summary>About coverage</summary><div class="help-content">
|
|
60
|
+
<p>Coverage records execution, not correctness. Imports and function definitions count as executable lines. A test can execute code without asserting its result.</p>
|
|
61
|
+
<p>Scopes include setup, teardown, and Python subprocesses started during that run. Background worker activity is attributed to the scenario, not to individual concurrent requests. A pre-existing GUI process is not captured automatically.</p>
|
|
62
|
+
<p>This view contains no execution counts, durations, call ordering, variable history, or native-code coverage. Branch destinations are line numbers within this file; “exit” means leaving the function. The source is a snapshot taken when this report was generated.</p>
|
|
63
|
+
</div></details>
|
|
64
|
+
</footer>
|
|
65
|
+
<script id="coverage-data" type="application/json">__COVERAGE_DATA__</script>
|
|
66
|
+
<script>
|
|
67
|
+
"use strict";
|
|
68
|
+
const data = JSON.parse(document.getElementById("coverage-data").textContent);
|
|
69
|
+
const scopeSelect = document.getElementById("scope");
|
|
70
|
+
const files = Object.keys(data.files).sort();
|
|
71
|
+
let currentFile = files[0];
|
|
72
|
+
let selectedLine = null;
|
|
73
|
+
const percent = (hit, total) => total ? `${(100 * hit / total).toFixed(1)}%` : "—";
|
|
74
|
+
const totalsText = s => `${percent(s.covered_lines, s.num_statements)} lines (${s.covered_lines}/${s.num_statements}) · ${percent(s.covered_branches, s.num_branches)} branches (${s.covered_branches}/${s.num_branches})`;
|
|
75
|
+
const scope = () => data.scopes[Number(scopeSelect.value)];
|
|
76
|
+
for (let index = 0; index < data.scopes.length; index++) {
|
|
77
|
+
const option = document.createElement("option");
|
|
78
|
+
option.value = index;
|
|
79
|
+
option.textContent = data.scopes[index].label;
|
|
80
|
+
scopeSelect.append(option);
|
|
81
|
+
}
|
|
82
|
+
function metricCell(hit, total) {
|
|
83
|
+
const td = document.createElement("td"); td.className = "percent";
|
|
84
|
+
td.textContent = percent(hit, total);
|
|
85
|
+
const bar = document.createElement("div"); bar.className = "bar";
|
|
86
|
+
const fill = document.createElement("i"); fill.style.width = `${total ? 100 * hit / total : 0}%`;
|
|
87
|
+
bar.append(fill); td.append(bar); return td;
|
|
88
|
+
}
|
|
89
|
+
function drawModules() {
|
|
90
|
+
const body = document.getElementById("modules"); body.replaceChildren();
|
|
91
|
+
for (const name of files) {
|
|
92
|
+
const row = document.createElement("tr"); row.setAttribute("aria-selected", String(name === currentFile));
|
|
93
|
+
const td = document.createElement("td");
|
|
94
|
+
const button = document.createElement("button"); button.type = "button";
|
|
95
|
+
button.textContent = name;
|
|
96
|
+
button.addEventListener("click", () => { currentFile = name; selectedLine = null; draw(); });
|
|
97
|
+
td.append(button); row.append(td);
|
|
98
|
+
const s = scope().files[name].summary;
|
|
99
|
+
row.append(metricCell(s.covered_lines, s.num_statements), metricCell(s.covered_branches, s.num_branches));
|
|
100
|
+
body.append(row);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function selectLine(line) {
|
|
104
|
+
selectedLine = line;
|
|
105
|
+
for (const row of document.querySelectorAll(".line.selected")) row.classList.remove("selected");
|
|
106
|
+
document.getElementById(`line-${line}`).classList.add("selected");
|
|
107
|
+
const labels = data.files[currentFile].contexts[String(line)] || [];
|
|
108
|
+
const missing = scope().files[currentFile].missing_branches.filter(([from]) => from === line).map(([,to]) => to < 0 ? "exit" : to);
|
|
109
|
+
const detail = document.getElementById("line-detail");
|
|
110
|
+
detail.textContent = `Line ${line} · ${labels.length ? "Recorded in: " + labels.join(", ") : "No recorded execution"}${missing.length ? " · Missing destinations in scope: " + missing.join(", ") : ""}`;
|
|
111
|
+
}
|
|
112
|
+
function drawSource() {
|
|
113
|
+
const measured = scope().files[currentFile];
|
|
114
|
+
const file = data.files[currentFile];
|
|
115
|
+
const statements = new Set(file.statements);
|
|
116
|
+
const hits = new Set(measured.executed);
|
|
117
|
+
const allHits = new Set(data.scopes[0].files[currentFile].executed);
|
|
118
|
+
const partial = new Set(measured.missing_branches.map(([from]) => from));
|
|
119
|
+
document.getElementById("filename").textContent = currentFile;
|
|
120
|
+
document.getElementById("filename").title = currentFile;
|
|
121
|
+
document.getElementById("file-summary").textContent = totalsText(measured.summary);
|
|
122
|
+
const code = document.getElementById("code"); code.replaceChildren();
|
|
123
|
+
const fragment = document.createDocumentFragment();
|
|
124
|
+
file.source.forEach((text, index) => {
|
|
125
|
+
const line = index + 1;
|
|
126
|
+
const row = document.createElement("div"); row.className = "line"; row.id = `line-${line}`;
|
|
127
|
+
if (statements.has(line)) row.classList.add(hits.has(line) ? partial.has(line) ? "partial" : "hit" : allHits.has(line) ? "elsewhere" : "miss");
|
|
128
|
+
const number = document.createElement("button"); number.type = "button"; number.textContent = line;
|
|
129
|
+
number.setAttribute("aria-label", `Inspect line ${line}`);
|
|
130
|
+
number.addEventListener("click", () => selectLine(line));
|
|
131
|
+
const content = document.createElement("code"); content.textContent = text || " ";
|
|
132
|
+
row.append(number, content); fragment.append(row);
|
|
133
|
+
});
|
|
134
|
+
code.append(fragment);
|
|
135
|
+
if (selectedLine !== null) selectLine(selectedLine);
|
|
136
|
+
else { code.scrollTop = 0; document.getElementById("line-detail").textContent = "Select a line number to inspect its recorded scenarios and missing branch destinations."; }
|
|
137
|
+
}
|
|
138
|
+
function draw() {
|
|
139
|
+
const s = scope().totals;
|
|
140
|
+
document.getElementById("totals").textContent = `${percent(s.covered_lines, s.num_statements)} lines · ${percent(s.covered_branches, s.num_branches)} branches`;
|
|
141
|
+
document.getElementById("totals").title = totalsText(s);
|
|
142
|
+
drawModules(); drawSource();
|
|
143
|
+
}
|
|
144
|
+
scopeSelect.addEventListener("change", draw);
|
|
145
|
+
document.getElementById("next-gap").addEventListener("click", () => {
|
|
146
|
+
const measured = scope().files[currentFile];
|
|
147
|
+
const hits = new Set(measured.executed);
|
|
148
|
+
const partial = new Set(measured.missing_branches.map(([from]) => from));
|
|
149
|
+
const gaps = data.files[currentFile].statements.filter(line => !hits.has(line) || partial.has(line));
|
|
150
|
+
if (!gaps.length) { document.getElementById("line-detail").textContent = "No uncovered lines or branches in this module and scope."; return; }
|
|
151
|
+
const next = gaps.find(line => line > (selectedLine || 0)) || gaps[0];
|
|
152
|
+
selectLine(next);
|
|
153
|
+
document.getElementById(`line-${next}`).scrollIntoView({block:"center"});
|
|
154
|
+
});
|
|
155
|
+
document.getElementById("generated").textContent = `Measured Python execution · Report generated ${new Date(data.created).toLocaleString()} · ${files.length} modules · ${data.contexts.length} recorded scopes`;
|
|
156
|
+
draw();
|
|
157
|
+
</script>
|
|
158
|
+
</html>
|