merge: track-e group C

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 11:12:15 +03:00
co-authored by Claude Opus 5
14 changed files with 3881 additions and 42 deletions
+14 -1
View File
@@ -317,6 +317,19 @@ async def test_com_backend_connects():
# number it already has?
@pytest.fixture(autouse=True)
def _restore_acad_doc():
"""`_measure_backend` swaps the module-level `_acad_doc`; put it back after
every test here. Left in place, the MagicMock document leaked into every
later test of the session that resolves the document itself (the settings
facade's COM tests read sysvars through `_acad_doc()`)."""
import backends.com_backend as cb
original = cb._acad_doc
yield
cb._acad_doc = original
def _measure_backend(entity):
"""A ComBackend whose HandleToObject returns exactly this entity."""
import backends.com_backend as cb
@@ -329,7 +342,7 @@ def _measure_backend(entity):
backend._run = _run
doc = MagicMock()
doc.HandleToObject.return_value = entity
cb._acad_doc = lambda: doc
cb._acad_doc = lambda: doc # restored by `_restore_acad_doc`
return backend
+6 -4
View File
@@ -55,14 +55,16 @@ class _Doc:
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
app = MagicMock()
app.GetVariable.return_value = 4159 # OSMODE current value is an int
monkeypatch.setattr(cb, "_acad_app", lambda: app)
# GetVariable / SetVariable are AcadDocument members (the Application has
# neither), so the fake is the document that `_acad_doc()` returns.
doc = MagicMock()
doc.GetVariable.return_value = 4159 # OSMODE current value is an int
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
out = await b.system_set_variable("OSMODE", "0")
name, val = app.SetVariable.call_args[0]
name, val = doc.SetVariable.call_args[0]
assert name == "OSMODE"
assert val == 0 and isinstance(val, int) # coerced from "0" to int 0
assert out["value"] == 0
+708
View File
@@ -0,0 +1,708 @@
"""drawing_properties_get / set: DWG summary and custom properties on both engines.
Headlessly the five summary fields live in the DWG SummaryInfo stream, which
ezdxf cannot write, so any non-null summary field is refused with
`capability: dwgprops` before anything is touched; custom properties are
`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both
engines (measured: they survive save + reopen on R2004 and newer; ezdxf
only emits the pairs for AC1018+, so an older document is refused headlessly
instead of losing the write at save).
Custom keys and values are validated once, by name, against what would break
*either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as
`Invalid key` (measured on AutoCAD 2026 by sweeping every printable ASCII
character through a scratch document: leading/trailing whitespace and exactly
the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key --
internal spaces, tabs, control characters, a no-break space and unicode are
accepted), two keys AutoCAD would call one key (its key compare is a simple
per-character case compare -- `Project`/`PROJECT` collide with `Duplicate
key`, `Stra\u00dfe`/`STRASSE` are both stored; `custom_key_fold` pins the
measured table), and line breaks, which ezdxf writes unescaped and which
corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules
(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`).
"""
from __future__ import annotations
import types
import pytest
from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import (
_CUSTOM_KEY_FORBIDDEN,
SUMMARY_FIELDS,
custom_key_fold,
validate_drawing_properties,
)
pytestmark = pytest.mark.asyncio
EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS}
# ── shared validation ───────────────────────────────────────────────────────
def test_validation_splits_writes_and_deletes():
written, to_write, to_delete = validate_drawing_properties(
{"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None}
)
assert written == {"title": "Gearbox"}
assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"]
assert validate_drawing_properties(None, None) == ({}, {}, [])
@pytest.mark.parametrize(
"summary, custom, exc, fragment",
[
({"edition": "1"}, None, ValueError, "edition"),
({"title": 3}, None, TypeError, "summary.title"),
(None, {"N": 3}, TypeError, "custom['N']"),
(None, {"": "x"}, TypeError, "non-empty"),
("Gearbox", None, TypeError, "summary"),
(None, ["PROJECT"], TypeError, "custom"),
# Keys AutoCAD's AddCustomInfo rejects as 'Invalid key' (measured on
# AutoCAD 2026) -- refused here so the live engine never fails
# mid-write after the summary and earlier keys were already applied.
(None, {" BAD ": "x"}, ValueError, "' BAD '"),
(None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"),
(None, {"\tTAB": "x"}, ValueError, "leading or trailing whitespace"),
(None, {"NB\xa0": "x"}, ValueError, "leading or trailing whitespace"),
(None, {"A=B": "x"}, ValueError, "'A=B' contains '='"),
(None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"),
(None, {"A:B/C": "x"}, ValueError, "'A:B/C' contains '/' ':'"),
# Case-variant duplicates: AutoCAD raises 'Duplicate key' on the second.
(None, {"Project": "a", "PROJECT": "b"}, ValueError, "differ only by case"),
(None, {"Project": "a", "PROJECT": None}, ValueError, "differ only by case"),
(None, {"Gr\u00fcn": "a", "GR\u00dcN": "b"}, ValueError, "differ only by case"),
# A line break is written unescaped by ezdxf and corrupts the DXF.
(None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"),
(None, {"NOTE": "line1\rline2"}, ValueError, "line break"),
(None, {"K\nEY": "x"}, ValueError, "the key contains a line break"),
(None, {"K\nEY": None}, ValueError, "the key contains a line break"),
],
)
def test_validation_refuses_by_name(summary, custom, exc, fragment):
with pytest.raises(exc) as excinfo:
validate_drawing_properties(summary, custom)
assert fragment in str(excinfo.value)
INVALID_KEY_CHARS = '"*,/:;<=>?\\`|'
def test_forbidden_set_is_exactly_the_thirteen_measured_characters():
assert _CUSTOM_KEY_FORBIDDEN == frozenset(INVALID_KEY_CHARS)
assert len(_CUSTOM_KEY_FORBIDDEN) == 13
@pytest.mark.parametrize("char", sorted(INVALID_KEY_CHARS))
def test_validation_refuses_every_addcustominfo_invalid_key_character(char):
"""Measured on AutoCAD 2026 (`AddCustomInfo(f"A{c}B", "v")` for every
c in 0x20-0x7E on a scratch document): exactly these thirteen raise
`Invalid key`. The earlier fix mirrored only `=` and `;`, so 'A:B',
'REV/2', 'SIZE, mm', 'A"B' ... still half-applied on the live engine."""
key = f"A{char}B"
with pytest.raises(ValueError, match="Invalid key") as excinfo:
validate_drawing_properties(None, {key: "x"})
assert repr(key) in str(excinfo.value) and repr(char) in str(excinfo.value)
@pytest.mark.parametrize(
"key",
[
"IN SP",
"T\tAB",
"\u00dcn\u00efcode",
"X" * 300,
"NB\xa0SP",
"A\x01B",
"A\x1fB",
"A!#$%&'()+-.@[]^_{}~B",
"\u4e2d\u2014\u200b",
],
)
def test_validation_accepts_keys_autocad_accepts(key):
"""Measured: internal spaces, tabs, control characters, a no-break space,
unicode, every other printable ASCII character and 300-char keys pass
AddCustomInfo; the validator must not be stricter than AutoCAD."""
assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, [])
@pytest.mark.parametrize("key", ["A=B", " PAD ", "A:B", "NB\xa0", 'A"B'])
def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key):
"""A delete never calls AddCustomInfo, and RemoveCustomByKey does not
validate syntax (measured: 'Key not found', never 'Invalid key'), so a key
a DXF script wrote must stay removable. The line-break rule still holds
for both directions (a delete of such a key is pinned refused above)."""
assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key])
# Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the
# first on a scratch Documents.Add() document, Close(False)): SAME is
# 'Duplicate key', DISTINCT is both stored. Every DISTINCT pair is equal under
# Python's str.casefold(), which is why a casefold() routing sent 'STRASSE' to
# SetCustomByKey over 'Stra\u00dfe' and failed 'Key not found' mid-write.
SAME_KEY_PAIRS = [
("Project", "PROJECT"),
("Gr\u00fcn", "GR\u00dcN"),
("\u00e9", "\u00c9"), # é / É
("\u00ff", "\u0178"), # ÿ / Ÿ
("\u0142", "\u0141"), # ł / Ł
("\u03c3", "\u03a3"), # σ / Σ
("\u044f", "\u042f"), # я / Я
("\u01c6", "\u01c4"), # dž / DŽ
("i", "I"),
]
# The six pairs str.casefold() merges (measured DISTINCT in AutoCAD).
CASEFOLD_MERGED_PAIRS = [
("Stra\u00dfe", "STRASSE"), # ß uppercases to SS
("\u1e9e", "\u00df"), # ẞ / ß
("kelvin", "\u212aelvin"), # k / Kelvin sign
("\u00b5", "\u039c"), # micro sign / Greek capital mu
("\u03c3\u03c2", "\u03a3\u03a3"), # σς / ΣΣ (final sigma)
("\u01c5", "\u01c4"), # Dž / DŽ (title-case digraph)
]
DISTINCT_KEY_PAIRS = CASEFOLD_MERGED_PAIRS + [
("\u0131", "I"), # dotless ı / I (the fold is not Turkish-aware either)
("\u0130", "i"), # dotted İ / i (lower() is two code points)
]
@pytest.mark.parametrize("a, b", SAME_KEY_PAIRS)
def test_custom_key_fold_merges_what_autocad_calls_one_key(a, b):
assert custom_key_fold(a) == custom_key_fold(b)
assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b)
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
def test_custom_key_fold_keeps_what_autocad_stores_twice(a, b):
assert custom_key_fold(a) != custom_key_fold(b)
assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b)
@pytest.mark.parametrize("a, b", CASEFOLD_MERGED_PAIRS)
def test_casefold_is_the_wrong_rule_for_these(a, b):
"""Why 4fe2d96 regressed: every one of these is casefold()-equal."""
assert a.casefold() == b.casefold()
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
def test_validation_accepts_two_keys_autocad_stores_side_by_side(a, b):
"""Measured: AddCustomInfo('Stra\u00dfe') then AddCustomInfo('STRASSE')
both succeed, so refusing them as 'differ only by case' was a false
refusal with a false reason. German \u00df keys are realistic title-block
metadata."""
assert validate_drawing_properties(None, {a: "1", b: "2"}) == ({}, {a: "1", b: "2"}, [])
# ── headless engine ─────────────────────────────────────────────────────────
async def test_get_reports_no_summary_and_empty_custom(backend):
assert await backend.drawing_properties_get() == {
"summary": EMPTY_SUMMARY,
"summary_available": False,
"custom": {},
"backend": "ezdxf",
}
async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path):
first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
assert first == {
"ok": True,
"summary_written": [],
"custom_written": ["PROJECT", "REV"],
"custom_deleted": [],
"backend": "ezdxf",
}
second = await backend.drawing_properties_set(
custom={"REV": None, "NOPE": None, "PROJECT": "X-2"}
)
assert second["custom_written"] == ["PROJECT"]
assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'"
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags"
path = str(tmp_path / "props.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
async def test_line_break_is_refused_before_any_write_and_the_file_still_opens(backend, tmp_path):
"""Measured: `custom={'NOTE': 'line1\\nline2', 'AFTER': 'ok'}` reported
`ok: True`, and the saved DXF failed to reopen with
`DXFStructureError: Invalid group code "line2"` -- the whole drawing lost,
reported as success. The request is refused as a whole, so AFTER is not
written either, and the file saved afterwards reopens."""
with pytest.raises(ValueError, match=r"custom\['NOTE'\]: the value contains a line break"):
await backend.drawing_properties_set(custom={"NOTE": "line1\nline2", "AFTER": "ok"})
assert (await backend.drawing_properties_get())["custom"] == {}
path = str(tmp_path / "clean.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_properties_get())["custom"] == {}
async def test_autocad_invalid_keys_are_refused_headlessly_too(backend):
"""The headless engine used to write ' BAD ', 'A=B' and 'A;B' without
complaint (and AutoCAD loaded them from the DXF) while the live engine
half-applied the same call; both now refuse before writing."""
with pytest.raises(ValueError, match="' BAD '"):
await backend.drawing_properties_set(custom={"AAA": "first", " BAD ": "x", "ZZZ": "n"})
assert (await backend.drawing_properties_get())["custom"] == {}
async def test_legacy_invalid_syntax_keys_stay_deletable_headlessly(tmp_path):
"""Regression of 2b9791f: `_check_custom_key` ran before the `value is
None` branch, so `{'A=B': None}` on a document carrying 'A=B' (any ezdxf
script writes such keys; AutoCAD loads them from the DXF) was refused with
a reason that only applies to AddCustomInfo -- the tool showed a key it
could never remove."""
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
path = tmp_path / "legacy.dxf"
doc = ezdxf.new("R2010")
for tag in ("A=B", "NB\xa0SP", " PAD ", "A:B"):
doc.header.custom_vars.append(tag, "legacy")
doc.saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
try:
await backend.drawing_open(str(path))
assert (await backend.drawing_properties_get())["custom"] == {
"A=B": "legacy",
"NB\xa0SP": "legacy",
" PAD ": "legacy",
"A:B": "legacy",
}
res = await backend.drawing_properties_set(custom={"A=B": None, " PAD ": None})
assert res["custom_deleted"] == ["A=B", " PAD "]
assert (await backend.drawing_properties_get())["custom"] == {
"NB\xa0SP": "legacy",
"A:B": "legacy",
}
# ...but writing such a key is still refused, before anything is touched.
with pytest.raises(ValueError, match="'A:B' contains ':'"):
await backend.drawing_properties_set(custom={"A:B": "new", "OK": "x"})
assert (await backend.drawing_properties_get())["custom"] == {
"NB\xa0SP": "legacy",
"A:B": "legacy",
}
finally:
await backend.disconnect()
async def test_custom_keys_match_case_insensitively_headlessly(backend):
"""Mirrors AutoCAD (measured on 2026): a write to 'PROJECT' over an
existing 'Project' updates it under the stored spelling instead of adding
a second tag AutoCAD would call a duplicate; a delete matches either
spelling."""
await backend.drawing_properties_set(custom={"Project": "X-1", "Rev": "A"})
res = await backend.drawing_properties_set(custom={"PROJECT": "X-2", "rev": None})
assert res["custom_written"] == ["PROJECT"] and res["custom_deleted"] == ["rev"]
assert list(backend._doc.header.custom_vars) == [("Project", "X-2")]
assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"}
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
async def test_headless_keeps_keys_autocad_keeps_apart(backend, a, b):
"""Measured: `custom={'Stra\u00dfe': 's1'}` then `{'STRASSE': 's2'}` gave
custom={'Stra\u00dfe': 's2'} headlessly -- the first value overwritten and
no 'STRASSE' tag -- where AutoCAD holds both. Same for the Kelvin sign."""
await backend.drawing_properties_set(custom={a: "s1"})
res = await backend.drawing_properties_set(custom={b: "s2"})
assert res["custom_written"] == [b]
assert list(backend._doc.header.custom_vars) == [(a, "s1"), (b, "s2")]
res = await backend.drawing_properties_set(custom={b: None})
assert res["custom_deleted"] == [b]
assert list(backend._doc.header.custom_vars) == [(a, "s1")]
async def _backend_for_version(tmp_path, version: str):
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
path = tmp_path / f"{version}.dxf"
ezdxf.new(version).saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_open(str(path))
return backend
@pytest.mark.parametrize("version, dxfversion", [("R12", "AC1009"), ("R2000", "AC1015")])
async def test_custom_properties_before_r2004_are_refused_rather_than_lost(
tmp_path, version, dxfversion
):
"""Measured: on an R12 or R2000 document `drawing_properties_set` reported
`ok: True, custom_written: ['PROJECT']`, `drawing_properties_get` echoed
it from memory, and `drawing_save_as` (version kept) wrote nothing --
ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY,
which does not exist before R2004. The header-only write that vanishes on
save, the class f3ff703 removed for CANNOSCALE. Refused up front, before
custom_vars is touched; the reopened file is read back to prove it."""
backend = await _backend_for_version(tmp_path, version)
try:
assert backend._doc.dxfversion == dxfversion
with pytest.raises(ValueError, match=dxfversion) as excinfo:
await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
assert "R2004 or newer" in str(excinfo.value)
assert "PROJECT" in str(excinfo.value)
assert list(backend._doc.header.custom_vars) == []
assert (await backend.drawing_properties_get())["custom"] == {}
# A delete on the same document is refused for the same reason: it
# would report a key removed that the file could never have held.
with pytest.raises(ValueError, match="R2004 or newer"):
await backend.drawing_properties_set(custom={"PROJECT": None})
# An empty request touches nothing and is not a write to refuse.
res = await backend.drawing_properties_set(custom={})
assert res["ok"] is True and res["custom_written"] == []
out = tmp_path / f"{version}_out.dxf"
await backend.drawing_save_as(str(out))
await backend.drawing_open(str(out))
assert backend._doc.dxfversion == dxfversion
assert (await backend.drawing_properties_get())["custom"] == {}
finally:
await backend.disconnect()
async def test_custom_properties_on_r2004_survive_save_and_reopen(tmp_path):
"""The oldest version that carries the pairs -- the boundary the refusal
sits on -- round-trips."""
backend = await _backend_for_version(tmp_path, "R2004")
try:
res = await backend.drawing_properties_set(custom={"PROJECT": "X-1"})
assert res["custom_written"] == ["PROJECT"]
out = tmp_path / "r2004_out.dxf"
await backend.drawing_save_as(str(out))
await backend.drawing_open(str(out))
assert backend._doc.dxfversion == "AC1018"
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-1"}
finally:
await backend.disconnect()
async def test_summary_is_refused_headlessly_before_any_write(backend):
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"})
assert excinfo.value.capability == "dwgprops"
assert "title" in str(excinfo.value)
assert (await backend.drawing_properties_get())["custom"] == {}
async def test_all_null_summary_is_not_a_summary_write(backend):
res = await backend.drawing_properties_set(summary={"title": None, "author": None})
assert res["ok"] is True and res["summary_written"] == []
async def test_malformed_custom_is_refused_by_name_before_any_write(backend):
with pytest.raises(TypeError, match=r"custom\['N'\]"):
await backend.drawing_properties_set(custom={"OK": "1", "N": 3})
assert (await backend.drawing_properties_get())["custom"] == {}
def test_both_capability_maps_declare_dwgprops():
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False
assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True
# ── server tools ────────────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
async def test_server_tools_round_trip_custom_and_refuse_summary(backend):
import server
ctx = _FakeCtx(backend)
res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx)
assert res["custom_written"] == ["PROJECT"]
got = await server.drawing_properties_get(ctx=ctx)
assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False
with pytest.raises(UnsupportedCapabilityError):
await server.drawing_properties_set(title="Gearbox", ctx=ctx)
# ── live engine, against a fake SummaryInfo ─────────────────────────────────
class _FakeSummaryInfo:
def __init__(self):
self.Title = "Old title"
self.Subject = ""
self.Author = ""
self.Keywords = "gear"
self.Comments = ""
self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")]
self.calls: list[tuple] = []
def NumCustomInfo(self):
self.calls.append(("NumCustomInfo",))
return len(self._custom)
def GetCustomByIndex(self, index):
self.calls.append(("GetCustomByIndex", index))
return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple
# Measured on AutoCAD 2026: the three mutators match keys by AutoCAD's
# simple per-character case compare (`custom_key_fold` pins the table --
# 'PROJECT' finds 'Project', 'STRASSE' does not find 'Stra\u00dfe'), an
# existing key keeps its stored spelling, a second Add is 'Duplicate key'
# (scode -2145386475), a Set/Remove of an absent key is 'Key not found'
# (scode -2145386476), and a key AddCustomInfo rejects is 'Invalid key'
# (scode -2145386465). The com_error shape is the one pywin32 raised live:
# a localised HRESULT text and the English description in the excepinfo.
def _index(self, key):
for i, (k, _v) in enumerate(self._custom):
if custom_key_fold(k) == custom_key_fold(key):
return i
return None
def AddCustomInfo(self, key, value):
self.calls.append(("AddCustomInfo", key, value))
if set(key) & _CUSTOM_KEY_FORBIDDEN:
raise _com_error("Invalid key", -2145386465)
if self._index(key) is not None:
raise _com_error("Duplicate key", -2145386475)
self._custom.append((key, value))
def SetCustomByKey(self, key, value):
self.calls.append(("SetCustomByKey", key, value))
i = self._index(key)
if i is None:
raise _com_error("Key not found", -2145386476)
self._custom[i] = (self._custom[i][0], value)
def RemoveCustomByKey(self, key):
self.calls.append(("RemoveCustomByKey", key))
i = self._index(key)
if i is None:
raise _com_error("Key not found", -2145386476)
del self._custom[i]
def _com_error(description, scode):
import pywintypes
return pywintypes.com_error(
-2147352567,
"\u00d6zel durum olu\u015ftu.",
(0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode),
None,
)
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
info = _FakeSummaryInfo()
document = types.SimpleNamespace(SummaryInfo=info)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, info
async def test_com_get_reads_summary_and_custom(com_backend):
backend, info = com_backend
assert await backend.drawing_properties_get() == {
"summary": {
"title": "Old title",
"subject": "",
"author": "",
"keywords": "gear",
"comments": "",
},
"summary_available": True,
"custom": {"PROJECT": "X-1", "OLD": "1"},
"backend": "com",
}
async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend):
backend, info = com_backend
res = await backend.drawing_properties_set(
summary={"title": "Gearbox", "author": "U. Can", "subject": None},
custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None},
)
assert res == {
"ok": True,
"summary_written": ["author", "title"],
"custom_written": ["PROJECT", "REV"],
"custom_deleted": ["OLD"],
"backend": "com",
}
assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear"
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
assert mutations == [
("SetCustomByKey", "PROJECT", "X-2"), # exact spelling present → set
("AddCustomInfo", "REV", "B"), # new → add
("RemoveCustomByKey", "OLD"), # removed
("RemoveCustomByKey", "NOPE"), # 'Key not found' → not reported deleted
]
assert info._custom == [("PROJECT", "X-2"), ("REV", "B")]
async def test_com_validates_before_touching_activex(com_backend):
backend, info = com_backend
with pytest.raises(TypeError):
await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3})
with pytest.raises(ValueError):
await backend.drawing_properties_set(summary={"edition": "1"})
assert info.calls == [] and info.Title == "Old title"
@pytest.mark.parametrize(
"key", ["A:B", "REV/2", "SIZE, mm", 'A"B', "A\\B", "A<B>", "A|B", "A*B", "A?B"]
)
async def test_com_refuses_every_addcustominfo_invalid_key_before_any_write(com_backend, key):
"""Re-measured on AutoCAD 2026 after 2b9791f: `summary={'subject':
'PARTIAL?'}, custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still
raised from AddCustomInfo('A:B') and left subject='PARTIAL?',
custom={'AAA': 'first'} -- the same half-write for every character the
first fix did not mirror. Nothing reaches SummaryInfo now."""
backend, info = com_backend
with pytest.raises(ValueError, match="Invalid key"):
await backend.drawing_properties_set(
summary={"subject": "PARTIAL?"},
custom={"AAA": "first", key: "x", "ZZZ": "never"},
)
assert info.calls == [] and info.Subject == ""
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend):
"""RemoveCustomByKey does not validate syntax (measured: 'Key not found'
for an absent 'A=B', never 'Invalid key'), so a key AutoCAD loaded from a
DXF is removed, not refused."""
backend, info = com_backend
info._custom.append(("A=B", "legacy"))
res = await backend.drawing_properties_set(custom={"A=B": None})
assert res["custom_deleted"] == ["A=B"]
assert ("RemoveCustomByKey", "A=B") in info.calls
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
async def test_com_lets_autocad_route_a_case_variant_key(com_backend):
"""Measured on AutoCAD 2026: AddCustomInfo('project') over an existing
'PROJECT' raises 'Duplicate key' -- a case-sensitive existence check
routed it there and failed mid-write; a casefold() check then routed
'STRASSE' over 'Stra\u00dfe' to SetCustomByKey and failed the same way.
Now only an exact spelling is routed here; otherwise AutoCAD's own
compare decides: 'Duplicate key' falls back to SetCustomByKey."""
backend, info = com_backend
res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"})
assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"]
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
assert mutations == [
("AddCustomInfo", "project", "X-2"), # AutoCAD: 'Duplicate key'
("SetCustomByKey", "project", "X-2"), # → set under the stored spelling
("AddCustomInfo", "New", "n"),
("RemoveCustomByKey", "old"),
]
assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept"
@pytest.mark.parametrize("stored, written", DISTINCT_KEY_PAIRS)
async def test_com_writes_a_key_autocad_keeps_apart_without_half_writing(
com_backend, stored, written
):
"""Measured live: with 'Stra\u00dfe' present, `summary={'subject': 'HALF?'},
custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': 'never?'}` routed
'STRASSE' to SetCustomByKey on the casefold() match, AutoCAD answered
'Key not found', and the request raised with subject and BEFORE applied
and AFTER never written. AddCustomInfo of both spellings succeeds in
AutoCAD; the route must not be guessed from a Python fold."""
backend, info = com_backend
info._custom.append((stored, "s1"))
# A delete of the other spelling is 'Key not found' in AutoCAD (measured:
# RuntimeError after the request's writes were applied): reported as not
# deleted, no error, and the rest of the request still lands.
res = await backend.drawing_properties_set(custom={written: None, "OLD": None})
assert res["custom_deleted"] == ["OLD"] and res["ok"] is True
assert info._custom == [("PROJECT", "X-1"), (stored, "s1")]
res = await backend.drawing_properties_set(
summary={"subject": "HALF?"},
custom={"BEFORE": "b", written: "s2", "AFTER": "never?"},
)
assert res["custom_written"] == sorted(["BEFORE", written, "AFTER"])
assert info.Subject == "HALF?"
assert info._custom == [
("PROJECT", "X-1"),
(stored, "s1"),
("BEFORE", "b"),
(written, "s2"),
("AFTER", "never?"),
]
assert ("SetCustomByKey", written, "s2") not in info.calls
# Each spelling is now its own key: deleting one keeps the other.
res = await backend.drawing_properties_set(custom={written: None})
assert res["custom_deleted"] == [written]
assert (written, "s2") not in info._custom and (stored, "s1") in info._custom
async def test_com_propagates_an_unrelated_summaryinfo_failure(com_backend):
"""Only 'Duplicate key' is answered with SetCustomByKey and only 'Key not
found' is swallowed on a delete; any other COM failure keeps raising (the
fixture runs the COM callable inline, so the raw com_error is what
surfaces here; `_run` wraps it in RuntimeError on the real path)."""
import pywintypes
backend, info = com_backend
def _boom(key, value):
info.calls.append(("AddCustomInfo", key, value))
raise _com_error("Some other failure", -2145386400)
info.AddCustomInfo = _boom
with pytest.raises(pywintypes.com_error, match="Some other failure"):
await backend.drawing_properties_set(custom={"NEW": "x"})
assert ("SetCustomByKey", "NEW", "x") not in info.calls
def _boom_remove(key):
info.calls.append(("RemoveCustomByKey", key))
raise _com_error("Some other failure", -2145386400)
info.RemoveCustomByKey = _boom_remove
with pytest.raises(pywintypes.com_error, match="Some other failure"):
await backend.drawing_properties_set(custom={"OLD": None})
async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend):
"""Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'},
custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed
COM error from AddCustomInfo(' BAD ') and left subject and AAA written,
ZZZ absent -- a half-applied write reported as an error. The shared
validator now names the key and nothing reaches SummaryInfo."""
backend, info = com_backend
with pytest.raises(ValueError, match="' BAD '"):
await backend.drawing_properties_set(
summary={"subject": "PARTIAL?"},
custom={"AAA": "first", " BAD ": "x", "ZZZ": "never"},
)
assert info.calls == [] and info.Subject == ""
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
+678
View File
@@ -0,0 +1,678 @@
"""drawing_settings, track E: limits, grid/snap, ortho/polar, PSLTSCALE, annotation
scale, unit formats and the current styles — on both engines.
Measured before writing (ezdxf 1.4.4, 2026-09-16):
* `doc.header["$GRIDMODE"]` (and GRIDUNIT / SNAPMODE / SNAPUNIT / POLARMODE /
POLARANG / CANNOSCALE / CANNOSCALEVALUE) raises `DXFKeyError: Invalid header
variable` — none of them is a DXF header variable. Grid and snap live on the
active VPORT; CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary; the
polar pair is registry-saved and has no home in a file at all.
* A `$LIMMAX` written to the header is overwritten at save by
`Drawing.update_limits()`, which copies the model-space LAYOUT's limits into
the header — so a header-only write reported success and vanished on reload.
"""
from __future__ import annotations
import json
import math
from types import SimpleNamespace
import pytest
from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import _SETTING_MAP, parse_scale, scale_value
pytestmark = pytest.mark.asyncio
NEW_KEYS = (
"limits",
"grid",
"grid_spacing",
"snap",
"snap_spacing",
"ortho",
"polar",
"polar_angle",
"psltscale",
"annotation_scale",
"linear_units",
"angular_units",
"dimstyle",
"textstyle",
)
def _active_vport(backend):
return backend._doc.viewports.get("*Active")[0]
# ── pure helpers ────────────────────────────────────────────────────────────
def test_parse_scale_canonicalises_and_refuses():
assert parse_scale("1:50") == ("1:50", 1.0, 50.0)
assert parse_scale(" 2 : 1 ") == ("2:1", 2.0, 1.0)
assert parse_scale("1:2.5") == ("1:2.5", 1.0, 2.5)
assert scale_value("1:50") == pytest.approx(0.02)
for bad in ("fifty", "1:0", "0:1", "1-50", "", True):
with pytest.raises(ValueError, match="annotation_scale"):
parse_scale(bad)
def test_every_new_key_is_in_the_map():
for key in NEW_KEYS:
assert key in _SETTING_MAP, key
# ── headless engine ─────────────────────────────────────────────────────────
async def test_snapshot_reports_every_key_including_the_unavailable_ones(backend):
snap = (await backend.drawing_settings())["settings"]
for key in NEW_KEYS:
assert key in snap, key
assert snap["limits"] == [[0.0, 0.0], [420.0, 297.0]]
assert snap["grid"] is False and snap["snap"] is False and snap["ortho"] is False
assert snap["psltscale"] is True
assert snap["annotation_scale"] == {"name": "1:1", "value": 1.0}
assert snap["linear_units"] == {"code": 2, "name": "decimal"}
assert snap["angular_units"] == {"code": 0, "name": "degrees"}
assert snap["dimstyle"] == "ISO-25"
assert snap["textstyle"] == "Standard"
# registry-saved: a file holds no value, and None says so rather than a guess
assert snap["polar"] is None
assert snap["polar_angle"] is None
async def test_limits_survive_a_save_and_reopen(backend, tmp_path):
res = await backend.drawing_settings({"limits": [[0, 0], [500, 350]]})
assert res["ok"] is True, res.get("errors")
assert res["changed"]["limits"] == [
[[0.0, 0.0], [420.0, 297.0]],
[[0.0, 0.0], [500.0, 350.0]],
]
layout = backend._doc.modelspace().dxf_layout.dxf
assert tuple(layout.limmax)[:2] == (500.0, 350.0), "the layout is what survives write()"
path = str(tmp_path / "limits.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_settings())["settings"]["limits"] == [
[0.0, 0.0],
[500.0, 350.0],
]
async def test_grid_and_snap_live_on_the_active_vport(backend, tmp_path):
res = await backend.drawing_settings(
{"grid": True, "grid_spacing": 5, "snap": "on", "snap_spacing": [2.5, 5]}
)
assert res["ok"] is True, res.get("errors")
vport = _active_vport(backend).dxf
assert vport.grid_on == 1 and vport.snap_on == 1
assert tuple(vport.grid_spacing)[:2] == (5.0, 5.0)
assert tuple(vport.snap_spacing)[:2] == (2.5, 5.0)
snap = (await backend.drawing_settings())["settings"]
assert snap["grid"] is True and snap["grid_spacing"] == 5.0
assert snap["snap"] is True and snap["snap_spacing"] == [2.5, 5.0]
path = str(tmp_path / "grid.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
again = (await backend.drawing_settings())["settings"]
assert again["grid"] is True and again["grid_spacing"] == 5.0
assert again["snap_spacing"] == [2.5, 5.0]
async def test_ortho_and_psltscale_are_header_variables(backend):
res = await backend.drawing_settings({"ortho": True, "psltscale": False})
assert res["ok"] is True, res.get("errors")
assert backend._doc.header["$ORTHOMODE"] == 1
assert backend._doc.header["$PSLTSCALE"] == 0
snap = (await backend.drawing_settings())["settings"]
assert snap["ortho"] is True and snap["psltscale"] is False
async def test_polar_is_refused_headlessly_by_capability(backend):
res = await backend.drawing_settings({"polar": True, "polar_angle": 45, "ortho": True})
assert res["ok"] is False
assert res["applied"] == {"ortho": True}, "the key that has a home is still applied"
assert "registry" in res["errors"]["polar"]
assert "registry" in res["errors"]["polar_angle"]
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.system_set_variable("POLARANG", 0.5)
assert excinfo.value.capability == "registry_sysvar"
async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(backend, tmp_path):
res = await backend.drawing_settings({"annotation_scale": "1:50"})
assert res["ok"] is True, res.get("errors")
assert res["changed"]["annotation_scale"] == [
{"name": "1:1", "value": 1.0},
{"name": "1:50", "value": 0.02},
]
vardict = backend._doc.rootdict.get("AcDbVariableDictionary")
assert vardict["CANNOSCALE"].dxf.value == "1:50"
scales = backend._doc.rootdict.get("ACAD_SCALELIST")
assert "1:50" in _scale_names(scales), "AutoCAD only honours a scale that is in the list"
assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02)
# Regression: the list used to be *only* the requested scale. AutoCAD
# repopulates its default list only when the dictionary is empty, so the
# saved file opened with a one-entry scale list and `CANNOSCALE` could no
# longer be changed there (measured, AutoCAD 2026). An empty list is seeded
# with AutoCAD's default metric list, keyed and ordered the way AutoCAD
# writes it, so 1:50 was already there and nothing was appended.
assert _scale_names(scales) == list(_AUTOCAD_DEFAULT_SCALES)
assert list(scales.keys()) == _AUTOCAD_DEFAULT_KEYS
# re-setting the same scale is not a change and adds no second list entry
again = await backend.drawing_settings({"annotation_scale": "1:50"})
assert again["changed"] == {}
assert len(list(scales.keys())) == 17
# a scale outside the default list is appended once, under the next key
# AutoCAD itself would use, and the defaults are not seeded twice
res = await backend.drawing_settings({"annotation_scale": "1:25"})
assert res["ok"] is True, res.get("errors")
assert _scale_names(scales) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"]
assert list(scales.keys()) == [*_AUTOCAD_DEFAULT_KEYS, "B7"]
await backend.drawing_settings({"annotation_scale": "1:50"})
path = str(tmp_path / "scale.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_settings())["settings"]["annotation_scale"] == {
"name": "1:50",
"value": 0.02,
}
reopened = backend._doc.rootdict.get("ACAD_SCALELIST")
assert _scale_names(reopened) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"]
assert "1:1" in _scale_names(reopened), "AutoCAD refuses a list without its unit scale"
assert backend._doc.audit().has_errors is False
async def test_reading_the_annotation_scale_never_creates_the_scale_list(backend):
"""The read side stays pure: a fresh headless drawing keeps its empty
ACAD_SCALELIST (AutoCAD fills it on open) until a scale is written."""
assert (await backend.drawing_settings())["settings"]["annotation_scale"] == {
"name": "1:1",
"value": 1.0,
}
scales = backend._doc.rootdict.get("ACAD_SCALELIST")
assert scales is None or len(list(scales.keys())) == 0
# AutoCAD 2026's default metric list, in its own order and under its own keys
# (`Documents.Add()` saved as DXF, MEASUREMENT 1): 17 SCALE objects, A0 … B6.
_AUTOCAD_DEFAULT_SCALES = (
"1:1", "1:2", "1:4", "1:5", "1:8", "1:10", "1:16", "1:20", "1:30", "1:40",
"1:50", "1:100", "2:1", "4:1", "8:1", "10:1", "100:1",
) # fmt: skip
_AUTOCAD_DEFAULT_KEYS = [f"A{i}" for i in range(10)] + [f"B{i}" for i in range(7)]
def _seed_autocad_style_scale_list(doc, names):
"""ACAD_SCALELIST the way AutoCAD saves it: keyed A0, A1, ... with the scale
name only in the SCALE object's group 300 (the default metric list already
carries 1:1, 1:2, 1:5, 1:10, 1:20, 1:50, 1:100, 2:1 ...)."""
from ezdxf.entities import factory
from ezdxf.lldxf.extendedtags import ExtendedTags
scales = doc.rootdict.get("ACAD_SCALELIST")
if scales is None:
scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
for index, name in enumerate(names):
paper, drawing = (float(part) for part in name.split(":"))
handle = doc.entitydb.next_handle()
text = (
f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n"
f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{int(paper == drawing)}\n"
)
entry = factory.load(ExtendedTags.from_text(text), doc)
doc.entitydb.add(entry)
doc.objects.add_object(entry)
scales.add(f"A{index}", entry)
return scales
def _scale_names(scales):
return [
tag.value
for _key, entry in scales.items()
for subclass in entry.xtags.subclasses
for tag in subclass
if tag.code == 300
]
async def test_annotation_scale_matches_an_autocad_keyed_scale_list_by_name(backend, tmp_path):
"""Regression: the presence check used to look the scale up by dictionary
*key*, so on any AutoCAD-authored drawing (keys A0, A1, ...) every write
appended a second SCALE object with the same name."""
scales = _seed_autocad_style_scale_list(backend._doc, ["1:1", "1:2", "1:50"])
assert list(scales.keys()) == ["A0", "A1", "A2"]
res = await backend.drawing_settings({"annotation_scale": "1:50"})
assert res["ok"] is True, res.get("errors")
assert res["changed"]["annotation_scale"][1] == {"name": "1:50", "value": 0.02}
assert list(scales.keys()) == ["A0", "A1", "A2"], "the existing entry is reused, not duplicated"
assert _scale_names(scales).count("1:50") == 1
assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50"
# a scale the list does not carry is still appended, exactly once, under
# the next key in AutoCAD's own A0 … A9, B0 … sequence; a non-empty list
# is never re-seeded with the defaults
res = await backend.drawing_settings({"annotation_scale": "1:25"})
assert res["ok"] is True, res.get("errors")
assert _scale_names(scales).count("1:25") == 1
assert list(scales.keys()) == ["A0", "A1", "A2", "A3"]
assert len(list(scales.keys())) == 4
await backend.drawing_settings({"annotation_scale": "1:25"})
assert len(list(scales.keys())) == 4
path = str(tmp_path / "autocad_keyed.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
reopened = backend._doc.rootdict.get("ACAD_SCALELIST")
assert sorted(_scale_names(reopened)) == ["1:1", "1:2", "1:25", "1:50"]
assert backend._doc.audit().has_errors is False
async def test_unit_formats_by_name_and_by_code(backend):
res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"})
assert res["ok"] is True, res.get("errors")
assert backend._doc.header["$LUNITS"] == 4
assert backend._doc.header["$AUNITS"] == 1
snap = (await backend.drawing_settings())["settings"]
assert snap["linear_units"] == {"code": 4, "name": "architectural"}
assert snap["angular_units"] == {"code": 1, "name": "dms"}
by_code = await backend.drawing_settings({"linear_units": 1, "angular_units": 3})
assert by_code["ok"] is True
assert backend._doc.header["$LUNITS"] == 1 and backend._doc.header["$AUNITS"] == 3
bad = await backend.drawing_settings({"linear_units": "furlongs", "angular_units": 9})
assert "architectural" in bad["errors"]["linear_units"]
assert "angular_units" in bad["errors"]["angular_units"]
assert backend._doc.header["$LUNITS"] == 1, "a refused value writes nothing"
async def test_current_styles_delegate_to_the_styles_contract(backend, monkeypatch):
calls: list[tuple[str, str]] = []
async def _dimstyle(name):
calls.append(("dimstyle_set_current", name))
backend._doc.header["$DIMSTYLE"] = name
return {"ok": True, "current": name, "previous": "ISO-25"}
async def _textstyle(name):
calls.append(("textstyle_set_current", name))
backend._doc.header["$TEXTSTYLE"] = name
return {"ok": True, "current": name, "previous": "Standard"}
monkeypatch.setattr(backend, "dimstyle_set_current", _dimstyle, raising=False)
monkeypatch.setattr(backend, "textstyle_set_current", _textstyle, raising=False)
res = await backend.drawing_settings({"dimstyle": " ANSI ", "textstyle": "ISOCP"})
assert res["ok"] is True, res.get("errors")
assert calls == [("dimstyle_set_current", "ANSI"), ("textstyle_set_current", "ISOCP")]
assert res["changed"]["dimstyle"] == ["ISO-25", "ANSI"]
snap = (await backend.drawing_settings())["settings"]
assert snap["dimstyle"] == "ANSI" and snap["textstyle"] == "ISOCP"
async def test_current_styles_are_refused_when_no_styles_contract_exists(backend, monkeypatch):
# Group S may or may not have merged: force the "absent" branch either way.
monkeypatch.setattr(backend, "dimstyle_set_current", None, raising=False)
res = await backend.drawing_settings({"dimstyle": "ANSI"})
assert res["ok"] is False
assert "dimstyle_set_current" in res["errors"]["dimstyle"]
assert (await backend.drawing_settings())["settings"]["dimstyle"] == "ISO-25"
async def test_changed_reports_only_values_that_moved(backend):
same = await backend.drawing_settings({"psltscale": True, "ortho": False})
assert same["ok"] is True and same["changed"] == {}
moved = await backend.drawing_settings({"psltscale": False, "ortho": False})
assert moved["changed"] == {"psltscale": [True, False]}
async def test_malformed_values_are_refused_before_any_write(backend):
before_limmax = tuple(backend._doc.header["$LIMMAX"])
res = await backend.drawing_settings(
{
"limits": [[0, 0], [0, 5]],
"annotation_scale": "fifty",
"grid_spacing": 0,
"snap_spacing": [1, 2, 3],
"polar": "maybe",
"dimstyle": "",
}
)
assert res["ok"] is False and res["applied"] == {}
assert set(res["errors"]) == {
"limits",
"annotation_scale",
"grid_spacing",
"snap_spacing",
"polar",
"dimstyle",
}
assert "upper-right" in res["errors"]["limits"]
assert "paper:drawing" in res["errors"]["annotation_scale"]
assert "greater than 0" in res["errors"]["grid_spacing"]
assert tuple(backend._doc.header["$LIMMAX"]) == before_limmax
assert backend._doc.rootdict.get("AcDbVariableDictionary") is None
assert tuple(_active_vport(backend).dxf.grid_spacing)[:2] != (0.0, 0.0)
async def test_raw_gridmode_routes_to_the_vport_and_the_old_error_is_gone(backend):
assert await backend.system_get_variable("GRIDMODE") == 0
out = await backend.system_set_variable("GRIDMODE", 1)
assert out["ok"] is True
assert _active_vport(backend).dxf.grid_on == 1
assert await backend.system_get_variable("GRIDMODE") == 1
async def test_raw_unknown_header_name_is_a_named_value_error(backend):
with pytest.raises(ValueError, match=r"\$HPNAME2"):
await backend.system_set_variable("HPNAME2", "x")
async def test_cannoscalevalue_is_read_only(backend):
with pytest.raises(ValueError, match="CANNOSCALEVALUE is read-only"):
await backend.system_set_variable("CANNOSCALEVALUE", 0.5)
async def _r12_backend(tmp_path):
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
path = tmp_path / "r12.dxf"
ezdxf.new("R12").saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_open(str(path))
return backend
async def test_annotation_scale_on_r12_is_refused_rather_than_lost(tmp_path):
"""Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf exports
neither AcDbVariableDictionary nor ACAD_SCALELIST — a CANNOSCALE written to
the in-memory document reported `changed 1:1 -> 1:50` and reloaded as 1:1.
The write is refused up front, before any dictionary is created, and the
other settings on the same call still apply (limits survive R12)."""
backend = await _r12_backend(tmp_path)
try:
# ezdxf seeds an *empty* ACAD_SCALELIST at new(); the refusal must not
# populate it (the default-list seeding) nor create the variable dict.
assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0
with pytest.raises(ValueError, match="R12") as excinfo:
await backend.system_set_variable("CANNOSCALE", "1:50")
assert "R2000 or newer" in str(excinfo.value)
assert backend._doc.rootdict.get("AcDbVariableDictionary") is None
assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0
res = await backend.drawing_settings(
{"annotation_scale": "1:50", "limits": [[0, 0], [420, 297]]}
)
assert res["ok"] is False
assert "R12" in res["errors"]["annotation_scale"]
assert "annotation_scale" not in res["applied"]
assert res["applied"]["limits"] == [[0, 0], [420, 297]]
out = tmp_path / "r12_out.dxf"
await backend.drawing_save_as(str(out))
await backend.drawing_open(str(out))
assert backend._doc.dxfversion == "AC1009"
read = (await backend.drawing_settings())["settings"]
assert read["annotation_scale"] == {"name": "1:1", "value": 1.0}
assert read["limits"] == [[0.0, 0.0], [420.0, 297.0]]
finally:
await backend.disconnect()
async def test_annotation_scale_on_r12_refuses_a_malformed_value_by_the_value(tmp_path):
"""A malformed scale is the caller's mistake whatever the file version, so
it is named as such — the R12 refusal is not allowed to mask it."""
backend = await _r12_backend(tmp_path)
try:
with pytest.raises(ValueError, match="paper:drawing"):
await backend.system_set_variable("CANNOSCALE", "fifty")
finally:
await backend.disconnect()
def test_both_capability_maps_declare_registry_sysvar():
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
ezdxf_features = EzdxfBackend().capabilities().to_dict()["features"]
com_features = ComBackend().capabilities().to_dict()["features"]
assert ezdxf_features["registry_sysvar"]["supported"] is False
assert com_features["registry_sysvar"]["supported"] is True
# ── server tool wiring ──────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
async def test_server_tool_carries_the_new_keys(backend):
import server
ctx = _FakeCtx(backend)
write = await server.drawing_settings(
settings={"limits": [[0, 0], [297, 210]], "grid": True, "annotation_scale": "1:2"},
ctx=ctx,
)
assert write["ok"] is True, write.get("errors")
read = await server.drawing_settings(ctx=ctx)
assert read["settings"]["limits"] == [[0.0, 0.0], [297.0, 210.0]]
assert read["settings"]["annotation_scale"] == {"name": "1:2", "value": 0.5}
# ── live engine, against a fake ActiveX application ─────────────────────────
class _FakeApp:
"""The ActiveX *Application*: it has an ActiveDocument and no sysvar
members at all. `GetVariable` / `SetVariable` live on `AcadDocument`;
a backend that calls them on the Application raises `AttributeError:
AutoCAD.Application.GetVariable` on a live seat before anything reaches
AutoCAD (measured, AutoCAD 2026) -- so this fake refuses them the same way
instead of quietly answering."""
def __init__(self):
self.ActiveDocument = _FakeDoc()
self.Documents = SimpleNamespace(Count=1)
def __getattr__(self, name):
if name in ("GetVariable", "SetVariable"):
raise AttributeError(f"AutoCAD.Application.{name}")
raise AttributeError(name)
class _FakeDoc:
"""Records GetVariable / SetVariable; stores what AutoCAD would store."""
def __init__(self):
self.store = {
"INSUNITS": 4,
"LIMMIN": (0.0, 0.0),
"LIMMAX": (420.0, 297.0),
"GRIDMODE": 0,
"GRIDUNIT": (10.0, 10.0),
"SNAPMODE": 0,
"SNAPUNIT": (10.0, 10.0),
"ORTHOMODE": 0,
"AUTOSNAP": 63,
"POLARANG": math.pi / 2,
"PSLTSCALE": 1,
"CANNOSCALE": "1:1",
"LUNITS": 2,
"AUNITS": 0,
"DIMSTYLE": "ISO-25",
"TEXTSTYLE": "Standard",
"DIMSCALE": 1.0,
}
self.calls: list[tuple] = []
def GetVariable(self, name):
self.calls.append(("GetVariable", name))
return self.store[name]
def SetVariable(self, name, value):
stored = getattr(value, "value", value) # a VARIANT array → its Python sequence
if isinstance(stored, list):
stored = tuple(stored)
self.calls.append(("SetVariable", name, stored))
self.store[name] = stored
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = _FakeApp()
# Only the Application is faked: the real `_acad_doc()` runs and resolves
# `ActiveDocument`, so the test exercises the true call path.
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app.ActiveDocument
async def test_com_facade_sends_autocad_shaped_values(com_backend):
backend, doc = com_backend
res = await backend.drawing_settings(
{
"limits": [[0, 0], [500, 350]],
"grid": True,
"grid_spacing": 5,
"polar": False,
"polar_angle": 45,
"annotation_scale": "1:50",
"linear_units": "architectural",
"psltscale": 0,
}
)
assert res["ok"] is True, res.get("errors")
sets = [call[1:] for call in doc.calls if call[0] == "SetVariable"]
assert sets == [
("LIMMIN", (0.0, 0.0)),
("LIMMAX", (500.0, 350.0)),
("GRIDMODE", 1),
("GRIDUNIT", (5.0, 5.0)),
("AUTOSNAP", 55), # 63 with bit 8 cleared: polar off, everything else kept
("POLARANG", pytest.approx(math.radians(45))),
("CANNOSCALE", "1:50"),
("LUNITS", 4),
("PSLTSCALE", 0),
]
assert res["changed"]["polar"] == [True, False]
assert res["changed"]["polar_angle"] == [90.0, 45.0]
async def test_com_point_variables_travel_as_variant_double_arrays(com_backend):
import pythoncom
backend, doc = com_backend
seen: dict = {}
real_set = doc.SetVariable
def _capture(name, value):
seen[name] = value
real_set(name, value)
doc.SetVariable = _capture
res = await backend.system_set_variable("LIMMAX", (300, 200))
variant = seen["LIMMAX"]
assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8
assert tuple(variant.value) == (300.0, 200.0)
# The VARIANT is an ActiveX argument only. The reply carries the plain
# Python list -- a VARIANT in the dict cannot be serialised by pydantic,
# and the tool would then report failure after AutoCAD applied the write.
assert res == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]}
json.dumps(res)
async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backend, monkeypatch):
"""Through the real tool: `system_set_variable` returns its dict verbatim
under `output_schema={"type": "object"}`, so anything pydantic cannot
serialise turns into `ToolError: outputSchema defined but no structured
output returned` *after* `SetVariable` has landed -- a retry/undo trap."""
from fastmcp import Client
import server
backend, doc = com_backend
async def _fake_make_backend():
return backend
monkeypatch.setattr(server, "_make_backend", _fake_make_backend)
async with Client(server.mcp) as client:
res = await client.call_tool("system_set_variable", {"name": "LIMMAX", "value": [300, 200]})
assert res.data == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]}
assert doc.store["LIMMAX"] == (300.0, 200.0)
async def test_com_refuses_before_touching_activex(com_backend):
backend, doc = com_backend
res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"})
assert res["ok"] is False
assert not [call for call in doc.calls if call[0] == "SetVariable"]
async def test_com_sysvars_are_read_and_written_on_the_document(com_backend):
"""Regression: both methods called `GetVariable` / `SetVariable` on the
object `_acad_app()` returns, which has neither, so every
`drawing_settings` read and write on the live engine died with
`AttributeError: AutoCAD.Application.GetVariable`."""
backend, doc = com_backend
assert await backend.system_get_variable("LIMMAX") == (420.0, 297.0)
res = await backend.system_set_variable("GRIDMODE", "1")
assert res == {"ok": True, "variable": "GRIDMODE", "value": 1}
assert doc.store["GRIDMODE"] == 1
assert doc.calls == [
("GetVariable", "LIMMAX"),
("GetVariable", "GRIDMODE"), # the type probe
("SetVariable", "GRIDMODE", 1),
]
async def test_com_sysvars_need_an_open_drawing(monkeypatch):
"""With no document open there is nothing to read the variable from:
`_acad_doc()`'s clear refusal is what surfaces, not an ActiveX error."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = _FakeApp()
app.Documents = SimpleNamespace(Count=0)
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
with pytest.raises(RuntimeError, match="No drawing is open"):
await backend.system_get_variable("LIMMAX")
with pytest.raises(RuntimeError, match="No drawing is open"):
await backend.system_set_variable("GRIDMODE", 1)
+415
View File
@@ -0,0 +1,415 @@
"""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 _App:
def GetVariable(self, name):
calls.append(("GetVariable", name))
return 2
def SetVariable(self, name, value):
calls.append(("SetVariable", name, value))
monkeypatch.setattr(module, "_acad_app", lambda: _App())
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
+10 -5
View File
@@ -523,8 +523,13 @@ async def test_tool_group_sizes_are_unchanged():
`drawing_apply_standard` (ISO or ANSI styles, units and layers in one
call) joined; the `style` tag sits ahead of `layer` and `drawing` in
`_GROUP_TAG_PRIORITY` so they never file under their secondary
`query` / `create` / `modify` / `drawing` tags. Every other number here has been
unchanged since the snapshot was taken.
`query` / `create` / `modify` / `drawing` tags. `system`
moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue
as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when
v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on
both engines) joined SECTION 20 — tagged `drawing` and `settings`, and
`settings` is not a group tag, so they file under drawing. Every other
number here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -533,7 +538,7 @@ async def test_tool_group_sizes_are_unchanged():
"blocks": 9,
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"drawing": 13,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 16,
@@ -544,10 +549,10 @@ async def test_tool_group_sizes_are_unchanged():
"premium": 12,
"solids": 5,
"styles": 10,
"system": 7,
"system": 8,
"templates": 2,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 176
assert sum(sizes.values()) == 179