zcbot/windows-node/adapters/freecad.model.author@v1/acceptance.py

367 lines
12 KiB
Python

"""Fixed target-machine acceptance suite for FreeCAD 1.1.3."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
ADAPTER_ROOT = Path(__file__).resolve().parent
WORKER = ADAPTER_ROOT / "worker.py"
CASES = ("basic", "parameters", "boolean", "transform", "step_import", "workspace")
def _json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def _digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _model(case: str, revision: int = 1) -> dict[str, object]:
common: dict[str, object] = {
"type": "recipe",
"recipe_version": 1,
"title": f"FreeCAD acceptance {case}",
"unit": "mm",
}
if case == "basic":
common.update(
{
"steps": [
{"id": "box", "op": "box", "length": 20, "width": 14, "height": 8},
{"id": "cylinder", "op": "cylinder", "radius": 4, "height": 12},
{
"id": "cone",
"op": "cone",
"radius1": 5,
"radius2": 2,
"height": 9,
},
{"id": "sphere", "op": "sphere", "radius": 6},
{
"id": "torus",
"op": "torus",
"major_radius": 8,
"minor_radius": 2,
},
{
"id": "all",
"op": "combine",
"targets": ["box", "cylinder", "cone", "sphere", "torus"],
},
],
"result": "all",
}
)
elif case == "parameters":
common.update(
{
"parameters": {"length": 20 + revision, "radius": 4},
"steps": [
{
"id": "body",
"op": "box",
"length": {"parameter": "length"},
"width": 12,
"height": 8,
},
{
"id": "hole",
"op": "cylinder",
"radius": {"parameter": "radius"},
"height": 8,
},
{"id": "result", "op": "cut", "target": "body", "tools": ["hole"]},
],
"result": "result",
}
)
elif case == "boolean":
common.update(
{
"steps": [
{"id": "a", "op": "box", "length": 20, "width": 20, "height": 10},
{"id": "b0", "op": "box", "length": 12, "width": 12, "height": 12},
{"id": "b", "op": "translate", "target": "b0", "vector": [8, 8, 0]},
{"id": "union", "op": "union", "target": "a", "tools": ["b"]},
{
"id": "intersection",
"op": "intersect",
"target": "a",
"tools": ["b"],
},
{
"id": "result",
"op": "cut",
"target": "union",
"tools": ["intersection"],
},
],
"result": "result",
}
)
elif case == "transform":
common.update(
{
"steps": [
{"id": "base", "op": "box", "length": 20, "width": 8, "height": 5},
{
"id": "moved",
"op": "translate",
"target": "base",
"vector": [5, 2, 3],
},
{
"id": "result",
"op": "rotate",
"target": "moved",
"axis_point": [0, 0, 0],
"axis_direction": [0, 0, 1],
"angle_degrees": 30,
},
],
"result": "result",
}
)
elif case == "step_import":
common.update(
{
"steps": [
{"id": "imported", "op": "import_step", "input": "geometry"},
{
"id": "result",
"op": "translate",
"target": "imported",
"vector": [3, 0, 0],
},
],
"result": "result",
}
)
else:
return _model("parameters", revision)
common["preview"] = {"width": 800, "height": 600, "background": "#F7F9FC"}
return common
def _job(
root: Path, name: str, model: dict[str, object], step_input: Path | None = None
) -> dict[str, object]:
directory = root / name
directory.mkdir(parents=True)
artifact_id = str(uuid.uuid4())
inputs = []
if step_input is not None:
target = directory / "input" / "geometry" / step_input.name
target.parent.mkdir(parents=True)
shutil.copy2(step_input, target)
inputs = [{"key": "geometry", "artifact_id": artifact_id}]
request = {
"schema_version": 1,
"inputs": inputs,
"operation": {"model": model},
"outputs": [],
}
record = {
"job_id": str(uuid.uuid4()),
"lease_id": str(uuid.uuid4()),
"request_digest": hashlib.sha256(
json.dumps(request, sort_keys=True).encode()
).hexdigest(),
"request": request,
}
_json(directory / "request" / "request.json", record)
result = subprocess.run(
[sys.executable, str(WORKER), str(directory)],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=300,
)
terminal = (
json.loads((directory / "terminal.json").read_text(encoding="utf-8"))
if (directory / "terminal.json").exists()
else {}
)
passed = result.returncode == 0 and terminal.get("status") == "succeeded"
required = [
"model.FCStd",
"model.step",
"model-preview.png",
"model-recipe.json",
"model-manifest.json",
".meta/provenance.json",
]
passed = passed and all(
(directory / "output" / item).is_file() for item in required
)
return {
"name": name,
"passed": passed,
"directory": str(directory),
"returncode": result.returncode,
"terminal": terminal,
"stderr": result.stderr[-1000:],
}
def _freecad_pids() -> set[int]:
query = subprocess.run(
["tasklist.exe", "/FO", "CSV", "/NH", "/FI", "IMAGENAME eq FreeCAD*.exe"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
result: set[int] = set()
for line in query.stdout.splitlines():
parts = [item.strip('"') for item in line.split('","')]
if len(parts) > 1 and parts[1].isdigit():
result.add(int(parts[1]))
return result
def _cancel_job(root: Path, baseline: set[int]) -> dict[str, object]:
directory = root / "cancel-dispatched-job"
request = {
"schema_version": 1,
"inputs": [],
"operation": {"model": _model("basic")},
"outputs": [],
}
record = {
"job_id": str(uuid.uuid4()),
"lease_id": str(uuid.uuid4()),
"request_digest": "cancel-test",
"request": request,
}
_json(directory / "request" / "request.json", record)
process = subprocess.Popen(
[sys.executable, str(WORKER), str(directory)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + 15
saw_child = False
while time.monotonic() < deadline and process.poll() is None:
if _freecad_pids() - baseline:
saw_child = True
break
time.sleep(0.1)
subprocess.run(
["taskkill.exe", "/PID", str(process.pid), "/T", "/F"],
check=False,
capture_output=True,
timeout=30,
)
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
return {"name": "cancel", "passed": False, "detail": "launcher did not exit"}
time.sleep(1)
return {"name": "cancel", "passed": saw_child and not (_freecad_pids() - baseline)}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--repeat", type=int, default=3)
args = parser.parse_args()
root = args.work_root.resolve()
if root.exists() or not root.parent.is_dir():
print(
"[ERR] --work-root must be a new directory under an existing parent.",
file=sys.stderr,
)
return 2
root.mkdir()
baseline_pids = _freecad_pids()
results: list[dict[str, object]] = []
source = _job(root, "00-step-source", _model("basic"))
results.append(source)
source_step = Path(source["directory"]) / "output" / "model.step"
for case in CASES[:-1]:
step = source_step if case == "step_import" else None
results.append(_job(root, f"case-{case}-空格-中文", _model(case), step))
first = _job(root, "workspace-revision-1", _model("workspace", 1))
second = _job(root, "workspace-revision-2", _model("workspace", 2))
workspace = root / "workspace"
current = workspace / "current"
rollback = workspace / "rollback"
shutil.copytree(Path(first["directory"]) / "output", current)
current.rename(rollback)
shutil.copytree(Path(second["directory"]) / "output", current)
rollback_hash = _digest(rollback / "model.FCStd")
shutil.rmtree(current)
rollback.rename(current)
workspace_passed = (
first["passed"]
and second["passed"]
and _digest(current / "model.FCStd") == rollback_hash
)
results.extend(
[
first,
second,
{"name": "workspace-revise-rollback", "passed": workspace_passed},
]
)
for index in range(max(1, min(args.repeat, 20))):
results.append(
_job(root, f"repeat-{index + 1:02d}", _model("parameters", index + 1))
)
results.append(_cancel_job(root, baseline_pids))
time.sleep(2)
residual = sorted(_freecad_pids() - baseline_pids)
report = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"passed": all(bool(item.get("passed")) for item in results) and not residual,
"cases": results,
"baseline_freecad_pids": sorted(baseline_pids),
"residual_freecad_pids": residual,
"coverage": [
"primitives",
"parameters",
"boolean",
"transform",
"STEP",
"STEP import",
"FCStd roundtrip",
"STEP roundtrip",
"workspace revise/rollback",
"cancellation",
"spaces/unicode paths",
"consecutive runs",
"process cleanup",
],
"note": "The cancellation case uses the same Windows process-tree termination semantics as the node supervisor.",
}
_json(root / "acceptance-report.json", report)
print(
"[OK] FreeCAD acceptance passed."
if report["passed"]
else "[ERR] FreeCAD acceptance failed."
)
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())