Files
autocad-mcp-pro/tests/test_layout_lifecycle.py
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00

393 lines
15 KiB
Python

"""M6 — deleting, renaming and copying sheet tabs.
`layout_list/create/set_current` shipped in v1.4; the rest of the tab lifecycle
did not, so a sheet could be made and never unmade. What makes this more than
three thin wrappers is that ezdxf's own guards stop well short of where a tool
boundary has to stop, and the gaps are all silent:
* `doc.layouts.delete("")` does not refuse — it deletes the *first* paper-space
layout and reports nothing unusual, and `doc.layouts.get("")` likewise
resolves to the active paper space. A caller who passed an empty string by
accident loses a sheet.
* `doc.layouts.rename()` skips the `is_valid_table_name` check that `new()`
performs, so `Bad/Name` and even `""` are accepted and the file still audits
clean.
* Deleting or renaming the *current* layout leaves `self._current_space`
pointing at a name that no longer exists. `_msp()` then silently falls back to
model space while `layout_list()` still reports the dead name — so
`entity_create_*` lands somewhere the caller was never told about.
* The entities on a deleted layout are destroyed but stay in `entitydb`, where
`_get_entity`'s `is None` check does not see them. Fetching a handle from a
deleted sheet used to raise `AttributeError: 'Line' object has no attribute
'dxf'` instead of a refusal.
ezdxf has no layout-copy API at all (`xref.load_paperspace` is cross-document
only), so `layout_copy` is a construction — and everything a construction can
get wrong here is invisible to the Auditor, which reports zero errors on a
duplicated tab order, a duplicated main viewport and a hatch whose boundary
still points into the layout it was copied from.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
OTHER = "A4-Sheet"
def _names(backend) -> list[str]:
return list(backend._doc.layouts.names_in_taborder())
def _entities_in(backend, layout_name: str) -> list[str]:
space = backend._doc.layouts.get(layout_name)
return sorted(e.dxftype() for e in space)
# ── layout_delete ───────────────────────────────────────────────────────────
async def test_deleting_a_layout_removes_the_tab_and_its_geometry(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
result = await backend.layout_delete(SHEET)
assert result["ok"] is True
assert result["deleted"] == SHEET
assert result["entities_destroyed"] == 1
assert SHEET not in _names(backend)
async def test_deleting_model_space_is_refused(backend):
result = await backend.layout_delete("Model")
assert result["ok"] is False
assert "Model" in result["error"]
assert "Model" in _names(backend)
async def test_a_blank_name_does_not_delete_the_first_sheet_it_finds(backend):
"""ezdxf's own delete("") destroys Layout1 and says nothing."""
before = _names(backend)
for blank in ("", " "):
result = await backend.layout_delete(blank)
assert result["ok"] is False, f"delete({blank!r}) must refuse"
assert _names(backend) == before
async def test_deleting_a_missing_layout_refuses_with_a_readable_error(backend):
"""ezdxf raises a bare KeyError here; the tool must not leak that."""
result = await backend.layout_delete("NOSUCH")
assert result["ok"] is False
assert "NOSUCH" in result["error"]
async def test_deleting_the_last_paper_space_layout_refuses(backend):
"""A DXF document must keep at least one paper-space layout."""
for name in _names(backend):
if name != "Model" and name != "Layout1":
await backend.layout_delete(name)
remaining = [n for n in _names(backend) if n != "Model"]
assert len(remaining) == 1, "fixture assumption: exactly one sheet left"
result = await backend.layout_delete(remaining[0])
assert result["ok"] is False
assert remaining[0] in _names(backend)
async def test_deleting_the_current_layout_leaves_every_report_agreeing(backend):
"""The three-way disagreement this test exists to prevent.
Before the fix: ``layout_list`` said the deleted sheet, ``$TILEMODE`` said
paper space, and ``_msp()`` quietly wrote to model space. Whatever the tool
decides the new current space is, all three must say the same thing and the
next entity must actually land there.
"""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
result = await backend.layout_delete(SHEET)
assert result["ok"] is True
current = (await backend.layout_list())["current"]
assert current == result["current"]
assert current in _names(backend)
line = await backend.entity_create_line(0, 0, 10, 0)
landed = backend._doc.modelspace() if current == "Model" else backend._doc.layouts.get(current)
assert line.handle in {e.dxf.handle for e in landed}
async def test_a_handle_from_a_deleted_layout_refuses_instead_of_crashing(backend):
"""The destroyed entity stays in entitydb; `is None` does not catch it."""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_delete(SHEET)
with pytest.raises(RuntimeError, match=line.handle):
await backend.entity_get(line.handle)
# ── layout_rename ───────────────────────────────────────────────────────────
async def test_renaming_keeps_the_entities_and_their_handles(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
result = await backend.layout_rename(SHEET, OTHER)
assert result["ok"] is True
assert OTHER in _names(backend) and SHEET not in _names(backend)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
async def test_renaming_the_current_layout_keeps_the_caller_on_it(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.layout_rename(SHEET, OTHER)
assert (await backend.layout_list())["current"] == OTHER
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in {e.dxf.handle for e in backend._doc.layouts.get(OTHER)}
@pytest.mark.parametrize("bad", ["", " ", "Bad/Name", "Bad*Name", "Bad?Name"])
async def test_an_invalid_new_name_is_refused(backend, bad):
"""`rename()` accepts all of these and the file still audits clean."""
await backend.layout_create(SHEET)
result = await backend.layout_rename(SHEET, bad)
assert result["ok"] is False
assert SHEET in _names(backend)
async def test_renaming_onto_an_existing_name_is_refused(backend):
await backend.layout_create(SHEET)
await backend.layout_create(OTHER)
result = await backend.layout_rename(SHEET, OTHER)
assert result["ok"] is False
assert SHEET in _names(backend) and OTHER in _names(backend)
@pytest.mark.parametrize("args", [("Model", OTHER), (SHEET, "Model")])
async def test_model_space_cannot_be_renamed_or_shadowed(backend, args):
await backend.layout_create(SHEET)
result = await backend.layout_rename(*args)
assert result["ok"] is False
assert "Model" in _names(backend)
async def test_renaming_a_missing_layout_refuses(backend):
result = await backend.layout_rename("NOSUCH", OTHER)
assert result["ok"] is False
# ── names are case-insensitive, like the engine underneath ──────────────────
#
# ezdxf resolves layout names case-insensitively in `get`, `new`, `rename`,
# `delete` and `set_active_layout` — but `names()` returns the *stored* case, so
# every guard written as `name in doc.layouts.names()` disagrees with the engine
# it is guarding. Both directions of that disagreement are bugs: a duplicate
# slips past the check and dies on a raw `DXFValueError` from inside ezdxf, and
# an existing layout typed in another case is reported as missing.
async def test_creating_a_layout_that_differs_only_by_case_refuses_cleanly(backend):
await backend.layout_create(SHEET)
result = await backend.layout_create(SHEET.upper())
assert result["ok"] is False
assert _names(backend).count(SHEET) == 1
@pytest.mark.parametrize(
"call",
[
lambda b, name: b.layout_delete(name),
lambda b, name: b.layout_set_current(name),
lambda b, name: b.viewport_list(name),
lambda b, name: b.layout_copy(name, "Copy-Of-It"),
],
ids=["delete", "set_current", "viewport_list", "copy_source"],
)
async def test_an_existing_layout_is_found_whatever_case_it_is_typed_in(backend, call):
await backend.layout_create(SHEET)
await backend.layout_create("Spare") # so delete is not refused as the last sheet
result = await call(backend, SHEET.upper())
assert result["ok"] is True, f"{SHEET.upper()} must resolve to {SHEET}"
@pytest.mark.parametrize(
"call",
[
lambda b, name: b.layout_rename("Spare", name),
lambda b, name: b.layout_copy("Spare", name),
],
ids=["rename_target", "copy_target"],
)
async def test_a_target_name_colliding_only_by_case_refuses_cleanly(backend, call):
await backend.layout_create(SHEET)
await backend.layout_create("Spare")
result = await call(backend, SHEET.upper())
assert result["ok"] is False
assert "Spare" in _names(backend)
async def test_a_layout_can_be_renamed_to_a_different_spelling_of_itself(backend):
"""Re-casing a name is a rename onto itself, not a collision."""
await backend.layout_create(SHEET)
result = await backend.layout_rename(SHEET, SHEET.upper())
assert result["ok"] is True
assert SHEET.upper() in _names(backend)
# ── layout_copy ─────────────────────────────────────────────────────────────
async def test_a_copy_carries_the_geometry_and_leaves_the_source_alone(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.entity_create_circle(50, 50, 10)
await backend.layout_set_current("Model")
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is True
assert result["entities_copied"] == 2
assert result["skipped"] == []
assert _entities_in(backend, OTHER) == _entities_in(backend, SHEET)
async def test_the_copy_is_independent_of_its_source(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_copy(SHEET, OTHER)
await backend.layout_delete(SHEET)
assert _entities_in(backend, OTHER) == ["LINE"]
async def test_the_copy_survives_a_save_and_reload_with_a_clean_audit(backend, tmp_path):
"""The Auditor is not evidence on its own, so check the structure too."""
import ezdxf
from ezdxf.audit import Auditor
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_copy(SHEET, OTHER)
path = tmp_path / "copied.dxf"
await backend.drawing_save_as(str(path))
doc = ezdxf.readfile(str(path))
auditor = Auditor(doc)
auditor.run()
assert not auditor.errors and not auditor.fixes
assert OTHER in doc.layouts.names()
assert [e.dxftype() for e in doc.layouts.get(OTHER)] == ["LINE"]
async def test_no_two_layouts_end_up_sharing_a_tab_order(backend):
"""A delete leaves a gap; letting new() assign taborder duplicates it."""
await backend.layout_create(SHEET)
await backend.layout_create("Scratch")
await backend.layout_delete("Scratch")
await backend.layout_copy(SHEET, OTHER)
orders = [backend._doc.layouts.get(n).dxf.taborder for n in _names(backend) if n != "Model"]
assert len(orders) == len(set(orders)), f"duplicate taborder: {orders}"
async def test_the_copy_keeps_the_source_paper_size(backend):
await backend.layout_create(SHEET)
source = backend._doc.layouts.get(SHEET)
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
expected = source.get_paper_limits()
await backend.layout_copy(SHEET, OTHER)
assert backend._doc.layouts.get(OTHER).get_paper_limits() == expected
async def test_copying_model_space_is_refused(backend):
"""Otherwise the construction happily dumps model geometry onto a sheet."""
result = await backend.layout_copy("Model", OTHER)
assert result["ok"] is False
assert OTHER not in _names(backend)
async def test_a_blank_source_does_not_resolve_to_whatever_sheet_is_active(backend):
"""ezdxf's `layouts.get("")` returns the active paper space."""
result = await backend.layout_copy("", OTHER)
assert result["ok"] is False
assert OTHER not in _names(backend)
async def test_copying_onto_an_existing_name_is_refused(backend):
await backend.layout_create(SHEET)
await backend.layout_create(OTHER)
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is False
async def test_an_associative_hatch_does_not_keep_pointing_into_the_source(backend):
"""A dangling cross-layout boundary reference the Auditor cannot see."""
from ezdxf.math import Vec3
await backend.layout_create(SHEET)
space = backend._doc.layouts.get(SHEET)
poly = space.add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10)], close=True)
hatch = space.add_hatch(color=2)
path = hatch.paths.add_polyline_path(
[(Vec3(v[0], v[1]).x, Vec3(v[0], v[1]).y) for v in [(0, 0), (10, 0), (10, 10), (0, 10)]],
is_closed=True,
)
path.source_boundary_objects = [poly.dxf.handle]
hatch.set_seed_points([(5, 5)])
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is True
source_handles = {e.dxf.handle for e in backend._doc.layouts.get(SHEET)}
for entity in backend._doc.layouts.get(OTHER):
if entity.dxftype() != "HATCH":
continue
for boundary in entity.paths:
leaked = set(getattr(boundary, "source_boundary_objects", [])) & source_handles
assert not leaked, f"copy still points at the source layout: {leaked}"
async def test_the_copy_does_not_end_up_with_two_main_viewports(backend):
"""Copying a page-set-up layout duplicates the id-1 viewport silently."""
await backend.layout_create(SHEET)
source = backend._doc.layouts.get(SHEET)
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
await backend.layout_copy(SHEET, OTHER)
target = backend._doc.layouts.get(OTHER)
mains = [vp for vp in target.viewports() if vp.dxf.get("id", 0) == 1]
assert len(mains) <= 1, "a layout may have at most one main viewport"
if mains:
assert target.dxf.viewport_handle == mains[0].dxf.handle