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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user