Files
autocad-mcp-pro/tests/test_sysvar_catalog.py
Umut CANandClaude Opus 5 f7528b2fa5 fix(com): every system variable is read and written on the ActiveX document, not the Application
AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):

    hasattr(app, 'GetVariable')  -> False
    hasattr(app, 'SetVariable')  -> False
    app.GetVariable('INSUNITS')  -> AttributeError: ... IAcadApplication
                                    instance ... has no attribute 'GetVariable'
    doc.GetVariable('INSUNITS')  -> 4
    doc.GetVariable('MEASUREMENT') -> 1
    doc.GetVariable('FILEDIA')   -> 1; doc.SetVariable('FILEDIA', 0) -> None,
                                    reads back 0, restored to 1
    doc.GetVariable('AUDITCTL')  -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''

Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.

Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD
CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the
command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which
makes the whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.

`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).

Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.

Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:25:11 +03:00

416 lines
16 KiB
Python

"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines.
Every `engines["ezdxf"]` claim is checked against the real headless backend
(read, and - where the variable is writable - a write of a value that differs
from the fresh document's, followed by a save and a reopen, so a header slot
ezdxf holds in memory but never exports cannot pass); every `saved_in` that is
not "drawing" is checked to be exactly the set the backend refuses with
`capability: registry_sysvar`. The catalogue cannot say a thing the backend
does not do.
"""
from __future__ import annotations
import math
import pytest
from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import _SETTING_MAP
from backends.ezdxf_backend import _UNSAVED_SYSVARS
from engineering.standards.sysvars import (
SAVED_IN,
SYSVAR_CATALOG,
SYSVAR_TYPES,
SysVar,
check_sysvar_value,
describe_sysvar,
search_sysvars,
)
asyncio_test = pytest.mark.asyncio
DIM_WHITELIST = (
"DIMTXT",
"DIMASZ",
"DIMEXE",
"DIMEXO",
"DIMGAP",
"DIMTAD",
"DIMTIH",
"DIMTOH",
"DIMDEC",
"DIMDSEP",
"DIMLUNIT",
"DIMZIN",
"DIMBLK",
"DIMTXSTY",
"DIMLWD",
"DIMLWE",
"DIMSCALE",
"DIMTOL",
"DIMTP",
"DIMTM",
"DIMTOLJ",
"DIMTFAC",
"DIMLFAC",
"DIMRND",
"DIMATFIT",
"DIMTMOVE",
"DIMCLRD",
"DIMCLRE",
"DIMCLRT",
"DIMSAH",
"DIMBLK1",
"DIMBLK2",
"DIMCEN",
)
SPEC_NAMES = (
"LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT "
"SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE "
"DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA "
"TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME "
"VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW "
"ISOLINES FACETRES DISPSILH"
).split()
#: A value that is not the fresh document's, for every writable headless entry
#: the generic rule below cannot pick one for: the two engines represent DIMDSEP
#: and CECOLOR differently (a character vs. its code; a colour name vs. its ACI
#: number), so the headless form is written; the name-valued ones must name
#: something the drawing has (`drawing_new` bootstraps GEOMETRY and CENTER;
#: `_set_annotation_scale` seeds AutoCAD's default scale list).
_PROBE_VALUES: dict[str, object] = {
"DIMDSEP": 44, # ',' as the character code the header holds (a fresh document has 46)
"CECOLOR": 1, # ACI red; the header holds the number
"CLAYER": "GEOMETRY",
"CELTYPE": "CENTER",
"CANNOSCALE": "1:2",
}
def _probe_value(var: SysVar, fresh):
"""A legal value for ``var`` that differs from ``fresh``, so the write is not a no-op."""
if var.name in _PROBE_VALUES:
return _PROBE_VALUES[var.name]
if var.type == "bool":
return 0 if int(fresh) else 1
if var.type == "enum":
return next(code for code in var.enum if code != int(fresh))
if var.type in ("int", "float"):
step = 1 if var.type == "int" else 1.0
low, high = var.range if var.range is not None else (-math.inf, math.inf)
candidate = fresh + step
return candidate if candidate <= high else fresh - step
if var.type == "point":
return (float(fresh[0]) + 1.0, float(fresh[1]) + 2.0)
return f"{fresh}_PROBE"
def _same(var: SysVar, back, expected) -> bool:
if var.type == "point":
return tuple(float(v) for v in back[:2]) == tuple(float(v) for v in expected[:2])
if var.type in ("bool", "int", "enum"):
return int(back) == int(expected)
if var.type == "float":
return float(back) == pytest.approx(float(expected))
return back == expected
# ── the data is complete ────────────────────────────────────────────────────
def test_catalogue_is_authored_not_sparse():
assert len(SYSVAR_CATALOG) >= 60
def test_every_dim_whitelist_variable_and_every_spec_name_is_present():
missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG]
assert missing == []
def test_every_entry_is_complete_and_well_typed():
for name, var in SYSVAR_CATALOG.items():
assert isinstance(var, SysVar) and var.name == name
assert var.type in SYSVAR_TYPES, name
assert var.meaning.strip(), name
assert var.saved_in in SAVED_IN, name
assert set(var.engines) == {"ezdxf", "com"}, name
assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name
if var.type == "enum":
assert var.enum and all(isinstance(k, int) for k in var.enum), name
assert var.default in var.enum, name
else:
assert var.enum is None, name
if var.range is not None:
low, high = var.range
assert low < high, name
def test_friendly_keys_cover_the_whole_facade():
covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key}
assert covered == set(_SETTING_MAP)
for key, (variable, _kind) in _SETTING_MAP.items():
assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable)
def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses():
unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"}
assert unsaved == set(_UNSAVED_SYSVARS)
for name in unsaved:
assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name
# ── describe / search / check ───────────────────────────────────────────────
def test_describe_is_case_insensitive_and_complete():
row = describe_sysvar("ltscale")
assert row["known"] is True and row["name"] == "LTSCALE"
assert row["type"] == "float" and row["range"][0] > 0
assert row["saved_in"] == "drawing"
assert row["engines"] == {"ezdxf": True, "com": True}
assert row["friendly_key"] == "ltscale"
assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0]
def test_describe_unknown_names_the_nearest_without_raising():
row = describe_sysvar("LTSCAL")
assert row["known"] is False and row["name"] == "LTSCAL"
assert "LTSCALE" in row["nearest"]
assert describe_sysvar("")["known"] is False
def test_search_matches_names_and_meanings():
names = [row["name"] for row in search_sysvars("scale")]
for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"):
assert expected in names
assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"]
assert search_sysvars(" ") == []
@pytest.mark.parametrize(
"name, value, fragment",
[
("DIMDEC", 9, "0..8"),
("dimdec", -1, "0..8"),
("DIMDEC", 2.5, "integer"),
("DIMTXT", "big", "number"),
("DIMTXT", 0, "out of range"),
("LUNITS", 7, "architectural"),
("ORTHOMODE", 2, "0 or 1"),
("LIMMAX", 5, "[x, y]"),
("DIMSTYLE", "ANSI", "read-only"),
("CANNOSCALEVALUE", 0.5, "read-only"),
("POLARANG", 0, "out of range"),
],
)
def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment):
message = check_sysvar_value(name, value)
assert message is not None
assert name.upper() in message and fragment in message
@pytest.mark.parametrize(
"name, value",
[
("DIMDEC", 3),
("DIMTXT", "3.5"),
("LUNITS", 4),
("ORTHOMODE", "on"),
("LIMMAX", [297, 210]),
("DIMDSEP", ","),
("NOSUCHVARIABLE", 12345),
],
)
def test_check_passes_good_values_and_unknown_names(name, value):
assert check_sysvar_value(name, value) is None
# ── the engine claims are true ──────────────────────────────────────────────
@asyncio_test
async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend):
unreadable = []
for name, var in SYSVAR_CATALOG.items():
if var.engines["ezdxf"] and await backend.system_get_variable(name) is None:
unreadable.append(name)
assert unreadable == []
@asyncio_test
async def test_every_writable_ezdxf_true_entry_survives_a_save_and_reopen(backend, tmp_path):
"""The write must land in the file, not only in memory.
The earlier form of this gate wrote each entry's *default* and read it back
without saving: 56 of its 66 writes were no-ops (default == fresh value)
and the rest could not tell a header slot ezdxf exports from one it holds
in memory and drops at save (`$OSMODE`, R12-only). Every write here moves
the value, and every read-back is from a reopened file.
"""
writable = {
name: var
for name, var in SYSVAR_CATALOG.items()
if var.engines["ezdxf"] and not var.read_only
}
probes: dict[str, object] = {}
for name, var in writable.items():
fresh = await backend.system_get_variable(name)
probe = _probe_value(var, fresh)
assert not _same(var, fresh, probe), f"{name}: the probe is a no-op write"
out = await backend.system_set_variable(name, probe)
assert out["ok"] is True, name
probes[name] = probe
path = str(tmp_path / "sysvars.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
lost = {}
for name, probe in probes.items():
back = await backend.system_get_variable(name)
if back is None or not _same(writable[name], back, probe):
lost[name] = (probe, back)
assert lost == {}, f"engines.ezdxf is True but the value did not survive the file: {lost}"
@asyncio_test
async def test_osmode_is_registry_saved_and_the_headless_engine_says_so(backend):
"""`$OSMODE` is an R12 header variable; ezdxf keeps it in memory and never
exports it for R2000+, so a write used to report `ok: True` and vanish at
save. It is registry-saved in AutoCAD and refused headlessly like POLARANG;
the read reports `None` instead of the template's memory."""
row = describe_sysvar("OSMODE")
assert row["saved_in"] == "registry" and row["engines"] == {"ezdxf": False, "com": True}
assert await backend.system_get_variable("OSMODE") is None
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.system_set_variable("OSMODE", 4134)
assert excinfo.value.capability == "registry_sysvar"
# the facade follows: no guess in the snapshot, a per-key refusal on write
assert (await backend.drawing_settings())["settings"]["osmode"] is None
res = await backend.drawing_settings({"osmode": 4134})
assert "registry" in res["errors"]["osmode"]
@asyncio_test
async def test_angbase_is_radians_at_the_boundary_and_degrees_in_the_file(backend, tmp_path):
"""ActiveX `GetVariable("ANGBASE")` and AutoLISP `getvar` hold radians
(measured: `SETVAR ANGBASE 90` reads back 1.5707963267948966), while the
DXF header stores `$ANGBASE` in degrees (the same drawing saved by AutoCAD
carries 90.0). The headless engine translates, so both engines report the
same number and the catalogue's "radians" is true on both."""
assert "radians" in describe_sysvar("ANGBASE")["meaning"]
assert "radians" in describe_sysvar("HPANG")["meaning"]
await backend.system_set_variable("ANGBASE", math.pi / 2)
assert backend._doc.header["$ANGBASE"] == pytest.approx(90.0)
assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2)
path = str(tmp_path / "angbase.dxf")
await backend.drawing_save_as(path)
text = open(path, encoding="utf-8").read()
start = text.index("$ANGBASE")
assert "90.0" in text[start : start + 40], text[start : start + 40]
await backend.drawing_open(path)
assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2)
@asyncio_test
async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend):
for name, var in SYSVAR_CATALOG.items():
if var.engines["ezdxf"]:
continue
if var.saved_in == "drawing":
with pytest.raises(ValueError, match=name): # no header slot in ezdxf
await backend.system_set_variable(name, var.default)
else:
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.system_set_variable(name, var.default)
assert excinfo.value.capability == "registry_sysvar", name
# ── server tools ────────────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
@asyncio_test
async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend):
from fastmcp.exceptions import ToolError
import server
ctx = _FakeCtx(backend)
with pytest.raises(ToolError, match="DIMDEC"):
await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx)
assert await backend.system_get_variable("DIMDEC") == 2
with pytest.raises(ToolError, match="read-only"):
await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx)
# a name the catalogue does not know still passes straight through
out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx)
assert out["ok"] is True
assert await backend.system_get_variable("MEASUREMENT") == 0
@asyncio_test
async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from fastmcp.exceptions import ToolError
import server
from backends import com_backend as module
calls: list[tuple] = []
class _Doc: # the sysvar host is AcadDocument; the Application has no such members
def GetVariable(self, name):
calls.append(("GetVariable", name))
return 2
def SetVariable(self, name, value):
calls.append(("SetVariable", name, value))
monkeypatch.setattr(module, "_acad_doc", lambda: _Doc())
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
with pytest.raises(ToolError, match="DIMDEC"):
await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend))
assert calls == []
@asyncio_test
async def test_describe_tool_returns_the_row_with_the_current_value(backend):
import server
ctx = _FakeCtx(backend)
row = await server.system_variable_describe(name="dimdec", ctx=ctx)
assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2
unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx)
assert unknown["known"] is False and "DIMDEC" in unknown["nearest"]
found = await server.system_variable_describe(search="decimal separator", ctx=ctx)
assert [row["name"] for row in found["matches"]] == ["DIMDSEP"]
index = await server.system_variable_describe(ctx=ctx)
assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"]
@asyncio_test
async def test_describe_tool_works_without_a_backend():
import server
class _NoBackendCtx:
lifespan_context = {"backend": None}
row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx())
assert row["known"] is True and "current" not in row