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