feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run

scripts/smoke_settings_com.py --build-dwt exited 0 on AutoCAD 2026 (25.1s),
2026-09-22: five templates/*.dwt written through drawing_template_save
(SaveAs code 66, each file opens with AC1032), page setup plotted and the
PDF's own /MediaBox read back as 420.158 x 297.039 mm, iso_a3_mech.dwt
reopened as source "bundled" with ISO-25 current, layer state / named view /
UCS / preferences / SummaryInfo / prompt line all through the COM engine.
The record is in CHANGELOG.md under Unreleased. The smoke keeps a ledger of
the documents it opens and closes them on the way out, so a failed run never
leaves scratch documents on the operator's seat. Two of its expectations
were corrected to the merged tree: mleaderstyle_create is implemented on COM
(spec: no `mleaderstyle` key), and plot_style_list marks catalogue rows with
`installed`. --interactive was not run unattended on the shared seat (an
unanswered GetPoint holds the command line); Task 23 measured the pick live.
*.dwt / *.dwg are binary in .gitattributes; AutoCAD's plot.log is ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 13:07:31 +03:00
co-authored by Claude Opus 5
parent b54271f4ac
commit 02dbb133b5
10 changed files with 855 additions and 1 deletions
+2
View File
@@ -2,3 +2,5 @@
* text=auto * text=auto
templates/*.dxf text eol=crlf templates/*.dxf text eol=crlf
templates/*.dwt binary templates/*.dwt binary
*.dwt binary
*.dwg binary
+4
View File
@@ -60,3 +60,7 @@ benchmarks/.competitors/
# committed one was stale and unvetted — but CI now installs from it with # committed one was stale and unvetted — but CI now installs from it with
# `uv sync --locked`, so ignoring it would silently disarm every locked lane. # `uv sync --locked`, so ignoring it would silently disarm every locked lane.
# Regenerate with `uv lock` whenever pyproject.toml dependencies change. # Regenerate with `uv lock` whenever pyproject.toml dependencies change.
# AutoCAD writes its plot-and-publish log into the cwd of the plotting process
# (scripts/smoke_settings_com.py plots from the repo root).
plot.log
+454
View File
@@ -174,6 +174,460 @@ AutoCAD type library (a package directory holding only a `__pycache__`) — an
environment fault, not a backend one; deleting `%TEMP%\gen_py\3.11` cleared environment fault, not a backend one; deleting `%TEMP%\gen_py\3.11` cleared
it and no backend change was needed. it and no backend change was needed.
### Live COM smoke — settings (track E)
`scripts/smoke_settings_com.py --build-dwt`, AutoCAD 2026 (`25.1s (LMS Tech)`),
Windows 11, 2026-09-22, in a new document (`Drawing27.dwg`) — styles →
page setup → `batch_plot` (the PDF's own `/MediaBox`) → the five `.dwt` twins
built and `iso_a3_mech.dwt` reopened → layer state round trip → named view →
UCS → preferences (one reversible set) → SummaryInfo → prompt line, all
through the COM engine, exit 0:
```json
{
"autocad": "25.1s (LMS Tech)",
"system_launch": {
"launched": false,
"attached": true,
"version": "25.1s (LMS Tech)",
"document": "Drawing9.dwg",
"visible": true,
"progid": "AutoCAD.Application",
"backend": "com"
},
"dwt_built": [
{
"name": "iso_a3_mech",
"path": "templates\\iso_a3_mech.dwt",
"bytes": 18240,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "iso_a1_arch",
"path": "templates\\iso_a1_arch.dwt",
"bytes": 17281,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "iso_a3_pid",
"path": "templates\\iso_a3_pid.dwt",
"bytes": 18528,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "ansi_b_mech",
"path": "templates\\ansi_b_mech.dwt",
"bytes": 17250,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "ansi_d_arch",
"path": "templates\\ansi_d_arch.dwt",
"bytes": 17278,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
}
],
"document": "Drawing27.dwg",
"styles": {
"dimstyle": {
"ok": true,
"name": "ISO-25-SMOKE",
"values": {
"DIMTXT": 2.5,
"DIMASZ": 2.5,
"DIMEXE": 1.25,
"DIMEXO": 0.625,
"DIMGAP": 0.625,
"DIMTAD": 1,
"DIMTIH": 0,
"DIMTOH": 0,
"DIMDEC": 2,
"DIMDSEP": ",",
"DIMLUNIT": 2,
"DIMZIN": 8,
"DIMBLK": "",
"DIMTXSTY": "ISOCP",
"DIMLWD": -2,
"DIMLWE": -2,
"DIMSCALE": 1.0
},
"written": [
"DIMASZ",
"DIMBLK",
"DIMDEC",
"DIMDSEP",
"DIMEXE",
"DIMEXO",
"DIMGAP",
"DIMLUNIT",
"DIMLWD",
"DIMLWE",
"DIMSCALE",
"DIMTAD",
"DIMTIH",
"DIMTOH",
"DIMTXSTY",
"DIMTXT",
"DIMZIN"
],
"current": true,
"textstyle_created": true
},
"textstyle": {
"ok": true,
"name": "ISOCP-SMOKE",
"font": "isocp.shx",
"font_resolved": true,
"current": false
},
"mleaderstyle": {
"ok": true,
"name": "SMOKE",
"values": {
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5
},
"textstyle_created": false
},
"mleaderstyles_on_seat": [
"SMOKE",
"Standard"
]
},
"page_setup": {
"applied": {
"paper": "ISO_A3",
"orientation": "landscape",
"size_mm": [
420.0,
297.0
],
"canonical_media_name": "ISO_A3_(420.00_x_297.00_MM)",
"plot_style": "monochrome.ctb",
"plot_style_known": true,
"scale": "1:1",
"scale_factor": 1.0,
"scale_ratio": [
1.0,
1.0
],
"dxf_standard_scale_type": 16,
"activex_standard_scale": 16,
"plot_area": "layout",
"plot_type": 5,
"device": "DWG To PDF.pc3",
"margins_mm": null,
"center": null,
"paper_units": "mm"
},
"changed": {
"paper": [
"",
"ISO_A3"
],
"canonical_media_name": [
"",
"ISO_A3_(420.00_x_297.00_MM)"
],
"size_mm": [
[
0.0,
0.0
],
[
420.0,
297.0
]
],
"plot_style": [
"",
"monochrome.ctb"
],
"device": [
"Microsoft Print to PDF",
"DWG To PDF.pc3"
],
"margins_mm": [
[
0.0,
0.0,
0.0,
0.0
],
[
17.0,
17.0,
5.0,
5.0
]
],
"paper_units": [
"inches",
"mm"
]
},
"pdf": {
"layout": "Layout1",
"ok": true,
"path": "C:\\Users\\VECTOR\\AppData\\Local\\Temp\\acadmcp_settings_smoke_sapms54v\\Drawing27-Layout1.pdf",
"bytes": 2774,
"mediabox_mm": [
420.158,
297.039
],
"paper": "ISO_A3"
},
"installed_plot_styles": [
"Autodesk-Color.stb",
"Autodesk-MONO.stb",
"DWF Virtual Pens.ctb",
"Fill Patterns.ctb",
"Grayscale.ctb",
"Screening 100%.ctb",
"Screening 25%.ctb",
"Screening 50%.ctb",
"Screening 75%.ctb",
"acad.ctb",
"acad.stb",
"monochrome.ctb",
"monochrome.stb"
]
},
"template": {
"path": "C:\\Users\\VECTOR\\Documents\\GitHub\\Autocad-MCP\\templates\\iso_a3_mech.dwt",
"source": "bundled",
"document": "Drawing28.dwg",
"dimstyle_current": [
"ISO-25"
],
"papers": [
"ISO_A3"
]
},
"documents": [
{
"name": "Drawing9.dwg",
"path": null,
"active": false,
"saved": false,
"entity_count": 0
},
{
"name": "Drawing27.dwg",
"path": null,
"active": true,
"saved": false,
"entity_count": 0
}
],
"layer_state": {
"saved": {
"ok": true,
"name": "SMOKE",
"layer_count": 3,
"replaced": false,
"chunks": 2,
"backend": "com"
},
"restored": {
"ok": true,
"name": "SMOKE",
"applied": {
"layers": 3,
"properties": [
"on",
"frozen",
"locked",
"color",
"linetype",
"lineweight",
"plot",
"current"
],
"current_layer": "0"
},
"missing_layers": [],
"new_layers": [],
"backend": "com"
},
"deleted": {
"ok": true,
"deleted": "SMOKE",
"backend": "com"
}
},
"view": {
"saved": {
"ok": true,
"name": "SMOKE_VIEW",
"center": [
100.0,
50.0
],
"height": 80.0,
"width": 160.9964412811388,
"replaced": false,
"backend": "com"
},
"restored": {
"ok": true,
"name": "SMOKE_VIEW",
"center": [
100.0,
50.0
],
"height": 80.0,
"width": 160.9964412811388,
"applied": "zoom_window",
"backend": "com",
"viewctr": [
100.0,
50.0
],
"viewsize": 80.0
}
},
"ucs": {
"set": {
"ok": true,
"name": "SMOKE_UCS",
"origin": [
10.0,
20.0,
0.0
],
"x_axis": [
1.0,
0.0,
0.0
],
"y_axis": [
0.0,
1.0,
0.0
],
"replaced": false,
"current": true,
"backend": "com"
},
"world": {
"ok": true,
"name": "world",
"current": true,
"backend": "com"
}
},
"preferences": {
"cursor_size": 5,
"read_only": [
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath"
],
"template_dwg_path": "C:\\Users\\VECTOR\\AppData\\Local\\Autodesk\\AutoCAD 2026\\R25.1\\enu\\Template"
},
"properties": {
"set": {
"ok": true,
"summary_written": [
"author",
"title"
],
"custom_written": [
"ACADMCP_SMOKE"
],
"custom_deleted": [],
"backend": "com"
},
"get": {
"summary": {
"title": "ACADMCP settings smoke",
"subject": "",
"author": "smoke_settings_com.py",
"keywords": "",
"comments": ""
},
"summary_available": true,
"custom": {
"ACADMCP_SMOKE": "1"
},
"backend": "com"
}
},
"prompt_message": {
"ok": true,
"text": "ACADMCP settings smoke: done",
"backend": "com"
},
"failures": []
}
```
The headless engine reports the identical `mediabox_mm` (to 1e-9: matplotlib
writes an exact 420 × 297; AutoCAD's PDF driver writes 420.158 × 297.039,
inside spec §9's ±0.5 mm), `layer_state.restored` and `properties.get.custom`
on the same steps; `summary_available` is `false` headlessly and the five
summary fields are `null` (capability `dwgprops`).
What the run refused before the green one, each fixed in the backend and
pinned by a fake-ActiveX test:
- Three runs died with `RPC_E_CALL_REJECTED` (-2147418111) on the read that
follows a document switch — `Documents.Count` straight after
`Documents.Open`, after `Close(False)` inside `document_close`, and in
`_ensure_document_state` — AutoCAD's own message filter refusing the call
while it still switches documents. pywin32 has no
`CoRegisterMessageFilter`, so the backend now waits that window out itself
(`_wait_out_rejected_call`, read-only callables only, 0.25 s → 1 s pauses
inside a 20 s budget) at those sites; any other error is not retried.
- `layer_list` on the reopened template raised `AttributeError: 'IAcadLayer'
object has no attribute 'Color'. Did you mean: 'color'?`. The type library
spells `IAcadEntity` / `IAcadLayer` `color` and `Lineweight`; late-bound
dispatch resolved `.Color` / `.LineWeight` case-insensitively, but once a
makepy cache exists (this machine's was regenerated on 2026-09-17) every
object a method returns is a generated wrapper and the lookup is exact. The
backend now spells both members as the type library does (works on both
wrappers) and the fakes model the measured names.
- `layer_state_restore` raised `'IAcadObject' object has no attribute
'Count'`: `Dictionaries.Item` and `IAcadDictionary.Item` are declared
`IAcadObject*` (the narrowing `_com_unnarrow` already handled for MLEADER
styles); the save before it went through the typed `Dictionaries.Add`, so
only the reopen paths failed. Both now un-narrow.
- A state saved with layer `0` current came back with two warnings and `0`
unapplied: the `Freeze` put on layer `0` raises 'Invalid layer' whatever
the value, even `False` while thawed (measured on a scratch document;
`color` / `Linetype` / `Lineweight` / `Plottable` / `LayerOn` / `Lock` /
`ActiveLayer` all fine on it). `Freeze` is now written only when it would
change the layer.
- Two of the smoke's own expectations predated the merged tree and were
corrected to it: `mleaderstyle_create` is implemented on COM through the
`ACAD_MLEADERSTYLE` dictionary (spec §4: no `mleaderstyle` key exists), so
the smoke now asserts the created style is listed with `arrow_size` 2.5;
and `plot_style_list` marks catalogue rows with `installed: true` (the
`source: "installed"` rows are files the catalogue does not know), so the
`monochrome.ctb` check reads that flag.
`--interactive` (`user_pick_point`) was not run in this session: the seat is
shared with other agents and nobody was confirmed at the keyboard, and an
unanswered `Utility.GetPoint` holds AutoCAD's command line — every COM client
gets `RPC_E_CALL_REJECTED` — until someone presses Esc, past the 60 s timeout.
The pick path was executed live earlier in the track (Task 23's measurement:
`GetPoint` answers in WCS while `GetEntity` answers in the current UCS). Each
plot also dropped AutoCAD's `plot.log` into the repo root; it is ignored now.
## [1.5.1] — 2026-08-06 ## [1.5.1] — 2026-08-06
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
+388
View File
@@ -0,0 +1,388 @@
# scripts/smoke_settings_com.py
"""Exercise the settings track once against the live AutoCAD on this machine.
Creates a NEW document (never touches the operator's open drawing) and runs
every SECTION 18-20 path that needs a live seat, through the same backend
methods the tools call:
system_launch (attach) -> {attached, launched, version}
dimstyle_create ISO-25 / textstyle_create ISOCP
/ mleaderstyle_create iso -> listed with arrow_size 2.5
page_setup_apply ISO_A3 -> batch_plot -> the PDF's own /MediaBox (420 x 297)
plot_style_list (installed .ctb files)
drawing_new(template=iso_a3_mech .dwt) -> mech layers, ISO-25 current, ISO_A3
document_list / document_activate
layer_state_save -> change -> layer_state_restore -> layer_state_delete
view_named_save / view_named_list / view_named_restore
ucs_set / ucs_list / ucs_restore("world") + a non-orthogonal refusal
preferences_get + ONE reversible preferences_set (Display.CursorSize)
+ a read-only key refused
drawing_properties_set / get (SummaryInfo + a custom property)
system_prompt_message
--interactive: user_pick_point (needs the operator at the keyboard)
--build-dwt: open each templates/<name>.dxf and save the .dwt twin
through drawing_template_save, before anything else
Prints one JSON report. Exit codes:
0 every assertion held (``failures`` is empty)
1 the seat answered but at least one assertion failed (listed)
2 no live CAD application reachable (pywin32 missing, or the ProgID
cannot be created) -- the COM paths then remain fake-tested only
Reachability is read from ``system_status()`` because ``connect()`` is lazy.
The ``Dispatch`` fallback *launches* the ProgID when nothing is running and a
cold start can exceed ``COM_CALL_TIMEOUT`` (60 s): open AutoCAD first.
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_settings_com.py
uv run --frozen python scripts/smoke_settings_com.py --build-dwt
uv run --frozen python scripts/smoke_settings_com.py --interactive --keep
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
#: The five bundled templates (spec §6). Pinned here rather than read from
#: the catalogue so the smoke also notices a catalogue that lost a row.
TEMPLATE_NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch")
#: Every DWG/DWT since R13 opens with "AC10xx" (AC1032 for the 2018 format).
DWG_MAGIC = b"AC10"
#: What the ISO A3 landscape sheet must read back as, from the PDF itself.
A3_LANDSCAPE_MM = (420.0, 297.0)
MEDIABOX_TOLERANCE_MM = 0.5
def _check(failures: list[str], label: str, ok: bool) -> None:
if not ok:
failures.append(label)
#: Names of the documents this run opened and has not closed yet. A run that
#: dies half-way closes them in ``main``'s ``finally`` (unless ``--keep``), so
#: a failed smoke never leaves its scratch documents on the operator's seat.
_OPENED: list[str] = []
async def _opened_now(backend) -> str:
"""Record the document a ``drawing_new`` / ``drawing_open`` just made active."""
name = (await backend.system_status()).get("active_document")
if name and name not in _OPENED:
_OPENED.append(name)
return name
async def _close_ours(backend, name: str) -> None:
await backend.document_close(name, discard=True)
if name in _OPENED:
_OPENED.remove(name)
async def _close_leftovers(backend) -> None:
open_names = {row["name"] for row in await backend.document_list()}
for name in list(_OPENED):
if name in open_names:
try:
await backend.document_close(name, discard=True)
except Exception as exc: # best effort on the way out
print(f"could not close {name}: {exc}", file=sys.stderr)
_OPENED.remove(name)
async def build_dwt(backend) -> list[dict]:
"""Open each bundled DXF over COM and save its .dwt twin next to it."""
from engineering.standards.templates import TEMPLATE_CATALOG, TEMPLATES_DIR
rows: list[dict] = []
if len(TEMPLATE_CATALOG) != len(TEMPLATE_NAMES):
raise SystemExit(
f"TEMPLATE_CATALOG has {len(TEMPLATE_CATALOG)} rows, the smoke pins "
f"{len(TEMPLATE_NAMES)}: update TEMPLATE_NAMES deliberately"
)
for name in TEMPLATE_NAMES:
dxf = Path(TEMPLATES_DIR).resolve() / f"{name}.dxf"
dwt = Path(TEMPLATES_DIR).resolve() / f"{name}.dwt"
if not dxf.exists():
raise SystemExit(f"{dxf} is missing: run scripts/build_templates.py first")
await backend.drawing_open(str(dxf))
opened_as = await _opened_now(backend)
saved = await backend.drawing_template_save(
str(dwt), name=name, description=f"AutoCAD MCP Pro bundled template {name}"
)
# SaveAs rebinds the document to the .dwt name (``document_rebound``).
if opened_as in _OPENED:
_OPENED.remove(opened_as)
await _close_ours(backend, await _opened_now(backend))
head = dwt.read_bytes()[:6]
rows.append(
{
"name": name,
"path": str(dwt.relative_to(ROOT)),
"bytes": dwt.stat().st_size,
"magic": head.decode("ascii", "replace"),
"is_dwg_container": head.startswith(DWG_MAGIC),
"format": saved["format"],
}
)
return rows
async def main(args: argparse.Namespace) -> 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
failures: list[str] = []
report: dict = {}
try:
status = await backend.system_status()
if not status.get("connected"):
print(f"no live AutoCAD reachable: {status.get('error', status)}")
return 2
report["autocad"] = status.get("autocad_version") or status.get("cad_progid")
report["system_launch"] = await backend.system_launch(visible=True)
_check(failures, "system_launch attached", report["system_launch"]["attached"] is True)
if args.build_dwt:
report["dwt_built"] = await build_dwt(backend)
_check(
failures,
"every .dwt is a DWG container",
all(row["is_dwg_container"] for row in report["dwt_built"]),
)
await backend.drawing_new()
smoke_document = await _opened_now(backend)
report["document"] = smoke_document
# ── styles ────────────────────────────────────────────────────────
from engineering.standards.dimstyles import resolve_dimstyle
from engineering.standards.mleaderstyles import resolve_mleaderstyle
created = await backend.dimstyle_create(
"ISO-25-SMOKE", resolve_dimstyle("iso-25", None), set_current=True
)
dimstyles = {row["name"]: row for row in await backend.dimstyle_list()}
smoke_style = dimstyles["ISO-25-SMOKE"]
_check(failures, "dimstyle DIMTXT 2.5", abs(smoke_style["values"]["DIMTXT"] - 2.5) < 1e-6)
_check(failures, "dimstyle DIMDSEP ','", smoke_style["values"]["DIMDSEP"] == ",")
_check(failures, "dimstyle current", smoke_style["current"] is True)
textstyle = await backend.textstyle_create("ISOCP-SMOKE", "isocp.shx")
_check(failures, "isocp.shx resolved on the seat", textstyle["font_resolved"] is True)
# Both engines create MLEADER styles (spec: COM through the
# ACAD_MLEADERSTYLE dictionary's AddObject; no `mleaderstyle` key).
mleader_created = await backend.mleaderstyle_create(
"SMOKE", resolve_mleaderstyle("iso", None)
)
mleader_styles = {row["name"]: row for row in await backend.mleaderstyle_list()}
_check(failures, "mleaderstyle created on COM", mleader_created["ok"] is True)
_check(failures, "mleaderstyle listed", "SMOKE" in mleader_styles)
_check(
failures,
"mleaderstyle ISO arrow 2.5",
abs(float(mleader_styles.get("SMOKE", {}).get("arrow_size", 0.0)) - 2.5) < 1e-6,
)
report["styles"] = {
"dimstyle": created,
"textstyle": textstyle,
"mleaderstyle": mleader_created,
"mleaderstyles_on_seat": sorted(mleader_styles),
}
# ── page setup + the PDF's own mediabox ───────────────────────────
from engineering.standards.papers import resolve_page_setup
from engineering.standards.plot import batch_plot
applied = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ISO_A3", orientation="landscape", scale="1:1")
)
listed = (await backend.page_setup_list("Layout1"))[0]
_check(
failures,
"page_setup_list says 420 x 297",
tuple(float(v) for v in listed["size_mm"]) == A3_LANDSCAPE_MM,
)
out_dir = Path(tempfile.mkdtemp(prefix="acadmcp_settings_smoke_"))
plotted = await batch_plot(backend, ["Layout1"], str(out_dir), "{drawing}-{layout}.pdf")
pdf_row = plotted["sheets"][0]
width_mm, height_mm = (float(v) for v in pdf_row["mediabox_mm"])
_check(
failures,
"PDF /MediaBox is 420 x 297 (+-0.5 mm)",
abs(width_mm - A3_LANDSCAPE_MM[0]) < MEDIABOX_TOLERANCE_MM
and abs(height_mm - A3_LANDSCAPE_MM[1]) < MEDIABOX_TOLERANCE_MM,
)
# Catalogue rows carry ``installed`` (True/False, None when the
# Preferences object is unreachable); ``source: "installed"`` marks
# files the catalogue does not know.
installed = sorted(
row["name"] for row in await backend.plot_style_list() if row.get("installed") is True
)
_check(failures, "monochrome.ctb installed on the seat", "monochrome.ctb" in installed)
report["page_setup"] = {
"applied": applied["applied"],
"changed": applied["changed"],
"pdf": pdf_row,
"installed_plot_styles": installed,
}
# ── bundled template over COM ─────────────────────────────────────
from engineering.standards.templates import resolve_template
template_path, template_source = resolve_template("iso_a3_mech", "com")
opened = await backend.drawing_new(template=template_path)
await _opened_now(backend)
template_layers = {layer.name for layer in await backend.layer_list()}
current_dimstyles = [row["name"] for row in await backend.dimstyle_list() if row["current"]]
template_papers = [row["paper"] for row in await backend.page_setup_list()]
_check(
failures,
"template carries the mech layers",
{"GEOMETRY", "DIM", "CENTER", "HIDDEN", "TITLEBLOCK"} <= template_layers,
)
_check(failures, "template has ISO-25 current", current_dimstyles == ["ISO-25"])
_check(failures, "template has an ISO_A3 page setup", "ISO_A3" in template_papers)
report["template"] = {
"path": str(template_path),
"source": template_source,
"document": opened["name"],
"dimstyle_current": current_dimstyles,
"papers": template_papers,
}
await _close_ours(backend, opened["name"])
activated = await backend.document_activate(smoke_document)
_check(failures, "smoke document re-activated", activated["active"] == smoke_document)
report["documents"] = await backend.document_list()
# ── layer state round trip ────────────────────────────────────────
await backend.layer_create("SMOKE_A", color=1)
await backend.layer_create("SMOKE_B", color=2)
saved = await backend.layer_state_save("SMOKE", description="settings smoke")
await backend.layer_modify("SMOKE_A", color=5)
await backend.layer_freeze("SMOKE_B")
restored = await backend.layer_state_restore("SMOKE")
after = {layer.name: layer for layer in await backend.layer_list()}
_check(failures, "layer state restored the colour", after["SMOKE_A"].color == 1)
_check(failures, "layer state thawed SMOKE_B", after["SMOKE_B"].is_frozen is False)
_check(failures, "restore reports nothing missing", restored["missing_layers"] == [])
_check(
failures,
"restore applied every layer",
restored["applied"]["layers"] == saved["layer_count"],
)
_check(failures, "restore raised no warnings", "warnings" not in restored)
states = await backend.layer_state_list()
_check(failures, "layer state listed", any(row["name"] == "SMOKE" for row in states))
deleted = await backend.layer_state_delete("SMOKE")
report["layer_state"] = {"saved": saved, "restored": restored, "deleted": deleted}
# ── named view ────────────────────────────────────────────────────
view = await backend.view_named_save("SMOKE_VIEW", center=[100.0, 50.0], height=80.0)
views = await backend.view_named_list()
_check(failures, "named view listed", any(row["name"] == "SMOKE_VIEW" for row in views))
report["view"] = {"saved": view, "restored": await backend.view_named_restore("SMOKE_VIEW")}
# ── UCS ───────────────────────────────────────────────────────────
ucs = await backend.ucs_set(
"SMOKE_UCS", [10.0, 20.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]
)
ucs_rows = await backend.ucs_list()
_check(
failures,
"ucs current",
any(row["name"] == "SMOKE_UCS" and row["current"] for row in ucs_rows),
)
try:
await backend.ucs_set("SMOKE_BAD", [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0])
_check(failures, "non-orthogonal UCS refused", False)
except ValueError as exc:
_check(failures, "non-orthogonal UCS refusal names the angle", "45" in str(exc))
report["ucs"] = {"set": ucs, "world": await backend.ucs_restore("world")}
# ── preferences: get, one reversible set, a read-only refusal ─────
prefs = await backend.preferences_get()
cursor = int(prefs["values"]["Display.CursorSize"])
probe = cursor + 1 if cursor < 100 else cursor - 1
bumped = await backend.preferences_set("Display.CursorSize", probe)
reverted = await backend.preferences_set("Display.CursorSize", cursor)
_check(failures, "preference set reports old", bumped["old"] == cursor)
_check(failures, "preference set reports new", bumped["new"] == probe)
_check(failures, "preference reverted", reverted["new"] == cursor)
_check(failures, "read-only keys listed", "Files.SupportPath" in prefs["read_only"])
try:
await backend.preferences_set("Files.SupportPath", "C:/nope")
_check(failures, "read-only preference refused", False)
except ValueError:
_check(failures, "read-only preference refused", True)
report["preferences"] = {
"cursor_size": cursor,
"read_only": prefs["read_only"],
"template_dwg_path": prefs["values"].get("Files.TemplateDwgPath"),
}
# ── SummaryInfo ───────────────────────────────────────────────────
written = await backend.drawing_properties_set(
summary={"title": "ACADMCP settings smoke", "author": "smoke_settings_com.py"},
custom={"ACADMCP_SMOKE": "1"},
)
props = await backend.drawing_properties_get()
_check(failures, "summary available on COM", props["summary_available"] is True)
_check(failures, "title round trip", props["summary"]["title"] == "ACADMCP settings smoke")
_check(failures, "custom property round trip", props["custom"].get("ACADMCP_SMOKE") == "1")
report["properties"] = {"set": written, "get": props}
# ── operator prompt line ──────────────────────────────────────────
report["prompt_message"] = await backend.system_prompt_message(
"ACADMCP settings smoke: done"
)
if args.interactive:
picked = await backend.user_pick_point("ACADMCP smoke: pick any point (Esc cancels)")
report["user_pick_point"] = picked
_check(
failures,
"pick returns a point, cancelled or timed_out",
bool(picked.get("cancelled"))
or bool(picked.get("timed_out"))
or ("x" in picked and "y" in picked),
)
report["failures"] = failures
print(json.dumps(report, indent=2, default=str))
if not args.keep:
await _close_ours(backend, smoke_document)
return 0 if not failures else 1
finally:
if not args.keep:
await _close_leftovers(backend)
await backend.disconnect()
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--build-dwt", action="store_true", help="build templates/*.dwt first")
parser.add_argument("--interactive", action="store_true", help="also run user_pick_point")
parser.add_argument("--keep", action="store_true", help="leave the smoke document open")
return parser
if __name__ == "__main__":
raise SystemExit(asyncio.run(main(_parser().parse_args())))
+7 -1
View File
@@ -26,7 +26,7 @@ listed by `drawing_template_list`. Start from one with
- `<name>.dwt` — the live twin, used by `drawing_new(template=…)` on the COM - `<name>.dwt` — the live twin, used by `drawing_new(template=…)` on the COM
backend. AutoCAD's DWT is a DWG container, which no headless library backend. AutoCAD's DWT is a DWG container, which no headless library
writes, so these are produced **on the live machine** by writes, so these are produced **on the live machine** by
`uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 25's
smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)` smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)`
(`SaveAs(path, ac2018_Template)`), then the files are committed once. While (`SaveAs(path, ac2018_Template)`), then the files are committed once. While
a `.dwt` is missing, `resolve_template` falls back to the DXF and reports a `.dwt` is missing, `resolve_template` falls back to the DXF and reports
@@ -43,3 +43,9 @@ to CRLF so a checkout on any platform equals a fresh build.
Rebuild after changing anything the build depends on (layer sets, the title Rebuild after changing anything the build depends on (layer sets, the title
block, the dimension presets, the settings facade) and commit the result block, the dimension presets, the settings facade) and commit the result
together with the change — the reproducibility test is the gate. together with the change — the reproducibility test is the gate.
The `.dwt` twins were built on 2026-09-22 on AutoCAD 2026 by
`scripts/smoke_settings_com.py --build-dwt` — each DXF opened over COM and
saved as a template (`AcSaveAsType` 66, `ac2018_Template`). Rebuild them the
same way after changing a DXF; `scripts/build_templates.py --check` gates the
DXF side only.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.