"""Fixed offline acceptance harness for the 2D CAD adapter. This validates the production worker and deterministic outputs. It does not claim AutoCAD, ZWCAD, or GstarCAD interoperability; those remain manual target-machine gates. """ from __future__ import annotations import argparse import hashlib import importlib.util import json import shutil import subprocess import sys from pathlib import Path from uuid import uuid4 WORKER_PATH = Path(__file__).with_name("worker.py") SPEC = importlib.util.spec_from_file_location("zcbot_cad2d_worker", WORKER_PATH) assert SPEC and SPEC.loader worker = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(worker) def _base(title: str, sheet: str = "A3", drawing_type: str = "floor_plan") -> dict: walls = [ {"id": "south", "start": [0, 0], "end": [12000, 0], "thickness": 240, "kind": "exterior"}, {"id": "east", "start": [12000, 0], "end": [12000, 9000], "thickness": 240, "kind": "exterior"}, {"id": "north", "start": [12000, 9000], "end": [0, 9000], "thickness": 240, "kind": "exterior"}, {"id": "west", "start": [0, 9000], "end": [0, 0], "thickness": 240, "kind": "exterior"}, {"id": "partition", "start": [5000, 0], "end": [5000, 9000], "thickness": 120, "kind": "interior"}, ] return { "schema_version": 1, "inputs": [], "outputs": [], "operation": {"drawing": { "type": drawing_type, "recipe_version": 1, "title": title, "unit": "mm", "sheet": {"size": sheet, "orientation": "landscape", "scale": 50}, "walls": walls, "openings": [ {"id": "entry", "wall_id": "south", "offset": 900, "width": 1000, "type": "single_door", "hinge": "start", "swing": "in"}, {"id": "window1", "wall_id": "north", "offset": 1200, "width": 1800, "type": "window"}, ], "areas": [{"id": "room", "boundary": [[0,0],[12000,0],[12000,9000],[0,9000],[0,0]], "name": "房间", "label_position": [6000,4500], "show_area": True}], "objects": [{"id": "desk1", "type": "desk", "position": [2500,2500], "rotation": 0, "label": "试验台"}], "dimensions": [{"id": "overall1", "type": "overall", "points": [[0,0],[5000,0],[12000,0]], "offset": -700}], "notes": [{"id": "note1", "position": [8000,8000], "text": "尺寸单位:mm"}], }}, } def _cases() -> dict[str, tuple[dict, str | None]]: small = _base("中文小三居", "A3") small["operation"]["drawing"]["walls"] += [ {"id":"left_split","start":[0,4500],"end":[5000,4500],"thickness":120,"kind":"interior"}, {"id":"right_split","start":[5000,5200],"end":[12000,5200],"thickness":120,"kind":"interior"}, ] small["operation"]["drawing"]["areas"] = [ {"id":"bed1","boundary":[[0,0],[5000,0],[5000,4500],[0,4500],[0,0]],"name":"卧室一","label_position":[2500,3200],"show_area":True}, {"id":"bed2","boundary":[[0,4500],[5000,4500],[5000,9000],[0,9000],[0,4500]],"name":"卧室二","label_position":[2500,6800],"show_area":True}, {"id":"bed3","boundary":[[5000,5200],[12000,5200],[12000,9000],[5000,9000],[5000,5200]],"name":"卧室三","label_position":[8500,7200],"show_area":True}, {"id":"living","boundary":[[5000,0],[12000,0],[12000,5200],[5000,5200],[5000,0]],"name":"客餐厅","label_position":[8500,3000],"show_area":True}, ] two_room = _base("两室一厅", "A4") two_room["operation"]["drawing"]["sheet"]["scale"] = 100 lab = _base("实验室设备布置", "A2", "equipment_layout") lab["operation"]["drawing"]["objects"] += [ {"id": "hood", "type": "fume_hood", "position": [8000,2500], "label": "通风柜"}, {"id": "exit", "type": "safety_exit", "position": [10500,7000], "label": "安全出口"}, ] workshop = _base("车间布置", "A2", "equipment_layout") workshop["operation"]["drawing"]["objects"] = [ {"id": "machine1", "type": "machine", "position": [3000,3000], "size": [2200,1600], "label": "成型机"}, {"id": "tank1", "type": "tank", "position": [9000,5000], "size": [1800,1800], "label": "储罐"}, ] dense = _base("密集门窗尺寸", "A2") dense["operation"]["drawing"]["dimensions"] = [{"id":"chain1","type":"chain","points":[[x,0] for x in range(0,12001,1000)],"offset":-900}] outside = _base("门窗越界") outside["operation"]["drawing"]["openings"][0]["offset"] = 11900 overlap = _base("洞口重叠") overlap["operation"]["drawing"]["openings"].append({"id":"entry2","wall_id":"south","offset":1500,"width":1200,"type":"opening"}) unclosed = _base("区域不闭合") unclosed["operation"]["drawing"]["areas"][0]["boundary"][-1] = [1, 0] collision = _base("对象碰撞警告") collision["operation"]["drawing"]["objects"][0]["position"] = [1000, 500] return { "small_three_bedroom": (small, None), "two_bedroom_living": (two_room, None), "laboratory_layout": (lab, None), "workshop_layout": (workshop, None), "dense_openings_dimensions": (dense, None), "opening_outside_wall": (outside, "CAD2D_OPENING_OUTSIDE_WALL"), "overlapping_openings": (overlap, "CAD2D_OPENINGS_OVERLAP"), "unclosed_area": (unclosed, "CAD2D_AREA_NOT_CLOSED"), "object_collision_warning": (collision, None), } def _digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _execute(root: Path, name: str, request: dict, expected_error: str | None, repeat: int) -> dict: digests: list[dict[str, str]] = [] result: dict = {"name": name, "passed": False, "expected_error": expected_error} for index in range(repeat): job = root / f"验证 输出 {name}-{index + 1}" (job / "request").mkdir(parents=True) record = {"job_id": str(uuid4()), "lease_id": str(uuid4()), "request_digest": "a" * 64, "request": request} (job / "request" / "request.json").write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8") try: artifacts = worker.run(job) if expected_error: result["detail"] = "expected failure but worker succeeded" return result current = {item["filename"]: _digest(job / "output" / (".meta/provenance.json" if item["filename"] == "provenance.json" else item["filename"])) for item in artifacts if item["filename"] not in {"provenance.json", "drawing.pdf"}} digests.append(current) report = json.loads((job / "output" / "validation-report.json").read_text(encoding="utf-8")) if not report["passed"] or report["checks"]["version"] != "R2013": result["detail"] = "mechanical validation failed" return result except worker.DrawingError as exc: if exc.code != expected_error: result["detail"] = f"unexpected error: {exc.code}" return result result.update({"passed": True, "observed_error": exc.code}) return result result["passed"] = all(item == digests[0] for item in digests[1:]) result["deterministic_repeats"] = len(digests) result["warnings"] = report["warnings"] return result def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--work-root", type=Path, required=True) parser.add_argument("--repeat", type=int, default=3, choices=range(1, 6)) parser.add_argument("--case", action="append", choices=sorted(_cases())) args = parser.parse_args() if args.work_root.exists(): print("[ERR] --work-root must not already exist", file=sys.stderr) return 2 args.work_root.mkdir(parents=True) selected = args.case or list(_cases()) results = [_execute(args.work_root, name, *_cases()[name], args.repeat) for name in selected] # Cancellation/cleanup gate: terminate a child before it can touch a job directory. child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) child.terminate() try: child.wait(timeout=5) cancellation = {"passed": True, "returncode": child.returncode} except subprocess.TimeoutExpired: child.kill(); child.wait(); cancellation = {"passed": False, "returncode": child.returncode} report = { "passed": all(item["passed"] for item in results) and cancellation["passed"], "adapter_version": worker.ADAPTER_VERSION, "cases": results, "cancellation_and_cleanup": cancellation, "path_coverage": "work root supports spaces and Chinese characters", "manual_compatibility_pending": ["AutoCAD", "ZWCAD", "GstarCAD"], } (args.work_root / "acceptance-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") print("[OK] 2D CAD acceptance passed." if report["passed"] else "[ERR] 2D CAD acceptance failed.") return 0 if report["passed"] else 1 if __name__ == "__main__": raise SystemExit(main())