merge: track-e group V

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:18:03 +03:00
co-authored by Claude Opus 5
21 changed files with 5901 additions and 81 deletions
+817
View File
@@ -0,0 +1,817 @@
"""Multi-document on both engines.
The headless backend used to hold exactly one ``Drawing``: ``drawing_new``
replaced it and silently discarded whatever was there. It now keeps a
registry keyed by path (or ``untitled-N``); every later tool call targets the
active entry, and ``drawing_close`` is the active-document case of
``document_close``.
Two rules are pinned here. ``document_close`` never drops unsaved work
without being told to (``discard=True``), and it refuses ``save=True`` on a
document that has nowhere to write (an untitled one) instead of guessing a
path — on the live engine ``Close(True)`` on an untitled drawing opens
AutoCAD's Save dialog and blocks the COM thread until the timeout, so the
refusal is what keeps the server answerable. ``drawing_close`` keeps its 1.4
contract (``save=True`` saves when it can; an untitled document is closed and
the result says the changes were discarded) because two shipped tests rely on
it; the strict path is the new tool.
"""
from __future__ import annotations
import os
import tempfile
import types
import pytest
from fastmcp import Client
import config
import server
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── headless engine ─────────────────────────────────────────────────────────
async def test_fixture_document_is_registered_and_active(backend):
rows = await backend.document_list()
assert rows == [
{
"name": "untitled-1",
"path": None,
"key": "untitled-1",
"active": True,
"saved": True,
"entity_count": 0,
}
]
async def test_two_documents_hold_separate_entities(backend):
first = (await backend.document_list())[0]["name"]
second = (await backend.drawing_new())["document"]
assert second == "untitled-2"
await backend.entity_create_circle(0, 0, 5)
rows = {r["name"]: r for r in await backend.document_list()}
assert rows[second]["entity_count"] == 1 and rows[second]["active"] is True
assert rows[first]["entity_count"] == 0 and rows[first]["active"] is False
switched = await backend.document_activate(first)
assert switched == {
"ok": True,
"active": first,
"previous": second,
"name": first,
"path": None,
}
assert (await backend.drawing_info()).entity_count == 0
await backend.entity_create_line(0, 0, 1, 1)
await backend.document_activate(second)
assert (await backend.drawing_info()).entity_count == 1
rows = {r["name"]: r for r in await backend.document_list()}
assert rows[first]["entity_count"] == 1 and rows[first]["saved"] is False
async def test_per_document_state_follows_the_active_entry(backend):
"""Layer, current space, dirty flag and undo stack are per document."""
await backend.layer_create("A-ONLY")
await backend.layer_set_current("A-ONLY")
await backend.drawing_new()
assert backend._current_layer == "0"
assert "A-ONLY" not in [lyr.name for lyr in await backend.layer_list()]
await backend.document_activate("untitled-1")
assert backend._current_layer == "A-ONLY"
assert backend._dirty is True
await backend.document_activate("untitled-2")
assert backend._dirty is False
async def test_active_layer_set_is_per_document(backend):
"""The standard ``drawing_apply_iso_layers`` bootstrapped is per drawing.
It drives ``_role_layer`` — where ``dimension_auto`` and
``construction_xline`` file their output — and the critique that checks
them. Held on the backend it bled across documents: an iso13567 sheet
next to a mech one pushed the mech drawing's dimensions onto
``M-DIMEN-T-N`` (created on demand), silently, and ``drawing_critique``
could not see it because the same stale set drove the critique.
"""
await backend.drawing_apply_iso_layers("iso13567")
assert backend._role_layer("dim") == "M-DIMEN-T-N"
second = (await backend.drawing_new())["document"]
assert backend._active_layer_set is None, "a fresh drawing has no explicit set"
assert backend._role_layer("dim") == "DIM"
line = await backend.entity_create_line(0, 0, 50, 0)
dims = await backend.dimension_auto([line.handle], style="chain")
assert dims[0].layer == "DIM"
assert "M-DIMEN-T-N" not in [lyr.name for lyr in await backend.layer_list()]
# Bidirectional: switching back restores the first document's set …
await backend.document_activate("untitled-1")
assert backend._active_layer_set == "iso13567"
assert backend._role_layer("construction") == "M-CONST-E-N"
# … and a set applied on B while A is open does not reach A.
await backend.document_activate(second)
await backend.drawing_apply_iso_layers("pid")
assert backend._role_layer("dim") == "DIM"
await backend.document_activate("untitled-1")
assert backend._active_layer_set == "iso13567"
async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path):
path = tmp_path / "part.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 1) # unsaved edit
reopened = await backend.drawing_open(str(path))
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
assert (await backend.drawing_info()).entity_count == 0, "reload reads the disk, not memory"
async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
rows = await backend.document_list()
assert rows[0]["key"] == os.path.abspath(str(path))
assert rows[0]["name"] == "gear.dxf" and rows[0]["path"] == str(path)
assert backend._active_key == os.path.abspath(str(path))
async def test_save_refuses_a_path_that_another_open_document_holds(backend, tmp_path):
"""One entry per file: SAVEAS onto an open drawing is refused, as AutoCAD does.
Without the refusal the registry held two entries for one path, the
resolver's exact-key shortcut picked the stale one on close, and the file
ended up holding the *other* document's bytes while ``saved`` stayed True.
"""
import ezdxf
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_line(0, 0, 1, 1) # A: dirty, on disk empty
second = (await backend.drawing_new())["document"]
await backend.entity_create_circle(0, 0, 5)
before = path.read_bytes()
for call in (backend.drawing_save_as, backend.drawing_save):
with pytest.raises(ValueError, match="gear.dxf.*already open.*document_close"):
await call(str(path))
assert path.read_bytes() == before, "a refusal writes nothing"
rows = {r["key"]: r for r in await backend.document_list()}
assert set(rows) == {key, second}, "a refusal re-keys nothing"
assert rows[second]["path"] is None and rows[second]["saved"] is False
assert rows[key]["saved"] is False
# The rejected document is untouched and can still be saved elsewhere.
other = tmp_path / "other.dxf"
await backend.drawing_save_as(str(other))
assert [e.dxftype() for e in ezdxf.readfile(str(other)).modelspace()] == ["CIRCLE"]
# The by-name lookup is unambiguous and closes the entry that owns the file.
await backend.entity_create_line(2, 2, 3, 3)
closed = await backend.document_close("gear.dxf", save=True)
assert closed["closed"] == key and closed["saved"] is True
assert [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()] == ["LINE"]
async def test_save_to_the_document_own_path_is_not_a_collision(backend, tmp_path):
path = tmp_path / "self.dxf"
await backend.drawing_save(str(path))
await backend.drawing_new()
await backend.document_activate("self.dxf")
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save())["ok"] is True
await backend.entity_create_circle(0, 0, 2)
assert (await backend.drawing_save(str(path)))["ok"] is True
await backend.entity_create_circle(0, 0, 3)
# A spelling that differs from the key still names the same file.
spelled = str(tmp_path / "sub" / ".." / "self.dxf")
assert (await backend.drawing_save_as(spelled))["ok"] is True
assert len(await backend.document_list()) == 2
assert backend._active_key == os.path.abspath(str(path))
async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend, tmp_path):
"""The save refusal keeps the registry one-entry-per-file; if that invariant
is ever broken anyway, every lookup of the pair refuses instead of guessing."""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
second = (await backend.drawing_new())["document"]
with pytest.raises(RuntimeError, match="already holds"):
backend._doc_path = str(path) # the setter enforces the invariant
backend._docs[second].path = str(path) # corrupt the registry behind its back
for wanted in (key, second, "dup.dxf", str(path)):
with pytest.raises(ValueError, match="share the file"):
backend._resolve_document_key(wanted)
with pytest.raises(ValueError, match="share the file"):
await backend.document_close(key, save=True)
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
def _filesystem_folds_case() -> bool:
"""Whether the temp filesystem answers to a case variant of a file's name.
Probed on disk rather than read off ``os.path.normcase``: the platform's
``normcase`` is the identity on macOS while the default APFS volume folds
case, so a ``normcase`` guard skipped every case-variant test exactly
where the old spelling-based registry identity was wrong.
"""
with tempfile.TemporaryDirectory() as folder:
probe = os.path.join(folder, "Probe.dxf")
with open(probe, "w", encoding="utf-8"):
pass
return os.path.exists(os.path.join(folder, "probe.dxf"))
# Two spellings of one file only exist where the filesystem folds case.
_case_folding_fs = pytest.mark.skipif(
not _filesystem_folds_case(), reason="filesystem is case-sensitive"
)
def _hard_link(target: str, link: str) -> None:
try:
os.link(target, link)
except (OSError, NotImplementedError, AttributeError) as exc:
pytest.skip(f"hard links unavailable here: {exc}")
def _junction(target_dir: str, link_dir: str) -> None:
"""A second name for a directory: an NTFS junction, or a symlink elsewhere."""
if os.name == "nt":
try:
import _winapi
_winapi.CreateJunction(target_dir, link_dir)
return
except (ImportError, AttributeError, OSError) as exc:
pytest.skip(f"NTFS junctions unavailable here: {exc}")
try:
os.symlink(target_dir, link_dir, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"directory symlinks unavailable here: {exc}")
def _modelspace_types(path) -> list[str]:
import ezdxf
return [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()]
@_case_folding_fs
async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path):
"""Registration keys by file identity, not by spelling.
``drawing_open`` of an already-open file spelled with another case used to
register a *second* entry (key compared as plain strings), and from there
every lookup of either entry was refused as ambiguous.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 5) # unsaved edit
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "reload reads the disk"
assert (await backend.document_activate(str(path)))["ok"] is True
@_case_folding_fs
async def test_resolver_accepts_any_spelling_of_a_registered_path(backend, tmp_path):
"""``document_activate`` / ``document_close`` compare paths as the OS does.
The live engine lowercases before comparing (``_com_find_document``); a
lowercase drive letter or a case variant used to be refused headless as
'no open document named ...' - a dual-engine parity break.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
spelled = str(path)
for variant in (
spelled.lower(),
spelled[0].lower() + spelled[1:],
spelled[0].upper() + spelled[1:],
spelled.upper(),
):
await backend.document_activate("untitled-2")
assert (await backend.document_activate(variant))["active"] == key, variant
assert len(await backend.document_list()) == 2
closed = await backend.document_close(spelled.lower())
assert closed["closed"] == key and closed["open_documents"] == 1
@_case_folding_fs
async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, tmp_path):
path = tmp_path / "Self.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save_as(str(path).lower()))["ok"] is True
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["saved"] is True
assert backend._active_key == key, "the same file keeps its key"
assert (await backend.document_activate(key))["ok"] is True
async def test_a_hard_link_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path):
"""Registry identity is the file (``st_dev``/``st_ino``), not the spelling.
``normcase(abspath)`` folded case and separators only, so a hard link -
which ``Path.resolve()`` cannot fold either - registered a second entry:
both rows said ``saved: True``, a save from either overwrote the other's
bytes, and closing the 'clean' one dropped its work as ``discarded_changes:
False``.
"""
target = tmp_path / "gear.dxf"
link = tmp_path / "gear_link.dxf"
await backend.drawing_save(str(target))
_hard_link(str(target), str(link))
assert os.path.samefile(target, link)
await backend.entity_create_line(0, 0, 1, 1) # unsaved edit
reopened = await backend.drawing_open(str(link))
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "the reload reads the disk"
key = rows[0]["key"]
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(target) == ["CIRCLE"], "one entry, one file, one save"
assert (await backend.document_activate(str(target)))["active"] == key
assert (await backend.document_activate(str(link)))["active"] == key
async def test_a_junction_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path):
"""Same rule through a directory junction (``mklink /J``), which
``os.path.abspath`` leaves untouched - the route in-process callers take."""
real_dir = tmp_path / "real"
real_dir.mkdir()
target = real_dir / "gear.dxf"
await backend.drawing_save(str(target))
_junction(str(real_dir), str(tmp_path / "via_junction"))
aliased = tmp_path / "via_junction" / "gear.dxf"
assert os.path.samefile(target, aliased)
await backend.entity_create_line(0, 0, 1, 1)
reopened = await backend.drawing_open(str(aliased))
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(target) == ["CIRCLE"]
await backend.drawing_new()
with pytest.raises(ValueError, match="already open as document"):
await backend.drawing_save_as(str(aliased))
assert _modelspace_types(target) == ["CIRCLE"], "a refusal writes nothing"
closed = await backend.document_close(str(aliased))
assert closed["discarded_changes"] is False and closed["open_documents"] == 1
@_case_folding_fs
async def test_case_variant_identity_does_not_depend_on_normcase(backend, tmp_path, monkeypatch):
"""macOS: ``os.path.normcase`` is the identity, the default APFS volume
folds case. Reproduced here by giving ``normcase`` its POSIX definition -
the registry must still see one file, because it asks the disk."""
monkeypatch.setattr(os.path, "normcase", lambda s: s)
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1)
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(path) == ["CIRCLE"]
assert len(await backend.document_list()) == 1
async def test_save_target_that_does_not_exist_yet_is_compared_by_spelling(backend, tmp_path):
"""A path with no file behind it cannot be stat'ed; it is still one file
with the spelling that another entry holds (an owner whose file was
removed from disk), and a save onto it is refused."""
path = tmp_path / "gone.dxf"
await backend.drawing_save(str(path))
path.unlink()
await backend.drawing_new()
with pytest.raises(ValueError, match="already open as document"):
await backend.drawing_save_as(str(path))
assert not path.exists(), "a refusal writes nothing"
assert (await backend.drawing_save_as(str(tmp_path / "other.dxf")))["ok"] is True
async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path):
"""``drawing_close`` bypasses the resolver, so the close itself must check.
With two entries on one file, ``drawing_close(save=True)`` wrote the active
entry's bytes over the file the other entry holds and reported
``saved: True`` - the surviving row then claimed the disk held its content.
"""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1) # the owner is dirty
await backend.drawing_new()
await backend.entity_create_circle(0, 0, 5)
backend._docs[backend._active_key].path = str(path) # corrupt the registry behind its back
before = path.read_bytes()
with pytest.raises(ValueError, match="share the file"):
await backend.drawing_close(save=True)
assert path.read_bytes() == before, "a refusal writes nothing"
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
assert (await backend.document_activate("gear.dxf"))["active"] == key
await backend.document_activate("untitled-2")
assert (await backend.document_activate(str(path)))["name"] == "gear.dxf"
await backend.document_activate("untitled-2")
assert (await backend.document_activate(key))["previous"] == "untitled-2"
with pytest.raises(ValueError, match="no open document named 'nope'"):
await backend.document_activate("nope")
with pytest.raises(TypeError, match="name_or_path"):
await backend.document_activate(None)
async def test_close_unsaved_refuses_unless_discard(backend):
await backend.entity_create_line(0, 0, 1, 1)
with pytest.raises(ValueError, match="untitled-1.*unsaved changes"):
await backend.document_close("untitled-1")
assert len(await backend.document_list()) == 1, "a refusal closes nothing"
with pytest.raises(ValueError, match="never been saved"):
await backend.document_close("untitled-1", save=True)
with pytest.raises(ValueError, match="mutually exclusive"):
await backend.document_close("untitled-1", save=True, discard=True)
result = await backend.document_close("untitled-1", discard=True)
assert result == {
"ok": True,
"closed": "untitled-1",
"saved": False,
"discarded_changes": True,
"active": None,
"open_documents": 0,
}
async def _create_layout(backend):
assert (await backend.layout_create("A3-Sheet"))["ok"] is True
async def _set_current_layout(backend):
assert (await backend.layout_set_current("Layout1"))["ok"] is True
async def _create_viewport(backend):
result = await backend.viewport_create("Layout1", 150, 100, 200, 150, 0, 0, 1.0)
assert result["ok"] is True
def _layout_names(path):
import ezdxf
return set(ezdxf.readfile(str(path)).layouts.names())
def _layout1_has_viewport(path):
import ezdxf
return any(
e.dxftype() == "VIEWPORT" and e.dxf.id != 1
for e in ezdxf.readfile(str(path)).layouts.get("Layout1")
)
def _tilemode(path):
import ezdxf
return int(ezdxf.readfile(str(path)).header.get("$TILEMODE", 1))
@pytest.mark.parametrize(
("mutate", "changed_on_disk"),
[
(_create_layout, lambda p: "A3-Sheet" in _layout_names(p)),
(_set_current_layout, lambda p: _tilemode(p) == 0),
(_create_viewport, _layout1_has_viewport),
],
ids=["layout_create", "layout_set_current", "viewport_create"],
)
async def test_layout_and_viewport_mutators_mark_the_document_unsaved(
monkeypatch, tmp_path, mutate, changed_on_disk
):
"""The close refusal is only as good as the dirty flag behind it.
``layout_create``, ``layout_set_current`` and ``viewport_create`` change
the file (a new layout tab, ``$TILEMODE`` plus the active layout, a
VIEWPORT) but used to leave ``_DocState.dirty`` untouched, so
``document_list`` reported ``saved: True``, ``document_close`` closed
without refusing and reported ``discarded_changes: False`` while the work
was gone — and the live engine, whose ``Saved`` is AutoCAD's DBMOD,
refused the same sequence. Marking dirty also gives each an undo step,
which is why undo is switched on here before the document exists (the
S0 snapshot is seeded by ``drawing_new`` only when history is enabled).
"""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
backend = EzdxfBackend()
await backend.connect()
try:
await backend.drawing_new()
path = tmp_path / "sheet.dxf"
await backend.drawing_save(str(path))
assert (await backend.document_list())[0]["saved"] is True
assert not changed_on_disk(path)
await mutate(backend)
assert (await backend.document_list())[0]["saved"] is False
with pytest.raises(ValueError, match="unsaved changes"):
await backend.document_close(None)
assert len(await backend.document_list()) == 1, "a refusal closes nothing"
# Every mutator pushes an undo snapshot; these must too.
assert (await backend.drawing_undo())["undo_depth"] == 0
assert (await backend.drawing_redo())["ok"] is True
result = await backend.document_close(None, save=True)
assert result["saved"] is True and result["discarded_changes"] is False
assert changed_on_disk(path)
finally:
await backend.disconnect()
async def test_close_with_save_writes_the_file(backend, tmp_path):
path = tmp_path / "saved.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 2)
before = path.read_bytes()
result = await backend.document_close(None, save=True)
assert result["saved"] is True and result["discarded_changes"] is False
assert path.read_bytes() != before
async def test_closing_the_last_document_leaves_the_no_document_state(backend):
await backend.document_close("untitled-1")
assert await backend.document_list() == []
status = await backend.system_status()
assert status["has_document"] is False
assert status["open_documents"] == 0 and status["active_document"] is None
with pytest.raises(RuntimeError, match="No document open"):
await backend.drawing_info()
# ...and the backend recovers exactly as before.
assert (await backend.drawing_new())["document"] == "untitled-2"
async def test_closing_the_active_document_activates_the_most_recently_used(backend):
await backend.drawing_new() # untitled-2
await backend.drawing_new() # untitled-3
await backend.document_activate("untitled-1")
await backend.document_activate("untitled-3")
result = await backend.document_close("untitled-3")
assert result["active"] == "untitled-1", "most recently activated, not most recently created"
async def test_closing_a_background_document_keeps_the_active_one(backend):
await backend.drawing_new() # untitled-2, active
await backend.entity_create_circle(0, 0, 1)
result = await backend.document_close("untitled-1")
assert result["active"] == "untitled-2"
assert (await backend.drawing_info()).entity_count == 1
async def test_drawing_close_keeps_its_legacy_contract(backend, tmp_path):
await backend.entity_create_line(0, 0, 1, 1)
closed = await backend.drawing_close(save=True)
assert closed["ok"] is True and closed["saved"] is False
assert closed["discarded_changes"] is True
assert "never been saved" in closed["warning"]
assert await backend.document_list() == []
await backend.drawing_new()
path = tmp_path / "keep.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 3)
before = path.read_bytes()
closed = await backend.drawing_close(save=True)
assert closed["saved"] is True and "warning" not in closed
assert path.read_bytes() != before
async def test_disconnect_drops_every_document_and_its_snapshots(monkeypatch):
import config
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 3)
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
await backend.entity_create_line(0, 0, 1, 1)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 2, 2)
snapshots = [p for state in backend._docs.values() for p in state.undo_stack]
assert len(snapshots) == 4 and all(p.exists() for p in snapshots)
await backend.disconnect()
assert backend._docs == {} and backend._active_key is None
assert not any(p.exists() for p in snapshots)
async def test_document_list_tool_over_the_wire(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_new", {"bootstrap": False})
payload = (await client.call_tool("document_list", {})).structured_content
assert payload["count"] == 2 and payload["active"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": "untitled-1"})
).structured_content
assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"
@_case_folding_fs
async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeypatch, tmp_path):
"""The wire canonicalises ``drawing_open`` paths (``validate_path`` resolves
them to disk casing) but not ``document_activate`` / ``document_close``
names, so a client spelling the drive letter in lowercase used to be
refused headless while the live engine accepted it."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
path = tmp_path / "Gear.dxf"
spelled = str(path)
lowered_drive = spelled[0].lower() + spelled[1:]
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": spelled})
await client.call_tool("drawing_new", {"bootstrap": False})
activated = (
await client.call_tool("document_activate", {"name_or_path": lowered_drive})
).structured_content
assert activated["name"] == "Gear.dxf" and activated["previous"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": spelled.lower()})
).structured_content
assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1
async def test_hard_link_over_the_wire_is_one_document(monkeypatch, tmp_path):
"""Route (a) of the review finding: ``validate_path().resolve()`` does not
fold a hard link, so the wire hands the backend a second name for the
open file. One entry, one save, and the disk holds the last edit."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
target = tmp_path / "gear.dxf"
link = tmp_path / "gear_link.dxf"
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": str(target)})
_hard_link(str(target), str(link))
await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 1, "y2": 1})
opened = (await client.call_tool("drawing_open", {"path": str(link)})).structured_content
assert opened["reloaded"] is True and opened["open_documents"] == 1
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 5})
await client.call_tool("drawing_save", {})
rows = (await client.call_tool("document_list", {})).structured_content
assert rows["count"] == 1 and rows["documents"][0]["saved"] is True
assert _modelspace_types(target) == ["CIRCLE"]
# ── live engine, against a fake ActiveX surface ──────────────────────────────
class _FakeDoc:
def __init__(self, app, name, full_name, *, saved=True, count=0):
self._app = app
self.Name = name
self.FullName = full_name
self.Saved = saved
self.ModelSpace = types.SimpleNamespace(Count=count)
self.calls: list[tuple[str, tuple]] = []
def Activate(self):
self.calls.append(("Activate", ()))
self._app.ActiveDocument = self
def Close(self, save_changes):
self.calls.append(("Close", (save_changes,)))
self._app.Documents._docs.remove(self)
docs = self._app.Documents._docs
self._app.ActiveDocument = docs[-1] if docs else None
class _FakeDocuments:
def __init__(self):
self._docs: list[_FakeDoc] = []
@property
def Count(self):
return len(self._docs)
def Item(self, index):
return self._docs[index]
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = types.SimpleNamespace(Documents=_FakeDocuments(), ActiveDocument=None)
first = _FakeDoc(app, "First.dwg", "C:/work/First.dwg", saved=True, count=3)
second = _FakeDoc(app, "Second.dwg", "C:/work/Second.dwg", saved=False, count=1)
untitled = _FakeDoc(app, "Drawing1.dwg", "", saved=False, count=0)
app.Documents._docs.extend([first, second, untitled])
app.ActiveDocument = first
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_acad_doc", lambda: app.ActiveDocument)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app
async def test_com_document_list_reads_the_documents_collection(com_backend):
backend, app = com_backend
rows = await backend.document_list()
assert rows == [
{
"name": "First.dwg",
"path": "C:/work/First.dwg",
"active": True,
"saved": True,
"entity_count": 3,
},
{
"name": "Second.dwg",
"path": "C:/work/Second.dwg",
"active": False,
"saved": False,
"entity_count": 1,
},
{"name": "Drawing1.dwg", "path": None, "active": False, "saved": False, "entity_count": 0},
]
async def test_com_activate_calls_activate_and_resets_document_scope(com_backend):
backend, app = com_backend
await backend._ensure_document_state()
backend._plan_spec = object()
backend._active_layer_set = "iso13567"
result = await backend.document_activate("Second.dwg")
assert result == {
"ok": True,
"active": "Second.dwg",
"previous": "First.dwg",
"name": "Second.dwg",
"path": "C:/work/Second.dwg",
}
assert app.Documents._docs[1].calls == [("Activate", ())]
assert backend._plan_spec is None, "a document switch clears drawing-scoped state"
assert backend._active_layer_set is None, "the layer set is drawing-scoped too"
assert backend._role_layer("dim") == "DIM"
with pytest.raises(ValueError, match="no open document named 'Third.dwg'"):
await backend.document_activate("Third.dwg")
async def test_com_close_refuses_before_touching_activex(com_backend):
backend, app = com_backend
second = app.Documents._docs[1]
with pytest.raises(ValueError, match="Second.dwg.*unsaved changes"):
await backend.document_close("Second.dwg")
untitled = app.Documents._docs[2]
with pytest.raises(ValueError, match="never been saved"):
await backend.document_close("Drawing1.dwg", save=True)
assert second.calls == [] and untitled.calls == []
async def test_com_close_passes_savechanges_through(com_backend):
backend, app = com_backend
first, second, untitled = app.Documents._docs
saved = await backend.document_close("Second.dwg", save=True)
assert second.calls == [("Close", (True,))]
assert saved["saved"] is True and saved["open_documents"] == 2
dropped = await backend.document_close("Drawing1.dwg", discard=True)
assert untitled.calls == [("Close", (False,))]
assert dropped["discarded_changes"] is True and dropped["open_documents"] == 1
clean = await backend.document_close(None) # the active, clean First.dwg
assert first.calls == [("Close", (False,))], "nothing to save, so nothing is asked to be saved"
assert clean["active"] is None
async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend):
backend, app = com_backend
app.ActiveDocument = app.Documents._docs[2] # the untitled one
with pytest.raises(ValueError, match="Save dialog"):
await backend.drawing_close(save=True)
assert app.Documents._docs[2].calls == []
+537
View File
@@ -0,0 +1,537 @@
"""The live-only environment trio: launch, preferences, operator prompts.
Headless there is no application to launch, no registry of preferences and
no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability``
defaults and refuses with a key ``system_capabilities`` declares. Live, the
COM paths are exercised against fake ActiveX objects asserting the call
shapes; ``scripts/smoke_settings_com.py`` runs them once for real.
A cancelled pick is an answer (``cancelled: true``), not an error; a
wall-clock timeout is ``timed_out: true``. Preference writes are refused by
name before any ActiveX call: read-only keys, unknown keys, values outside
the authored range.
"""
from __future__ import annotations
import types
import pytest
from fastmcp import Client
import config
import server
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import EzdxfBackend
from engineering.environment.preferences import (
PREFERENCE_KEYS,
READ_ONLY_KEYS,
SAVE_AS_TYPES,
decode_value,
describe_preferences,
split_key,
validate_preference,
)
pytestmark = pytest.mark.asyncio
# ── the whitelist, pure ─────────────────────────────────────────────────────
def test_whitelist_is_the_spec_list():
assert set(PREFERENCE_KEYS) == {
"OpenSave.SaveAsType",
"OpenSave.AutoSaveInterval",
"OpenSave.CreateBackup",
"OpenSave.IncrementalSavePercent",
"Display.CursorSize",
"Drafting.AutoSnapMarkerSize",
"Drafting.AutoSnapTooltip",
"Selection.PickBoxSize",
"Output.DefaultPlotStyleTable",
"Output.DefaultOutputDevice",
}
assert READ_ONLY_KEYS == (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval")
assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12
def test_validate_preference_coerces_and_refuses_by_name():
assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10
assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10
assert validate_preference("OpenSave.CreateBackup", False) is False
assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64
assert validate_preference("OpenSave.SaveAsType", 48) == 48
assert (
validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb"
)
with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"):
validate_preference("Files.SupportPath", "C:/x")
with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"):
validate_preference("Display.Theme", 1)
with pytest.raises(ValueError, match="between 0 and 600, got 601"):
validate_preference("OpenSave.AutoSaveInterval", 601)
with pytest.raises(TypeError, match="takes an int"):
validate_preference("Selection.PickBoxSize", 2.5)
with pytest.raises(TypeError, match="takes a bool"):
validate_preference("OpenSave.CreateBackup", 1)
with pytest.raises(ValueError, match="must be one of"):
validate_preference("OpenSave.SaveAsType", "ac1999_dwg")
with pytest.raises(TypeError, match="takes a non-empty string"):
validate_preference("Output.DefaultOutputDevice", "")
def test_decode_value_maps_enum_values_back_to_names():
assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg"
assert decode_value("OpenSave.SaveAsType", 999) == 999
assert decode_value("OpenSave.CreateBackup", 1) is True
assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b"
def test_describe_preferences_covers_every_key():
rows = {row["key"]: row for row in describe_preferences()}
assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
assert rows["OpenSave.AutoSaveInterval"] == {
"key": "OpenSave.AutoSaveInterval",
"kind": "int",
"range": [0, 600],
"enum": None,
"writable": True,
}
assert rows["Files.SupportPath"]["writable"] is False
# ── headless: declared refusals ─────────────────────────────────────────────
@pytest.mark.parametrize(
"method, args, key",
[
("system_launch", (), "live_application"),
("preferences_get", (), "preferences"),
("preferences_set", ("Display.CursorSize", 5), "preferences"),
("user_pick_point", ("Pick",), "interactive_prompt"),
("user_select", ("Select",), "interactive_prompt"),
("system_prompt_message", ("hi",), "interactive_prompt"),
],
)
async def test_headless_refuses_with_the_declared_key(backend, method, args, key):
assert is_capability_default(EzdxfBackend, method)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await getattr(backend, method)(*args)
assert excinfo.value.capability == key
assert "ezdxf" in str(excinfo.value)
feature = backend.capabilities().features[key]
assert feature.supported is False and feature.reason
async def test_refusal_reaches_the_client_with_the_key(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
result = await client.call_tool(
"user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False
)
assert result.is_error is True
payload = result.structured_content
assert payload["ok"] is False and payload["capability"] == "interactive_prompt"
caps = (await client.call_tool("system_capabilities", {})).structured_content
assert caps["features"]["interactive_prompt"]["supported"] is False
# ── live engine, against a fake ActiveX surface ──────────────────────────────
def _com_error(description: str):
pywintypes = pytest.importorskip("pywintypes")
return pywintypes.com_error(
-2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None
)
class _FakeUtility:
"""IAcadUtility as measured on AutoCAD 2026.
``GetPoint`` answers in WCS; ``GetEntity``'s PickedPoint is in the
*current UCS* (``ucs_origin`` / ``ucs_xdir`` / ``ucs_ydir``, world by
default); ``TranslateCoordinates`` only accepts a VT_ARRAY|VT_R8 VARIANT —
the plain tuple GetEntity returns is refused as "Invalid argument Point".
"""
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
self.point = (12.5, 7.25, 0.0)
self.picked_ucs = (3.0, 4.0, 0.0)
self.ucs_origin = (0.0, 0.0, 0.0)
self.ucs_xdir = (1.0, 0.0, 0.0)
self.ucs_ydir = (0.0, 1.0, 0.0)
self.raise_on_pick = None
self.entity = types.SimpleNamespace(Handle="1F3")
def GetPoint(self, base, prompt):
self.calls.append(("GetPoint", (base, prompt)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.point
def GetEntity(self, prompt):
self.calls.append(("GetEntity", (prompt,)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.entity, self.picked_ucs
def TranslateCoordinates(self, point, from_cs, to_cs, displacement):
import pythoncom
from win32com.client import VARIANT
if not isinstance(point, VARIANT) or point.varianttype != (
pythoncom.VT_ARRAY | pythoncom.VT_R8
):
raise _com_error("Invalid argument Point in TranslateCoordinates")
self.calls.append(
("TranslateCoordinates", (tuple(point.value), from_cs, to_cs, displacement))
)
assert (from_cs, to_cs, displacement) == (1, 0, False) # acUCS → acWorld, a point
u, v, w = point.value
ox, oy, oz = self.ucs_origin
xx, xy, xz = self.ucs_xdir
yx, yy, yz = self.ucs_ydir
zx, zy, zz = (
xy * yz - xz * yy,
xz * yx - xx * yz,
xx * yy - xy * yx,
)
return (
ox + u * xx + v * yx + w * zx,
oy + u * xy + v * yy + w * zy,
oz + u * xz + v * yz + w * zz,
)
def Prompt(self, text):
self.calls.append(("Prompt", (text,)))
class _FakeSelectionSet:
def __init__(self, name, items):
self.Name = name
self._items = items
self.calls: list[str] = []
@property
def Count(self):
return len(self._items)
def Item(self, index):
return self._items[index]
def SelectOnScreen(self):
self.calls.append("SelectOnScreen")
def Delete(self):
self.calls.append("Delete")
class _FakeSelectionSets:
def __init__(self, items):
self.items = items
self.sets: list[_FakeSelectionSet] = []
def Add(self, name):
ss = _FakeSelectionSet(name, self.items)
self.sets.append(ss)
return ss
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
utility = _FakeUtility()
selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")]
document = types.SimpleNamespace(
Name="Part.dwg",
FullName="C:/work/Part.dwg",
Utility=utility,
SelectionSets=_FakeSelectionSets(selection),
)
preferences = types.SimpleNamespace(
OpenSave=types.SimpleNamespace(
SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50
),
Display=types.SimpleNamespace(CursorSize=5),
Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True),
Selection=types.SimpleNamespace(PickBoxSize=3),
Output=types.SimpleNamespace(
DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3"
),
Files=types.SimpleNamespace(
SupportPath="C:/support",
TemplateDwgPath="C:/t",
PrinterStyleSheetPath="C:/p",
PrinterConfigPath="C:/pc3",
),
)
app = types.SimpleNamespace(
Preferences=preferences,
Version="25.0",
Documents=types.SimpleNamespace(Count=1),
ActiveDocument=document,
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
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, app, document
async def test_com_no_longer_inherits_the_defaults():
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends.com_backend import ComBackend
for method in (
"system_launch",
"preferences_get",
"preferences_set",
"user_pick_point",
"user_select",
"system_prompt_message",
):
assert not is_capability_default(ComBackend, method), method
async def test_com_pick_point_returns_the_operator_point(com_backend):
backend, app, document = com_backend
import pythoncom
result = await backend.user_pick_point("Pick the base point")
assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"}
(call,) = document.Utility.calls
assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing
assert call[1][1] == "\nPick the base point"
async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend):
backend, app, document = com_backend
document.Utility.raise_on_pick = _com_error("User input is a keyword")
result = await backend.user_pick_point("Pick")
assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"}
async def test_com_pick_point_other_com_errors_still_raise(com_backend):
backend, app, document = com_backend
pywintypes = pytest.importorskip("pywintypes")
document.Utility.raise_on_pick = pywintypes.com_error(
-2147418111, "Call was rejected", None, None
)
with pytest.raises(pywintypes.com_error):
await backend.user_pick_point("Pick")
async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch):
backend, app, document = com_backend
monkeypatch.setattr(config.settings, "com_call_timeout", 7.0)
async def _timed_out(func, *args, **kwargs):
raise RuntimeError(
"AutoCAD did not respond within 7s. The application may be showing a modal dialog"
)
monkeypatch.setattr(backend, "_run", _timed_out)
result = await backend.user_pick_point("Pick")
assert result["timed_out"] is True and result["timeout_s"] == 7.0
assert "did not respond" in result["error"]
result = await backend.user_select("Pick", "multiple")
assert result["timed_out"] is True
async def test_com_prompt_validation_happens_before_activex(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="prompt must not be empty"):
await backend.user_pick_point(" ")
with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"):
await backend.user_select("Pick", "many")
assert document.Utility.calls == [] and document.SelectionSets.sets == []
async def test_com_select_single_uses_getentity(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Pick the flange")
assert result == {
"cancelled": False,
"mode": "single",
"handles": ["1F3"],
"picked": [3.0, 4.0],
"backend": "com",
}
assert document.Utility.calls == [
("GetEntity", ("\nPick the flange",)),
("TranslateCoordinates", ((3.0, 4.0, 0.0), 1, 0, False)),
]
document.Utility.raise_on_pick = _com_error("Function cancelled")
assert (await backend.user_select("Pick"))["cancelled"] is True
async def test_com_select_single_reports_the_pick_in_wcs_under_a_ucs(com_backend):
"""MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the current UCS.
UCS origin (100,50,0) rotated 90 deg (X dir (0,1,0)); the operator picked
a circle at WCS (105,70): GetEntity reported (20,-5,0) and
TranslateCoordinates(acUCS → acWorld) gave (105,70,0). GetPoint already
answers in WCS, so ``user_pick_point`` and ``user_select`` must agree.
"""
backend, app, document = com_backend
utility = document.Utility
utility.ucs_origin = (100.0, 50.0, 0.0)
utility.ucs_xdir = (0.0, 1.0, 0.0)
utility.ucs_ydir = (-1.0, 0.0, 0.0)
utility.picked_ucs = (20.0, -5.0, 0.0)
result = await backend.user_select("Pick the circle")
assert result["picked"] == [105.0, 70.0]
assert result["handles"] == ["1F3"] and result["cancelled"] is False
# A translated UCS without rotation (origin (100,50)): (15,20) → (115,70).
utility.ucs_xdir, utility.ucs_ydir = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)
utility.picked_ucs = (15.0, 20.0, 0.0)
assert (await backend.user_select("Pick"))["picked"] == [115.0, 70.0]
# GetPoint is not translated — it already answers in WCS.
utility.point = (110.0, 70.0, 0.0)
point = await backend.user_pick_point("Pick")
assert (point["x"], point["y"]) == (110.0, 70.0)
assert not any(name == "TranslateCoordinates" for name, _ in utility.calls[-1:])
async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Select the pipes", mode="multiple")
assert result == {
"cancelled": False,
"mode": "multiple",
"handles": ["A1", "B2"],
"count": 2,
"backend": "com",
}
assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))]
(ss,) = document.SelectionSets.sets
assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"]
async def test_com_prompt_message_writes_to_the_command_line(com_backend):
backend, app, document = com_backend
result = await backend.system_prompt_message("Drawing saved by the assistant")
assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"}
assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))]
async def test_com_preferences_get_reads_the_whitelist(com_backend):
backend, app, document = com_backend
result = await backend.preferences_get()
assert result["read_only"] == list(READ_ONLY_KEYS)
assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg"
assert result["values"]["OpenSave.AutoSaveInterval"] == 10
assert result["values"]["Files.SupportPath"] == "C:/support"
assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
subset = await backend.preferences_get(["Display.CursorSize"])
assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"}
with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"):
await backend.preferences_get(["Display.Theme"])
async def test_com_preferences_set_reports_old_and_new(com_backend):
backend, app, document = com_backend
result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert result == {
"ok": True,
"key": "OpenSave.AutoSaveInterval",
"old": 10,
"new": 15,
"changed": True,
"backend": "com",
}
assert app.Preferences.OpenSave.AutoSaveInterval == 15
same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert same["changed"] is False
enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg")
assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg"
assert app.Preferences.OpenSave.SaveAsType == 60
async def test_com_preferences_set_refuses_before_any_write(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="read-only"):
await backend.preferences_set("Files.SupportPath", "C:/evil")
with pytest.raises(ValueError, match="between 1 and 100"):
await backend.preferences_set("Display.CursorSize", 0)
with pytest.raises(ValueError, match="unknown preference"):
await backend.preferences_set("Display.Theme", 1)
assert app.Preferences.Files.SupportPath == "C:/support"
assert app.Preferences.Display.CursorSize == 5
async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch):
backend, app, document = com_backend
from backends import com_backend as module
monkeypatch.setattr(module, "_COM_STATE", {})
calls: list[str] = []
def _get_active(progid):
calls.append(f"GetActiveObject:{progid}")
raise RuntimeError("not running")
def _dispatch(progid):
calls.append(f"Dispatch:{progid}")
app.Visible = False
return app
monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active)
monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch)
result = await backend.system_launch(visible=True)
assert result == {
"launched": True,
"attached": False,
"version": "25.0",
"document": "Part.dwg",
"visible": True,
"progid": config.settings.cad_progid,
"backend": "com",
}
assert calls == [
f"GetActiveObject:{config.settings.cad_progid}",
f"Dispatch:{config.settings.cad_progid}",
]
assert app.Visible is True and module._COM_STATE["app"] is app
again = await backend.system_launch()
assert again["launched"] is False and again["attached"] is True
assert len(calls) == 2, "an app already cached is attached, not re-dispatched"
async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path):
backend, app, document = com_backend
from backends import com_backend as module
opened: list[str] = []
app.Documents = types.SimpleNamespace(
Count=1,
Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg"),
)
monkeypatch.setattr(module, "_COM_STATE", {"app": app})
target = tmp_path / "gear.dwg"
target.write_bytes(b"")
result = await backend.system_launch(open_path=str(target))
assert result["document"] == "gear.dwg" and opened == [str(target.resolve())]
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError):
await backend.system_launch(open_path="../../etc/passwd")
+514
View File
@@ -0,0 +1,514 @@
"""Portable layer states: save → change → restore equals the snapshot.
These are the server's own layer states — JSON chunks in an XRECORD under
``ACADMCP_LAYERSTATES`` in the drawing's named object dictionary. They live
in the file and travel with it; they do NOT appear in AutoCAD's Layer States
Manager. The codec is engine-neutral, so the ezdxf tests prove the format and
the COM tests prove the ActiveX call shapes.
"""
from __future__ import annotations
import json
import pytest
from backends.base import LayerInfo
from engineering.environment.layer_states import (
DICT_NAME,
PROPERTIES,
decode_state,
diff_snapshot,
encode_state,
snapshot_from_layers,
validate_properties,
)
pytestmark = pytest.mark.asyncio
def _layer(name, **overrides) -> LayerInfo:
base = dict(
name=name,
color=7,
linetype="Continuous",
lineweight=-3,
is_on=True,
is_frozen=False,
is_locked=False,
is_current=False,
)
base.update(overrides)
return LayerInfo(**base)
# ── codec ───────────────────────────────────────────────────────────────────
def test_snapshot_records_every_property_and_the_current_layer():
state = snapshot_from_layers(
[_layer("0"), _layer("HIDDEN", color=8, linetype="HIDDEN", lineweight=35, is_frozen=True)],
"HIDDEN",
plot={"HIDDEN": False},
description="before plotting",
)
assert state == {
"version": 1,
"description": "before plotting",
"current_layer": "HIDDEN",
"layers": {
"0": {
"on": True,
"frozen": False,
"locked": False,
"color": 7,
"linetype": "Continuous",
"lineweight": -3,
"plot": True,
},
"HIDDEN": {
"on": True,
"frozen": True,
"locked": False,
"color": 8,
"linetype": "HIDDEN",
"lineweight": 35,
"plot": False,
},
},
}
def test_encode_chunks_are_at_most_255_ascii_characters_and_round_trip():
layers = [_layer(f"LAYER-{i:03d}-{'x' * 40}") for i in range(60)]
state = snapshot_from_layers(layers, "0")
chunks = encode_state(state)
assert len(chunks) > 5
assert all(len(c) <= 255 and c.isascii() for c in chunks)
assert decode_state(chunks) == state
def test_unicode_layer_names_survive_the_ascii_chunking():
state = snapshot_from_layers([_layer("KÖRPER-Ø20")], "KÖRPER-Ø20")
chunks = encode_state(state)
assert all(c.isascii() for c in chunks), "escaped so a chunk boundary never splits a code point"
assert decode_state(chunks)["current_layer"] == "KÖRPER-Ø20"
@pytest.mark.parametrize(
"chunks, fragment",
[
(["not json"], "not valid JSON"),
([json.dumps({"version": 2, "layers": {}})], "version"),
([json.dumps({"version": 1, "layers": {"A": {"on": True}}})], "lacks"),
([json.dumps([1, 2])], "version"),
],
)
def test_decode_refuses_anything_that_is_not_a_state(chunks, fragment):
with pytest.raises(ValueError, match=fragment):
decode_state(chunks)
def test_diff_reports_missing_and_new_layers_sorted():
state = snapshot_from_layers([_layer("A"), _layer("B"), _layer("C")], "0")
missing, new = diff_snapshot(state, [_layer("C"), _layer("Z"), _layer("A"), _layer("Y")])
assert missing == ["B"] and new == ["Y", "Z"]
def test_validate_properties_defaults_to_all_and_names_the_bad_one():
assert validate_properties(None) == PROPERTIES
assert validate_properties(["color", "on", "color"]) == ("color", "on")
with pytest.raises(ValueError, match=r"properties\[1\]: 'colour'"):
validate_properties(["on", "colour"])
with pytest.raises(TypeError, match="list"):
validate_properties("on")
with pytest.raises(ValueError, match="at least one"):
validate_properties([])
# ── headless engine ─────────────────────────────────────────────────────────
async def _seed(backend):
await backend.layer_create("GEOMETRY", color=7, linetype="Continuous", lineweight=50)
await backend.layer_create("HIDDEN", color=8, linetype="HIDDEN", lineweight=35)
await backend.layer_create("CENTER", color=1, linetype="CENTER", lineweight=25)
await backend.layer_set_current("GEOMETRY")
await backend.layer_freeze("CENTER")
await backend.layer_hide("HIDDEN")
async def _table(backend) -> dict:
return {
lyr.name: (
lyr.color,
lyr.linetype,
lyr.lineweight,
lyr.is_on,
lyr.is_frozen,
lyr.is_locked,
lyr.is_current,
)
for lyr in await backend.layer_list()
}
async def test_save_change_restore_equals_the_snapshot(backend):
await _seed(backend)
before = await _table(backend)
saved = await backend.layer_state_save("PLOT", description="plot set")
# measured: ezdxf.new() already has "0" and "Defpoints", so the seeded
# table is five layers and serialises to three <= 255-character chunks
assert saved == {
"ok": True,
"name": "PLOT",
"layer_count": 5,
"replaced": False,
"chunks": 3,
"backend": "ezdxf",
}
await backend.layer_thaw("CENTER")
await backend.layer_show("HIDDEN")
await backend.layer_lock("GEOMETRY")
await backend.layer_modify("HIDDEN", color=3, linetype="Continuous", lineweight=13)
await backend.layer_set_current("0")
assert await _table(backend) != before
restored = await backend.layer_state_restore("PLOT")
assert restored["applied"] == {
"layers": 5,
"properties": list(PROPERTIES),
"current_layer": "GEOMETRY",
}
assert restored["missing_layers"] == [] and restored["new_layers"] == []
assert await _table(backend) == before
async def test_restore_applies_only_the_requested_properties(backend):
await _seed(backend)
await backend.layer_state_save("S")
await backend.layer_thaw("CENTER")
await backend.layer_modify("CENTER", color=5)
result = await backend.layer_state_restore("S", properties=["color"])
assert result["applied"]["properties"] == ["color"]
center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
assert center.color == 1 and center.is_frozen is False, "frozen was not requested"
async def test_restore_reports_missing_and_new_layers_and_never_creates(backend):
await _seed(backend)
await backend.layer_state_save("S")
await backend.layer_delete("CENTER")
await backend.layer_create("NEW-ONE")
result = await backend.layer_state_restore("S")
assert result["missing_layers"] == ["CENTER"] and result["new_layers"] == ["NEW-ONE"]
assert "CENTER" not in [lyr.name for lyr in await backend.layer_list()]
async def test_plot_flag_and_current_layer_round_trip(backend):
await _seed(backend)
backend._doc.layers.get("HIDDEN").dxf.plot = 0
await backend.layer_state_save("S")
backend._doc.layers.get("HIDDEN").dxf.plot = 1
await backend.layer_set_current("0")
await backend.layer_state_restore("S", properties=["plot", "current"])
assert backend._doc.layers.get("HIDDEN").dxf.plot == 0
assert backend._current_layer == "GEOMETRY"
assert backend._doc.header["$CLAYER"] == "GEOMETRY"
async def test_state_survives_save_and_reopen(backend, tmp_path):
await _seed(backend)
await backend.layer_state_save("PLOT", description="d")
path = tmp_path / "states.dxf"
await backend.drawing_save(str(path))
await backend.document_close(None)
await backend.drawing_open(str(path))
assert await backend.layer_state_list() == [
{"name": "PLOT", "description": "d", "layer_count": 5}
]
await backend.layer_thaw("CENTER")
await backend.layer_state_restore("PLOT")
center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
assert center.is_frozen is True
async def test_xrecord_chunks_stay_under_256_characters(backend):
for i in range(80):
await backend.layer_create(f"LONG-LAYER-NAME-{i:03d}-{'x' * 30}")
saved = await backend.layer_state_save("BIG")
assert saved["chunks"] > 10
xrecord = backend._doc.rootdict.get(DICT_NAME).get("BIG")
tags = [t for t in xrecord.tags if t.code == 1000]
assert len(tags) == saved["chunks"]
assert all(len(t.value) <= 255 for t in tags)
async def test_replace_delete_and_missing_names(backend):
await backend.layer_state_save("S")
again = await backend.layer_state_save("s")
assert again["replaced"] is True, "AutoCAD dictionaries are case-insensitive"
assert [row["name"] for row in await backend.layer_state_list()] == ["s"]
assert await backend.layer_state_delete("S") == {"ok": True, "deleted": "s", "backend": "ezdxf"}
assert await backend.layer_state_list() == []
with pytest.raises(ValueError, match="no layer state named 'S'"):
await backend.layer_state_restore("S")
with pytest.raises(ValueError, match="no layer state named 'S'"):
await backend.layer_state_delete("S")
async def test_refusals_happen_before_any_write(backend):
with pytest.raises(ValueError, match="layer state name"):
await backend.layer_state_save(" ")
with pytest.raises(TypeError, match="description"):
await backend.layer_state_save("S", description=3)
assert DICT_NAME not in backend._doc.rootdict, "a refused save creates no dictionary"
await backend.layer_state_save("S")
with pytest.raises(ValueError, match=r"properties\[0\]: 'plottable'"):
await backend.layer_state_restore("S", properties=["plottable"])
assert len(list(backend._doc.rootdict.get(DICT_NAME).keys())) == 1
# ── live engine, against a fake ActiveX surface ──────────────────────────────
class _FakeXRecord:
def __init__(self, name):
self.name = name
self.types = None
self.values = None
self.deleted = False
def SetXRecordData(self, types_, values):
self.types, self.values = list(types_), list(values)
def GetXRecordData(self):
return self.types, self.values
def Delete(self):
self.deleted = True
class _FakeDict:
def __init__(self, name):
self.Name = name
self.entries: list[_FakeXRecord] = []
self.calls: list[tuple[str, tuple]] = []
@property
def Count(self):
return len(self.entries)
def Item(self, index):
return self.entries[index]
def GetName(self, obj):
return obj.name
def AddXRecord(self, keyword):
self.calls.append(("AddXRecord", (keyword,)))
rec = _FakeXRecord(keyword)
self.entries.append(rec)
return rec
def Remove(self, name):
self.calls.append(("Remove", (name,)))
rec = next(e for e in self.entries if e.name == name)
self.entries.remove(rec)
return rec
class _FakeDictionaries:
def __init__(self):
self.dicts: dict[str, _FakeDict] = {}
self.calls: list[tuple[str, tuple]] = []
def Item(self, name):
self.calls.append(("Item", (name,)))
if name not in self.dicts:
raise RuntimeError(f"no dictionary {name}")
return self.dicts[name]
def Add(self, name):
self.calls.append(("Add", (name,)))
self.dicts[name] = _FakeDict(name)
return self.dicts[name]
class _FakeLayer:
"""Models the two refusals measured live (AutoCAD 2026): the active layer
cannot be frozen, and a frozen layer cannot be made active."""
def __init__(self, name, **props):
self.Name = name
self.Color = props.get("color", 7)
self.Linetype = props.get("linetype", "Continuous")
self.LineWeight = props.get("lineweight", -3)
self.LayerOn = props.get("on", True)
self._frozen = props.get("frozen", False)
self.Lock = props.get("locked", False)
self.Plottable = props.get("plot", True)
self.document = None
@property
def Freeze(self):
return self._frozen
@Freeze.setter
def Freeze(self, value):
if value and self.document is not None and self.document.ActiveLayer is self:
raise RuntimeError("AutoCAD COM error: cannot freeze the current layer")
self._frozen = bool(value)
class _FakeDocument:
def __init__(self, layers, current):
self.Dictionaries = _FakeDictionaries()
self.Layers = _FakeLayers(layers)
self._active = current
for lyr in layers:
lyr.document = self
@property
def ActiveLayer(self):
return self._active
@ActiveLayer.setter
def ActiveLayer(self, layer):
if layer.Freeze:
# Measured: com_error 'Error setting active layer' (-2145320874).
raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Error setting active layer")
self._active = layer
class _FakeLayers:
def __init__(self, layers):
self._layers = layers
@property
def Count(self):
return len(self._layers)
def Item(self, key):
if isinstance(key, int):
return self._layers[key]
for lyr in self._layers:
if lyr.Name == key:
return lyr
raise RuntimeError(f"no layer {key}")
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
layers = [
_FakeLayer("0"),
_FakeLayer("GEOMETRY", lineweight=50),
_FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False),
]
document = _FakeDocument(layers, current=layers[1])
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None)
monkeypatch.setattr(module, "_regen", lambda: None)
monkeypatch.setattr(module, "_ai", lambda values: list(values))
monkeypatch.setattr(module, "_avar", lambda values: list(values))
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, document
async def test_com_save_creates_the_dictionary_and_one_xrecord_of_1000_chunks(com_backend):
backend, document = com_backend
result = await backend.layer_state_save("PLOT", description="d")
assert result["ok"] and result["backend"] == "com" and result["replaced"] is False
assert document.Dictionaries.calls == [("Item", (DICT_NAME,)), ("Add", (DICT_NAME,))]
states = document.Dictionaries.dicts[DICT_NAME]
assert states.calls == [("AddXRecord", ("PLOT",))]
(rec,) = states.entries
assert rec.types == [1000] * len(rec.values)
assert all(len(v) <= 255 for v in rec.values)
state = decode_state(rec.values)
assert state["current_layer"] == "GEOMETRY"
assert state["layers"]["HIDDEN"]["plot"] is False
again = await backend.layer_state_save("plot")
assert again["replaced"] is True and again["name"] == "PLOT"
assert len(states.entries) == 1, "SetXRecordData on the existing record, no second AddXRecord"
async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_backend):
backend, document = com_backend
await backend.layer_state_save("S")
hidden = document.Layers.Item("HIDDEN")
hidden.Freeze = False
hidden.Color = 3
hidden.Plottable = True
document.ActiveLayer = document.Layers.Item("0")
result = await backend.layer_state_restore("S")
assert result["applied"]["layers"] == 3
assert result["applied"]["current_layer"] == "GEOMETRY"
assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False
assert document.ActiveLayer.Name == "GEOMETRY"
assert result["missing_layers"] == [] and result["new_layers"] == []
async def test_com_restore_thaws_the_states_current_layer_before_making_it_current(com_backend):
"""The spec's own acceptance path, measured live: save with GEOMETRY
current and HIDDEN frozen → thaw HIDDEN, make 0 current, freeze GEOMETRY
→ restore. AutoCAD refuses ``ActiveLayer = <frozen>``, so the write
before the loop raised an opaque COM error and nothing was applied."""
backend, document = com_backend
await backend.layer_state_save("PLOT")
geometry = document.Layers.Item("GEOMETRY")
hidden = document.Layers.Item("HIDDEN")
hidden.Freeze = False
document.ActiveLayer = document.Layers.Item("0")
geometry.Freeze = True
result = await backend.layer_state_restore("PLOT")
assert "warnings" not in result, result
assert document.ActiveLayer is geometry and geometry.Freeze is False
assert hidden.Freeze is True
assert result["applied"] == {
"layers": 3,
"properties": list(PROPERTIES),
"current_layer": "GEOMETRY",
}
async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(com_backend):
"""Without ``frozen`` in the requested properties the thaw is not ours to
make: AutoCAD's refusal lands in ``warnings`` and the rest still applies."""
backend, document = com_backend
await backend.layer_state_save("PLOT")
geometry = document.Layers.Item("GEOMETRY")
hidden = document.Layers.Item("HIDDEN")
hidden.Color = 3
document.ActiveLayer = document.Layers.Item("0")
geometry.Freeze = True
result = await backend.layer_state_restore("PLOT", properties=["color", "current"])
assert result["applied"]["current_layer"] is None
assert result["applied"]["layers"] == 3 and hidden.Color == 8
assert geometry.Freeze is True and document.ActiveLayer.Name == "0"
(warning,) = result["warnings"]
assert warning.startswith("GEOMETRY: cannot be made current: ")
async def test_com_list_and_delete(com_backend):
backend, document = com_backend
await backend.layer_state_save("A", description="one")
await backend.layer_state_save("B")
assert await backend.layer_state_list() == [
{"name": "A", "description": "one", "layer_count": 3},
{"name": "B", "description": None, "layer_count": 3},
]
assert (await backend.layer_state_delete("a"))["deleted"] == "A"
states = document.Dictionaries.dicts[DICT_NAME]
assert states.calls[-1] == ("Remove", ("A",))
with pytest.raises(ValueError, match="no layer state named 'A'"):
await backend.layer_state_delete("A")
+16 -7
View File
@@ -535,8 +535,17 @@ async def test_tool_group_sizes_are_unchanged():
`query`/`export`, so they file with the sheets they set up. `templates`
moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
(tagged `template`, which ranks above `drawing`), then 3 -> 4 when
`drawing_template_save` joined. Every other number
here has been unchanged since the snapshot was taken.
`drawing_template_save` joined. `drawing`
moved 13 -> 16 when v1.6's `document_list` / `document_activate` /
`document_close` (multi-document on both engines) opened SECTION 20;
`layers` moved 14 -> 18 with the four `layer_state_*` tools and `view`
4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where
AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. `system`
moved 8 -> 14 with v1.6's live-only environment tools (`system_launch`,
`system_preferences_get/set`, `user_pick_point`, `user_select`,
`system_prompt_message`), which refuse headlessly with declared
capability keys. 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 == {
@@ -545,21 +554,21 @@ async def test_tool_group_sizes_are_unchanged():
"blocks": 9,
"corner_ops": 4,
"dimensions": 5,
"drawing": 13,
"drawing": 16,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layers": 18,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
"system": 14,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
"view": 10,
}
assert sum(sizes.values()) == 185
assert sum(sizes.values()) == 204
+618
View File
@@ -0,0 +1,618 @@
"""Named views and UCS on both engines.
Headless, a named view is a VIEW table entry and "restore" writes the
``*Active`` VPORT (the view the file opens on) — ezdxf's header has no
``$VIEWCTR``; measured. A UCS is a UCS table entry plus the ``$UCS*`` header
variables. Tool coordinates stay WCS on both engines: a UCS is stored and
made current for the operator, and no tool interprets its inputs in it.
"""
from __future__ import annotations
import types
import pytest
from fastmcp import Client
import server
from engineering.environment.ucs import is_world_axes, resolve_ucs_axes
from engineering.environment.views import resolve_view_args
pytestmark = pytest.mark.asyncio
# ── pure helpers ────────────────────────────────────────────────────────────
def test_view_args_validate_before_any_write():
assert resolve_view_args([10, 20], 50, None) == {
"center": (10.0, 20.0),
"height": 50.0,
"width": None,
}
assert resolve_view_args(None, None, None) == {"center": None, "height": None, "width": None}
with pytest.raises(ValueError, match="height must be > 0"):
resolve_view_args(None, 0, None)
with pytest.raises(ValueError, match="width must be > 0"):
resolve_view_args(None, 10, -1)
with pytest.raises(ValueError, match="center"):
resolve_view_args([1], None, None)
with pytest.raises(ValueError, match="finite"):
resolve_view_args([float("nan"), 0], None, None)
def test_ucs_axes_are_normalised_and_orthogonality_is_measured():
axes = resolve_ucs_axes([10, 10, 0], [0, 2, 0], [-3, 0, 0])
assert axes["origin"] == (10.0, 10.0, 0.0)
assert axes["x_axis"] == (0.0, 1.0, 0.0) and axes["y_axis"] == (-1.0, 0.0, 0.0)
assert axes["z_axis"] == (0.0, 0.0, 1.0)
assert axes["angle_deg"] == pytest.approx(90.0)
with pytest.raises(ValueError, match=r"measured 45\.00"):
resolve_ucs_axes([0, 0, 0], [1, 0, 0], [1, 1, 0])
with pytest.raises(ValueError, match="x_axis.*zero"):
resolve_ucs_axes([0, 0, 0], [0, 0, 0], [0, 1, 0])
with pytest.raises(ValueError, match="origin"):
resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0])
def test_world_is_decided_by_the_frame_not_the_name():
assert is_world_axes((0, 0, 0), (1, 0, 0), (0, 1, 0))
assert is_world_axes([0.0, 0.0, 1e-12], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0])
assert not is_world_axes((0, 20, 0), (1, 0, 0), (0, 1, 0)) # UCS Origin
assert not is_world_axes((0, 0, 0), (0, 1, 0), (-1, 0, 0)) # rotated 90°
assert not is_world_axes((0, 0), (1, 0, 0), (0, 1, 0))
# ── headless: named views ───────────────────────────────────────────────────
async def test_view_defaults_to_the_drawing_extents(backend):
await backend.entity_create_line(0, 0, 100, 50)
result = await backend.view_named_save("OVERALL")
assert result["ok"] and result["replaced"] is False
assert result["center"] == [50.0, 25.0]
# extents 100 x 50 at the *Active VPORT's default aspect 1.34: the width
# is the binding side, so height = 100 / 1.34
assert result["height"] == pytest.approx(100.0 / 1.34)
assert result["width"] == pytest.approx(100.0)
view = backend._doc.views.get("OVERALL")
assert tuple(view.dxf.center)[:2] == (50.0, 25.0)
async def test_view_on_an_empty_drawing_needs_an_explicit_window(backend):
with pytest.raises(ValueError, match="no extents"):
await backend.view_named_save("EMPTY")
assert "EMPTY" not in backend._doc.views
result = await backend.view_named_save("EMPTY", center=[0, 0], height=10)
assert result["center"] == [0.0, 0.0] and result["height"] == 10.0
assert result["width"] == pytest.approx(13.4)
async def test_view_replace_restore_and_list(backend):
await backend.view_named_save("V", center=[1, 2], height=10, width=15)
again = await backend.view_named_save("v", center=[5, 5], height=20)
assert again["replaced"] is True and again["name"] == "V"
assert len(list(backend._doc.views)) == 1
restored = await backend.view_named_restore("v")
assert restored == {
"ok": True,
"name": "V",
"center": [5.0, 5.0],
"height": 20.0,
"width": pytest.approx(26.8),
"applied": "header_only",
"store": "vport_active",
"vport_height": 20.0,
"aspect_ratio": pytest.approx(1.34),
"backend": "ezdxf",
}
(vport,) = backend._doc.viewports.get("*Active")
assert tuple(vport.dxf.center)[:2] == (5.0, 5.0) and vport.dxf.height == 20.0
assert await backend.view_named_list() == [
{"name": "V", "center": [5.0, 5.0], "height": 20.0, "width": pytest.approx(26.8)}
]
with pytest.raises(ValueError, match="no named view 'NOPE'"):
await backend.view_named_restore("NOPE")
with pytest.raises(ValueError, match="height must be > 0"):
await backend.view_named_save("BAD", center=[0, 0], height=0)
assert "BAD" not in backend._doc.views
async def test_view_restore_keeps_the_vport_aspect_and_fits_the_window(backend):
"""Measured (AutoCAD 2026, display aspect 2.013): the *Active VPORT's
aspect ratio is reconciled against the real display by the viewport's
lower-left corner — a 10x20 view at (5, 6) written with aspect 0.5
opened at VIEWCTR (20.134, 6), and the default 1.34 at (11.734, 6). The
aspect is the display's (AutoCAD writes it at save), so restore leaves it
alone and fits the window into it, like `-VIEW _R` does."""
(vport,) = backend._doc.viewports.get("*Active") or [backend._doc.viewports.new("*Active")]
vport.dxf.aspect_ratio = 2.0125 # what AutoCAD wrote for a 2262 x 1124 display
await backend.view_named_save("TALL", center=[5, 6], height=20, width=10)
await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100)
tall = await backend.view_named_restore("TALL")
assert vport.dxf.aspect_ratio == pytest.approx(2.0125), "never rewritten from w / h"
assert tuple(vport.dxf.center)[:2] == (5.0, 6.0)
assert vport.dxf.height == 20.0 and tall["vport_height"] == 20.0
assert tall["aspect_ratio"] == pytest.approx(2.0125)
wide = await backend.view_named_restore("WIDE")
assert vport.dxf.aspect_ratio == pytest.approx(2.0125)
assert vport.dxf.height == pytest.approx(100.0 / 2.0125)
assert wide["vport_height"] == pytest.approx(100.0 / 2.0125) and wide["height"] == 20.0
async def test_view_is_written_as_a_plan_view_on_disk(backend, tmp_path):
"""ezdxf's VIEW default is ``direction=(1, 1, 1)`` — an isometric view. The
tool stores the plan window of ``center``/``height``, so the entry must
say so explicitly (DXF 11/21/31, 12/22/32 and 50) or AutoCAD restores it
oblique and reads ``center`` in that DCS."""
await backend.entity_create_line(0, 0, 100, 50)
await backend.view_named_save("OVERALL")
view = backend._doc.views.get("OVERALL")
assert tuple(view.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(view.dxf.target) == (0.0, 0.0, 0.0)
assert view.dxf.view_twist == 0.0
import ezdxf
path = tmp_path / "views.dxf"
await backend.drawing_save_as(str(path))
reopened = ezdxf.readfile(str(path)).views.get("OVERALL")
assert tuple(reopened.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(reopened.dxf.target) == (0.0, 0.0, 0.0)
assert tuple(reopened.dxf.center)[:2] == (50.0, 25.0)
async def test_view_restore_carries_the_orientation_into_the_vport(backend):
"""A foreign drawing's VIEW may be oblique or twisted; restoring it must
not silently flatten it to a plan view of the same center."""
backend._doc.views.add(
"ISO",
dxfattribs={
"center": (5.0, 6.0, 0.0),
"height": 20.0,
"width": 30.0,
"direction": (1.0, 1.0, 1.0),
"target": (1.0, 2.0, 3.0),
"view_twist": 15.0,
},
)
result = await backend.view_named_restore("iso")
assert result["name"] == "ISO" and result["center"] == [5.0, 6.0]
(vport,) = backend._doc.viewports.get("*Active")
assert tuple(vport.dxf.direction) == (1.0, 1.0, 1.0)
assert tuple(vport.dxf.target) == (1.0, 2.0, 3.0)
assert vport.dxf.view_twist == 15.0
# ...and restoring a view the tool saved puts the VPORT back on plan.
await backend.view_named_save("PLAN", center=[0, 0], height=10)
await backend.view_named_restore("PLAN")
(vport,) = backend._doc.viewports.get("*Active")
assert tuple(vport.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(vport.dxf.target) == (0.0, 0.0, 0.0)
assert vport.dxf.view_twist == 0.0
# ── headless: UCS ───────────────────────────────────────────────────────────
async def test_ucs_set_stores_the_entry_and_makes_it_current(backend):
result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
assert result == {
"ok": True,
"name": "FRONT",
"origin": [10.0, 10.0, 0.0],
"x_axis": [0.0, 1.0, 0.0],
"y_axis": [-1.0, 0.0, 0.0],
"replaced": False,
"current": True,
"backend": "ezdxf",
}
header = backend._doc.header
assert header["$UCSNAME"] == "FRONT" and tuple(header["$UCSORG"]) == (10.0, 10.0, 0.0)
assert tuple(header["$UCSXDIR"]) == (0.0, 1.0, 0.0)
rows = await backend.ucs_list()
assert rows[0] == {
"name": "world",
"origin": [0.0, 0.0, 0.0],
"x_axis": [1.0, 0.0, 0.0],
"y_axis": [0.0, 1.0, 0.0],
"current": False,
}
assert rows[1]["name"] == "FRONT" and rows[1]["current"] is True
async def test_ucs_non_orthogonal_is_refused_before_any_write(backend):
with pytest.raises(ValueError, match="measured 60"):
await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [0.5, 0.8660254, 0])
assert "SKEW" not in backend._doc.ucs and backend._doc.header["$UCSNAME"] == ""
async def test_ucs_restore_world_and_named(backend):
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
world = await backend.ucs_restore("world")
assert world["name"] == "world" and backend._doc.header["$UCSNAME"] == ""
assert tuple(backend._doc.header["$UCSXDIR"]) == (1.0, 0.0, 0.0)
assert (await backend.ucs_list())[0]["current"] is True
named = await backend.ucs_restore("front")
assert named["name"] == "FRONT" and backend._doc.header["$UCSNAME"] == "FRONT"
with pytest.raises(ValueError, match="no UCS named 'NOPE'"):
await backend.ucs_restore("NOPE")
with pytest.raises(ValueError, match="reserved"):
await backend.ucs_set("world", [0, 0, 0], [1, 0, 0], [0, 1, 0])
async def test_ucs_replace_keeps_one_entry(backend):
await backend.ucs_set("A", [0, 0, 0], [1, 0, 0], [0, 1, 0])
again = await backend.ucs_set("a", [1, 1, 0], [1, 0, 0], [0, 1, 0])
assert again["replaced"] is True and again["name"] == "A"
assert len(list(backend._doc.ucs)) == 1
async def test_ucs_list_does_not_call_an_unnamed_ucs_world(backend):
"""``UCS Origin`` / ``UCS 3P`` without saving — the common kind — leaves
``$UCSNAME`` empty exactly like WCS does (AutoCAD 2026 writes
``$UCSNAME=''``, ``$UCSORG=(0,20,0)`` after ``_.UCS _O``). The frame, not
the name, says whether the drawing is in WCS."""
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
header = backend._doc.header
header["$UCSNAME"] = ""
header["$UCSORG"] = (50.0, 20.0, 0.0)
header["$UCSXDIR"] = (0.0, 1.0, 0.0)
header["$UCSYDIR"] = (-1.0, 0.0, 0.0)
rows = await backend.ucs_list()
assert [r["name"] for r in rows] == ["world", "FRONT", None]
assert [r["current"] for r in rows] == [False, False, True]
assert rows[2] == {
"name": None,
"origin": [50.0, 20.0, 0.0],
"x_axis": [0.0, 1.0, 0.0],
"y_axis": [-1.0, 0.0, 0.0],
"current": True,
}
# ...and the same empty name with the WCS frame really is world.
await backend.ucs_restore("world")
rows = await backend.ucs_list()
assert [r["name"] for r in rows] == ["world", "FRONT"]
assert rows[0]["current"] is True and rows[1]["current"] is False
async def test_ucs_list_a_stale_name_falls_back_to_the_frame(backend):
"""``$UCSNAME`` naming an entry the table no longer holds is not a
current row; the frame decides between world and unnamed."""
header = backend._doc.header
header["$UCSNAME"] = "GONE"
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True)]
header["$UCSORG"] = (0.0, 20.0, 0.0)
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", False), (None, True)]
assert rows[1]["origin"] == [0.0, 20.0, 0.0]
async def test_ucs_list_tool_reports_an_unnamed_ucs_over_the_wire(monkeypatch, tmp_path):
"""The exact file AutoCAD writes after ``_.UCS _O 0,20,0`` without saving:
``$UCSNAME=''`` and ``$UCSORG=(0,20,0)``. The tool used to answer
``current: "world"``."""
import ezdxf
doc = ezdxf.new("R2018")
doc.ucs.add(
"FRONT", dxfattribs={"origin": (10, 10, 0), "xaxis": (0, 1, 0), "yaxis": (-1, 0, 0)}
)
doc.header["$UCSNAME"] = ""
doc.header["$UCSORG"] = (0.0, 20.0, 0.0)
path = tmp_path / "unnamed_ucs.dxf"
doc.saveas(str(path))
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_open", {"path": str(path)})
payload = (await client.call_tool("ucs_list", {})).structured_content
assert payload["current"] is None and payload["current_unnamed"] is True
assert payload["count"] == 3
assert [r["name"] for r in payload["ucs"]] == ["world", "FRONT", None]
assert payload["ucs"][0]["current"] is False
assert payload["ucs"][2]["current"] is True
assert payload["ucs"][2]["origin"] == [0.0, 20.0, 0.0]
# ── live engine, against a fake ActiveX surface ──────────────────────────────
class _FakeView:
def __init__(self, name):
self.Name = name
self.Center = (0.0, 0.0)
self.Height = 100.0
self.Width = 150.0
class _FakeViews:
def __init__(self):
self.views: list[_FakeView] = []
self.calls: list[tuple[str, tuple]] = []
@property
def Count(self):
return len(self.views)
def Item(self, key):
if isinstance(key, int):
return self.views[key]
for v in self.views:
if v.Name.lower() == key.lower():
return v
raise RuntimeError(f"no view {key}")
def Add(self, name):
self.calls.append(("Add", (name,)))
v = _FakeView(name)
self.views.append(v)
return v
class _FakeUcs:
def __init__(self, name, origin, xv, yv):
self.Name = name
self.Origin = origin
self.XVector = xv
self.YVector = yv
class _FakeUcsCollection:
def __init__(self):
self.items: list[_FakeUcs] = []
self.calls: list[tuple[str, tuple]] = []
@property
def Count(self):
return len(self.items)
def Item(self, key):
if isinstance(key, int):
return self.items[key]
for u in self.items:
if u.Name.lower() == key.lower():
return u
raise RuntimeError(f"no ucs {key}")
def Add(self, origin, x_point, y_point, name):
self.calls.append(("Add", (origin, x_point, y_point, name)))
# ActiveX stores unit vectors derived from the two points.
xv = tuple(p - o for p, o in zip(x_point, origin, strict=True))
yv = tuple(p - o for p, o in zip(y_point, origin, strict=True))
u = _FakeUcs(name, origin, xv, yv)
self.items.append(u)
return u
# Measured (AutoCAD 2026): `-VIEW _S` of VIEWSIZE 20 recorded width 40.267
# and the corner-anchored restores landed at x = 10.134 / 20.134 — all three
# say the display aspect was 2.0134, i.e. a 2263 x 1124 SCREENSIZE.
_SCREEN = (2263.0, 1124.0)
_DISPLAY_ASPECT = _SCREEN[0] / _SCREEN[1] # 2.0134
class _FakeDocument:
"""``GetVariable`` is an AcadDocument member (``hasattr(app, "GetVariable")``
is False live). ``ActiveViewport`` is deliberately STALE — measured, the
AcadViewport object keeps the document's initial view whatever the
display shows — so any code that reads it saves the wrong window."""
def __init__(self, variables):
self.Views = _FakeViews()
self.UserCoordinateSystems = _FakeUcsCollection()
self.variables = variables
self._viewport = types.SimpleNamespace(Center=(298.98, 148.5), Height=297.0, Width=597.97)
self.viewport_sets = 0
self.ActiveUCS = None
self.commands: list[str] = []
def GetVariable(self, name):
return self.variables[name]
@property
def ActiveViewport(self):
return self._viewport
@ActiveViewport.setter
def ActiveViewport(self, value):
self._viewport = value
self.viewport_sets += 1
def SendCommand(self, macro):
self.commands.append(macro)
class _FakeApp:
"""No ``GetVariable``. ``ZoomWindow`` models ZOOM Window / `-VIEW _R`:
the window is centred and fitted to the display aspect (measured live:
`-VIEW _R` of a 10x20 view at (5, 6) gives VIEWCTR (5, 6), VIEWSIZE 20)."""
def __init__(self, document):
self.document = document
self.zoom_windows: list[tuple[tuple, tuple]] = []
def ZoomWindow(self, lower_left, upper_right):
self.zoom_windows.append((tuple(lower_left), tuple(upper_right)))
w = upper_right[0] - lower_left[0]
h = upper_right[1] - lower_left[1]
cx, cy = lower_left[0] + w / 2.0, lower_left[1] + h / 2.0
self.document.variables["VIEWCTR"] = (cx, cy, 0.0)
self.document.variables["VIEWSIZE"] = max(h, w / _DISPLAY_ASPECT)
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
variables = {
"CMDACTIVE": 0,
"UCSNAME": "",
"WORLDUCS": 1,
"UCSORG": (0.0, 0.0, 0.0),
"UCSXDIR": (1.0, 0.0, 0.0),
"UCSYDIR": (0.0, 1.0, 0.0),
"VIEWCTR": (5.0, 6.0, 0.0),
"VIEWSIZE": 20.0,
"SCREENSIZE": _SCREEN,
}
document = _FakeDocument(variables)
app = _FakeApp(document)
assert not hasattr(app, "GetVariable"), "AcadApplication has no GetVariable member"
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_regen", lambda: None)
monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z)))
monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values))
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, document, variables
async def test_com_view_save_defaults_to_viewctr_viewsize_not_the_viewport_object(com_backend):
"""Measured (AutoCAD 2026): after ``_.ZOOM _C 5,6 20`` VIEWCTR is (5, 6)
and VIEWSIZE 20 while ``doc.ActiveViewport`` still answers the initial
view (298.98, 148.5) x 297 — and AutoCAD's own `-VIEW _S` records
centre (5, 6), height 20, width 20 x the display aspect (40.267)."""
backend, document, _ = com_backend
result = await backend.view_named_save("HOME")
assert document.Views.calls == [("Add", ("HOME",))]
(view,) = document.Views.views
assert view.Center == (5.0, 6.0) and view.Height == 20.0
assert view.Width == pytest.approx(20.0 * _DISPLAY_ASPECT)
assert result["center"] == [5.0, 6.0] and result["replaced"] is False
assert result["width"] == pytest.approx(40.267, abs=1e-3), "what `-VIEW _S` recorded live"
again = await backend.view_named_save("home", center=[1, 2], height=10)
assert again["replaced"] is True and len(document.Views.views) == 1
assert view.Center == (1.0, 2.0) and view.Height == 10.0
assert view.Width == pytest.approx(10.0 * _DISPLAY_ASPECT), (
"width defaults to the display aspect"
)
assert document.viewport_sets == 0
async def test_com_view_restore_zooms_the_window_like_view_r(com_backend):
"""Measured (AutoCAD 2026, display aspect 2.013): writing Center/Height/
Width into the AcadViewport lands a 30x20 view at (5, 6) on VIEWCTR
(10.134, 6) and a 10x20 one on (20.134, 6) — AutoCAD anchors the
lower-left corner and widens. `-VIEW _R` gives VIEWCTR (5, 6), VIEWSIZE
20 for both, and so does ZOOM Window on the saved rectangle."""
backend, document, variables = com_backend
from backends import com_backend as module
app = module._acad_app() # the fixture's _FakeApp
await backend.view_named_save("HOME", center=[5, 6], height=20, width=30)
await backend.view_named_save("TALL", center=[5, 6], height=20, width=10)
await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100)
for name, expect_size in (("HOME", 20.0), ("TALL", 20.0), ("WIDE", 100.0 / _DISPLAY_ASPECT)):
result = await backend.view_named_restore(name)
assert result["applied"] == "zoom_window" and result["center"] == [5.0, 6.0]
assert result["viewctr"] == [5.0, 6.0], name
assert result["viewsize"] == pytest.approx(expect_size), name
assert variables["VIEWCTR"][:2] == (5.0, 6.0)
assert app.zoom_windows == [
((-10.0, -4.0, 0.0), (20.0, 16.0, 0.0)),
((0.0, -4.0, 0.0), (10.0, 16.0, 0.0)),
((-45.0, -4.0, 0.0), (55.0, 16.0, 0.0)),
]
# The stale AcadViewport object was neither written nor re-assigned.
assert document.viewport_sets == 0
assert document.ActiveViewport.Center == (298.98, 148.5)
assert await backend.view_named_list() == [
{"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0},
{"name": "TALL", "center": [5.0, 6.0], "height": 20.0, "width": 10.0},
{"name": "WIDE", "center": [5.0, 6.0], "height": 20.0, "width": 100.0},
]
async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend):
backend, document, variables = com_backend
result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0])
assert document.UserCoordinateSystems.calls == [
("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT"))
]
assert document.ActiveUCS is document.UserCoordinateSystems.items[0]
assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True
variables["UCSNAME"] = "FRONT"
rows = await backend.ucs_list()
assert rows[0]["name"] == "world" and rows[0]["current"] is False
assert rows[1] == {
"name": "FRONT",
"origin": [10.0, 10.0, 0.0],
"x_axis": [0.0, 1.0, 0.0],
"y_axis": [-1.0, 0.0, 0.0],
"current": True,
}
async def test_com_ucs_list_reads_worlducs_not_the_empty_name(com_backend):
"""Verified live on AutoCAD 2026: after ``_.UCS _O 10,10,0`` (unnamed),
``UCSNAME=""`` and ``WORLDUCS=0`` — the same empty name WCS has."""
backend, document, variables = com_backend
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
variables.update(
{
"UCSNAME": "",
"WORLDUCS": 0,
"UCSORG": (0.0, 20.0, 0.0),
"UCSXDIR": (1.0, 0.0, 0.0),
"UCSYDIR": (0.0, 1.0, 0.0),
}
)
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [
("world", False),
("FRONT", False),
(None, True),
]
assert rows[2]["origin"] == [0.0, 20.0, 0.0] and rows[2]["x_axis"] == [1.0, 0.0, 0.0]
# WORLDUCS=1 with the same empty name is world, and no unnamed row appears.
variables.update({"WORLDUCS": 1, "UCSORG": (0.0, 0.0, 0.0)})
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True), ("FRONT", False)]
async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend):
backend, document, variables = com_backend
result = await backend.ucs_restore("world")
assert document.commands == ["_.UCS _W\n"] and result["name"] == "world"
variables["CMDACTIVE"] = 1
with pytest.raises(RuntimeError, match="CMDACTIVE"):
await backend.ucs_restore("world")
assert len(document.commands) == 1
async def test_com_ucs_restore_world_refuses_when_cmdactive_cannot_be_read(com_backend):
"""The guard once swallowed a failed read as "idle" — with the read on the
wrong object (``app.GetVariable``) it failed on every call, so `_.UCS _W`
went into whatever prompt was open. A read failure now refuses."""
backend, document, variables = com_backend
del variables["CMDACTIVE"]
with pytest.raises(RuntimeError, match="cannot verify AutoCAD is idle"):
await backend.ucs_restore("world")
assert document.commands == []
async def test_com_ucs_reads_sysvars_from_the_document_never_the_application(com_backend):
"""Live, ``AutoCAD.Application.GetVariable`` raises AttributeError: the
member is on AcadDocument. The fixture's app has none, so every UCS path
here would blow up on the old code."""
backend, document, variables = com_backend
from backends import com_backend as module
assert not hasattr(module._acad_app(), "GetVariable")
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True)]
assert (await backend.ucs_restore("world"))["current"] is True
async def test_com_ucs_refuses_before_activex(com_backend):
backend, document, _ = com_backend
with pytest.raises(ValueError, match="measured"):
await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0])
assert document.UserCoordinateSystems.calls == []