Files
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

96 lines
2.6 KiB
Python

"""XPath coverage tracker for infamy test runs.
Writes covered xpaths to $NINEPM_LOG_PATH/xpath_coverage.log, one per line.
Call track_xpath(), track_module(), or track_dict() from transport methods.
"""
import os
import threading
_lock = threading.Lock()
_tracked: set[str] = set()
def _log_path() -> str | None:
log_dir = os.environ.get("NINEPM_LOG_PATH")
if not log_dir:
return None
return os.path.join(log_dir, "xpath_coverage.log")
def _flush(xpaths: list[str]) -> None:
path = _log_path()
if not path or not xpaths:
return
try:
with open(path, "a", encoding="utf-8") as f:
for x in xpaths:
f.write(x + "\n")
except OSError:
pass
def _record(xpaths: list[str]) -> None:
new: list[str] = []
with _lock:
for x in xpaths:
if x not in _tracked:
_tracked.add(x)
new.append(x)
_flush(new)
def _normalize(xpath: str) -> str:
"""Strip module prefix from every segment except the root.
pyang and the CSV always use /mod:top/child (no mid-path prefixes).
Tests sometimes pass /mod:top/other-mod:child; we canonicalize here.
"""
if not xpath or ":" not in xpath:
return xpath
segs = xpath.lstrip("/").split("/")
out = []
for i, seg in enumerate(segs):
out.append(seg if i == 0 else seg.split(":", 1)[-1])
return "/" + "/".join(out)
def track_xpath(xpath: str | None) -> None:
"""Record a direct xpath access (get_data, delete_xpath, call_action, ...)."""
if xpath:
_record([_normalize(xpath)])
def track_module(modname: str | None) -> None:
"""Record a module-level access (call_dict with no specific path)."""
if modname:
_record([f"/{modname}:*"])
def track_dict(modname: str | None, data: object) -> None:
"""Record xpaths implied by a config/rpc dict (put_config_dicts, ...)."""
if not modname or not isinstance(data, dict):
return
_record(list(_dict_to_xpaths(modname, data)))
def _dict_to_xpaths(modname: str, data: object, prefix: str = "") -> set[str]:
xpaths: set[str] = set()
if isinstance(data, dict):
for key, value in data.items():
node = key.split(":", 1)[-1]
if not prefix:
xpath = f"/{modname}:{node}"
else:
xpath = f"{prefix}/{node}"
xpaths.add(xpath)
xpaths.update(_dict_to_xpaths(modname, value, xpath))
elif isinstance(data, list):
for item in data:
xpaths.update(_dict_to_xpaths(modname, item, prefix))
return xpaths