367 lines
12 KiB
Python
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())
|