Files
linuxcncWebWASM/scripts/generate_linuxcnc_source_test_matrix.py

127 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Inventory LinuxCNC source tests using the source runtests contract."""
from __future__ import annotations
import hashlib
import json
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "coverage/linuxcnc-source-test-s5.report.json"
TEST_ROOT = ROOT / "linuxcnc/tests"
RUNNER = ROOT / "linuxcnc/scripts/runtests"
UNIT_ROOT = ROOT / "linuxcnc/unit_tests"
CONFIG_ROOT = ROOT / "linuxcnc/configs/sim"
HOST_DIRS = {"linuxcncrsh", "linuxcncrsh-tcp", "mqtt", "hm2-idrom", "rtapi-shmem", "halmodule.0", "halmodule.1", "module-loading", "uspace", "lowlevel", "tclsh-extensions"}
SIM_ADAPTER_DIRS = {"abort", "ccomp", "build", "halui", "hard-limits", "io-startup", "lathe", "limit3", "mdi-queue", "mdi-queue-length", "mdi-while-queuebuster-waitflag", "motion", "motion-logger", "oword", "remap", "rs274ngc-startup", "single-step", "startup-state", "statbuffer-g5x-abort", "tlo", "tool-info", "toolchanger", "trajectory-planner"}
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def canonical(value: object) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def file_hashes(directory: Path) -> list[dict[str, object]]:
files = []
for path in sorted(p for p in directory.rglob("*") if p.is_file() and ".git" not in p.parts):
data = path.read_bytes()
files.append({"path": path.relative_to(ROOT).as_posix(), "bytes": len(data), "sha256": sha256(data)})
return files
def classify(directory: Path, test_file: Path) -> tuple[str, str]:
relative_parts = set(directory.relative_to(TEST_ROOT).parts)
names = {path.name for path in directory.iterdir() if path.is_file()}
if relative_parts & HOST_DIRS or "control" in names and "sudo" in directory.joinpath("control").read_text(encoding="utf-8", errors="replace"):
return "HOST_EXCLUDED", "host_capability_or_privilege"
if relative_parts & SIM_ADAPTER_DIRS:
return "WORKER_SIM_ADAPTER", "task_motion_or_ui_adapter_required"
if "skip" in names:
return "SOURCE_BLOCKED", "source_skip_marker"
if "expected" in names and test_file.name == "test.hal":
return "PURE_REGRESSION", "runtests_expected_stdout"
if "expected" in names and test_file.name in {"test", "test.sh"}:
return "PURE_REGRESSION", "runtests_expected_stdout"
if "checkresult" in names:
return "PURE_FUNCTIONAL", "runtests_checkresult"
if test_file.name == "test.sh" or test_file.name == "test":
return "WORKER_SIM_ADAPTER", "shell_or_executable_adapter_required"
return "SOURCE_BLOCKED", "expected_or_checkresult_missing"
def runner_command(test_file: Path) -> str:
relative = test_file.relative_to(ROOT).as_posix()
if test_file.name == "test.hal": return f"halrun -f {relative}"
if test_file.name == "test.sh": return f"bash -x {relative}"
return f"./{relative}"
def discover_tests() -> list[dict[str, object]]:
entries = []
for path in sorted(TEST_ROOT.rglob("*")):
if not path.is_file() or path.name not in {"test.hal", "test.sh", "test"}: continue
directory = path.parent
adapter, reason = classify(directory, path)
entries.append({
"source_id": f"source-test:{path.relative_to(ROOT).as_posix()}",
"path": path.relative_to(ROOT).as_posix(),
"test_directory": directory.relative_to(ROOT).as_posix(),
"test_file": path.name,
"adapter": adapter,
"reason": reason,
"runner_command": runner_command(path),
"has_expected": directory.joinpath("expected").is_file(),
"has_checkresult": directory.joinpath("checkresult").is_file(),
"has_readme": directory.joinpath("README").is_file(),
"xfail": directory.joinpath("xfail").is_file(),
"source_hash": sha256(canonical(file_hashes(directory))),
"files": file_hashes(directory),
})
return entries
def unit_entries() -> list[dict[str, object]]:
values = []
for suite, target, build in [("interp", "test_interp", "linuxcnc/meson.build"), ("tp", "test_blendmath", "linuxcnc/meson.build")]:
directory = UNIT_ROOT / suite
values.append({"source_id": f"source-unit:{suite}", "suite": suite, "target": target, "adapter": "UNIT_NATIVE_WASM", "runner_command": f"meson test -C build-linuxcnc {target}", "build_file": build, "source_hash": sha256(canonical(file_hashes(directory))), "files": file_hashes(directory)})
return values
def generate(output: Path = OUTPUT) -> dict[str, object]:
tests = discover_tests()
units = unit_entries()
config_paths = sorted(path.relative_to(ROOT).as_posix() for path in CONFIG_ROOT.rglob("*.ini"))
config_matrix = json.loads((ROOT / "coverage/full-configuration-startup-matrix.report.json").read_text(encoding="utf-8"))
adapter_counts = Counter(entry["adapter"] for entry in tests)
report: dict[str, object] = {
"schema": "linuxcnc-source-test-matrix.v1",
"task_id": "S5-01",
"context_id": json.loads((ROOT / "context-lock.json").read_text(encoding="utf-8"))["context_id"],
"status": "verified",
"runner": {"path": RUNNER.relative_to(ROOT).as_posix(), "sha256": sha256(RUNNER.read_bytes()), "semantics": ["find test.hal/test.sh/test", "halrun or bash/executable", "expected byte compare", "checkresult exit status", "skip/xfail/control handling"]},
"counts": {"test_entries": len(tests), "test_directories": len({entry["test_directory"] for entry in tests}), "unit_suites": len(units), "sim_ini_files": len(config_paths), "matrix_entrypoints": config_matrix["counts"]["entrypoints"], "matrix_registered_entries": config_matrix["counts"]["registered_entries"], "adapter_status": dict(sorted(adapter_counts.items()))},
"adapters": {"PURE_REGRESSION": "expected stdout byte comparison", "PURE_FUNCTIONAL": "checkresult-compatible structured result", "WORKER_SIM_ADAPTER": "real Worker/WASM logical tick adapter", "UNIT_NATIVE_WASM": "native and WASM unit target adapter", "HOST_EXCLUDED": "stable exclusion only; never counted as pass", "SOURCE_BLOCKED": "missing/skip source input retained"},
"source_tests": tests,
"unit_tests": units,
"sim_entrypoints": config_paths,
"next_tasks": ["S5-02 execute pure runtests subset", "S5-03 port interp/tp unit targets", "S5-04 port interp/remap tests", "S5-05 port HAL tests", "S5-06 port IO/startup/device tests", "S5-07 run all sim entrypoints"],
"boundaries": ["source tests requiring Linux RT, sudo, TCP, GUI or real devices are HOST_EXCLUDED", "missing expected/checkresult remains SOURCE_BLOCKED", "no derived test is promoted to official oracle"],
"report_sha256": None,
}
report["report_sha256"] = sha256(canonical({key: value for key, value in report.items() if key != "report_sha256"}))
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return report
if __name__ == "__main__":
report = generate()
print(f"linuxcnc_source_test_matrix={report['status']} tests={report['counts']['test_entries']} units={report['counts']['unit_suites']} sim_entries={report['counts']['matrix_entrypoints']}")