The evidence for track H, in the shape tracks A, E, B + G and F used.
- benchmarks/tasks_v7.py: 21 tasks, DEFAULT_MATRIX = "v7"; v6 to v2 stay
addressable. Both new tasks read the synthetic plant pair and nothing
else. takeoff_roundtrip: the P&ID called schematic, every routable run's
layout length equal to the rectilinear MST of its tags, sizes and services
as drawn (a return drawn on the supply layer included), the cable metres
rounded up after the allowance, an unstated power left empty.
understand_foreign: inches over millimetres flagged, the stray entity found
by handle, two plan copies separated, five layers classified. A test breaks
each input and watches its gate fail by name.
- Five correctness checks, every number worked out by hand in its docstring;
the published A/B report is re-run over 48 checks: 26 / 48 -> 48 / 48,
zero regressed.
- scripts/smoke_understand_com.py reads the pair through the live engine -
snapshots, the DWG route, both takeoffs and a diff - and closes every
document it opens; the CHANGELOG records what it returned.
scripts/field_test_takeoff.py runs the takeoffs on a user's own files,
refuses to write inside the repository and prints no quantity by default.
- The README token table is remeasured; check_doc_numbers derives the task
count from the v7 matrix; CLAUDE.md, both READMEs, the CHANGELOG and the
spec status line say what shipped.
One reader defect found on the way: describe.tag_occurrences read a wiring
callout ("Wiring to CP-2") as a place its panel is written, so the scale
check called CP-2 ambiguous and read "Wiring to CP1" as a phantom tag CP1 -
10 tags and 45 pairs (13.3 % within 10 %) where the generator placed 11 and
55 (21.8 %). A callout is no longer a tag occurrence, the rule
network.tag_occurrences already kept; two tests failed before the fix.
The smoke works only in documents it opened from its own temporary folder,
known by full path; it attaches with GetActiveObject only (the backend's
Dispatch fallback would launch AutoCAD), checks in the same COM-thread call
that the active document is its own before each snapshot, the DWG save and
the move, closes its documents with Close(False) and re-activates the
document that was active when it started. No new ActiveX member: Documents,
Item, FullName, ActiveDocument, Activate and Close are the ones the backend
already calls. Measured on AutoCAD 2026 (25.1s): exit 0, model-space types
equal file / live / DWG, export 0.104 s, no document of the run left open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""Exercise track H once against the live AutoCAD on this machine.
|
|
|
|
Builds the synthetic plant pair (tests/fixtures/plant_pair.py) in a temporary
|
|
folder and reads it through the live engine - never an operator's drawing:
|
|
|
|
open pid.dxf -> take_snapshot(backend) (Document.Export, "DXF")
|
|
-> drawing_save_as(pid.dwg), close it
|
|
-> take_snapshot(backend, pid.dwg) (opened read-only, exported, closed)
|
|
open layout.dxf -> take_snapshot(backend)
|
|
pipe_rows / cable_rows on the two live snapshots, against the pair's truth
|
|
entity_move one tag text by 10 mm -> take_snapshot -> diff_snapshots
|
|
against the snapshot taken before the move
|
|
|
|
and prints what came back as JSON. Exit codes:
|
|
|
|
0 every live snapshot holds the same model-space entities (by type) as the
|
|
file it came from, the takeoffs match the truth, and the diff reports
|
|
exactly the one moved entity
|
|
1 it ran, but something disagreed (the JSON says what)
|
|
2 no live CAD application reachable (pywin32 missing, or no running
|
|
instance to attach to) -- the COM paths then remain fake-tested only
|
|
|
|
**The operator's drawings are never touched.** AutoCAD may have other
|
|
documents open, with unsaved work in them, while this runs. So:
|
|
|
|
* the smoke only ever attaches to a running instance (``GetActiveObject``);
|
|
it never creates one, because ``Dispatch`` would launch AutoCAD;
|
|
* every document it works in is one it brought in itself from the files it
|
|
just wrote into its own temporary folder, and it knows them by full path -
|
|
never by a name another document could share;
|
|
* every call that reads or writes *the current document* (a live snapshot, the
|
|
DWG save, the move) first checks, inside the same COM-thread call, that the
|
|
active document is the smoke's own - and refuses otherwise;
|
|
* on the way out (also when a step fails) it closes its own documents with
|
|
``Close(False)`` and re-activates the document that was active when it
|
|
started; unless ``--keep`` is given, then its documents stay open;
|
|
* nothing it prints names a document other than its own.
|
|
|
|
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_understand_com.py
|
|
uv run --frozen python scripts/smoke_understand_com.py --keep # leave them open
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
#: ``RPC_E_CALL_REJECTED``: AutoCAD's message filter refused the call because
|
|
#: it was busy (a document still initialising after ``Documents.Open``). A
|
|
#: refused call was not executed.
|
|
RPC_E_CALL_REJECTED = -2147418111
|
|
|
|
#: How long a refused call is retried before the smoke gives up on it.
|
|
REJECTED_PATIENCE_S = 90.0
|
|
|
|
#: Full paths (normalised) of the documents this run opened and has not closed.
|
|
_OPENED: list[str] = []
|
|
|
|
#: The run's state shared with the COM thread: the folder its files live in,
|
|
#: the document that must be active for the next current-document call (None:
|
|
#: the call does not touch the current document), and the document that was
|
|
#: active when the run started (a COM object, used on the COM thread only).
|
|
_STATE: dict = {"folder": None, "expect": None, "start": None}
|
|
|
|
|
|
class NotOurDocument(RuntimeError):
|
|
"""The active document is not the one the smoke is about to read or write."""
|
|
|
|
|
|
def _key(path) -> str:
|
|
return str(Path(str(path)).resolve()).lower()
|
|
|
|
|
|
def _ours(path) -> bool:
|
|
folder = _STATE["folder"]
|
|
return bool(path) and folder is not None and Path(_key(path)).parent == Path(_key(folder))
|
|
|
|
|
|
def _check_active() -> None:
|
|
"""COM thread: refuse unless the active document is the expected own one."""
|
|
expected = _STATE["expect"]
|
|
if expected is None:
|
|
return
|
|
from backends import com_backend as com
|
|
|
|
app = com._acad_app()
|
|
active = str(app.ActiveDocument.FullName) if int(app.Documents.Count) else ""
|
|
if not active or _key(active) != expected:
|
|
# Never name the other document: it may be an operator's drawing.
|
|
raise NotOurDocument(
|
|
f"refused: the active document is not the smoke's own {Path(expected).name}"
|
|
)
|
|
|
|
|
|
def _patient(backend) -> None:
|
|
"""Retry any call AutoCAD refused with ``RPC_E_CALL_REJECTED``, and run the
|
|
active-document check in the same COM-thread call as the call it guards."""
|
|
run = backend._run
|
|
|
|
async def patient(func, *args, **kwargs):
|
|
def guarded(*a, **kw):
|
|
_check_active()
|
|
return func(*a, **kw)
|
|
|
|
deadline = time.monotonic() + REJECTED_PATIENCE_S
|
|
pause = 0.5
|
|
while True:
|
|
try:
|
|
return await run(guarded, *args, **kwargs)
|
|
except RuntimeError as exc:
|
|
cause = exc.__cause__
|
|
hr = cause.args[0] if cause is not None and cause.args else None
|
|
if hr != RPC_E_CALL_REJECTED or time.monotonic() + pause > deadline:
|
|
raise
|
|
await asyncio.sleep(pause)
|
|
pause = min(pause * 2.0, 5.0)
|
|
|
|
backend._run = patient
|
|
|
|
|
|
class _Current:
|
|
"""``async with _Current(path):`` - calls inside it must find ``path`` active."""
|
|
|
|
def __init__(self, path) -> None:
|
|
self.path = _key(path)
|
|
|
|
async def __aenter__(self):
|
|
_STATE["expect"] = self.path
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
_STATE["expect"] = None
|
|
return False
|
|
|
|
|
|
async def _attach(backend) -> str | None:
|
|
"""Attach to the running instance only, and remember the active document.
|
|
|
|
Returns the application's version, or None when nothing is running (the
|
|
backend's own ``_acad_app`` would fall back to ``Dispatch`` and launch
|
|
AutoCAD - a smoke must never do that)."""
|
|
import win32com.client
|
|
|
|
import config
|
|
from backends import com_backend as com
|
|
|
|
def _sync():
|
|
try:
|
|
app = win32com.client.GetActiveObject(config.settings.cad_progid)
|
|
except Exception:
|
|
return None
|
|
com._COM_STATE["app"] = app
|
|
_STATE["start"] = app.ActiveDocument if int(app.Documents.Count) else None
|
|
return str(app.Version)
|
|
|
|
return await backend._run(_sync)
|
|
|
|
|
|
async def _open_paths(backend) -> set[str]:
|
|
"""Full paths of the open documents that are the smoke's own (COM thread)."""
|
|
|
|
def _sync():
|
|
from backends import com_backend as com
|
|
|
|
app = com._acad_app()
|
|
paths = (str(app.Documents.Item(i).FullName) for i in range(int(app.Documents.Count)))
|
|
return {_key(p) for p in paths if _ours(p)}
|
|
|
|
return await backend._run(_sync)
|
|
|
|
|
|
async def _open(backend, path) -> str:
|
|
"""``drawing_open`` of one of the smoke's own files; returns its key."""
|
|
if not _ours(path):
|
|
raise NotOurDocument(f"refused: {Path(path).name} is not in the smoke's own folder")
|
|
result = await backend.drawing_open(str(path))
|
|
key = _key(result.get("path") or path)
|
|
if key not in _OPENED:
|
|
_OPENED.append(key)
|
|
return key
|
|
|
|
|
|
async def _close(backend, key: str) -> None:
|
|
"""Close one of the smoke's own documents, unsaved (``Close(False)``)."""
|
|
if not _ours(key):
|
|
raise NotOurDocument("refused: not one of the smoke's own documents")
|
|
if key in await _open_paths(backend):
|
|
await backend.document_close(key, discard=True)
|
|
if key in _OPENED:
|
|
_OPENED.remove(key)
|
|
|
|
|
|
async def _close_leftovers(backend) -> None:
|
|
for key in list(_OPENED):
|
|
try:
|
|
await _close(backend, key)
|
|
except Exception as exc: # best effort on the way out
|
|
print(f"could not close {Path(key).name}: {type(exc).__name__}", file=sys.stderr)
|
|
if key in _OPENED:
|
|
_OPENED.remove(key)
|
|
|
|
|
|
async def _reactivate_start(backend) -> None:
|
|
"""Hand the seat back as it was: the starting document active again."""
|
|
|
|
def _sync():
|
|
start = _STATE["start"]
|
|
if start is None:
|
|
return
|
|
try:
|
|
start.Activate()
|
|
except Exception: # it may have been closed by its owner meanwhile
|
|
pass
|
|
|
|
try:
|
|
await backend._run(_sync)
|
|
except Exception as exc:
|
|
print(f"could not re-activate the starting document: {type(exc).__name__}", file=sys.stderr)
|
|
|
|
|
|
def _model(snapshot):
|
|
"""The snapshot with its model-space records only (AutoCAD adds a paper-space
|
|
viewport when it initialises a layout; the takeoffs read model space)."""
|
|
return dataclasses.replace(
|
|
snapshot, records=tuple(r for r in snapshot.records if r.space == "Model")
|
|
)
|
|
|
|
|
|
def _types(snapshot) -> dict[str, int]:
|
|
return dict(sorted(Counter(r.type for r in _model(snapshot).records).items()))
|
|
|
|
|
|
def _check_takeoffs(truth, pid, layout) -> dict:
|
|
from engineering.understand.network import build_network
|
|
from engineering.understand.scale import scale_check
|
|
from engineering.understand.takeoff import cable_rows, pipe_rows
|
|
|
|
scale = scale_check(pid, layout)
|
|
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
|
pipe = pipe_rows(build_network(pid), pid, layout, scale=scale, services=services)
|
|
found = {(r["service"], tuple(sorted(r["tags"]))): r for r in pipe["runs"]}
|
|
pipe_mismatch = []
|
|
for run in truth["pipe_runs"]:
|
|
got = found.get((run["service"], tuple(run["tags"])))
|
|
want = run["layout_length_mm"]
|
|
have = None if got is None else got.get("layout_m")
|
|
agrees = got is not None and got["status"] == run["status"]
|
|
if want is None:
|
|
agrees = agrees and have is None
|
|
else:
|
|
agrees = agrees and have is not None and abs(float(have) * 1000.0 - want) <= 1e-6
|
|
if not agrees:
|
|
pipe_mismatch.append({"run": run["id"], "want": want, "have": have})
|
|
rows = {row["tag"]: row for row in cable_rows(pid, layout)["rows"]}
|
|
cable_mismatch = [
|
|
want["tag"]
|
|
for want in truth["cable_rows"]
|
|
if rows.get(want["tag"], {}).get("cable_m") != want["roundup_m"]
|
|
or rows.get(want["tag"], {}).get("panel") != want["panel"]
|
|
]
|
|
return {
|
|
"scale_verdict": scale["verdict"],
|
|
"within_10pct": scale.get("within_10pct"),
|
|
"runs_checked": len(truth["pipe_runs"]),
|
|
"pipe_mismatch": pipe_mismatch,
|
|
"loads_checked": len(truth["cable_rows"]),
|
|
"cable_mismatch": cable_mismatch,
|
|
}
|
|
|
|
|
|
async def _smoke(backend, folder: Path) -> dict:
|
|
from engineering.understand.diff import diff_snapshots
|
|
from engineering.understand.snapshot import read_snapshot, take_snapshot
|
|
from tests.fixtures.plant_pair import build_plant_pair
|
|
|
|
truth = build_plant_pair(folder)
|
|
|
|
pid_key = await _open(backend, truth["pid"])
|
|
async with _Current(pid_key):
|
|
started = time.perf_counter()
|
|
live_pid = await take_snapshot(backend)
|
|
export_s = time.perf_counter() - started
|
|
dwg = folder / "pid.dwg"
|
|
await backend.drawing_save_as(str(dwg), "dwg")
|
|
# SaveAs renamed the document: it is now pid.dwg, and still the smoke's own.
|
|
dwg_key = _key(dwg)
|
|
_OPENED.append(dwg_key)
|
|
for key in (pid_key, dwg_key):
|
|
await _close(backend, key)
|
|
dwg_pid = await take_snapshot(backend, str(dwg))
|
|
|
|
layout_key = await _open(backend, truth["layout"])
|
|
async with _Current(layout_key):
|
|
live_layout = await take_snapshot(backend)
|
|
|
|
takeoffs = _check_takeoffs(truth, _model(live_pid), _model(live_layout))
|
|
|
|
tag = next(
|
|
r
|
|
for r in live_layout.records
|
|
if r.type == "TEXT" and r.layer == "TAGS" and r.space == "Model"
|
|
)
|
|
async with _Current(layout_key):
|
|
await backend.entity_move(tag.handle, 10.0, 0.0)
|
|
after = await take_snapshot(backend)
|
|
diff = diff_snapshots(_model(live_layout), _model(after))
|
|
await _close(backend, layout_key)
|
|
|
|
file_pid, file_layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
|
return {
|
|
"export_s": round(export_s, 3),
|
|
"live_pid_source": live_pid.source,
|
|
"dwg_pid_source": dwg_pid.source,
|
|
"model_space": {
|
|
"pid_file": _types(file_pid),
|
|
"pid_live": _types(live_pid),
|
|
"pid_dwg": _types(dwg_pid),
|
|
"layout_file": _types(file_layout),
|
|
"layout_live": _types(live_layout),
|
|
},
|
|
"paper_space_layouts": sorted({r.space for r in live_layout.records} - {"Model"}),
|
|
"takeoffs": takeoffs,
|
|
"diff": {
|
|
"moved": tag.handle,
|
|
"added": len(diff["added"]),
|
|
"removed": len(diff["removed"]),
|
|
"changed": len(diff["changed"]),
|
|
"moved_is_the_change": f'"{tag.handle}"' in json.dumps(diff["changed"]),
|
|
},
|
|
}
|
|
|
|
|
|
async def main(keep_document: bool = False) -> int:
|
|
try:
|
|
from backends.com_backend import ComBackend
|
|
except ImportError as exc:
|
|
print(f"pywin32 not available: {exc}")
|
|
return 2
|
|
backend = ComBackend()
|
|
try:
|
|
await backend.connect()
|
|
except Exception as exc:
|
|
print(f"no live AutoCAD reachable: {exc}")
|
|
return 2
|
|
_patient(backend)
|
|
folder = Path(tempfile.mkdtemp(prefix="acadmcp_understand_smoke_"))
|
|
_STATE["folder"] = str(folder)
|
|
try:
|
|
version = await _attach(backend)
|
|
if version is None:
|
|
print("no live AutoCAD reachable: no running instance to attach to")
|
|
return 2
|
|
report = {"autocad": version, **await _smoke(backend, folder)}
|
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
spaces = report["model_space"]
|
|
ok = (
|
|
spaces["pid_live"] == spaces["pid_file"] == spaces["pid_dwg"]
|
|
and spaces["layout_live"] == spaces["layout_file"]
|
|
and report["takeoffs"]["scale_verdict"] == "schematic"
|
|
and not report["takeoffs"]["pipe_mismatch"]
|
|
and not report["takeoffs"]["cable_mismatch"]
|
|
and report["diff"]["changed"] == 1
|
|
and report["diff"]["added"] == report["diff"]["removed"] == 0
|
|
and report["diff"]["moved_is_the_change"]
|
|
)
|
|
return 0 if ok else 1
|
|
finally:
|
|
_STATE["expect"] = None
|
|
if not keep_document:
|
|
await _close_leftovers(backend)
|
|
await _reactivate_start(backend)
|
|
_STATE["start"] = None
|
|
await backend.disconnect()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:])))
|