"""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())