Files
infix/test/utils/coverage_report.py
Ejub Sabic 9853e08145 test: Added xpath coverage report generation (make test)
This commit introduces xpath coverage test report. It plugs into transport layer of test system and generates .md report.
Commit also introduces "make xpath-coverage-report" used to generate pdf out of md.

Resolves: #581
Signed-off-by: Ejub Sabic <ejub1946@outlook.com>
2026-06-15 12:26:51 +02:00

192 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Generate an XPath coverage report after a test run.
Usage:
coverage_report.py <xpaths_all.csv> <xpath_coverage.log> <output.md>
xpaths_all.csv - produced by extract_xpaths.py; columns: module,keyword,xpath
xpath_coverage.log - produced by infamy/coverage.py; one xpath per line
output.md - destination for the markdown report
"""
import csv
import os
import sys
from collections import defaultdict
from datetime import datetime
# Thresholds for colour coding
GREEN_THRESHOLD = 80 # %
ORANGE_THRESHOLD = 1 # % (anything >= 1 and < 80 is orange)
# Modules excluded from the report
SKIP_MODULES: set[str] = {
"infix-if-base", # submodule of infix-interfaces; only a structural choice node
}
def load_all_xpaths(csv_path: str) -> list[dict]:
rows = []
with open(csv_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["module"] not in SKIP_MODULES:
rows.append(row)
return rows
def load_tracked_xpaths(log_path: str) -> set[str]:
tracked: set[str] = set()
if not os.path.exists(log_path):
return tracked
with open(log_path, encoding="utf-8") as f:
for line in f:
x = line.strip()
if x:
tracked.add(x)
return tracked
def is_covered(yang_xpath: str, tracked: set[str], module: str,
xpath_module: dict[str, str]) -> bool:
""" Return True if yang_xpath was exercised by a test. """
if f"/{module}:*" in tracked:
return True
if yang_xpath in tracked:
return True
prefix = yang_xpath + "/"
for t in tracked:
if not prefix.startswith(t + "/"):
continue
# Determine the defining module of the tracked ancestor
t_module = xpath_module.get(t)
if t_module is None:
# Not in the CSV; infer from the root path segment (e.g. /mod:node → mod)
seg = t.lstrip("/").split("/")[0]
t_module = seg.split(":")[0] if ":" in seg else None
if t_module == module:
return True
return False
def build_coverage(all_xpaths: list[dict], tracked: set[str]) -> dict:
"""Return per-module coverage data.
Returns:
{module: {"total": int, "covered": int, "rows": [{"xpath", "keyword", "covered"}]}}
"""
xpath_module: dict[str, str] = {row["xpath"]: row["module"] for row in all_xpaths}
modules: dict[str, dict] = defaultdict(lambda: {"total": 0, "covered": 0, "rows": []})
for row in all_xpaths:
mod = row["module"]
xpath = row["xpath"]
kw = row["keyword"]
hit = is_covered(xpath, tracked, mod, xpath_module)
modules[mod]["total"] += 1
modules[mod]["covered"] += int(hit)
modules[mod]["rows"].append({"xpath": xpath, "keyword": kw, "covered": hit})
# Sort rows within each module by xpath
for mod_data in modules.values():
mod_data["rows"].sort(key=lambda r: r["xpath"])
return dict(sorted(modules.items()))
def status_emoji(pct: float) -> str:
if pct >= GREEN_THRESHOLD:
return "🟢"
if pct >= ORANGE_THRESHOLD:
return "🟠"
return "🔴"
def pct(covered: int, total: int) -> float:
return 100.0 * covered / total if total else 0.0
def write_markdown(coverage: dict, output_path: str) -> None:
total_all = sum(d["total"] for d in coverage.values())
covered_all = sum(d["covered"] for d in coverage.values())
overall_pct = pct(covered_all, total_all)
lines: list[str] = []
lines.append("# XPath Coverage Report\n")
lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d')}\n")
lines.append("")
# ── Overall summary ──────────────────────────────────────────────────────
lines.append("## Summary\n")
lines.append(
f"{status_emoji(overall_pct)} **Overall: "
f"{covered_all}/{total_all} XPaths covered "
f"({overall_pct:.1f}%)**\n"
)
lines.append("")
lines.append("| Status | Module | Covered | Total | % |")
lines.append("|--------|--------|--------:|------:|--:|")
for mod, d in sorted(coverage.items()):
p = pct(d["covered"], d["total"])
lines.append(
f"| {status_emoji(p)} | {mod} "
f"| {d['covered']} | {d['total']} | {p:.1f}% |"
)
lines.append("")
# ── Per-module detail ─────────────────────────────────────────────────────
lines.append("## Details\n")
for mod, d in coverage.items():
p = pct(d["covered"], d["total"])
lines.append(
f"### {status_emoji(p)} {mod} "
f"({d['covered']}/{d['total']}{p:.1f}%)\n"
)
lines.append("| XPath | Keyword | Covered |")
lines.append("|-------|---------|:-------:|")
for row in d["rows"]:
tick = "✅" if row["covered"] else "❌"
lines.append(f"| `{row['xpath']}` | {row['keyword']} | {tick} |")
lines.append("")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"Coverage report written to {output_path}")
def main() -> None:
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <xpaths_all.csv> <xpath_coverage.log> <output.md>")
sys.exit(1)
all_csv = sys.argv[1]
cov_log = sys.argv[2]
out_md = sys.argv[3]
if not os.path.exists(all_csv):
print(f"Error: XPath list not found: {all_csv}", file=sys.stderr)
sys.exit(1)
all_xpaths = load_all_xpaths(all_csv)
tracked = load_tracked_xpaths(cov_log)
coverage = build_coverage(all_xpaths, tracked)
write_markdown(coverage, out_md)
total_all = sum(d["total"] for d in coverage.values())
covered_all = sum(d["covered"] for d in coverage.values())
print(
f"Overall: {covered_all}/{total_all} XPaths covered "
f"({pct(covered_all, total_all):.1f}%)"
)
if __name__ == "__main__":
main()