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>
This commit is contained in:
Umut CAN
2026-08-06 09:45:20 +03:00
co-authored by Claude Opus 5
parent d5a0ff5109
commit b03a7615b1
107 changed files with 32640 additions and 4810 deletions
+27
View File
@@ -1,10 +1,37 @@
"""Shared fixtures for AutoCAD MCP tests."""
import pytest
import pytest_asyncio
from backends.ezdxf_backend import EzdxfBackend
@pytest.fixture(autouse=True)
def pin_headless_backend(request, monkeypatch):
"""Every test talks to the headless engine unless it says otherwise.
This suite runs on developer machines that have AutoCAD installed, and a
test that opens ``Client(server.mcp)`` without pinning would run the real
lifespan: ``AUTOCAD_MCP_BACKEND`` unset means ``auto``, which on Windows
means attaching to the operator's open, unsaved drawing and creating
entities in it. That has actually happened here. Defaulting the whole suite
to ``ezdxf`` is the only guard that covers tests nobody has written yet.
Opt out with ``@pytest.mark.live_backend`` for a test that deliberately
exercises backend selection.
"""
if request.node.get_closest_marker("live_backend"):
return
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
def pytest_configure(config):
config.addinivalue_line(
"markers",
"live_backend: test drives backend selection itself; do not pin AUTOCAD_MCP_BACKEND",
)
@pytest_asyncio.fixture
async def backend():
"""Create an ezdxf backend with a fresh empty document."""
+1
View File
@@ -0,0 +1 @@
"""Static fixtures for the test suite."""
+179
View File
@@ -0,0 +1,179 @@
"""Golden query set for tool discovery: English in, one right tool out.
Sixty-six cases (51 tuning + 15 holdout) split into a **tuning** set and a
**holdout** set. The split is
the point of the file. Ranking work is measured against
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
before any scoring parameter was chosen, and were not looked at until the
scorer was frozen. A retrieval number quoted against cases the ranker was fitted
to is not evidence of anything.
Rules the cases follow:
* Every ``expect`` is a tool that genuinely, unambiguously does the job. Where
two tools would both be defensible answers (``view_screenshot`` vs
``view_zoom_and_screenshot``, ``entity_delete`` vs ``entity_delete_many``)
the query is worded to pick one, or the case is left out. A golden set that
scores ambiguity is measuring the author, not the ranker.
* ``kind`` records what the case exercises, so accuracy can be reported per
category instead of as one flattering average.
* ``risk`` is the ceiling a caller would sensibly pass. It is ``"read"`` for
the counting questions — the ones where stock BM25 answers "how many entities
are on the GEOMETRY layer" with ``entity_delete_many``.
"""
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["ALL_CASES", "HOLDOUT_CASES", "TUNING_CASES", "GoldenCase"]
@dataclass(frozen=True, slots=True)
class GoldenCase:
"""One query and the tool it must find."""
query: str
expect: str
kind: str # "command" | "synonym" | "paraphrase" | "counting"
risk: str = "any"
def __str__(self) -> str: # readable pytest ids
return f"{self.kind}:{self.query}"
# ── tuning set ──────────────────────────────────────────────────────────────
# Free to be measured against, argued with and re-measured.
TUNING_CASES: tuple[GoldenCase, ...] = (
# AutoCAD command names a drafter types verbatim.
# Re-pointed in v1.5.0 (M8): BPOLY now has a real destination. It was
# parked on entity_create_polyline only because nothing traced a boundary,
# and this is a correction of the expectation, not a tuning of the ranker.
GoldenCase("BPOLY", "boundary_trace", "command"),
GoldenCase("PEDIT", "entity_edit_geometry", "command"),
GoldenCase("WBLOCK", "block_create_from_entities", "command"),
GoldenCase("OVERKILL", "drawing_refine", "command"),
GoldenCase("LAYTRANS", "template_apply_layers", "command"),
GoldenCase("MATCHPROP", "entity_set_properties", "command"),
GoldenCase("QSELECT", "entity_select_smart", "command"),
GoldenCase("DIMLINEAR", "dimension_linear", "command"),
GoldenCase("ARRAYPOLAR", "entity_array_polar", "command"),
GoldenCase("MLEADER", "leader_create_mleader", "command"),
GoldenCase("BHATCH", "entity_create_hatch", "command"),
GoldenCase("LAYISO", "layer_isolate", "command"),
GoldenCase("XLINE", "construction_xline", "command"),
GoldenCase("EXPLODE", "block_explode", "command"),
GoldenCase("DXFOUT", "drawing_export_dxf", "command"),
GoldenCase("QDIM", "dimension_auto", "command"),
GoldenCase("CHA", "entity_chamfer", "command"),
GoldenCase("DLI", "dimension_linear", "command"),
GoldenCase("LAYDEL", "layer_delete", "command"),
GoldenCase("PURGE", "drawing_purge", "command"),
# Shop-floor vocabulary.
GoldenCase("bill of materials", "entity_create_table", "synonym"),
GoldenCase("parts list", "entity_create_table", "synonym"),
GoldenCase("round the corner", "entity_fillet", "synonym"),
GoldenCase("bolt circle", "entity_array_polar", "synonym"),
GoldenCase("title block", "titleblock_apply_iso_a3", "synonym"),
GoldenCase("keyed bore", "keyway_draw_keyed_bore", "synonym"),
GoldenCase("feature control frame", "gd_frame", "synonym"),
GoldenCase("object snap", "point_from_snap", "synonym"),
GoldenCase("zoom to fit", "view_zoom_extents", "synonym"),
GoldenCase("smooth curve", "entity_create_spline", "synonym"),
GoldenCase("true position", "gd_frame", "synonym"),
GoldenCase("datum triangle", "datum_feature", "synonym"),
# Paraphrases — no shared vocabulary with the tool name.
GoldenCase("I need the corner rounded off with a 3 mm radius", "entity_fillet", "paraphrase"),
GoldenCase(
"put an arrow with a note pointing at the hole", "leader_create_mleader", "paraphrase"
),
GoldenCase("turn a layer off so it does not print", "layer_hide", "paraphrase"),
GoldenCase("make the whole shape smaller by half", "entity_scale", "paraphrase"),
GoldenCase(
"repeat this shape around a centre point six times", "entity_array_polar", "paraphrase"
),
GoldenCase(
"the two lines overshoot, cut them back to where they cross", "entity_trim", "paraphrase"
),
GoldenCase("write a paragraph of general notes", "entity_create_mtext", "paraphrase"),
GoldenCase("take a picture of the current drawing", "view_screenshot", "paraphrase"),
GoldenCase("start a checkpoint I can roll back to", "transaction_begin", "paraphrase"),
GoldenCase(
"what is wrong with this drawing before I finish it", "drawing_critique", "paraphrase"
),
GoldenCase("fill this closed area with section hatching", "entity_create_hatch", "paraphrase"),
GoldenCase("bevel a corner at 45 degrees", "entity_chamfer", "paraphrase"),
GoldenCase("load the dashed hidden linetype", "linetype_load", "paraphrase"),
# Counting / inspection. Under stock BM25 the first of these answers with
# entity_delete_many.
GoldenCase(
"how many entities are on the GEOMETRY layer",
"analysis_entity_stats",
"counting",
risk="read",
),
GoldenCase("what layers exist in this drawing", "layer_list", "counting", risk="read"),
GoldenCase("how big is the drawing overall", "analysis_bounding_box", "counting", risk="read"),
# Re-pointed when analysis_measure_entity was added, NOT to match the
# ranker: "this closed polyline" names an entity, and until that tool existed
# the only destination was the one tool that cannot look at the drawing. The
# expectation was wrong the whole time; the case is now answerable.
GoldenCase(
"what is the area of this closed polyline",
"analysis_measure_entity",
"counting",
risk="read",
),
# Withdrawn: "list every circle in the drawing" -> analysis_select_by_type.
# ``entity_list(type_filter="CIRCLE")`` answers it just as correctly (and
# with a layer filter and paging on top), so the case scored the author's
# preference, not the ranker. Replaced rather than re-labelled: relabelling
# a case to whatever the ranker returned is how a golden set stops being
# evidence.
GoldenCase(
"what is inside this rectangular window",
"analysis_find_in_region",
"counting",
risk="read",
),
GoldenCase(
"where is this block used in the drawing",
"block_find_references",
"counting",
risk="read",
),
)
# ── holdout set ─────────────────────────────────────────────────────────────
# Written alongside the tuning cases, before any scoring constant was chosen,
# and deliberately not measured until the scorer was frozen. Do not tune
# against these. If a holdout case fails, the honest fix is a new tuning case
# that captures the same class of failure — not an edit to this tuple.
HOLDOUT_CASES: tuple[GoldenCase, ...] = (
GoldenCase("DDEDIT", "entity_edit_text", "command"),
GoldenCase("MVIEW", "viewport_create", "command"),
GoldenCase("SPL", "entity_create_spline", "command"),
GoldenCase("TOLERANCE", "gd_frame", "command"),
GoldenCase("crosshatch", "entity_create_hatch", "synonym"),
GoldenCase("wall thickness", "entity_offset", "synonym"),
GoldenCase("undo the undo", "drawing_redo", "synonym"),
GoldenCase("pitch circle", "entity_create_circle", "synonym"),
GoldenCase("I want a parallel copy 5 mm inside the outline", "entity_offset", "paraphrase"),
GoldenCase("get rid of the unused blocks and layers", "drawing_purge", "paraphrase"),
GoldenCase("change the wording of this label", "entity_edit_text", "paraphrase"),
GoldenCase("make a scaled window of the model on the sheet", "viewport_create", "paraphrase"),
GoldenCase(
"how far apart are these two points",
"analysis_measure_distance",
"counting",
risk="read",
),
GoldenCase("which blocks are defined", "block_list", "counting", risk="read"),
GoldenCase("what are the properties of this entity", "entity_get", "counting", risk="read"),
)
ALL_CASES: tuple[GoldenCase, ...] = TUNING_CASES + HOLDOUT_CASES
+220
View File
@@ -0,0 +1,220 @@
"""M8/F15 — annotation objects: wipeout, revision cloud, MTEXT mask, find/replace.
Four tools that fill real gaps rather than restating the 141 already here. Each
carries a measured caveat:
* **WIPEOUT** is a real DXF entity (`add_wipeout`) that round-trips.
* **REVCLOUD** is genuinely available: `ezdxf.revcloud.add_entity()` emits the
bulged LWPOLYLINE that AutoCAD's REVCLOUD *is*, stamps the `RevcloudProps`
xdata and picks the bulge sign from the winding direction — so the result is
recognised by `is_revcloud()` rather than merely resembling a cloud. The trap
is the segment length: pass one larger than the shortest edge and you get a
"cloud" with no arcs at all, which looks like a plain rectangle and reports
success.
* **MTEXT background mask** round-trips (`bg_fill`, `box_fill_scale`) — proven
through saveas/readfile, because a mask that vanishes on save is worse than
no mask.
* **find/replace** is the one with a real scope problem. TEXT, MTEXT, ATTRIB
and ATTDEF are all readable *and* writable, so all four are in scope. A
DIMENSION's `dxf.text` is `'<>'` — the override placeholder, not the
measurement — so replacing text inside dimensions would either do nothing or
destroy the association with the measured value. The tool reports which types
it searched instead of silently skipping some, because "0 replacements" and
"I did not look there" are different answers.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]]
# ── wipeout ─────────────────────────────────────────────────────────────────
async def test_a_wipeout_is_created_and_reports_its_handle(backend):
result = await backend.entity_create_wipeout(SQUARE)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "WIPEOUT"
async def test_a_wipeout_survives_save_and_reload(backend, tmp_path):
import ezdxf
created = await backend.entity_create_wipeout(SQUARE)
path = tmp_path / "wipeout.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
assert reloaded is not None and reloaded.dxftype() == "WIPEOUT"
@pytest.mark.parametrize("bad", [[], [[0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]]])
async def test_a_wipeout_needs_at_least_three_points(backend, bad):
"""Two points enclose no area; a zero-area mask masks nothing."""
result = await backend.entity_create_wipeout(bad)
assert result["ok"] is False
# ── revision cloud ──────────────────────────────────────────────────────────
async def test_a_revision_cloud_is_a_bulged_polyline(backend):
"""The arcs are the cloud. A revcloud without bulges is a rectangle."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "LWPOLYLINE"
bulges = [point[4] for point in entity.get_points()]
assert all(abs(b) > 0.1 for b in bulges), "every segment must carry an arc"
assert result["segments"] == len(bulges)
async def test_the_cloud_is_recognisable_as_one(backend):
from ezdxf.revcloud import is_revcloud
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert is_revcloud(backend._doc.entitydb.get(result["handle"]))
async def test_a_segment_longer_than_the_path_is_refused(backend):
"""Otherwise the result is a plain rectangle reported as a cloud."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10_000.0)
assert result["ok"] is False
assert "segment_length" in result["error"]
@pytest.mark.parametrize("bad", [0.0, -5.0])
async def test_a_non_positive_segment_length_is_refused(backend, bad):
result = await backend.entity_create_revcloud(SQUARE, segment_length=bad)
assert result["ok"] is False
# ── MTEXT background mask ───────────────────────────────────────────────────
async def test_a_background_mask_round_trips(backend, tmp_path):
"""A mask that vanishes on save is worse than no mask."""
import ezdxf
mtext = await backend.entity_create_mtext("SECTION A-A", 10, 10)
result = await backend.text_set_background(mtext.handle, color=2, scale=1.5)
assert result["ok"] is True
path = tmp_path / "masked.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(mtext.handle)
assert reloaded.dxf.bg_fill == 1
assert reloaded.dxf.box_fill_scale == pytest.approx(1.5)
async def test_a_background_mask_can_be_turned_off_again(backend):
mtext = await backend.entity_create_mtext("X", 10, 10)
await backend.text_set_background(mtext.handle, color=2)
result = await backend.text_set_background(mtext.handle, enabled=False)
assert result["ok"] is True
assert result["enabled"] is False
assert backend._doc.entitydb.get(mtext.handle).dxf.get("bg_fill", 0) == 0
async def test_a_background_mask_on_plain_text_is_refused(backend):
"""Only MTEXT carries a background fill; TEXT silently would not."""
text = await backend.entity_create_text("PLAIN", 0, 0)
result = await backend.text_set_background(text.handle, color=2)
assert result["ok"] is False
assert "MTEXT" in result["error"]
@pytest.mark.parametrize("bad", [0.5, 0.0, -1.0])
async def test_a_mask_scale_below_one_is_refused(backend, bad):
"""A box smaller than the text is not a mask, it is a stripe."""
mtext = await backend.entity_create_mtext("X", 0, 0)
result = await backend.text_set_background(mtext.handle, color=2, scale=bad)
assert result["ok"] is False
# ── find / replace ──────────────────────────────────────────────────────────
async def _tagged_block(backend):
doc = backend._doc
block = doc.blocks.new("TAGGED")
block.add_attdef("PART", (0, 0), text="OLD")
ref = doc.modelspace().add_blockref("TAGGED", (0, 0))
ref.add_auto_attribs({"PART": "OLD"})
return ref
async def test_find_replace_covers_text_mtext_and_block_attributes(backend):
"""Silently skipping attributes inside blocks is this tool's honesty trap.
Four hits, not three: the ATTDEF in the block *definition* carries the
default value the next insert will use, so leaving it behind would fix the
drawing and reintroduce the old text on the next block reference.
"""
text = await backend.entity_create_text("OLD value", 0, 0)
mtext = await backend.entity_create_mtext("OLD value", 0, 10)
await _tagged_block(backend)
result = await backend.text_find_replace("OLD", "NEW")
assert result["ok"] is True
assert result["replaced"] == 4
changed = {row["type"] for row in result["entities"]}
assert changed == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW value"
assert backend._doc.entitydb.get(mtext.handle).text == "NEW value"
async def test_it_reports_which_types_it_searched(backend):
""" "0 replacements" and "I never looked there" are different answers."""
result = await backend.text_find_replace("NOTHING", "X")
assert result["ok"] is True
assert result["replaced"] == 0
assert set(result["searched_types"]) == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert "DIMENSION" in result["note"]
async def test_a_dry_run_changes_nothing(backend):
text = await backend.entity_create_text("OLD", 0, 0)
result = await backend.text_find_replace("OLD", "NEW", dry_run=True)
assert result["replaced"] == 1
assert result["dry_run"] is True
assert backend._doc.entitydb.get(text.handle).dxf.text == "OLD"
async def test_it_can_be_scoped_to_a_layer(backend):
await backend.entity_create_text("OLD", 0, 0, layer="TEXT")
other = await backend.entity_create_text("OLD", 0, 10, layer="DIM")
result = await backend.text_find_replace("OLD", "NEW", layer="TEXT")
assert result["replaced"] == 1
assert backend._doc.entitydb.get(other.handle).dxf.text == "OLD"
async def test_an_empty_search_string_is_refused(backend):
"""Replacing "" matches between every character and destroys the text."""
result = await backend.text_find_replace("", "X")
assert result["ok"] is False
async def test_case_sensitivity_is_the_callers_choice(backend):
text = await backend.entity_create_text("Old Value", 0, 0)
result = await backend.text_find_replace("old", "NEW", match_case=False)
assert result["replaced"] == 1
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW Value"
+101
View File
@@ -0,0 +1,101 @@
"""T0.8-B — which engine a test talks to must not be a matter of luck.
``_make_backend`` read ``os.environ["AUTOCAD_MCP_BACKEND"]`` directly while
``config.settings.backend`` — the setting that *looks* like the control — had no
production reader at all. So ``monkeypatch.setattr(config.settings, "backend",
"ezdxf")`` was a no-op that read like a safety guard, and on a developer machine
with AutoCAD open an unguarded test would attach to the operator's live unsaved
drawing and start creating entities in it. That is not hypothetical: it happened
during this release's development.
The fix makes ``Settings.backend`` a property that reads the environment live,
and points ``_make_backend`` at it. One source of truth, and late binding is
preserved so ``monkeypatch.setenv`` — which four call sites and
``benchmarks/token_suite.py`` already use correctly — keeps working. Assigning to
``config.settings.backend`` now raises instead of quietly protecting nothing.
"""
from __future__ import annotations
import sys
import types
import pytest
import config
import server
pytestmark = pytest.mark.asyncio
class _ComBackendSentinel:
"""Stands in for the real COM backend and screams if anyone builds it."""
constructed: list[str] = []
def __init__(self, *args, **kwargs):
_ComBackendSentinel.constructed.append("ComBackend() CONSTRUCTED")
raise AssertionError("a test tried to construct the live COM backend")
@pytest.fixture
def com_sentinel(monkeypatch):
"""Replace backends.com_backend with a module that cannot connect anywhere."""
_ComBackendSentinel.constructed.clear()
fake = types.ModuleType("backends.com_backend")
fake.ComBackend = _ComBackendSentinel
monkeypatch.setitem(sys.modules, "backends.com_backend", fake)
return _ComBackendSentinel
async def test_the_env_var_is_what_actually_selects_the_backend(monkeypatch, com_sentinel):
from backends.ezdxf_backend import EzdxfBackend
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
backend = await server._make_backend()
assert isinstance(backend, EzdxfBackend)
assert com_sentinel.constructed == [], "the COM backend must never have been touched"
await backend.disconnect()
async def test_settings_backend_reports_what_make_backend_will_use(monkeypatch):
"""The setting must not be able to disagree with the selector."""
for value in ("ezdxf", "com", "auto"):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", value)
assert config.settings.backend == value
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", " EzDxF ")
assert config.settings.backend == "ezdxf", "must normalise like the selector does"
monkeypatch.delenv("AUTOCAD_MCP_BACKEND", raising=False)
assert config.settings.backend == "auto"
async def test_assigning_to_settings_backend_fails_loudly(monkeypatch):
"""The dead monkeypatch that made two tests *look* guarded must now raise.
Silently accepting the assignment is the whole bug: the test passes, reads
as safe, and talks to live AutoCAD anyway.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
with pytest.raises(AttributeError):
config.settings.backend = "com"
assert config.settings.backend == "ezdxf"
async def test_make_backend_reads_the_setting_not_the_environment_directly(monkeypatch):
"""One source of truth: patching the property must steer the selector.
If ``_make_backend`` goes back to reading ``os.environ`` itself, this fails —
which is the regression guard, because that direct read is what let the
setting and the behaviour drift apart in the first place.
"""
from backends.ezdxf_backend import EzdxfBackend
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "com")
monkeypatch.setattr(type(config.settings), "backend", property(lambda self: "ezdxf"))
backend = await server._make_backend()
assert isinstance(backend, EzdxfBackend)
await backend.disconnect()
+169
View File
@@ -0,0 +1,169 @@
"""M9 — the v3 task matrix.
v2 returned 10/10 for this server. That was not a good sign: every task in it
tested something the server was designed around, so the score could only ever
have been full marks. The five tasks added here were chosen because they can
fail, and three of them did fail while being written — a hatch could not be
measured at all, and the boundary tools disagreed with the measurement tool
about the same shape.
Two assertions in this file exist purely to keep the matrix honest rather than
to test behaviour:
* the plan's ``tool_discovery_bilingual`` and ``measurement_massprops`` must
**not** appear, because neither capability shipped; and
* the published competitor reports must not carry the new task ids, because
those servers were never run against them and a zero we invented would read
exactly like a zero we measured.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from backends.base import UnsupportedCapabilityError
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
from benchmarks.adapters.base import BenchmarkAdapter
from benchmarks.run_competitors import run_tasks
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import MATRICES, NEW_TASKS_V3, TASKS_V3, task_by_id
PUBLISHED = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published"
# ── shape of the matrix ─────────────────────────────────────────────────────
def test_v3_extends_v2_without_disturbing_it():
"""A renumbered or reweighted v2 task would silently invalidate the
published v1.4 reports, which are keyed by task id."""
assert TASKS_V3[: len(TASKS_V2)] == TASKS_V2
assert len(NEW_TASKS_V3) == 5
assert len(TASKS_V3) == 15
def test_every_task_id_is_unique():
ids = [task.task_id for task in TASKS_V3]
assert len(ids) == len(set(ids))
def test_the_two_capabilities_that_did_not_ship_are_not_advertised_as_tasks():
ids = {task.task_id for task in TASKS_V3}
assert "tool_discovery_bilingual" not in ids, (
"the Turkish normalizer was built and removed; this repo is English "
"throughout, so a bilingual task would be a benchmark for nothing"
)
assert "measurement_massprops" not in ids, (
"no centroid or moment-of-inertia tool ships — F2 was cut to entity_measure"
)
assert "tool_discovery" in ids
assert "measure_from_handle" in ids
def test_task_by_id_can_still_address_the_v2_matrix():
assert task_by_id("core_geometry", "v2").category == "creation"
with pytest.raises(KeyError):
task_by_id("hatch_islands", "v2")
def test_matrices_registry_exposes_both_sets():
assert MATRICES["v2"] == TASKS_V2
assert MATRICES["v3"] == TASKS_V3
# ── the reference adapter actually answers them ─────────────────────────────
def test_the_reference_adapter_implements_every_v3_task():
"""An unimplemented task scores `unsupported` and is easy to not notice."""
missing = [
task.task_id
for task in TASKS_V3
if not hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}")
]
assert missing == []
@pytest.mark.asyncio
@pytest.mark.parametrize("task", NEW_TASKS_V3, ids=lambda t: t.task_id)
async def test_each_new_task_passes_on_the_headless_backend(task, tmp_path):
report = await run_tasks(AutoCADMCPProAdapter(), [task], tmp_path, timeout=120.0)
result = report["results"][0]
assert result["status"] == "pass", result["message"]
assert result["metrics"], "a task that proves nothing reports no evidence"
@pytest.mark.asyncio
async def test_the_full_v3_matrix_runs_green(tmp_path):
report = await run_tasks(AutoCADMCPProAdapter(), TASKS_V3, tmp_path, timeout=180.0)
assert report["summary"]["attempted"] == 15
assert report["summary"]["passed"] == 15, [
(item["task_id"], item["status"], item["message"]) for item in report["results"]
]
assert report["matrix"] == "v3"
# ── a capability refusal is not a failure ───────────────────────────────────
class RefusingAdapter(BenchmarkAdapter):
name = "refusing"
async def setup(self, artifact_dir):
return None
async def run_task(self, task):
raise UnsupportedCapabilityError("chspace", "no change-space member on this engine")
async def cleanup(self):
return None
@pytest.mark.asyncio
async def test_a_capability_refusal_is_reported_unsupported_not_failed(tmp_path):
"""`fail` and `unsupported` are different claims about a competitor.
"Your server got this wrong" and "your engine cannot reach this" are not
the same finding, and the runner used to collapse the second into the
first because a refusal is still an exception.
"""
report = await run_tasks(RefusingAdapter(), [NEW_TASKS_V3[0]], tmp_path)
result = report["results"][0]
assert result["status"] == "unsupported"
assert "chspace" in result["message"]
assert report["summary"]["unsupported"] == 1
assert report["summary"]["score"] == 0.0
# ── nothing was invented for the competitors ────────────────────────────────
@pytest.mark.parametrize(
"filename", ["beiming183-autocad-mcp.json", "puran-water-autocad-mcp.json"]
)
def test_competitor_reports_carry_no_result_for_the_new_tasks(filename):
report = json.loads((PUBLISHED / filename).read_text(encoding="utf-8"))
scored = {item["task_id"] for item in report["results"]}
new_ids = {task.task_id for task in NEW_TASKS_V3}
assert scored & new_ids == set(), (
"these servers were pinned and run against the v2 matrix at v1.4; a "
"result here would be fabricated"
)
assert report.get("matrix", "v2") == "v2"
def test_the_published_reference_report_covers_the_whole_v3_matrix():
report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
scored = {item["task_id"] for item in report["results"]}
assert scored == {task.task_id for task in TASKS_V3}
assert report["matrix"] == "v3"
assert report["summary"]["score"] == 100.0
+168
View File
@@ -0,0 +1,168 @@
"""M5 — `block_create_from_entities` on both engines.
The live backend refused this outright ("not supported in COM backend. Use
system_run_command with _BLOCK instead"), which pushed users at the free-text
escape hatch for something ActiveX does directly: `Blocks.Add` makes the
definition and `Document.CopyObjects` moves entities into it. Meanwhile the
tool's own docstring said the opposite — "works by using AutoCAD's BLOCK command
(COM backend only)" — so the one backend that could not do it was named as the
one that could.
Both engines also used to swallow handles they could not resolve: pass five
handles with two typos and you got `entity_count: 3` and no mention of the two.
The count is now accompanied by what was skipped.
The COM path is unit-tested against a fake ActiveX object. This machine has a
live AutoCAD and connecting to it during development is forbidden, so that path
ships verified at this level only — the CHANGELOG says so rather than implying
hardware coverage.
"""
from __future__ import annotations
import types
import pytest
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── headless ────────────────────────────────────────────────────────────────
async def test_ezdxf_builds_the_definition_from_real_entities(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
circle = await backend.entity_create_circle(5, 5, 2)
result = await backend.block_create_from_entities("BRACKET", [line.handle, circle.handle])
assert result["ok"] is True
assert result["entity_count"] == 2
assert result["skipped"] == []
block = backend._doc.blocks.get("BRACKET")
assert sorted(e.dxftype() for e in block) == ["CIRCLE", "LINE"]
async def test_ezdxf_reports_handles_it_could_not_resolve(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.block_create_from_entities("PARTIAL", [line.handle, "NOSUCH", "ALSONO"])
assert result["entity_count"] == 1
assert result["skipped"] == ["NOSUCH", "ALSONO"], (
"a count with no mention of what was dropped is the same shape of lie "
"as the audit reporting no fixes"
)
async def test_ezdxf_refuses_a_block_with_nothing_in_it(backend):
with pytest.raises(RuntimeError, match="NOSUCH"):
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
assert "EMPTY" not in backend._doc.blocks, "a failed call must leave no stub behind"
async def test_ezdxf_honours_the_base_point(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("BASED", [line.handle], base_x=3.0, base_y=4.0)
block = backend._doc.blocks.get("BASED")
assert tuple(block.block.dxf.base_point)[:2] == (3.0, 4.0)
# ── live backend, against a fake ActiveX surface ────────────────────────────
class _FakeBlock:
def __init__(self, name):
self.Name = name
class _FakeDocument:
"""The three ActiveX members the implementation is allowed to touch."""
def __init__(self, handles):
self._handles = handles
self.Blocks = types.SimpleNamespace(Add=self._add_block)
self.copied = None
self.added = None
def _add_block(self, base_point, name):
self.added = (tuple(base_point.value), name)
return _FakeBlock(name)
def HandleToObject(self, handle):
if handle not in self._handles:
raise RuntimeError(f"no object with handle {handle}")
return types.SimpleNamespace(Handle=handle, ObjectName="AcDbLine")
def CopyObjects(self, objects, owner, *rest):
self.copied = ([o.Handle for o in objects.value], owner.Name)
@pytest.fixture
def com_backend(monkeypatch):
"""A ComBackend whose document is a fake — no COM connection is made."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
document = _FakeDocument({"2F", "30"})
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_regen", lambda: None)
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_creates_the_definition_and_copies_into_it(com_backend):
backend, document = com_backend
result = await backend.block_create_from_entities("BRACKET", ["2F", "30"], 3.0, 4.0)
assert result["ok"] is True
assert result["entity_count"] == 2
assert result["skipped"] == []
assert document.added == ((3.0, 4.0, 0.0), "BRACKET"), "base point must reach Blocks.Add"
assert document.copied == (["2F", "30"], "BRACKET")
async def test_com_reports_handles_it_could_not_resolve(com_backend):
backend, document = com_backend
result = await backend.block_create_from_entities("PARTIAL", ["2F", "NOSUCH"])
assert result["entity_count"] == 1
assert result["skipped"] == ["NOSUCH"]
assert document.copied == (["2F"], "PARTIAL")
async def test_com_refuses_a_block_with_nothing_in_it(com_backend):
backend, document = com_backend
with pytest.raises(RuntimeError, match="NOSUCH"):
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
assert document.copied is None, "nothing may be copied when nothing resolved"
async def test_both_backends_return_the_same_key_set(backend, com_backend):
com, _ = com_backend
line = await backend.entity_create_line(0, 0, 10, 0)
headless = await backend.block_create_from_entities("A", [line.handle])
live = await com.block_create_from_entities("A", ["2F"])
assert headless.keys() == live.keys()
async def test_ezdxf_still_declares_no_capability_gap_here():
"""Both engines can do this now, so it must not look like a boundary."""
features = EzdxfBackend().capabilities().features
assert "block_definition" not in features, (
"a capability key would imply one engine cannot; both can"
)
+447
View File
@@ -0,0 +1,447 @@
"""F3 — boundary tracing: `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities`.
Two tools that turn loose edges into one closed LWPOLYLINE. Every assertion here
exists because a measured ezdxf behaviour makes the *plausible* implementation
lie:
* **A boundary with no boundary is the silent-success case.** A seed dropped in
open space, or inside a "region" whose edges leave a gap, has no enclosing
loop. Returning an empty or open polyline with ``ok: True`` is exactly the
failure mode this repo spent a milestone deleting, so both are refusals — and
the gap case must name the dangling endpoint, because "no loop here" and
"your top edge is 5 mm short" are different problems with different fixes.
* **`edgeminer.find_all_loops` is O(n!)** — measured 63.9 s at 40 edges and a
TimeoutError at 60. It must never be called; `find_loop_by_edge` is 0.0057 s
at 60 edges. Nothing below asserts on runtime, but the nested-region test
pins the *behaviour* that makes the cheap call correct: the loop returned is
the nearest enclosing one, not the outermost.
* **`edgesmith.lwpolyline_from_chain` does not close what it chains.** Measured
on four LINEs of a square: five points, the fifth a duplicate of the first,
and ``closed = False``. That polyline draws like a square and behaves like an
open one — HATCH association, area queries and `entity_offset` all see an open
path. So the stored entity is checked for the closed *flag* and for the
absence of the duplicate vertex, not just for looking right.
* **`edgesmith.loop_area` ignores bulges.** Measured on two semicircular ARCs
chained into a circle: it returns **0.0** where the true area is 314.16. Use
it and every arc-bounded boundary — every fillet, every slot, every keyway
pocket — reports an area of roughly nothing while claiming success.
* **The reported area comes from the polyline, not from a copy of it.** The
first fix for the bulge problem flattened the chain at a fixed 0.01 sag,
which left `boundary_trace` saying 139.2177 and `entity_measure` saying
139.2699 about the same figure, with nothing in either payload marking one
as the approximation. The stored vertices carry exact bulges, so both tools
now read those and round the same way.
Coordinates in and out are WCS, as everywhere else. ``area`` is unsigned:
the same square wound clockwise or counter-clockwise measures 1600.0 either
way, and a caller asking "how big is this pocket" never wants a negative
answer.
"""
from __future__ import annotations
import math
import re
import pytest
pytestmark = pytest.mark.asyncio
# A 40 x 40 square: area 1600.
SQUARE = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)]
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
def _numbers(text: str) -> list[float]:
"""Every number an error message mentions, however it was formatted."""
return [float(match) for match in _NUMBER.findall(text)]
def _points(backend, handle) -> list[tuple[float, float]]:
entity = backend._doc.entitydb.get(handle)
return [(round(float(p[0]), 6), round(float(p[1]), 6)) for p in entity.get_points()]
def _bulges(backend, handle) -> list[float]:
entity = backend._doc.entitydb.get(handle)
return [float(p[4]) for p in entity.get_points()]
async def _square_of_lines(backend, points=SQUARE, layer=None):
"""Four LINEs, corner to corner, returned as handles."""
handles = []
for index in range(len(points)):
x1, y1 = points[index]
x2, y2 = points[(index + 1) % len(points)]
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
handles.append(line.handle)
return handles
# ── boundary_trace: the happy path, measured ────────────────────────────────
async def test_a_seed_inside_a_square_traces_its_four_edges(backend):
await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
assert result["ok"] is True
assert result["closed"] is True
assert result["vertices"] == 4, "four edges, four corners — not five"
assert result["area"] == pytest.approx(1600.0)
async def test_the_traced_boundary_is_a_real_lwpolyline(backend):
await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "LWPOLYLINE"
assert set(_points(backend, result["handle"])) == set(SQUARE)
async def test_the_boundary_is_closed_by_flag_not_by_a_duplicate_vertex(backend):
"""`lwpolyline_from_chain` measured: 5 points, last == first, closed False.
That polyline looks shut and is not. Hatching, offsetting and area queries
all treat it as an open path.
"""
await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
entity = backend._doc.entitydb.get(result["handle"])
assert entity.closed is True
stored = _points(backend, result["handle"])
assert len(stored) == 4
assert stored[0] != stored[-1]
async def test_the_boundary_survives_save_and_reload(backend, tmp_path):
import ezdxf
await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
path = tmp_path / "boundary.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
assert reloaded.dxftype() == "LWPOLYLINE"
assert reloaded.closed is True
assert len(list(reloaded.get_points())) == 4
# ── boundary_trace: the refusals ────────────────────────────────────────────
async def test_a_seed_outside_every_closed_region_is_refused(backend):
"""A boundary with no boundary. An empty polyline reported as success is
the exact shape of lie this repo removes tools for."""
await _square_of_lines(backend)
result = await backend.boundary_trace(500.0, 500.0)
assert result["ok"] is False
assert result.get("handle") is None
assert result["error"]
async def test_a_seed_in_open_space_with_no_edges_at_all_is_refused(backend):
result = await backend.boundary_trace(0.0, 0.0)
assert result["ok"] is False
assert result["error"]
async def test_a_region_with_a_gap_is_refused_and_the_error_names_the_gap(backend):
"""The top edge stops 5 mm short at (35, 40).
"No loop found" sends the user hunting the whole drawing; naming the
dangling endpoint sends them to the one line that is short.
"""
await backend.entity_create_line(0, 0, 40, 0)
await backend.entity_create_line(40, 0, 40, 40)
await backend.entity_create_line(40, 40, 35, 40) # 5 mm short of the corner
await backend.entity_create_line(0, 40, 0, 0)
result = await backend.boundary_trace(20.0, 20.0)
assert result["ok"] is False
assert "gap" in result["error"].lower()
assert 35.0 in _numbers(result["error"]), (
"the error must carry the dangling endpoint, not just the word 'gap'"
)
async def test_a_gap_inside_the_tolerance_is_closed(backend):
"""Real drawings miss corners by microns; tolerance is what it is for."""
await backend.entity_create_line(0, 0, 40, 0)
await backend.entity_create_line(40, 0, 40, 40)
await backend.entity_create_line(40, 40, 0.001, 40)
await backend.entity_create_line(0, 40, 0, 0)
result = await backend.boundary_trace(20.0, 20.0, tolerance=0.01)
assert result["ok"] is True
assert result["closed"] is True
assert result["area"] == pytest.approx(1600.0, rel=1e-3)
async def test_the_same_gap_outside_the_tolerance_is_still_refused(backend):
"""Otherwise `tolerance` would be decoration."""
await backend.entity_create_line(0, 0, 40, 0)
await backend.entity_create_line(40, 0, 40, 40)
await backend.entity_create_line(40, 40, 0.001, 40)
await backend.entity_create_line(0, 40, 0, 0)
result = await backend.boundary_trace(20.0, 20.0, tolerance=1e-9)
assert result["ok"] is False
# ── boundary_trace: which loop, and what it is made of ──────────────────────
async def test_nested_regions_return_the_inner_loop(backend):
"""AutoCAD's BOUNDARY traces the nearest enclosing loop.
Returning the outer square here would hatch straight over the island —
and it is also the answer a whole-drawing loop search hands you first.
"""
await _square_of_lines(backend) # 40 x 40, area 1600
inner = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]
await _square_of_lines(backend, inner) # 20 x 20, area 400
result = await backend.boundary_trace(20.0, 20.0)
assert result["ok"] is True
assert result["area"] == pytest.approx(400.0)
assert set(_points(backend, result["handle"])) == set(inner)
async def test_a_seed_between_the_two_loops_returns_the_outer_ring(backend):
"""Same drawing, different seed: the loop that encloses *this* point."""
await _square_of_lines(backend)
await _square_of_lines(backend, [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)])
result = await backend.boundary_trace(5.0, 5.0)
assert result["ok"] is True
assert result["area"] == pytest.approx(1600.0)
async def test_source_handles_report_which_entities_formed_the_boundary(backend):
"""A traced boundary the caller cannot attribute is a polyline from nowhere."""
handles = await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
assert set(result["source_handles"]) == set(handles)
assert result["handle"] not in result["source_handles"]
async def test_the_boundary_is_independent_of_its_sources(backend):
"""BOUNDARY makes a new entity, it does not alias the edges. Deleting a
source line must not empty the polyline that was traced from it."""
handles = await _square_of_lines(backend)
result = await backend.boundary_trace(20.0, 20.0)
await backend.entity_delete(handles[0])
entity = backend._doc.entitydb.get(result["handle"])
assert entity is not None
assert entity.closed is True
assert set(_points(backend, result["handle"])) == set(SQUARE)
async def test_the_layer_filter_restricts_the_candidate_edges(backend):
"""A construction line through the square halves it.
Unfiltered, the nearest enclosing loop around (10, 20) is the left half
(area 800). Scoped to GEOMETRY, the divider is not an edge and the loop is
the whole square (1600). If `layer` were ignored both answers would be 800.
"""
await _square_of_lines(backend, layer="GEOMETRY")
await backend.entity_create_line(20, 0, 20, 40, layer="CONSTRUCTION")
unfiltered = await backend.boundary_trace(10.0, 20.0)
filtered = await backend.boundary_trace(10.0, 20.0, layer="GEOMETRY")
assert unfiltered["area"] == pytest.approx(800.0)
assert filtered["area"] == pytest.approx(1600.0)
# ── boundary_from_entities: the happy path ──────────────────────────────────
async def test_named_entities_chain_into_one_closed_polyline(backend):
"""The handles arrive in drawing order, not chain order; chaining is the
tool's whole job."""
handles = await _square_of_lines(backend)
shuffled = [handles[2], handles[0], handles[3], handles[1]]
result = await backend.boundary_from_entities(shuffled)
assert result["ok"] is True
assert result["closed"] is True
assert result["vertices"] == 4
assert result["area"] == pytest.approx(1600.0)
assert set(_points(backend, result["handle"])) == set(SQUARE)
async def test_the_chained_polyline_is_closed_without_a_duplicate_vertex(backend):
handles = await _square_of_lines(backend)
result = await backend.boundary_from_entities(handles)
entity = backend._doc.entitydb.get(result["handle"])
assert entity.closed is True
stored = _points(backend, result["handle"])
assert len(stored) == 4 and stored[0] != stored[-1]
async def test_a_chained_polyline_round_trips(backend, tmp_path):
import ezdxf
handles = await _square_of_lines(backend)
result = await backend.boundary_from_entities(handles)
path = tmp_path / "chained.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
assert reloaded.closed is True
assert len(list(reloaded.get_points())) == 4
async def test_two_arcs_close_into_a_circle_whose_area_counts_the_bulges(backend):
"""`loop_area` returns 0.0 for this exact chain (measured).
Two semicircles enclose 314.16 mm^2; their two *vertices* enclose nothing.
An area taken from the vertex polygon reports ~0 for every arc-bounded
pocket in the drawing while still saying ok.
"""
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
result = await backend.boundary_from_entities([upper.handle, lower.handle])
assert result["ok"] is True
assert result["closed"] is True
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
assert all(abs(b) > 0.1 for b in _bulges(backend, result["handle"])), (
"a circle stored without bulges is a straight line between two points"
)
async def test_the_reported_area_matches_measuring_the_polyline_it_just_made(backend):
"""Two tools, one shape, one number.
`boundary_*` used to report a curve flattened at a fixed 0.01 sag while
`entity_measure` read the stored bulges analytically, so the same figure
came back as 139.2177 from one tool and 139.2699 from the other with
nothing in either payload to say which was the approximation. The polyline
carries exact bulges either way — there was never a reason to answer from
a coarser copy of it.
"""
# 10 x 10 square whose top edge is a semicircle: 100 + pi*25/2.
await backend.entity_create_line(0, 0, 10, 0)
await backend.entity_create_line(10, 0, 10, 10)
await backend.entity_create_line(0, 10, 0, 0)
await backend.entity_create_arc(5, 10, 5, 0, 180)
traced = await backend.boundary_trace(5.0, 5.0)
measured = await backend.entity_measure(traced["handle"])
true_area = 100.0 + math.pi * 25.0 / 2.0
# rel=1e-8, not tighter: both tools round to six decimals on the way out.
assert measured["area"] == pytest.approx(true_area, rel=1e-8)
assert traced["area"] == pytest.approx(measured["area"], rel=1e-9), (
"the boundary tool and the measure tool must not disagree about the "
"shape the boundary tool just drew"
)
async def test_boundary_from_entities_reports_the_same_exact_area(backend):
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
result = await backend.boundary_from_entities([upper.handle, lower.handle])
measured = await backend.entity_measure(result["handle"])
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-8)
assert result["area"] == pytest.approx(measured["area"], rel=1e-12), "same six decimals"
# ── boundary_from_entities: the refusals ────────────────────────────────────
async def test_an_open_chain_is_refused_and_names_the_dangling_endpoint(backend):
"""Three sides of a rectangle: the ends at (0, 0) and (7, 25) never meet."""
first = await backend.entity_create_line(0, 0, 40, 0)
second = await backend.entity_create_line(40, 0, 40, 25)
third = await backend.entity_create_line(40, 25, 7, 25)
result = await backend.boundary_from_entities([first.handle, second.handle, third.handle])
assert result["ok"] is False
reported = _numbers(result["error"])
assert 7.0 in reported and 25.0 in reported, (
"'not closed' without the loose end leaves the caller measuring by hand"
)
async def test_a_chain_broken_in_the_middle_is_refused(backend):
"""Four segments, all four endpoints paired — except two of them are 6 mm
apart. The count is right and the chain is not."""
first = await backend.entity_create_line(0, 0, 40, 0)
second = await backend.entity_create_line(40, 0, 40, 40)
third = await backend.entity_create_line(34, 40, 0, 40) # starts 6 mm inboard
fourth = await backend.entity_create_line(0, 40, 0, 0)
result = await backend.boundary_from_entities(
[first.handle, second.handle, third.handle, fourth.handle]
)
assert result["ok"] is False
assert 34.0 in _numbers(result["error"])
@pytest.mark.parametrize("count", [0, 1])
async def test_fewer_than_two_handles_is_refused(backend, count):
"""One segment cannot enclose anything; zero is a no-op reported as work."""
handles = await _square_of_lines(backend)
result = await backend.boundary_from_entities(handles[:count])
assert result["ok"] is False
async def test_a_non_linear_entity_is_refused_by_type(backend):
"""TEXT has no endpoints. Skipping it quietly would chain three of four
sides and then blame the geometry for not closing."""
handles = await _square_of_lines(backend)
text = await backend.entity_create_text("NOTE", 20, 20)
result = await backend.boundary_from_entities(handles + [text.handle])
assert result["ok"] is False
assert "TEXT" in result["error"]
assert text.handle in result["error"]
async def test_an_unresolvable_handle_is_refused_by_name(backend):
handles = await _square_of_lines(backend)
result = await backend.boundary_from_entities(handles + ["NOSUCH"])
assert result["ok"] is False
assert "NOSUCH" in result["error"]
+935
View File
@@ -0,0 +1,935 @@
"""``cad_batch`` — the ordered multi-tool executor.
One round trip instead of N, with a later step able to reference an earlier
one's handle so the model never has to echo it back.
Where the win actually comes from, measured on a 17-step mounting-plate task
(ISO layers, 4 outline lines, 2 fillets, 2 snapped midpoints, 5 circles, 2
dimensions) against the real in-memory server:
* **Payload alone: 1.28x** — and *0.74x* with ``verbose=True``. The batched
request is the LARGER of the two (1,579 vs 1,357 chars: every step carries a
``{tool, args, bind}`` wrapper the sequential arm gets free from the protocol
envelope). Batching does not win by making payloads smaller.
* **Agent loop: 9.07x** — 340,447 -> 37,548 input tokens. This is the real
mechanism, and it is *turn elimination*: 18 billed turns become 2, and each
turn re-sends the whole conversation plus the 18,306-token tool prefix.
So the honest framing is that batching multiplies with the discovery lane
rather than beating it — search alone is 18.29x on the same task, and the two
together are 106.99x. Nothing here should claim batching is the biggest lever.
What these tests actually guard is the *dangerous* half of the idea, because
the happy path is the easy half:
* **Privilege.** A single ``cad_batch`` grant must not reach the raw
command/LISP escape hatches. The deny set is derived from the live
``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future
escape hatch is denied the day it is registered, and a denied step refuses
the *whole* batch before anything executes — otherwise ``on_error="continue"``
would turn the denylist into a no-op.
* **Error typing.** The re-entrant call path reformats every non-FastMCP
exception into ``ToolError(f"Error calling tool {name!r}: {e}")``. The
classifier must recover the real cause from the ``__cause__`` chain and
never from the message text.
* **Atomicity honesty.** ezdxf rolls back by restoring a full DXF snapshot;
COM ends an undo mark and fires ``_UNDO B`` at AutoCAD without ever
confirming it landed. The payload must say which one it got, and must not
claim a rollback it did not verify.
* **dry_run.** Validates against each tool's real JSON Schema without
executing a single step.
"""
from __future__ import annotations
import json
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
import server
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def _entity_count(client) -> int:
info = (await client.call_tool("drawing_info", {})).structured_content or {}
return int(info["entity_count"])
async def _batch(client, steps, **kwargs):
payload = {"steps": steps, **kwargs}
return (await client.call_tool("cad_batch", payload)).structured_content
def dumps(obj) -> str:
"""Compact JSON — how the payload is actually charged for."""
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
LINE = "entity_create_line"
def _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, **extra):
return {"tool": LINE, "args": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, **extra}
# ---------------------------------------------------------------------------
# Registration and metadata
# ---------------------------------------------------------------------------
async def test_cad_batch_is_registered_with_a_discovery_card():
tools = {t.name: t for t in await server._registered_tools()}
assert "cad_batch" in tools, "cad_batch must be a registered tool"
card = (tools["cad_batch"].meta or {})["cad"]
assert card["cost"] == "destructive", "it can run any mutating tool, so it costs the worst case"
assert card["summary"] and "\n" not in card["summary"]
assert card["synonyms"], "the alias corpus must cover it"
async def test_cad_batch_is_filed_under_the_batch_group():
groups = await server._tool_groups()
assert "cad_batch" in groups["batch"]
assert {"entity_batch_create", "entity_batch_modify"} <= set(groups["batch"]), (
"the pre-existing batch tools stay: cad_batch supersedes them in scope, not in the registry"
)
# ---------------------------------------------------------------------------
# The happy path
# ---------------------------------------------------------------------------
async def test_a_three_step_batch_runs_in_one_round_trip(client):
result = await _batch(
client,
[
_line(),
_line(y1=10.0, y2=10.0),
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
],
)
assert result["ok"] is True
assert (result["steps"], result["executed"], result["succeeded"], result["failed"]) == (
3,
3,
3,
0,
)
assert [row["status"] for row in result["results"]] == ["ok", "ok", "ok"]
assert await _entity_count(client) == 3
async def test_a_successful_step_reports_only_its_handle_by_default(client):
result = await _batch(client, [_line()])
payload = result["results"][0]["result"]
assert set(payload) == {"handle"}, (
"an EntityInfo is fully recoverable via entity_get(handle); re-emitting it "
"in every batch row is exactly the cost cad_batch exists to remove"
)
assert payload["handle"]
async def test_verbose_restores_the_full_step_result(client):
result = await _batch(client, [_line()], verbose=True)
payload = result["results"][0]["result"]
assert payload["handle"] and payload["type"] == "LINE"
assert "properties" in payload
async def test_a_result_without_a_handle_is_never_trimmed(client):
"""Compaction only drops what ``entity_get(handle)`` can give back."""
result = await _batch(client, [{"tool": "drawing_info", "args": {}}])
payload = result["results"][0]["result"]
assert payload["entity_count"] == 0
assert payload["name"]
async def test_steps_run_in_the_order_given(client):
"""Reads interleaved with writes see the document as of their own position."""
result = await _batch(
client,
[
_line(),
{"tool": "drawing_info", "args": {}},
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
{"tool": "drawing_info", "args": {}},
],
)
assert result["ok"] is True
assert result["results"][1]["result"]["entity_count"] == 1
assert result["results"][3]["result"]["entity_count"] == 2
# ---------------------------------------------------------------------------
# Binding — the half that removes the handle echo
# ---------------------------------------------------------------------------
async def test_a_bound_handle_feeds_a_later_step(client):
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 5.0, "dy": 5.0}},
],
)
assert result["ok"] is True
assert result["bindings"] == ["edge"]
handle = result["results"][0]["result"]["handle"]
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert got["properties"]["start"][0] == pytest.approx(5.0)
async def test_a_dotted_reference_reaches_into_a_step_result(client):
"""``point_from_snap`` returns {x, y}; without ``$p.x`` that is a round trip."""
result = await _batch(
client,
[
_line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, bind="edge"),
{
"tool": "point_from_snap",
"args": {"handle": "$edge", "snap": "mid"},
"bind": "mid",
},
{
"tool": "entity_create_circle",
"args": {"cx": "$mid.x", "cy": "$mid.y", "radius": 1.0},
},
],
)
assert result["ok"] is True, result["results"]
handle = result["results"][2]["result"]["handle"]
circle = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert circle["properties"]["center"][0] == pytest.approx(5.0)
async def test_a_list_index_is_a_valid_reference_segment():
resolved = server._resolve_batch_ref("$box.handles.1", {"box": {"handles": ["A", "B", "C"]}})
assert resolved == "B"
async def test_a_bare_reference_resolves_to_the_handle():
resolved = server._resolve_batch_ref("$edge", {"edge": {"handle": "2F1", "type": "LINE"}})
assert resolved == "2F1"
async def test_a_bare_reference_to_a_handleless_result_is_a_typed_error():
with pytest.raises(server.BatchReferenceError) as excinfo:
server._resolve_batch_ref("$info", {"info": {"entity_count": 3}})
assert "handle" in str(excinfo.value)
async def test_a_doubled_dollar_escapes_a_literal_dollar(client):
"""Text really does start with ``$`` sometimes; ``$$`` is the way out."""
result = await _batch(
client,
[{"tool": "entity_create_text", "args": {"text": "$$4.50", "x": 0, "y": 0, "height": 2.5}}],
)
handle = result["results"][0]["result"]["handle"]
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert got["properties"]["text"] == "$4.50"
async def test_only_a_whole_string_is_a_reference():
"""Partial interpolation is deliberately unsupported — it is ambiguous."""
assert server._substitute_batch_refs("cost is $edge each", {"edge": {"handle": "1"}}) == (
"cost is $edge each"
)
# ---------------------------------------------------------------------------
# dry_run — the real JSON Schema, nothing executed
# ---------------------------------------------------------------------------
async def test_dry_run_validates_without_executing(client):
result = await _batch(client, [_line(), _line(y1=5.0, y2=5.0)], dry_run=True)
assert result["dry_run"] is True
assert result["ok"] is True
assert result["executed"] == 0
assert [row["status"] for row in result["results"]] == ["valid", "valid"]
assert await _entity_count(client) == 0, "a dry run must not draw anything"
async def test_dry_run_rejects_a_missing_required_argument(client):
result = await _batch(client, [{"tool": LINE, "args": {"x1": 0, "y1": 0}}], dry_run=True)
assert result["ok"] is False
row = result["results"][0]
assert row["status"] == "invalid"
assert row["error"]["kind"] == "invalid_args"
assert "x2" in row["error"]["message"]
async def test_dry_run_rejects_an_argument_of_the_wrong_type(client):
result = await _batch(
client,
[{"tool": LINE, "args": {"x1": "over there", "y1": 0, "x2": 1, "y2": 1}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "invalid_args"
async def test_dry_run_rejects_an_unknown_argument(client):
result = await _batch(
client,
[{"tool": LINE, "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 1, "colour": 3}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "invalid_args"
async def test_dry_run_names_an_unknown_tool(client):
result = await _batch(client, [{"tool": "entity_create_dragon", "args": {}}], dry_run=True)
row = result["results"][0]
assert row["error"]["kind"] == "unknown_tool"
assert "entity_create_dragon" in row["error"]["message"]
async def test_dry_run_catches_a_forward_reference(client):
"""A step may only reference something an *earlier* step bound."""
result = await _batch(
client,
[
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 1.0, "dy": 0.0}},
_line(bind="edge"),
],
dry_run=True,
)
row = result["results"][0]
assert row["error"]["kind"] == "unresolved_ref"
assert "edge" in row["error"]["message"]
async def test_dry_run_catches_a_reference_nobody_binds(client):
result = await _batch(
client,
[{"tool": "entity_move", "args": {"handle": "$typo", "dx": 1.0, "dy": 0.0}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "unresolved_ref"
async def test_a_reference_stands_in_for_any_type_during_validation(client):
"""``$mid.x`` is a float at run time and a string at validation time.
The validator must not reject it for that — but it must still check
everything around it, which the following test proves.
"""
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, "bind": "m"},
{"tool": "entity_create_circle", "args": {"cx": "$m.x", "cy": "$m.y", "radius": 1.0}},
],
dry_run=True,
)
assert result["ok"] is True, result["results"]
async def test_a_reference_does_not_excuse_the_rest_of_the_step(client):
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "entity_move", "args": {"handle": "$edge", "dx": "sideways", "dy": 0.0}},
],
dry_run=True,
)
assert result["results"][1]["error"]["kind"] == "invalid_args"
async def test_an_invalid_step_stops_a_real_batch_before_anything_runs(client):
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line(), {"tool": "entity_create_dragon", "args": {}}])
assert "entity_create_dragon" in str(excinfo.value)
assert await _entity_count(client) == 0, "validation must fail closed, before step 1 draws"
async def test_a_malformed_step_is_reported_not_crashed(client):
result = await _batch(client, [{"args": {"x1": 0}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "malformed_step"
# ---------------------------------------------------------------------------
# Privilege — the deny set
# ---------------------------------------------------------------------------
async def test_the_deny_set_is_derived_from_the_escape_cost_cards():
"""Hand-listing would go stale the day a new escape hatch is registered."""
escapes = {
tool.name
for tool in await server._registered_tools()
if ((tool.meta or {}).get("cad") or {}).get("cost") == "escape"
}
assert escapes, "the registry must still carry escape-cost tools for this to mean anything"
assert server._batch_denied_tools() == escapes | {"cad_batch"}
async def test_the_raw_command_and_lisp_hatches_are_denied():
denied = server._batch_denied_tools()
assert {"system_run_command", "system_run_lisp"} <= denied
@pytest.mark.parametrize("tool", ["system_run_command", "system_run_lisp"])
async def test_a_denied_tool_refuses_the_whole_batch(client, tool):
args = {"command": "_ZOOM E"} if tool.endswith("command") else {"expression": "(+ 1 1)"}
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line(), {"tool": tool, "args": args}])
message = str(excinfo.value)
assert tool in message
assert "denied" in message.lower() or "not callable" in message.lower()
assert await _entity_count(client) == 0, "a denied step must abort before step 1 draws"
async def test_a_denied_step_is_not_survivable_with_on_error_continue(client):
"""Otherwise the deny set would just be a step you skip past."""
with pytest.raises(ToolError):
await _batch(
client,
[{"tool": "system_run_lisp", "args": {"expression": "(+ 1 1)"}}, _line()],
on_error="continue",
)
assert await _entity_count(client) == 0
async def test_cad_batch_cannot_nest_inside_itself(client):
"""No recursion, and no laundering a denied step through a nested executor."""
result = await _batch(
client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}], dry_run=True
)
assert result["results"][0]["error"]["kind"] == "denied"
with pytest.raises(ToolError, match="cad_batch"):
await _batch(client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}])
async def test_a_tool_with_no_discovery_card_is_not_run_blind(client):
"""Fail closed on unknown provenance: the deny set is computed from the local
registry, but a mounted or transformed provider can supply a tool it never saw.
Every tool this server registers carries a card (there is a coverage gate),
so this costs the native surface nothing."""
@server.mcp.tool(name="_batch_uncarded_probe")
async def _uncarded(ctx=None) -> dict:
return {"ok": True}
try:
result = await _batch(client, [{"tool": "_batch_uncarded_probe", "args": {}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "denied"
assert "card" in result["results"][0]["error"]["message"]
finally:
server.mcp.local_provider.remove_tool("_batch_uncarded_probe")
async def test_a_dry_run_reports_a_denied_step_rather_than_hiding_it(client):
result = await _batch(
client,
[{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}],
dry_run=True,
)
assert result["ok"] is False
assert result["results"][0]["error"]["kind"] == "denied"
async def test_the_refusal_points_at_the_direct_tool(client):
"""The escape hatch stays reachable — separately, where its own gate applies."""
with pytest.raises(ToolError) as excinfo:
await _batch(client, [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}])
assert "directly" in str(excinfo.value)
# ---------------------------------------------------------------------------
# The production laundering vector: DISCOVERY_MODE=search
# ---------------------------------------------------------------------------
#
# In search mode the advertised surface is just ``search_tools`` + ``call_tool``,
# and ``call_tool`` invokes any tool by name. That is a general-purpose proxy
# sitting inside the same server as a denylist — i.e. exactly the shape of a way
# around it. The deny set is derived from ``cost="escape"`` cards, and the proxy
# is not a local component, so nothing in the *denylist* stops it; what stops it
# is the fail-closed "no @cad_tool card, no execution" rule.
#
# These pin that, because the coupling is invisible: giving ``call_tool`` a card
# — an entirely reasonable thing to want, so it is costed and discoverable —
# silently reopens the escalation path with no other test failing.
@pytest_asyncio.fixture
async def search_mode(monkeypatch):
"""The real search surface, restored to ``off`` afterwards."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
server._apply_discovery_mode("search")
try:
async with Client(server.mcp) as connected:
yield connected
finally:
server._apply_discovery_mode("off")
async def test_search_mode_advertises_the_proxy_that_makes_this_a_risk(search_mode):
names = {tool.name for tool in await search_mode.list_tools()}
assert names == {"search_tools", "call_tool"}, (
"if the search surface changed, the laundering test below is aiming at the wrong target"
)
@pytest.mark.parametrize("hatch", ["system_run_command", "system_run_lisp"])
async def test_the_search_proxy_cannot_launder_a_denied_hatch(search_mode, hatch):
"""cad_batch -> call_tool -> system_run_* must not execute."""
result = (
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [{"tool": "call_tool", "args": {"name": hatch, "arguments": {}}}],
"dry_run": True,
},
},
)
).structured_content or {}
assert result["ok"] is False
assert result["results"][0]["error"]["kind"] == "denied"
async def test_the_laundered_hatch_is_refused_before_anything_executes(search_mode):
"""Not merely reported — the whole batch is refused, so the proxy never runs."""
with pytest.raises(ToolError) as excinfo:
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [
{
"tool": "call_tool",
"args": {
"name": "system_run_command",
"arguments": {"command": "_ZOOM E"},
},
}
]
},
},
)
assert "refused all 1 steps - nothing was executed" in str(excinfo.value)
async def test_cad_batch_still_works_through_the_search_proxy(search_mode):
"""The denial above must be a denial, not search mode breaking batching."""
result = (
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [{"tool": "drawing_new", "args": {}}, _line()],
},
},
)
).structured_content or {}
assert result["ok"] is True
assert result["succeeded"] == 2
async def test_a_batch_step_may_still_call_the_older_batch_tools(client):
"""The reconciliation: they compose. cad_batch orders, entity_batch_create bulks."""
result = await _batch(
client,
[
{
"tool": "entity_batch_create",
"args": {
"entities": [
{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
{"type": "circle", "cx": 0, "cy": 0, "radius": 1},
]
},
},
_line(),
],
)
assert result["ok"] is True
assert await _entity_count(client) == 3
async def test_a_tool_hidden_by_the_profile_is_not_reachable_through_a_batch(client):
"""cad_batch must not be a way around TOOL_PROFILE or the ENABLE_3D gate."""
result = await _batch(client, [{"tool": "solid_box", "args": {}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "unknown_tool"
# ---------------------------------------------------------------------------
# Error taxonomy — typed, never string-sniffed
# ---------------------------------------------------------------------------
async def test_a_backend_capability_refusal_is_typed_as_unsupported(client, tmp_path):
result = await _batch(
client,
[{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "part.dwg")}}],
on_error="continue",
)
error = result["results"][0]["error"]
assert error["kind"] == "unsupported"
assert error["capability"] == "dwg", "the capability key must survive the re-entrant wrapper"
async def test_the_classifier_reads_the_cause_chain_not_the_message():
"""The proof that no message text is being sniffed: same words, different type."""
from backends.ezdxf_backend import UnsupportedCapabilityError
liar = RuntimeError("the headless ezdxf backend cannot write DWG (capability 'dwg')")
assert server._classify_batch_error(liar)["kind"] == "failed"
honest = ToolError("Error calling tool 'drawing_save_as': nothing useful here")
honest.__cause__ = UnsupportedCapabilityError("dwg", "no DWG writer")
classified = server._classify_batch_error(honest)
assert classified["kind"] == "unsupported"
assert classified["capability"] == "dwg"
async def test_a_backend_failure_is_typed_as_failed(client):
result = await _batch(
client, [{"tool": "entity_get", "args": {"handle": "NOSUCH"}}], on_error="continue"
)
assert result["results"][0]["error"]["kind"] == "failed"
async def test_a_deliberate_tool_refusal_is_typed_as_refused(client):
result = await _batch(
client,
[{"tool": "drawing_open", "args": {"path": "../../etc/passwd"}}],
on_error="continue",
)
assert result["results"][0]["error"]["kind"] == "refused"
async def test_a_bound_value_of_the_wrong_type_is_typed_at_run_time(client):
"""The one thing dry_run cannot check, reported precisely when it happens."""
result = await _batch(
client,
[
{"tool": "entity_create_text", "args": {"text": "T", "x": 0, "y": 0}, "bind": "t"},
{"tool": "entity_move", "args": {"handle": "$t", "dx": "$t.type", "dy": 0.0}},
],
on_error="continue",
)
assert result["results"][1]["error"]["kind"] == "invalid_args"
async def test_every_reported_kind_is_a_declared_kind(client, tmp_path):
result = await _batch(
client,
[
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "p.dwg")}},
_line(),
],
on_error="continue",
)
kinds = {row["error"]["kind"] for row in result["results"] if row["status"] == "error"}
assert kinds <= set(server.BATCH_ERROR_KINDS)
assert kinds
# ---------------------------------------------------------------------------
# on_error
# ---------------------------------------------------------------------------
async def test_stop_is_the_default_because_both_backends_deliver_it(client):
result = await _batch(client, [_line()])
assert result["on_error"] == "stop"
async def test_stop_leaves_earlier_steps_applied_and_says_so(client):
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
on_error="stop",
)
assert result["ok"] is False
statuses = [row["status"] for row in result["results"]]
assert statuses == ["ok", "error", "skipped"]
assert result["skipped"] == 1
assert await _entity_count(client) == 1
assert result["atomicity"]["rolled_back"] is False
assert result["atomicity"]["guarantee"] == "none"
async def test_continue_runs_every_step(client):
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
on_error="continue",
)
assert [row["status"] for row in result["results"]] == ["ok", "error", "ok"]
assert result["succeeded"] == 2 and result["failed"] == 1
assert await _entity_count(client) == 2
async def test_continue_does_not_execute_a_step_whose_binding_never_landed(client):
"""A dependent step must not silently run on a stale or missing value."""
result = await _batch(
client,
[
{"tool": "entity_get", "args": {"handle": "NOSUCH"}, "bind": "ghost"},
{"tool": "entity_move", "args": {"handle": "$ghost", "dx": 1.0, "dy": 0.0}},
_line(),
],
on_error="continue",
)
assert result["results"][0]["status"] == "error"
assert result["results"][1]["status"] == "error"
assert result["results"][1]["error"]["kind"] == "unresolved_ref"
assert result["results"][2]["status"] == "ok"
async def test_an_unknown_on_error_mode_is_rejected(client):
with pytest.raises(ToolError):
await _batch(client, [_line()], on_error="pray")
# ---------------------------------------------------------------------------
# Atomicity — honest per backend
# ---------------------------------------------------------------------------
class _Caps:
def __init__(self, mode, supported=True):
from backends.base import CapabilityMap, FeatureCapability
self.map = CapabilityMap(
backend="stub", features={"transactions": FeatureCapability(supported, mode)}
)
def capabilities(self):
return self.map
async def test_a_snapshot_backend_is_reported_as_a_snapshot_guarantee():
guarantee, note = server._batch_rollback_guarantee(_Caps("snapshot"))
assert guarantee == "snapshot"
assert "snapshot" in note.lower()
async def test_an_undo_mark_backend_is_never_called_atomic():
"""COM ends the undo mark and fires ``_UNDO B`` without confirming it landed."""
guarantee, note = server._batch_rollback_guarantee(_Caps("undo_mark"))
assert guarantee == "best_effort_undo"
lowered = note.lower()
assert "not atomic" in lowered, "it must say so, not merely omit the word"
assert "best-effort" in lowered
assert "does not confirm" in lowered
async def test_a_backend_without_transactions_gets_no_guarantee():
guarantee, _ = server._batch_rollback_guarantee(_Caps(None, supported=False))
assert guarantee == "none"
async def test_an_unrecognised_transaction_mode_is_not_assumed_good():
guarantee, _ = server._batch_rollback_guarantee(_Caps("time_travel"))
assert guarantee == "unverified"
async def test_rollback_restores_the_document_on_ezdxf(client):
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
result = await _batch(
client,
[_line(), _line(y1=4.0, y2=4.0), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
on_error="rollback",
)
assert result["ok"] is False
assert await _entity_count(client) == 1, "the two batch lines must be gone"
atomicity = result["atomicity"]
assert atomicity["guarantee"] == "snapshot"
assert atomicity["rolled_back"] is True
assert atomicity["verified"] is True
assert atomicity["before"] == atomicity["after"]
async def test_a_successful_rollback_batch_commits_and_keeps_its_work(client):
result = await _batch(client, [_line(), _line(y1=4.0, y2=4.0)], on_error="rollback")
assert result["ok"] is True
assert result["atomicity"]["rolled_back"] is False
assert result["atomicity"]["committed"] is True
assert await _entity_count(client) == 2
async def test_rollback_reports_the_guarantee_it_actually_got(client):
result = await _batch(client, [_line()], on_error="rollback")
atomicity = result["atomicity"]
assert atomicity["mode"] == "rollback"
assert atomicity["guarantee"] in server.BATCH_ROLLBACK_GUARANTEES
assert atomicity["note"]
async def test_rollback_refuses_when_the_backend_declines_the_checkpoint(client, monkeypatch):
"""A checkpoint we did not open is not one we can roll back to.
This is the live COM path: ``ComBackend.transaction_begin`` *returns*
``{"ok": False, "error": "A transaction is already active"}`` rather than
raising, so a batch that took the return value on faith would run every
step and then report a rollback it never had. Simulated on the headless
backend, whose own transaction stack happily nests.
"""
from backends.ezdxf_backend import EzdxfBackend
async def _decline(self):
return {"ok": False, "error": "A transaction is already active"}
monkeypatch.setattr(EzdxfBackend, "transaction_begin", _decline)
result = await _batch(client, [_line()], on_error="rollback")
assert result["ok"] is False
assert result["executed"] == 0
assert result["atomicity"]["transaction"] is False
assert "already active" in result["atomicity"]["note"]
assert await _entity_count(client) == 0, "nothing may run without the checkpoint"
async def test_a_rollback_batch_does_not_disturb_a_transaction_the_caller_opened(client):
"""The headless backend's checkpoints are a stack; ours must pop only its own."""
await client.call_tool("transaction_begin", {})
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
on_error="rollback",
)
assert result["atomicity"]["rolled_back"] is True
assert await _entity_count(client) == 1, "the caller's circle survives our rollback"
await client.call_tool("transaction_rollback", {})
assert await _entity_count(client) == 0, "the caller's own checkpoint still works"
async def test_non_rollback_modes_make_no_atomicity_claim(client):
"""And say it in four keys, not in two paragraphs about a rollback that was
never on the table — that boilerplate measured 61% of a short batch's reply."""
for mode in ("stop", "continue"):
result = await _batch(client, [_line()], on_error=mode)
atomicity = result["atomicity"]
assert atomicity["transaction"] is False
assert atomicity["rolled_back"] is False
assert atomicity["guarantee"] == "none"
assert set(atomicity) == {"mode", "guarantee", "transaction", "rolled_back", "note"}
assert "atomic" not in atomicity["note"].lower()
assert len(dumps(atomicity)) < 200
async def test_a_dry_run_opens_no_transaction(client):
result = await _batch(client, [_line()], on_error="rollback", dry_run=True)
assert result["atomicity"]["transaction"] is False
status = (await client.call_tool("system_status", {})).structured_content or {}
assert status.get("transaction_depth", 0) == 0
# ---------------------------------------------------------------------------
# Limits and progress
# ---------------------------------------------------------------------------
async def test_an_empty_batch_is_rejected(client):
with pytest.raises(ToolError):
await _batch(client, [])
async def test_the_step_count_is_capped(client):
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line()] * (server.MAX_BATCH_STEPS + 1))
assert str(server.MAX_BATCH_STEPS) in str(excinfo.value)
async def test_progress_is_reported_for_every_step(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
seen: list[tuple[float, float | None]] = []
async def handler(progress, total, message=None):
seen.append((progress, total))
async with Client(server.mcp, progress_handler=handler) as connected:
await connected.call_tool("drawing_new", {})
await connected.call_tool(
"cad_batch", {"steps": [_line(), _line(y1=2.0, y2=2.0), _line(y1=4.0, y2=4.0)]}
)
assert seen, "a long batch is the canonical case for ctx.report_progress"
assert (3.0, 3.0) in [(p, t) for p, t in seen], "the final tick must report completion"
# ---------------------------------------------------------------------------
# The rollback claim must come from the backend, not from control flow
# ---------------------------------------------------------------------------
async def test_rollback_is_not_claimed_when_the_backend_declines_it(client):
"""A step that commits consumes the checkpoint out from under us.
``transaction_rollback()`` then returns ``{"ok": False, "error": "No active
transaction to rollback"}``. Reporting ``rolled_back: True`` off control
flow would be the same class of lie as writing DXF bytes into a .dwg.
"""
steps = [
_line(),
{"tool": "transaction_commit", "args": {}},
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
]
result = await _batch(client, steps, on_error="rollback")
assert result["ok"] is False
assert result["atomicity"]["rolled_back"] is False, (
"the backend refused the rollback; the payload must not claim it happened"
)
assert await _entity_count(client) == 1, "the line really did survive"
async def test_commit_is_not_claimed_when_the_backend_declines_it(client):
"""Same rule on the success path: a step that already committed leaves
nothing for the batch's own commit to do."""
steps = [_line(), {"tool": "transaction_commit", "args": {}}]
result = await _batch(client, steps, on_error="rollback")
assert result["ok"] is True
assert result["atomicity"]["committed"] is False, (
"the checkpoint was already gone; do not report a commit that did not happen"
)
async def test_a_read_only_finding_is_not_a_failed_step(client):
"""A checker reporting findings must not be mistaken for a step that failed.
``validation_check`` is ``readOnlyHint=True`` and answers
``{"ok": len(issues) == 0}`` — ``ok: False`` there means *it found something*,
not *it could not run*. Treating that as a refusal made a read-only check
trigger a verified rollback that destroyed the geometry the batch had just
drawn. A mutator's ``ok: False`` still means it did not mutate.
"""
steps = [_line(), {"tool": "validation_check", "args": {}}]
result = await _batch(client, steps, on_error="rollback")
assert result["succeeded"] == 2
assert result["failed"] == 0
assert result["ok"] is True
assert result["atomicity"]["rolled_back"] is False
assert await _entity_count(client) == 1, "the read-only check must not undo the line"
+153
View File
@@ -0,0 +1,153 @@
"""``CAD_PROGID`` — which COM application the live backend attaches to.
The ProgID was hardcoded twice inside ``_acad_app``, so the only CAD this server
could ever drive was AutoCAD itself. GstarCAD, ZWCAD and BricsCAD all expose the
same ActiveX object model under a different ProgID, and the connection is the
only thing standing between them and the existing 100+ COM methods.
Two things make this more delicate than a string swap, and both are tested here:
* **Nothing may connect.** These tests run on a machine with AutoCAD installed.
The ``Dispatch`` branch is not a passive probe — it COM-launches the
application and sets ``Visible = True``. So the fake below records the ProgID
it was *asked* for and returns a stub; every assertion is on the recorded
calls, never on a returned object. ``raising=False`` on the ``win32com`` patch
is load-bearing: that name only exists in the module when the pywin32 imports
succeeded, so without it these tests would pass on a Windows dev box and error
on Linux CI.
* **The cache would hide the bug.** ``_acad_app`` short-circuits on
``_COM_STATE["app"]``, so a warm cache makes it perform zero lookups and the
test asserts nothing at all. The fixture rebinds ``_COM_STATE`` to a fresh
dict rather than mutating it — that also guarantees a test can never leave a
fake application object in the real process-wide cache.
An unknown ProgID fails loudly rather than falling back to AutoCAD. Unlike
``TOOL_PROFILE`` and ``DISCOVERY_MODE``, which fall back with a warning because
they have closed enums, this is an open string: an unrecognised value is not
"invalid", it is "unverifiable by us", and it has no principled substitute. The
blast radius decides it — refusing costs a readable startup error, while falling
back launches and attaches to a live AutoCAD the user explicitly said they were
not using.
"""
from __future__ import annotations
import types
import pytest
import config
from backends import com_backend as cb
DEFAULT = "AutoCAD.Application"
OTHER = "BricscadApp.AcadApplication"
@pytest.fixture
def com_calls(monkeypatch):
"""Record every ProgID ``_acad_app`` asks for, and connect to nothing."""
calls: list[tuple[str, str]] = []
def _get_active_object(progid):
calls.append(("GetActiveObject", progid))
raise OSError("no running instance (fake)")
def _dispatch(progid):
calls.append(("Dispatch", progid))
return types.SimpleNamespace(Visible=False)
monkeypatch.setattr(
cb,
"win32com",
types.SimpleNamespace(
client=types.SimpleNamespace(
GetActiveObject=_get_active_object,
Dispatch=_dispatch,
)
),
raising=False,
)
monkeypatch.setattr(cb, "_COM_STATE", {})
return calls
# -- the setting ------------------------------------------------------------
def test_the_default_progid_is_autocad(monkeypatch):
monkeypatch.delenv("CAD_PROGID", raising=False)
assert config.settings.cad_progid == DEFAULT
def test_the_setting_is_read_live_and_stripped(monkeypatch):
monkeypatch.setenv("CAD_PROGID", f" {OTHER} ")
assert config.settings.cad_progid == OTHER
def test_case_is_preserved_because_the_value_is_shown_to_humans(monkeypatch):
"""``gstarcad.application`` in an error message reads like a typo we made."""
monkeypatch.setenv("CAD_PROGID", "GstarCAD.Application")
assert config.settings.cad_progid == "GstarCAD.Application"
def test_an_empty_value_falls_back_to_the_default(monkeypatch):
monkeypatch.setenv("CAD_PROGID", " ")
assert config.settings.cad_progid == DEFAULT
def test_assigning_to_the_setting_fails_loudly(monkeypatch):
"""Same reasoning as ``backend``: a silent no-op assignment reads as control."""
monkeypatch.setenv("CAD_PROGID", OTHER)
with pytest.raises(AttributeError):
config.settings.cad_progid = DEFAULT
assert config.settings.cad_progid == OTHER
# -- what the connection actually asks for ----------------------------------
def test_unset_attaches_to_autocad_on_both_paths(monkeypatch, com_calls):
monkeypatch.delenv("CAD_PROGID", raising=False)
cb._acad_app()
assert com_calls == [("GetActiveObject", DEFAULT), ("Dispatch", DEFAULT)]
def test_the_configured_progid_reaches_both_call_sites(monkeypatch, com_calls):
"""The Dispatch fallback is the one that launches an application.
Honouring the setting on ``GetActiveObject`` and then quietly launching
AutoCAD on the fallback would be worse than ignoring the setting entirely.
"""
monkeypatch.setenv("CAD_PROGID", OTHER)
cb._acad_app()
assert [progid for _, progid in com_calls] == [OTHER, OTHER]
assert DEFAULT not in [progid for _, progid in com_calls]
def test_a_failed_connection_names_the_progid_it_tried(monkeypatch, com_calls):
def _boom(progid):
com_calls.append(("Dispatch", progid))
raise OSError("class not registered (fake)")
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
with pytest.raises(RuntimeError) as excinfo:
cb._acad_app()
message = str(excinfo.value)
assert "Nope.NotRegistered" in message, "a bare 'cannot connect to AutoCAD' misdirects"
assert "CAD_PROGID" in message, "the message must say which knob to turn"
assert DEFAULT not in [progid for _, progid in com_calls], "must never fall back to AutoCAD"
def test_a_failed_connection_does_not_poison_the_cache(monkeypatch, com_calls):
def _boom(progid):
raise OSError("class not registered (fake)")
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
with pytest.raises(RuntimeError):
cb._acad_app()
assert "app" not in cb._COM_STATE
+5 -4
View File
@@ -33,11 +33,12 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
"reason": None,
}
assert caps["features"]["mleader"]["mode"] == "composite"
# v1.4: paper-space layouts/viewports are native on both backends;
# projecting model content through viewports stays COM-only, and 3D
# ACIS solids remain impossible headlessly.
# Paper-space layouts/viewports are native on both backends. Viewport
# model projection was declared COM-only in v1.4 and is not — measured in
# tests/test_viewport_ops.py, which renders a sheet holding nothing but a
# viewport. 3D ACIS solids do remain impossible headlessly.
assert caps["features"]["paper_space"]["supported"] is True
assert caps["features"]["viewport_render"]["supported"] is False
assert caps["features"]["viewport_render"]["supported"] is True
assert caps["features"]["solid_3d"]["supported"] is False
+118
View File
@@ -0,0 +1,118 @@
"""``@capability``: the escape hatch that is not allowed to become a hole.
M7 split a 1854-line ABC with 112 abstract methods into per-domain contracts.
The split makes contract changes navigable; it does not fix the reason contract
changes hurt, which is that every method is an `@abstractmethod`, so adding one
makes *both* backends fail at instantiation until both are written — and two
test modules construct `ComBackend` at import time, so a half-finished edit
takes the whole suite red.
`@capability` gives such a method a default. The default is the dangerous part,
so it is pinned here:
* It **raises** `UnsupportedCapabilityError`, never returns a quiet
`{"ok": False}`. A method nobody implemented must be indistinguishable from a
deliberate refusal, not from a soft failure — the v1.5.0 honesty pass exists
because things that quietly did nothing looked like things that worked.
* Its capability key is **declared in both backends' capability maps**. Without
this test the decorator would be a way to ship refusals that
`system_capabilities` never mentions, which is worse than the build break it
replaces: a client that pre-checks the feature map would be told the call is
fine and then be refused.
* Overriding it is ordinary subclassing, so an implementing backend pays
nothing.
"""
from __future__ import annotations
import inspect
import pytest
from backends.base import AutoCADBackend, UnsupportedCapabilityError
from backends.capability import capability_keys, is_capability_default
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
# Marked per-test rather than module-wide: half of these are plain registry
# checks, and pytest-asyncio warns on a sync test carrying the mark.
asyncio_test = pytest.mark.asyncio
def _feature_keys(backend) -> set[str]:
return set(backend.capabilities().to_dict()["features"])
# ── the registry is real ────────────────────────────────────────────────────
def test_at_least_one_method_uses_the_decorator():
"""An unused abstraction is dead weight; this keeps the gate meaningful."""
assert capability_keys(), "no @capability methods — delete the decorator or use it"
def test_every_declared_key_exists_in_both_capability_maps():
"""The gate. A refusal the feature map never mentions is a lie by omission."""
ezdxf_keys = _feature_keys(EzdxfBackend())
com_keys = _feature_keys(ComBackend())
for key in capability_keys():
assert key in ezdxf_keys, f"@capability({key!r}) is undeclared on the ezdxf backend"
assert key in com_keys, f"@capability({key!r}) is undeclared on the COM backend"
def test_the_contract_still_exposes_every_method_it_used_to():
"""The mixin split moved code; it must not have dropped any."""
for names in capability_keys().values():
for name in names:
assert callable(getattr(AutoCADBackend, name, None)), f"{name} vanished in the split"
# ── the default behaves like a deliberate refusal ───────────────────────────
@asyncio_test
async def test_a_backend_that_does_not_implement_it_refuses_with_the_key():
backend = ComBackend()
assert is_capability_default(ComBackend, "entity_change_space"), (
"fixture assumption: the COM backend inherits the default here"
)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_change_space(["1A"], "2B")
assert excinfo.value.capability == "chspace"
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert payload["capability"] == "chspace"
@asyncio_test
async def test_the_refusal_names_the_backend_and_says_what_to_do_instead():
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await ComBackend().entity_change_space(["1A"], "2B")
message = str(excinfo.value)
assert "com" in message
assert "ezdxf" in message, "a refusal that does not name the way out is half a refusal"
@asyncio_test
async def test_the_default_never_returns_a_soft_failure():
"""The design decision this file exists to defend.
Returning ``{"ok": False, "capability": ...}`` would satisfy every caller's
type expectations and silently mean "nothing happened".
"""
try:
result = await ComBackend().entity_change_space(["1A"], "2B")
except UnsupportedCapabilityError:
return
pytest.fail(f"the default returned {result!r} instead of raising")
@asyncio_test
async def test_an_implementing_backend_is_unaffected(backend):
assert not is_capability_default(EzdxfBackend, "entity_change_space")
assert inspect.iscoroutinefunction(EzdxfBackend.entity_change_space)
result = await backend.entity_change_space([], "nope")
assert result["ok"] is False
assert "error" in result, "the real implementation still does its own validation"
+158
View File
@@ -0,0 +1,158 @@
"""T0.7 — a capability refusal has to survive the trip to the client.
The backends know precisely why they cannot do a thing: they raise
``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
key of ``capabilities().features`` so a client can pre-check the same boundary
through ``system_capabilities``. That type does not reach anybody.
``FastMCP.call_tool`` rewraps any non-``FastMCPError`` as
``ToolError(f"Error calling tool {name!r}: {e}") from e``; the ``from e`` keeps
the cause alive **in process** — which is the only reason ``cad_batch`` can
classify it — but ``__cause__`` does not serialise. Over the wire the client got
an English sentence and nothing else, so "this backend cannot do that" was
indistinguishable from "your arguments were wrong" except by substring matching.
Subclassing ``FastMCPError`` does not fix this; it changes nothing the client can
observe. The one channel that carries a machine-readable payload *and* the error
flag is ``ToolResult(structured_content=..., is_error=True)``, so a single
middleware — the last place the exception type is still knowable — converts the
refusal, and no tool function changes at all.
The other half is ``cad_batch``: a tool may decline *by value* rather than by
raising, and counting that as a succeeded step is the same class of lie as
writing DXF bytes into a ``.dwg`` or reporting a rollback the backend refused.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
# ── the wire contract ───────────────────────────────────────────────────────
async def test_refusal_reaches_the_client_as_a_machine_readable_payload(client, tmp_path):
target = tmp_path / "part.dwg"
result = await client.call_tool("drawing_save_as", {"path": str(target)}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert isinstance(payload, dict), "a refusal must not arrive as prose only"
assert payload["ok"] is False
assert payload["kind"] == "unsupported"
assert payload["capability"] == "dwg"
assert payload["tool"] == "drawing_save_as"
assert payload["backend"] == "ezdxf"
# The prose channel still works for clients that only read text, and it is
# the FULL sentence — a refusal that names no way forward is not help.
assert payload["error"] in result.content[0].text
assert ".dxf" in payload["error"]
assert not target.exists(), "a refusal must not leave a mislabelled file behind"
async def test_the_capability_is_a_key_a_client_can_pre_check(client, tmp_path):
"""The whole point of the key: ask before calling, get the same answer."""
caps = (await client.call_tool("system_capabilities", {})).structured_content or {}
features = caps["features"]
result = await client.call_tool(
"drawing_save_as", {"path": str(tmp_path / "x.dwg")}, raise_on_error=False
)
capability = result.structured_content["capability"]
assert features[capability]["supported"] is False
async def test_ordinary_errors_are_left_exactly_as_they_were(client):
"""The middleware must not turn every failure into a capability refusal."""
result = await client.call_tool("entity_get", {"handle": "NOSUCH"}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert not (isinstance(payload, dict) and payload.get("kind") == "unsupported")
async def test_a_successful_call_is_untouched(client):
result = await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 5, "y2": 0})
assert result.is_error is False
assert result.structured_content["handle"]
# ── one refusal mechanism, not two ──────────────────────────────────────────
async def test_solid_tools_raise_the_typed_refusal_instead_of_returning_a_dict():
"""Returning ``{"ok": False}`` puts a refusal outside the middleware's reach.
The payload the client sees is unchanged; what flips is ``is_error``, from a
quiet False to an honest True.
"""
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.solid_box(0, 0, 0, 10, 10, 10)
assert excinfo.value.capability == "solid_3d"
assert excinfo.value.to_dict() == {
"ok": False,
"error": str(excinfo.value),
"capability": "solid_3d",
}
await backend.disconnect()
async def test_the_refusal_type_lives_on_the_shared_base():
"""Both backends must be able to raise the same class, not two look-alikes."""
from backends.ezdxf_backend import UnsupportedCapabilityError as from_ezdxf
assert from_ezdxf is UnsupportedCapabilityError
# ── cad_batch: a declined step is not a succeeded step ──────────────────────
async def _batch(client, steps, **kwargs):
payload = {"steps": steps, **kwargs}
return (await client.call_tool("cad_batch", payload)).structured_content
async def test_batch_does_not_count_a_step_that_declined_by_value_as_succeeded(client):
"""``drawing_redo`` with nothing to redo returns ``{"ok": False}`` and did nothing."""
result = await _batch(
client,
[
{"tool": "entity_create_line", "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 0}},
{"tool": "drawing_redo", "args": {}},
],
on_error="continue",
)
assert result["succeeded"] == 1, "the redo did nothing; it cannot be a success"
assert result["failed"] == 1
redo_row = result["results"][1]
assert redo_row["status"] == "error"
assert redo_row["error"]["kind"] in {"refused", "unsupported"}
+319
View File
@@ -0,0 +1,319 @@
"""M6 — CHSPACE: moving geometry between model space and a sheet.
AutoCAD's CHSPACE moves objects *through* a viewport, scaling and translating
them by that viewport's zoom so they look identical on screen before and after.
ezdxf's `move_to_layout` does the move and applies **no geometric change at
all** — so the obvious implementation reports `ok: true`, produces a 100 mm
feature as 100 mm of paper inside a 1:2 viewport, and writes a file that audits
perfectly clean. That is the exact class of silent lie this release exists to
remove, so the transform is the tool, not a nicety.
The matrix is `viewport.get_transformation_matrix()`, not a hand-rolled
`translate(-view_center) @ scale @ translate(center)`. The hand-rolled version
ignores `view_target_point`, and with a target point set it misplaces geometry
by tens of millimetres while `is_top_view` is still True and the twist angle is
still zero — i.e. past every guard one would naturally write. The regression
test below pins the result against what ezdxf's own rendering pipeline projects.
What this refuses, and why each refusal is not pedantry:
* **Non-top views and twisted viewports** — ezdxf returns a meaningless
top-view matrix for the first with no complaint, and rotates about the paper
origin for the second.
* **Dimensions** — `transform` halves `get_measurement()` while the baked
dimension-block text still reads the old value. Whichever the consumer
trusts, the other is a lie, so a dimension moves only with
`freeze_dimensions=True`, which bakes the pre-transform measurement into the
text first.
* **ACIS bodies** — `3DSOLID`, `BODY`, `REGION` and `SURFACE` accept
`transform()` and move nothing; the matrix is parked in a pending
transformation the headless engine cannot apply.
* **Tables and proxies** — `ACAD_TABLE`, `ACAD_PROXY_ENTITY` and `OLE2FRAME`
raise `NotImplementedError` from `transform()`, and `hasattr(e, "transform")`
is `True` for all of them, so a `hasattr` dispatch does not filter them out.
* **Viewports** — ezdxf will happily move one into model space; AutoCAD's
CHSPACE never does.
* **Entities already in the target space** — `move_to_layout` to the same
layout silently succeeds, and a no-op must not read as a move.
Clipping is *flagged*, not refused: AutoCAD also lets you move geometry that
falls outside the viewport, so each moved entity reports `inside_viewport` and
`on_sheet` rather than being rejected.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
async def _sheet_with_viewport(backend, *, scale=0.5, name=SHEET, **vp_dxf):
"""An A3 sheet with one 1:2 viewport centred on the page."""
await backend.layout_create(name)
layout = backend._doc.layouts.get(name)
layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
created = await backend.viewport_create(name, 210, 148.5, 200, 150, 50, 25, scale=scale)
viewport = backend._doc.entitydb.get(created["handle"])
for key, value in vp_dxf.items():
viewport.dxf.set(key, value)
return created["handle"], viewport
def _layout_of(backend, handle: str) -> str:
"""The layout tab an entity lives on, by owning block record."""
doc = backend._doc
owner = doc.entitydb.get(handle).dxf.owner
if owner == doc.modelspace().block_record.dxf.handle:
return "Model"
for name in doc.layouts.names():
if name != "Model" and doc.layouts.get(name).block_record.dxf.handle == owner:
return name
return owner
# ── the transform is the point ──────────────────────────────────────────────
async def test_moving_to_paper_scales_the_geometry_by_the_viewport(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
line = await backend.entity_create_line(50, 25, 150, 25) # 100 mm through the view centre
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is True
assert result["moved"][0]["handle"] == line.handle
moved = backend._doc.entitydb.get(line.handle)
length = (moved.dxf.end - moved.dxf.start).magnitude
assert length == pytest.approx(50.0), "a 100 mm model line is 50 mm of paper at 1:2"
async def test_the_moved_geometry_lands_where_the_renderer_projects_it(backend):
"""Regression test for the hand-rolled matrix that ignores view_target_point.
With ``view_target_point`` set, the naive
``translate(-view_center) @ scale @ translate(center)`` misplaces the view
centre by 25 mm while ``is_top_view`` is True and the twist is zero — so no
guard catches it and only this comparison does.
"""
from ezdxf.math import Vec3
vp, viewport = await _sheet_with_viewport(backend, scale=0.5, view_target_point=(50, 25, 0))
expected = viewport.get_transformation_matrix().transform(Vec3(60, 30, 0))
line = await backend.entity_create_line(60, 30, 70, 30)
await backend.entity_change_space([line.handle], vp)
moved = backend._doc.entitydb.get(line.handle)
assert moved.dxf.start.x == pytest.approx(expected.x)
assert moved.dxf.start.y == pytest.approx(expected.y)
async def test_the_move_preserves_the_handle(backend):
"""Every tool in this server addresses entities by handle."""
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
await backend.entity_change_space([line.handle], vp)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
assert _layout_of(backend, line.handle) == SHEET
async def test_a_round_trip_returns_the_original_coordinates(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
line = await backend.entity_create_line(50, 25, 150, 25)
await backend.entity_change_space([line.handle], vp, direction="to_paper")
await backend.entity_change_space([line.handle], vp, direction="to_model")
moved = backend._doc.entitydb.get(line.handle)
assert moved.dxf.start.x == pytest.approx(50.0)
assert moved.dxf.end.x == pytest.approx(150.0)
assert _layout_of(backend, line.handle) == "Model"
async def test_the_reported_scale_matches_the_viewport(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.25)
line = await backend.entity_create_line(50, 25, 90, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["scale"] == pytest.approx(0.25)
assert result["viewport"] == vp
# ── what it refuses ─────────────────────────────────────────────────────────
async def test_a_twisted_viewport_is_refused(backend):
"""ezdxf rotates about the paper origin; the alternative is unvalidatable."""
vp, _ = await _sheet_with_viewport(backend, view_twist_angle=30.0)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is False
assert "twist" in result["error"].lower()
assert _layout_of(backend, line.handle) == "Model", "a refused move must not move anything"
async def test_a_non_top_view_viewport_is_refused(backend):
"""ezdxf hands back a meaningless top-view matrix with no complaint."""
vp, _ = await _sheet_with_viewport(backend, view_direction_vector=(1, 1, 1))
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is False
assert _layout_of(backend, line.handle) == "Model"
async def test_a_viewport_handle_that_is_not_a_viewport_is_refused(backend):
await _sheet_with_viewport(backend)
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.entity_change_space([line.handle], line.handle)
assert result["ok"] is False
async def test_an_unfrozen_dimension_is_refused(backend):
"""transform() halves the measurement while the block text keeps the old one."""
vp, _ = await _sheet_with_viewport(backend)
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
result = await backend.entity_change_space([dim.handle], vp)
assert result["ok"] is True
assert result["moved"] == []
reason = result["refused"][0]["reason"]
assert "dimension" in reason.lower() and "freeze_dimensions" in reason
async def test_freezing_bakes_the_measurement_before_scaling(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
result = await backend.entity_change_space([dim.handle], vp, freeze_dimensions=True)
assert result["ok"] is True
row = result["moved"][0]
assert row["frozen_text"] == "100"
assert backend._doc.entitydb.get(dim.handle).dxf.text == "100"
@pytest.mark.parametrize("maker", ["add_body", "add_3dsolid", "add_region"])
async def test_acis_bodies_are_refused_because_transform_moves_nothing(backend, maker):
vp, _ = await _sheet_with_viewport(backend)
entity = getattr(backend._doc.modelspace(), maker)()
result = await backend.entity_change_space([entity.dxf.handle], vp)
assert result["moved"] == []
assert entity.dxftype() in result["refused"][0]["reason"]
async def test_moving_a_viewport_itself_is_refused(backend):
vp, _ = await _sheet_with_viewport(backend)
other, _ = await _sheet_with_viewport(backend, name="Second")
result = await backend.entity_change_space([other], vp)
assert result["moved"] == []
assert "VIEWPORT" in result["refused"][0]["reason"]
async def test_an_entity_already_in_the_target_space_is_refused(backend):
"""`move_to_layout` to the same layout silently succeeds."""
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
await backend.entity_change_space([line.handle], vp)
result = await backend.entity_change_space([line.handle], vp)
assert result["moved"] == []
assert "already" in result["refused"][0]["reason"].lower()
async def test_an_entity_on_a_different_sheet_is_refused(backend):
"""`move_to_layout` raises a raw DXFValueError from deep inside ezdxf here.
Worse, it raises *after* the transform has already been applied: the failed
call left a line that had been scaled and translated by the viewport and
then stayed exactly where it was, on the wrong sheet.
"""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
await backend.layout_create("Other-Sheet")
await backend.layout_set_current("Other-Sheet")
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.layout_set_current("Model")
before = backend._doc.entitydb.get(line.handle).dxf.end
result = await backend.entity_change_space([line.handle], vp, direction="to_model")
assert result["moved"] == []
reason = result["refused"][0]["reason"]
assert "Other-Sheet" in reason
assert backend._doc.entitydb.get(line.handle).dxf.end == before, (
"a refused entity must not be left rescaled"
)
assert _layout_of(backend, line.handle) == "Other-Sheet"
async def test_one_bad_handle_does_not_rescale_the_good_ones_and_abort(backend):
"""A raw exception mid-loop would leave the batch half-applied."""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
await backend.layout_create("Other-Sheet")
await backend.layout_set_current("Other-Sheet")
stray = await backend.entity_create_line(0, 0, 10, 0)
await backend.layout_set_current("Model")
good = await backend.entity_create_line(50, 25, 150, 25)
result = await backend.entity_change_space([stray.handle, good.handle], vp)
assert [row["handle"] for row in result["moved"]] == [good.handle]
assert result["refused"][0]["handle"] == stray.handle
assert _layout_of(backend, good.handle) == SHEET
async def test_an_unknown_handle_is_reported_without_stopping_the_others(backend):
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle, "DEADBEEF"], vp)
assert [row["handle"] for row in result["moved"]] == [line.handle]
assert result["refused"][0]["handle"] == "DEADBEEF"
async def test_an_unknown_direction_is_refused(backend):
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp, direction="sideways")
assert result["ok"] is False
async def test_no_handles_is_refused_rather_than_reported_as_success(backend):
vp, _ = await _sheet_with_viewport(backend)
result = await backend.entity_change_space([], vp)
assert result["ok"] is False
# ── what it flags rather than refuses ───────────────────────────────────────
async def test_geometry_outside_the_viewport_is_flagged_not_refused(backend):
"""AutoCAD moves it too; the caller just has to be told it left the sheet."""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
inside = await backend.entity_create_line(50, 25, 60, 25)
outside = await backend.entity_create_line(800, 25, 900, 25)
result = await backend.entity_change_space([inside.handle, outside.handle], vp)
rows = {row["handle"]: row for row in result["moved"]}
assert rows[inside.handle]["inside_viewport"] is True
assert rows[inside.handle]["on_sheet"] is True
assert rows[outside.handle]["inside_viewport"] is False
assert rows[outside.handle]["on_sheet"] is False
+132
View File
@@ -0,0 +1,132 @@
"""``ComBackend._run`` deadline honesty — the STA executor must only be rebuilt
when *our* deadline actually expired.
On Python 3.11+ ``TimeoutError`` **is** ``asyncio.TimeoutError`` (and an
``OSError`` subclass), so a wrapped COM call that raises ``TimeoutError`` itself
— a socket read against a dead network share, a helper that re-raises one —
arrives at ``except TimeoutError`` looking exactly like a blown deadline. Taking
it at face value told the user AutoCAD "did not respond within 60s" for a call
that failed instantly, and (far worse than the ezdxf equivalent) threw away the
live single-thread STA executor and the cached AutoCAD connection with it.
These tests exercise the real ``_run`` — a plain ``ThreadPoolExecutor`` stands in
for the COM apartment, no AutoCAD and no win32 calls involved.
"""
from __future__ import annotations
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
import backends.com_backend as cb
import config
from backends.com_backend import ComBackend
pytestmark = pytest.mark.asyncio
@pytest.fixture
def com_backend():
"""A ComBackend wired to a real (AutoCAD-free) single-thread executor."""
b = ComBackend()
# No initializer: _com_init would CoInitialize a real STA apartment.
b._executor = ThreadPoolExecutor(max_workers=1)
b._connected = True
yield b
if b._executor is not None:
b._executor.shutdown(wait=False)
b._executor = None
def _dead_share():
"""Stand-in for a COM/file call that raises TimeoutError of its own accord."""
raise TimeoutError("the network share went away")
async def test_call_raising_TimeoutError_is_not_blamed_on_the_deadline(com_backend, monkeypatch):
"""A TimeoutError *from the call* is an ordinary failure, not our deadline."""
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
started = time.monotonic()
with pytest.raises(TimeoutError, match="network share") as excinfo:
await com_backend._run(_dead_share)
assert time.monotonic() - started < 5, "it failed instantly; nothing waited 30s"
assert "did not respond" not in str(excinfo.value)
assert not isinstance(excinfo.value, RuntimeError), "the original error must survive"
async def test_call_raising_TimeoutError_keeps_the_sta_executor(com_backend, monkeypatch):
"""Rebuilding the apartment on an ordinary error drops a live AutoCAD session."""
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
monkeypatch.setitem(cb._COM_STATE, "app", object())
live = com_backend._executor
with pytest.raises(TimeoutError):
await com_backend._run(_dead_share)
assert com_backend._executor is live, "the STA executor must not be rebuilt"
assert com_backend._connected is True, "the AutoCAD connection must not be dropped"
assert "app" in cb._COM_STATE, "the cached COM app must survive an ordinary error"
assert await asyncio.wait_for(com_backend._run(lambda: "next"), timeout=5) == "next"
async def test_disabled_timeout_still_surfaces_a_callable_TimeoutError(com_backend, monkeypatch):
"""With COM_CALL_TIMEOUT=0 there is no deadline at all to blame."""
monkeypatch.setattr(config.settings, "com_call_timeout", 0)
live = com_backend._executor
with pytest.raises(TimeoutError, match="network share"):
await com_backend._run(_dead_share)
assert com_backend._executor is live
assert com_backend._connected is True
async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeypatch):
"""The real overrun path is deliberate and must keep working: the worker is
stuck inside an uncancellable COM call, so it is abandoned and replaced."""
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
monkeypatch.setitem(cb._COM_STATE, "app", object())
stuck = com_backend._executor
entered = threading.Event()
def _hang():
entered.set()
time.sleep(2.0)
return "too late"
started = time.monotonic()
with pytest.raises(RuntimeError, match="did not respond") as excinfo:
await com_backend._run(_hang)
assert entered.is_set()
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
assert isinstance(excinfo.value.__cause__, TimeoutError)
assert com_backend._executor is not stuck, "the stuck apartment must be replaced"
assert com_backend._connected is False
assert "app" not in cb._COM_STATE, "the cached app belongs to the abandoned thread"
# The anti-deadlock guarantee: the next call runs on the fresh executor
# instead of queueing behind the still-sleeping worker.
monkeypatch.setattr(config.settings, "com_call_timeout", 10)
assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh"
async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch):
"""Sanity: non-timeout failures never touched the executor and still don't."""
monkeypatch.setattr(config.settings, "com_call_timeout", 5)
live = com_backend._executor
def _boom():
raise ValueError("nope")
with pytest.raises(ValueError, match="nope"):
await com_backend._run(_boom)
assert com_backend._executor is live
assert await com_backend._run(lambda x: x * 2, 21) == 42
+210
View File
@@ -0,0 +1,210 @@
"""Gates for the AutoCAD command / synonym alias corpus (``discovery.aliases``).
FastMCP's ``BM25SearchTransform`` indexes only tool name + description +
parameter names + parameter descriptions (see
``fastmcp/server/transforms/search/base.py::_extract_searchable_text``).
AutoCAD command names appear in none of those for this server, so a drafter
searching for ``BPOLY`` or ``QSELECT`` scores df=0 and gets nothing back.
These tests both *prove* that gap is real against the live registry and lock the
corpus that closes it to the live tool list, so the corpus can never silently
drift away from the registered tools.
"""
from __future__ import annotations
import re
import pytest
import server
from discovery.aliases import (
SHARED_ACAD_COMMANDS,
TOOL_ALIASES,
ToolAliases,
alias_text,
aliases_for,
)
# AutoCAD command tokens: uppercase alphanumerics, no spaces, at least two
# characters (single-letter aliases like "L"/"C" are deliberately excluded --
# they are pure BM25 noise and collide with ordinary prose).
_ACAD_RE = re.compile(r"^[A-Z][A-Z0-9]+$")
# Commands proven below to be absent from every live tool's searchable text.
#
# Deliberately NOT in this list, and why:
# FILLET -- already present: the tool is literally named ``entity_fillet``.
# XCLIP -- the server has no xref/clipping support at all; any mapping
# would misroute the search, so the corpus honestly omits it.
# REVCLOUD -- no revision-cloud tool exists; a revcloud would have to be
# hand-built from arcs, so no single tool is the right answer.
#: AutoCAD commands that appear nowhere in the live tool text, so a
#: description-only ranker scores them df=0 and returns nothing.
#:
#: BPOLY and QSELECT used to be here and were removed in v1.5.0 (M8), when
#: `boundary_trace` and `selection_filter` landed and named those commands in
#: their own docstrings. The alias corpus is no longer the *only* way to reach
#: them, and saying otherwise would overstate what the corpus is worth. Two
#: commands moving out of this list is the honest cost of the drawing tools
#: growing to cover them — the corpus still carries the rest.
DF0_COMMANDS = (
"PEDIT",
"WBLOCK",
"OVERKILL",
"LAYTRANS",
"MATCHPROP",
)
async def _registered_tools() -> list:
return [t for t in await server._registered_tools() if getattr(t, "name", None)]
async def _registered_names() -> set[str]:
return {t.name for t in await _registered_tools()}
def _searchable_text(tool) -> str:
"""Mirror of fastmcp's ``_extract_searchable_text`` -- what BM25 indexes."""
parts = [tool.name]
if tool.description:
parts.append(tool.description)
schema = getattr(tool, "parameters", None) or {}
for param_name, param_info in (schema.get("properties") or {}).items():
parts.append(param_name)
if isinstance(param_info, dict) and param_info.get("description"):
parts.append(str(param_info["description"]))
return " ".join(parts).lower()
def _acad_owners() -> dict[str, list[str]]:
owners: dict[str, list[str]] = {}
for tool_name, record in TOOL_ALIASES.items():
for command in record.acad:
owners.setdefault(command, []).append(tool_name)
for names in owners.values():
names.sort()
return owners
# ── coverage ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_every_registered_tool_has_an_alias_record():
"""Adding a tool without an alias record must fail this gate."""
missing = sorted(await _registered_names() - set(TOOL_ALIASES))
assert not missing, f"tools with no alias record: {missing}"
@pytest.mark.asyncio
async def test_no_alias_key_is_an_unknown_tool():
"""Catches typos and tools that were renamed or removed."""
unknown = sorted(set(TOOL_ALIASES) - await _registered_names())
assert not unknown, f"alias keys that are not registered tools: {unknown}"
def test_every_record_is_a_tool_aliases_instance():
bad = sorted(k for k, v in TOOL_ALIASES.items() if not isinstance(v, ToolAliases))
assert not bad, f"records that are not ToolAliases: {bad}"
# ── shape ───────────────────────────────────────────────────────────────────
def test_acad_commands_are_uppercase_and_space_free():
bad: list[str] = []
for tool_name, record in TOOL_ALIASES.items():
for command in record.acad:
if not _ACAD_RE.fullmatch(command):
bad.append(f"{tool_name}:{command!r}")
assert not bad, f"malformed AutoCAD command tokens: {bad}"
def test_acad_commands_are_unique_within_a_record():
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.acad)) != len(v.acad))
assert not bad, f"records with a repeated AutoCAD command: {bad}"
def test_synonyms_are_lowercase_non_empty_and_trimmed():
bad: list[str] = []
for tool_name, record in TOOL_ALIASES.items():
for phrase in record.synonyms:
if not phrase or phrase != phrase.lower() or phrase != phrase.strip():
bad.append(f"{tool_name}:{phrase!r}")
assert not bad, f"malformed synonyms: {bad}"
def test_every_tool_has_at_least_one_synonym():
"""``acad`` may legitimately be empty; a record with nothing in it is dead weight."""
empty = sorted(k for k, v in TOOL_ALIASES.items() if not v.synonyms)
assert not empty, f"records with no synonyms: {empty}"
def test_synonyms_are_unique_within_a_record():
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.synonyms)) != len(v.synonyms))
assert not bad, f"records with a repeated synonym: {bad}"
# ── uniqueness ──────────────────────────────────────────────────────────────
#
# Rule: one AutoCAD command routes to exactly one tool. The only exceptions are
# the commands in ``SHARED_ACAD_COMMANDS`` -- option/dialog-driven umbrella
# commands (LAYER, ZOOM, ARRAY, LAYOUT, MEASUREGEOM, SETVAR) and the two places
# where this server genuinely exposes one operation through two tools (INSERT ->
# block_insert / entity_create_block_ref, ERASE -> entity_delete /
# entity_delete_many). Anything else is a copy-paste bug.
def test_each_acad_command_routes_to_a_single_tool():
unexpected = {
command: names
for command, names in _acad_owners().items()
if len(names) > 1 and command not in SHARED_ACAD_COMMANDS
}
assert not unexpected, f"AutoCAD command claimed by several tools: {unexpected}"
def test_shared_command_allowlist_has_no_stale_entries():
actually_shared = {c for c, names in _acad_owners().items() if len(names) > 1}
stale = sorted(SHARED_ACAD_COMMANDS - actually_shared)
assert not stale, f"SHARED_ACAD_COMMANDS lists commands that are not shared: {stale}"
# ── the df=0 regression guard ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_key_autocad_commands_are_absent_from_live_tool_text():
"""Proves the gap: these commands score df=0 against the live BM25 corpus."""
tools = await _registered_tools()
hits = {
command: sorted(t.name for t in tools if command.lower() in _searchable_text(t))
for command in DF0_COMMANDS
}
leaked = {c: names for c, names in hits.items() if names}
assert not leaked, f"command already indexed -- drop it from DF0_COMMANDS: {leaked}"
def test_key_autocad_commands_are_covered_by_the_corpus():
"""...and proves the corpus closes it."""
owners = _acad_owners()
missing = sorted(c for c in DF0_COMMANDS if c not in owners)
assert not missing, f"df=0 commands still unreachable via the corpus: {missing}"
# ── helpers ─────────────────────────────────────────────────────────────────
def test_aliases_for_returns_the_record_or_none():
assert aliases_for("entity_fillet") is TOOL_ALIASES["entity_fillet"]
assert aliases_for("no_such_tool") is None
def test_alias_text_contains_commands_and_synonyms():
record = TOOL_ALIASES["entity_create_circle"]
text = alias_text("entity_create_circle")
for token in (*record.acad, *record.synonyms):
assert token in text
assert alias_text("no_such_tool") == ""
+668
View File
@@ -0,0 +1,668 @@
"""The discovery search transform must find tools a drafter can actually name.
Stock ``BM25SearchTransform`` indexes tool name + description + parameter names
+ parameter descriptions and nothing else. Measured against this server's live
catalog, that means ``PEDIT``, ``WBLOCK``, ``OVERKILL``,
``LAYTRANS`` and ``MATCHPROP`` all score df=0 and return *nothing*,
and "how many entities are on the GEOMETRY layer" returns ``entity_delete_many``
as its top hit. Both are proved here against the real catalog before anything is
asserted about the replacement.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from fastmcp import Client
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.transforms.search.base import serialize_tools_for_output_json
import config
import server
from discovery.serialize import serialize_search_results
from discovery.transform import CadSearchTransform, tool_risk
from tests.data.discovery_golden import ALL_CASES, HOLDOUT_CASES, TUNING_CASES
pytestmark = pytest.mark.asyncio
@pytest.fixture(scope="module")
def catalog():
"""The real, untransformed 131-tool catalog.
Synchronous on purpose: ``_registered_tools`` only reads the in-process
component registry, and a module-scoped *async* fixture would need its own
event-loop scope under ``asyncio_mode = strict``.
"""
return asyncio.run(server._registered_tools())
@pytest.fixture
def transform():
return CadSearchTransform()
@pytest.fixture
def stock():
return BM25SearchTransform()
def _names(tools) -> list[str]:
return [t.name for t in tools]
# ── the df=0 vocabulary gap ─────────────────────────────────────────────────
# Verified against the live catalog: stock BM25 returns *zero* hits for every
# one of these. FILLET is deliberately absent — it is already indexed (df=2,
# the tool is literally named entity_fillet), so citing it would overstate the
# gap.
#
# BPOLY and QSELECT left this list in v1.5.0 (M8): `boundary_trace` and
# `selection_filter` name those commands in their own docstrings, so stock BM25
# now finds them and the gap is genuinely closed for those two. Keeping them
# here would have made the corpus look more load-bearing than it is. The
# remaining five are still unreachable without it.
DF_ZERO_COMMANDS = [
("PEDIT", "entity_edit_geometry"),
("WBLOCK", "block_create_from_entities"),
("OVERKILL", "drawing_refine"),
("LAYTRANS", "template_apply_layers"),
("MATCHPROP", "entity_set_properties"),
]
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_stock_bm25_cannot_find_these_commands_at_all(stock, catalog, command, expected):
"""Guard the guard: the gap is real, not an artefact of a weak assertion."""
assert await stock._search(catalog, command) == []
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_autocad_command_names_resolve(transform, catalog, command, expected):
assert _names(transform.rank(catalog, command).hits)[0] == expected
@pytest.mark.parametrize(
("command", "expected"),
[
("DLI", "dimension_linear"),
("REC", "entity_create_rectangle"),
("CHA", "entity_chamfer"),
("TR", "entity_trim"),
("MI", "entity_mirror"),
("XL", "construction_xline"),
],
)
async def test_short_command_aliases_resolve(transform, catalog, command, expected):
"""Two- and three-letter aliases are what drafters actually type."""
assert _names(transform.rank(catalog, command).hits)[0] == expected
async def test_searchable_text_extends_the_stock_document(transform, catalog):
"""The hook adds alias vocabulary; it never drops the stock text."""
from fastmcp.server.transforms.search.base import _extract_searchable_text
tool = next(t for t in catalog if t.name == "entity_create_polyline")
text = transform._extract_searchable_text(tool)
assert _extract_searchable_text(tool) in text
assert "PLINE" in text
assert "closed profile" in text
async def test_a_tool_with_no_alias_record_is_still_indexed(transform, catalog):
"""An un-aliased tool must stay findable, not vanish from the index."""
tool = next(t for t in catalog if t.name == "entity_create_line")
stripped = tool.model_copy(update={"name": "zzz_unaliased_probe"})
hits = transform.rank([*catalog, stripped], "zzz_unaliased_probe").hits
assert _names(hits)[0] == "zzz_unaliased_probe"
# ── the two boosts ──────────────────────────────────────────────────────────
async def test_an_exact_command_outranks_tools_that_share_a_description_word(transform, catalog):
""" "CIRCLE" is the command; a dozen tools merely mention circles."""
hits = _names(transform.rank(catalog, "CIRCLE", limit=5).hits)
assert hits[0] == "entity_create_circle"
async def test_a_command_name_that_is_also_an_english_word_stays_english(transform, catalog):
"""LAYER is a command *and* a noun.
Boosting on a bare lowercase "layer" made layer_modify / layer_list /
layer_create the top three for this query and pushed layer_delete out of
the answer entirely — exactly backwards. Measured regression, not a
hypothetical.
"""
hits = _names(transform.rank(catalog, "delete the layer called GEOMETRY", limit=8).hits)
assert "layer_delete" in hits
assert not {"layer_create", "layer_modify", "layer_list"} & set(hits[:3])
async def test_the_same_command_in_capitals_is_treated_as_a_command(transform, catalog):
"""The other half of the rule: LAYER typed as a command routes to LAYER tools."""
hits = set(_names(transform.rank(catalog, "LAYER", limit=5).hits))
assert {"layer_create", "layer_list", "layer_modify"} <= hits
async def test_a_lone_lowercase_command_still_counts_as_a_command(transform, catalog):
"""Nobody shouts when the command is the whole message."""
assert _names(transform.rank(catalog, "bpoly").hits)[0] == "boundary_trace"
async def test_articles_do_not_break_synonym_phrase_matching(transform, catalog):
"""The corpus says "delete a layer"; users say "delete the layer"."""
assert "layer_delete" in _names(transform.rank(catalog, "delete the layer", limit=5).hits)
# ── the risk filter ─────────────────────────────────────────────────────────
COUNTING_QUERY = "how many entities are on the GEOMETRY layer"
async def test_stock_bm25_answers_a_counting_question_with_destructive_tools(stock, catalog):
"""Guard the guard: the destructive-hit problem is measured, not assumed.
This asserted the top hit was ``entity_delete_many`` until
``analysis_measure_entity`` was added and displaced it to second. The
ranking order is incidental; what the risk ceiling exists to prevent is a
read-only question being answered with tools that destroy geometry, and that
is still exactly what stock BM25 does here.
"""
hits = await stock._search(catalog, COUNTING_QUERY)
risky = [t.name for t in hits if tool_risk(t) == "destructive"]
assert risky, f"expected destructive hits among {_names(hits)}"
assert "entity_delete_many" in risky
@pytest.mark.parametrize(
("name", "expected"),
[
("drawing_info", "read"),
("analysis_entity_stats", "read"),
("entity_create_line", "write"),
("entity_delete_many", "destructive"),
("layer_delete", "destructive"),
("transaction_rollback", "destructive"),
],
)
async def test_risk_is_derived_from_the_mcp_annotations(catalog, name, expected):
tool = next(t for t in catalog if t.name == name)
assert tool_risk(tool) == expected
async def test_read_ceiling_keeps_destructive_tools_out_of_a_counting_answer(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, risk="read")
assert [tool_risk(t) for t in outcome.hits] == ["read"] * len(outcome.hits)
assert "analysis_entity_stats" in _names(outcome.hits)
async def test_write_ceiling_allows_mutation_but_not_destruction(transform, catalog):
outcome = transform.rank(catalog, "delete the entities on the GEOMETRY layer", risk="write")
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
async def test_the_filter_frees_slots_rather_than_shrinking_the_answer(transform, catalog):
"""Filtering must promote lower-ranked safe tools, not just delete rows."""
unfiltered = transform.rank(catalog, COUNTING_QUERY, limit=5)
filtered = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert len(unfiltered.hits) == 5
assert len(filtered.hits) == 5
assert set(_names(filtered.hits)) - set(_names(unfiltered.hits))
async def test_the_filter_records_exactly_what_it_hid(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert "entity_delete_many" in _names(outcome.hidden)
# Only tools the caller would otherwise have seen — not the whole catalog.
assert len(outcome.hidden) <= 5
async def test_nothing_is_recorded_as_hidden_when_the_filter_is_off(transform, catalog):
assert transform.rank(catalog, COUNTING_QUERY, limit=5).hidden == ()
# ── the risk filter must be visible in the payload ──────────────────────────
class _FakeServer:
def __init__(self, tools):
self._tools = list(tools)
async def list_tools(self, run_middleware: bool = True):
return list(self._tools)
class _FakeContext:
"""Just enough Context for ``_get_visible_tools`` to reach the catalog."""
def __init__(self, tools):
self.fastmcp = _FakeServer(tools)
async def _search_payload(transform, catalog, query: str, **kwargs):
tool = transform._make_search_tool()
return await tool.fn(query=query, ctx=_FakeContext(catalog), **kwargs)
async def test_the_payload_says_when_the_risk_filter_removed_hits(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="read")
notice = [line for line in payload.splitlines() if line.startswith("Risk filter:")]
assert len(notice) == 1
assert "entity_delete_many" in notice[0]
assert 'risk="any"' in notice[0]
async def test_a_mixed_intent_is_never_silently_halved(transform, catalog):
""" "list the layers then delete GEOMETRY" is half a read and half a delete."""
payload = await _search_payload(
transform, catalog, "list the layers then delete GEOMETRY", risk="read"
)
assert "layer_list" in payload
notice = next(line for line in payload.splitlines() if line.startswith("Risk filter:"))
assert "entity_delete" in notice
async def test_no_notice_when_the_filter_removed_nothing(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="any")
assert "Risk filter:" not in payload
async def test_a_structured_serializer_gets_the_notice_as_a_sibling_key(catalog):
"""There is no line to append to when the serializer returns JSON."""
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="read")
assert isinstance(payload["results"], list)
assert "entity_delete_many" in payload["notice"]
async def test_a_structured_serializer_returns_bare_results_when_nothing_was_hidden(catalog):
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="any")
assert isinstance(payload, list)
# ── the search tool's own schema ────────────────────────────────────────────
async def test_search_tool_advertises_limit_and_risk(transform):
properties = transform._make_search_tool().parameters["properties"]
assert set(properties) == {"query", "limit", "risk"}
assert properties["risk"]["enum"] == ["any", "write", "read"]
assert properties["limit"]["default"] == transform._max_results
@pytest.mark.parametrize(("asked", "served"), [(999, 25), (0, 1), (-4, 1), (3, 3)])
async def test_limit_is_clamped_to_a_usable_range(transform, catalog, asked, served):
outcome = transform.rank(catalog, "line", limit=asked)
assert len(outcome.hits) == served
async def test_an_unknown_risk_value_is_rejected_rather_than_ignored(transform, catalog):
with pytest.raises(ValueError, match="risk"):
transform.rank(catalog, "line", risk="mostly-harmless")
# ── the compact serializer ──────────────────────────────────────────────────
def _tools(catalog, *names):
by_name = {t.name: t for t in catalog}
return [by_name[n] for n in names]
async def test_one_card_per_tool_on_one_line_each(catalog):
tools = _tools(catalog, "entity_create_circle", "entity_delete", "drawing_info")
lines = serialize_search_results(tools).splitlines()
assert len(lines) == 3
assert lines[0].startswith("entity_create_circle")
async def test_a_card_carries_name_summary_and_required_params(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_create_circle")).splitlines()
assert "entity_create_circle" in line
assert "req: cx, cy, radius" in line
async def test_a_card_marks_the_risk_level(catalog):
text = serialize_search_results(
_tools(catalog, "drawing_info", "entity_create_circle", "entity_delete")
)
assert "drawing_info (read)" in text
assert "entity_create_circle (write)" in text
assert "entity_delete (destructive)" in text
async def test_a_card_carries_the_autocad_command(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_chamfer")).splitlines()
assert "acad: CHAMFER" in line
async def test_the_cad_meta_summary_wins_over_the_docstring(catalog):
"""@cad_tool exists to give a hit a hand-written one-liner."""
tool = next(t for t in catalog if t.name == "transaction_begin")
summary = (tool.meta or {})["cad"]["summary"]
assert summary in serialize_search_results([tool])
async def test_the_first_docstring_line_is_the_fallback_summary(catalog):
"""A card still reads well for a tool that carries no @cad_tool summary.
The fallback is exercised by stripping the meta channel off a real tool
rather than by naming one that happens to lack it: the @cad_tool rollout
is filling the catalog in, so "a tool without cad meta" is not a stable
fixture, while the serializer's fallback branch has to keep working for
any tool a transform hands it (a mounted server's, say).
"""
tool = next(t for t in catalog if t.name == "entity_create_circle")
bare = tool.model_copy(update={"meta": None})
first_line = bare.description.strip().splitlines()[0]
assert first_line in serialize_search_results([bare])
async def test_a_long_summary_is_truncated_on_a_word_boundary(catalog):
tool = next(t for t in catalog if t.name == "entity_create_circle")
verbose = tool.model_copy(update={"description": "word " * 200, "meta": None})
(line,) = serialize_search_results([verbose]).splitlines()
assert len(line) < 250
assert line.count("...") == 1
async def test_a_zero_argument_tool_says_so(catalog):
(line,) = serialize_search_results(_tools(catalog, "drawing_info")).splitlines()
assert "req: (none)" in line
async def test_an_empty_result_set_is_explicit():
assert serialize_search_results([]) == "No tools matched the query."
# Budgets read off the measured output, not chosen first and enforced onto the
# renderer. Over the 15 REALISTIC_QUERIES below at limit=5 the worst rendered
# payload measured 798 characters and the worst single card in the whole
# 131-tool catalog measured 191 (p50 123, p90 160). The thresholds sit ~30%
# above those so an added tool with a long docstring does not turn into a
# spurious CI failure — a real regression would be a multiple, not a nudge.
MAX_CARD_CHARS = 256
MAX_PAYLOAD_CHARS = 1024
REALISTIC_QUERIES = [
"FILLET",
"BPOLY",
"round the corner",
"bill of materials",
"how many entities are on the GEOMETRY layer",
"draw a hole in the plate",
"put a leader note on the chamfer",
"dimension everything with a chain",
"make a title block",
"save the drawing as a pdf",
"select every circle on the GEOMETRY layer",
"gear",
"keyway in the hub bore",
"screenshot",
"check the drawing before finalising",
]
async def test_no_single_card_exceeds_the_measured_budget(catalog):
"""Checked over every registered tool, not just the ones a query happens to hit."""
worst = max(catalog, key=lambda tool: len(serialize_search_results([tool])))
assert len(serialize_search_results([worst])) <= MAX_CARD_CHARS, worst.name
@pytest.mark.parametrize("query", REALISTIC_QUERIES)
async def test_a_five_hit_payload_stays_inside_the_measured_budget(transform, catalog, query):
hits = transform.rank(catalog, query, limit=5).hits
assert len(serialize_search_results(hits)) <= MAX_PAYLOAD_CHARS
async def test_the_serializer_is_far_smaller_than_stock_json(transform, catalog):
"""Payload size is a real gain, but it is *not* the differentiator.
Stock BM25 with this same serializer would close most of the gap; what it
cannot do is find BPOLY at all. Asserted as a ratio so it stays honest.
"""
hits = transform.rank(catalog, "round the corner", limit=5).hits
compact = len(serialize_search_results(hits))
stock_json = len(json.dumps(serialize_tools_for_output_json(hits)))
assert compact < stock_json / 5
# ── the golden set ──────────────────────────────────────────────────────────
TOP_K = 3
# Measured on the tuning set with the scorer frozen: top-1 47/51 (92.2%),
# top-3 50/51 (98.0%). The bars sit below those, not on them.
TUNING_TOP1_BAR = 0.85
TUNING_TOP3_BAR = 0.94
# Declared *before* the holdout set was measured for the first time, and not
# revised afterwards. Lower than the tuning bar on purpose: a ranker that
# scores the same on data it never saw is the exception, not the expectation.
HOLDOUT_TOP3_BAR = 0.80
# A tuning case that fails for a reason this module cannot fix. "smaller" and
# "by half" appear nowhere in discovery/aliases.py (which has "shrink" but not
# "smaller"), so no scoring change reaches entity_scale — only a corpus edit
# does, and the corpus is a separate deliverable. Left in the set rather than
# quietly deleted: it is the honest ceiling of an alias-driven approach.
KNOWN_CORPUS_GAPS = {"make the whole shape smaller by half"}
def _tuning_params():
return [
pytest.param(
case,
marks=(
[pytest.mark.xfail(reason="vocabulary missing from discovery/aliases.py")]
if case.query in KNOWN_CORPUS_GAPS
else []
),
id=str(case),
)
for case in TUNING_CASES
]
async def _accuracy(transform, stock, catalog, cases):
"""(mine_top1, mine_topk, stock_top1, stock_topk, misses) over ``cases``."""
mine1 = mine_k = stock1 = stock_k = 0
misses = []
for case in cases:
# risk="any" for both, so the A/B never credits the risk filter.
ours = _names(transform.rank(catalog, case.query, limit=5).hits)
theirs = _names(await stock._search(catalog, case.query))
mine1 += ours[:1] == [case.expect]
stock1 += theirs[:1] == [case.expect]
if case.expect in ours[:TOP_K]:
mine_k += 1
else:
misses.append(f"{case.query!r} want={case.expect} got={ours[:TOP_K]}")
stock_k += case.expect in theirs[:TOP_K]
return mine1, mine_k, stock1, stock_k, misses
@pytest.mark.parametrize("case", _tuning_params())
async def test_tuning_case_finds_its_tool(transform, catalog, case):
hits = _names(transform.rank(catalog, case.query, limit=5).hits)
assert case.expect in hits[:TOP_K], hits
async def test_tuning_accuracy_holds_its_measured_bar(transform, stock, catalog):
mine1, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, TUNING_CASES)
n = len(TUNING_CASES)
assert mine1 / n >= TUNING_TOP1_BAR, f"top-1 {mine1}/{n}"
assert mine_k / n >= TUNING_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_holdout_accuracy_clears_the_bar_declared_before_it_was_measured(
transform, stock, catalog
):
_, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, HOLDOUT_CASES)
n = len(HOLDOUT_CASES)
assert mine_k / n >= HOLDOUT_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_it_beats_stock_bm25_on_the_whole_golden_set(transform, stock, catalog):
"""The A/B. Both sides run unfiltered, so this measures vocabulary and
ranking only — none of the credit goes to the risk filter.
Scored on top-1, not a top-`TOP_K` ratio. A ratio between two scores over a
shared corpus is not a property of this transform: improving an unrelated
tool's docstring adds vocabulary to *both* indexes, so a `mine_k >= 2 *
stock_k` assertion moves when stock gets **better**, which is a good outcome
being reported as a regression. It fired exactly that way when the undo/redo
docstrings were written. Top-1 is also the number that matters — a search
tool returns one answer to act on.
"""
mine1, mine_k, stock1, stock_k, _ = await _accuracy(transform, stock, catalog, ALL_CASES)
n = len(ALL_CASES)
report = (
f"top-1 mine {mine1}/{n} vs stock {stock1}/{n}; "
f"top-{TOP_K} mine {mine_k}/{n} vs stock {stock_k}/{n}"
)
assert mine1 >= 2 * stock1, report
assert mine_k > stock_k, report
async def test_stock_bm25_cannot_answer_a_fifth_of_the_set_at_all(transform, stock, catalog):
"""The thesis, stated as the thing no amount of tuning can change.
The corpus gap is *data*, not parameters: AutoCAD command names appear in
none of the tool descriptions, so those queries score df=0 and stock returns
an empty list. No serializer and no BM25 constant invents vocabulary that is
not in the index — which is why the alias corpus, not the ranking, is what
this transform actually contributes.
"""
empty_stock = [c.query for c in ALL_CASES if not await stock._search(catalog, c.query)]
empty_mine = [c.query for c in ALL_CASES if not transform.rank(catalog, c.query, limit=5).hits]
assert len(empty_stock) >= 15, f"only {len(empty_stock)} empty — has the corpus changed?"
assert empty_mine == [], f"the alias corpus must cover every case; empty: {empty_mine}"
COUNTING_CASES = [case for case in ALL_CASES if case.kind == "counting"]
@pytest.mark.parametrize("case", COUNTING_CASES, ids=str)
async def test_a_counting_question_never_answers_with_a_destructive_tool(transform, catalog, case):
outcome = transform.rank(catalog, case.query, limit=5, risk=case.risk)
assert case.risk == "read"
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
assert case.expect in _names(outcome.hits)[:TOP_K]
async def test_the_risk_ceiling_is_what_removes_the_delete_tool(transform, catalog):
"""Not the ranking — the ranking still surfaces it, and says that it did."""
query = "how many entities are on the GEOMETRY layer"
assert "entity_delete_many" in _names(transform.rank(catalog, query, limit=5).hits)
filtered = transform.rank(catalog, query, limit=5, risk="read")
assert "entity_delete_many" not in _names(filtered.hits)
assert "entity_delete_many" in filtered.notice()
# ── DISCOVERY_MODE ──────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _restore_discovery_mode():
"""Attaching a transform mutates the shared server; always put it back."""
yield
server._apply_discovery_mode("off")
async def test_discovery_is_off_by_default(monkeypatch):
"""v1.5.0 ships the flag, not the switch: turning it on is a wire-level
breaking change for every connected client and needs its own migration."""
monkeypatch.delenv("DISCOVERY_MODE", raising=False)
assert config.Settings().discovery_mode == "off"
async def test_the_env_var_selects_search_mode(monkeypatch):
monkeypatch.setenv("DISCOVERY_MODE", "SEARCH")
assert config.Settings().discovery_mode == "search"
async def test_off_leaves_the_whole_catalog_on_the_wire():
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
assert "search_tools" not in names
async def test_search_replaces_the_catalog_with_the_two_synthetic_tools():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert names == {"search_tools", "call_tool"}
async def test_search_mode_is_reversible_and_attaches_only_once():
server._apply_discovery_mode("search")
server._apply_discovery_mode("search")
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_an_unknown_discovery_mode_falls_back_to_off():
assert server._apply_discovery_mode("semantic-vector-magic") == "off"
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_the_search_tool_answers_on_the_wire():
"""End to end: a real client, a real AutoCAD command, a real payload."""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BPOLY"})
assert "boundary_trace" in result.content[0].text
async def test_the_risk_notice_survives_to_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool(
"search_tools",
{"query": "how many entities are on the GEOMETRY layer", "risk": "read"},
)
text = result.content[0].text
assert "analysis_entity_stats" in text
assert "Risk filter:" in text
assert "entity_delete_many" in text
async def test_search_cannot_surface_a_tool_the_profile_disabled():
"""Capability-aware discovery has to survive the transform.
The 3D solid tools are registered but disabled unless ENABLE_3D=true, so
searching for one must come back empty rather than advertising a call that
would only be rejected. This works because the search reads the *filtered*
catalog through _get_visible_tools, not the raw registry.
"""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BOX", "limit": 10})
assert "solid_box" not in result.content[0].text
async def test_limit_reaches_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "layer", "limit": 2})
assert len(result.content[0].text.splitlines()) == 2
async def test_search_mode_does_not_shrink_what_the_server_reports_about_itself():
"""system_about reads the untransformed registry; prove it under the transform."""
server._apply_discovery_mode("search")
assert await server._registered_tool_count() > 100
assert len(await server._tool_groups()) > 5
+25 -4
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import pytest
import config
pytestmark = pytest.mark.asyncio
@@ -46,14 +48,33 @@ async def test_drawing_purge(backend):
async def test_drawing_audit(backend):
result = await backend.drawing_audit()
assert result.get("ok") is True
# Asserting only `ok` is why the audit debt survived to 1.5.0: the tool
# repaired documents and reported nothing about it, and this test was green
# throughout. The contract is the whole key set (see backends/base.py).
assert {"ok", "repaired", "fixes", "fix_count", "errors", "error_count"} <= result.keys()
async def test_drawing_undo(backend):
async def test_drawing_undo(backend, monkeypatch):
# `assert isinstance(result, dict)` used to be the whole test, and it passed
# for years against an undo that could only ever fail. See
# tests/test_undo_redo.py for the full contract.
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 100, 0)
result = await backend.drawing_undo()
assert isinstance(result, dict)
assert result["ok"] is True
assert len(list(backend._doc.modelspace())) == 0
async def test_drawing_redo(backend):
async def test_drawing_redo(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 100, 0)
await backend.drawing_undo()
result = await backend.drawing_redo()
assert isinstance(result, dict)
assert result["ok"] is True
assert len(list(backend._doc.modelspace())) == 1
+267
View File
@@ -0,0 +1,267 @@
"""T0.5 — ``entity_measure``: ask the drawing, not the model's memory.
Companion to ``tests/test_measure.py``, which covers the arithmetic. This file
covers the part that was actually missing: addressing an entity **by handle**.
Before this, neither backend could answer "what is the area of the polyline I
just drew". ``analysis_measure_area`` shoelaced points the caller supplied, and
the live COM backend did the same — despite having a document, a
``HandleToObject`` lookup it already used fourteen times, and an ActiveX ``Area``
property sitting right there.
Two distinctions here are load-bearing and are asserted, not assumed:
* A LINE has no area on *any* engine, so that is a plain error — tagging it with
a ``capability`` would falsely imply "switch backends and this works".
* A REGION has no area on the *headless* engine only, so that is a capability
refusal naming the live backend as the way forward.
"""
from __future__ import annotations
import math
import pytest
from backends.base import UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
#: 100x100 square, bottom edge bulged outward into a semicircle of radius 50.
BULGED_SQUARE = [(0, 0, 0, 0, 0.0), (100, 0, 0, 0, 1.0), (100, 100, 0, 0, 0.0), (0, 100, 0, 0, 0.0)]
TRUE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0
TRUE_PERIMETER = 300.0 + math.pi * 50.0
def _add_bulged_square(backend, close=True):
pline = backend._doc.modelspace().add_lwpolyline(BULGED_SQUARE, format="xyseb", close=close)
return pline.dxf.handle
# ── the capability that did not exist ───────────────────────────────────────
async def test_measures_a_bulged_polyline_exactly(backend):
"""The 28.2% the reported-points workflow lost."""
handle = _add_bulged_square(backend)
result = await backend.entity_measure(handle)
assert result["handle"] == handle
assert result["type"] == "LWPOLYLINE"
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
assert result["perimeter"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
assert result["method"] == "analytic_bulge"
assert result["exact"] is True
assert result["flatten_tolerance"] is None
assert result["closed"] is True
assert result["assumed_closed"] is False
assert result["self_intersecting"] is False
assert result["backend"] == "ezdxf"
async def test_a_straight_polyline_reports_the_plain_analytic_method(backend):
pline = backend._doc.modelspace().add_lwpolyline(
[(0, 0), (10, 0), (10, 10), (0, 10)], close=True
)
result = await backend.entity_measure(pline.dxf.handle)
assert result["area"] == pytest.approx(100.0)
assert result["perimeter"] == pytest.approx(40.0)
assert result["method"] == "analytic"
assert result["exact"] is True
async def test_measures_a_circle(backend):
circle = await backend.entity_create_circle(5, 5, 4)
result = await backend.entity_measure(circle.handle)
assert result["area"] == pytest.approx(math.pi * 16.0)
assert result["perimeter"] == pytest.approx(2 * math.pi * 4.0)
assert result["method"] == "analytic"
assert result["exact"] is True
assert result["closed"] is True
async def test_an_open_boundary_says_it_was_closed(backend):
"""AutoCAD's AREA closes an open boundary; the payload must admit it."""
handle = _add_bulged_square(backend, close=False)
result = await backend.entity_measure(handle)
assert result["closed"] is False
assert result["assumed_closed"] is True
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
async def test_a_self_intersecting_polyline_is_flagged(backend):
"""Shoelace cancels crossed lobes, so the 0.0 has to come with a warning."""
pline = backend._doc.modelspace().add_lwpolyline(
[(0, 0), (100, 100), (100, 0), (0, 100)], close=True
)
result = await backend.entity_measure(pline.dxf.handle)
assert result["self_intersecting"] is True
# ── the two kinds of "no" ───────────────────────────────────────────────────
async def test_a_line_has_no_area_on_any_engine_so_it_is_not_a_capability_gap(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
with pytest.raises(RuntimeError) as excinfo:
await backend.entity_measure(line.handle)
assert not isinstance(excinfo.value, UnsupportedCapabilityError), (
"tagging this `capability` would tell the user to switch backends, "
"where no backend can give a LINE an area"
)
assert "LWPOLYLINE" in str(excinfo.value), "the refusal must list what IS measurable"
async def test_a_region_is_a_real_capability_boundary(backend):
"""ezdxf stores ACIS opaquely and cannot evaluate it; live AutoCAD can."""
region = backend._doc.modelspace().new_entity("REGION", {})
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_measure(region.dxf.handle)
assert excinfo.value.capability == "measure_area_acis"
assert "com" in str(excinfo.value).lower(), "a refusal must name the way forward"
async def test_measure_capability_is_declared_on_both_backends():
assert EzdxfBackend().capabilities().features["measure_area_acis"].supported is False
assert ComBackend().capabilities().features["measure_area_acis"].supported is True
# ── HATCH: the type the refusal advertised and then refused ─────────────────
#
# `_MEASURABLE_TYPES` named HATCH, so a caller who asked for a hatch's area got
# a RuntimeError whose own text listed HATCH as measurable. Whichever half was
# right, the pair could not both be — and a hatch is the one entity whose area
# a drafter actually asks for, because it is the section they have to fill.
def _hatch_with_island(backend, *, style: int = 0):
"""20x20 outer square, 10x10 island. Filled area is 400 - 100 = 300."""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.dxf.hatch_style = style
hatch.paths.add_polyline_path(
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
)
island = hatch.paths.add_edge_path(flags=0)
island.add_line((5, 5), (15, 5))
island.add_line((15, 5), (15, 15))
island.add_line((15, 15), (5, 15))
island.add_line((5, 15), (5, 5))
return hatch.dxf.handle
async def test_a_hatch_reports_the_area_it_actually_fills(backend):
handle = _hatch_with_island(backend)
result = await backend.entity_measure(handle)
assert result["type"] == "HATCH"
assert result["area"] == pytest.approx(300.0, abs=1e-6), (
"the island is a hole in the fill; counting it is the 33% error"
)
assert result["loop_count"] == 2
assert result["hatch_style"] == "normal"
assert result["exact"] is True, "every edge here is a straight line — nothing was flattened"
async def test_hatch_style_ignore_fills_straight_over_the_island(backend):
"""AutoCAD's HPISLAND=2 fills the island too. The number must follow."""
handle = _hatch_with_island(backend, style=2)
result = await backend.entity_measure(handle)
assert result["area"] == pytest.approx(400.0, abs=1e-6)
assert result["hatch_style"] == "ignore"
async def test_a_nested_island_alternates_under_the_normal_style(backend):
"""Depth 2 is where `normal` and `outer` stop agreeing, so it is the only
case that proves the depths were computed rather than assumed."""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.dxf.hatch_style = 0 # normal
hatch.paths.add_polyline_path(
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
)
hatch.paths.add_polyline_path([(4, 4), (16, 4), (16, 16), (4, 16)], is_closed=True, flags=0)
hatch.paths.add_polyline_path([(8, 8), (12, 8), (12, 12), (8, 12)], is_closed=True, flags=0)
result = await backend.entity_measure(hatch.dxf.handle)
# 400 - 144 + 16: the innermost loop is filled again.
assert result["area"] == pytest.approx(272.0, abs=1e-6)
assert result["loop_count"] == 3
async def test_a_curved_hatch_edge_says_it_was_flattened(backend):
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
result = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-5)
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
assert result["exact"] is False, "a flattened arc is not an exact area"
assert result["flatten_tolerance"] == 1e-5
async def test_tightening_the_tolerance_does_not_make_a_curved_hatch_exact(backend):
"""The floor is the arc-to-Bezier conversion, not the flattening.
A caller who sees `flatten_tolerance` naturally assumes a smaller number
buys a better area, all the way to the truth. For hatch edges it does not:
ezdxf hands the boundary over as cubic Beziers, and tightening the
tolerance converges on the *Bezier's* area, which sits ~0.028% above the
circle it stands in for. The residual does not shrink with the tolerance,
which is why `exact` is False rather than the tolerance being presented as
the accuracy knob.
"""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
true_area = math.pi * 100.0
coarse = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-3)
fine = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-7)
finest = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-9)
# Four orders of magnitude of extra tolerance buy nothing after the first.
assert finest["area"] == pytest.approx(fine["area"], rel=1e-7)
residual = abs(finest["area"] - true_area) / true_area
assert residual > 2e-4, "the Bezier error survives any tolerance"
assert abs(coarse["area"] - true_area) / true_area < residual, (
"the coarse run is only closer by accident — an inscribed chord error "
"that happens to cancel part of the Bezier overshoot"
)
assert finest["exact"] is False
# ── entity_get stops dropping the curve ─────────────────────────────────────
async def test_entity_get_reports_area_and_length_for_a_closed_polyline(backend):
"""``props["length"] = ent.length()`` was dead code — LWPolyline has no
``length()``, so the AttributeError went to a debug log and the key silently
never appeared."""
handle = _add_bulged_square(backend)
info = await backend.entity_get(handle)
assert info.properties["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
assert info.properties["length"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
async def test_entity_get_still_reports_only_xy_points(backend):
"""The bulge feeds the measurement; it must not change the points contract."""
handle = _add_bulged_square(backend)
info = await backend.entity_get(handle)
assert all(len(point) == 2 for point in info.properties["points"])
+325
View File
@@ -0,0 +1,325 @@
"""F2 — LIST: the raw DXF attribute set for one handle.
`entity_get` reports a *curated* view: the handful of derived fields the drafting
tools need, already flattened to WCS xy. `entity_measure` reports lengths and
areas. Neither answers the question AutoCAD's LIST answers — "what is actually
stored on this object?" — and that is the only reason this tool exists. It must
not restate the other two.
Four measured facts shape every test below.
* **The dump is not JSON-serialisable as it comes out of ezdxf.**
`entity.dxfattribs()` returns `Vec3` for `start` / `end` / `insert` / `center`.
Measured: `json.dumps` raises ``Object of type Vec3 is not JSON serializable``
for LINE, CIRCLE, TEXT and INSERT. A tool that hands that dict back either dies
at the wire boundary or gets silently coerced into `"Vec3(0.0, 0.0, 0.0)"` —
a string where the caller expected a point. So `json.dumps(result)` is asserted
outright, for all five point-carrying types, and the values are asserted to
still be *numbers* afterwards: stringifying the Vec3 would satisfy `json.dumps`
and destroy the payload.
* **The dump is WCS, like everything else crossing a tool boundary.**
A mirrored CIRCLE stores `center = (30, 20)` with `extrusion = (0, 0, -1)`;
it is drawn at (-30, 20), which is what `entity_get` reports. A raw
`dxfattribs()` echo therefore contradicts `entity_get` in the same payload by
60 mm — the exact defect T0.4 was opened to remove. The dump still reports the
`extrusion` it translated *from*, because a caller who cannot see the frame
cannot tell a translated point from an untranslated one.
* **`dxfattribs()` on an LWPOLYLINE contains no geometry at all.**
Measured: the keys are `flags`, `handle`, `layer`, `owner` — the vertices live
in `get_points()`, not in the attribute dict. A tool that dumps `dxfattribs()`
and stops reports a polyline with no points and calls it a full property dump.
The vertices must carry their **bulge** too: `entity_get` gives bare `[x, y]`
pairs, and reconstructing a bulged outline from those loses 28.2% of the area.
* **Only stored attributes are reported.** `thickness` is absent from a fresh
LINE and appears once set. Padding the dump with schema defaults would make an
explicitly-set 0.0 indistinguishable from "never touched", which is the one
question a raw attribute dump exists to answer.
The refusal: an unknown or deleted handle raises `RuntimeError`, matching
`entity_get`. Returning `{}` for a handle that is not there would report "this
entity has no properties" for an entity that has no existence.
"""
from __future__ import annotations
import json
import pytest
pytestmark = pytest.mark.asyncio
#: Mirror line = the Y axis (x -> -x). This is what produces extrusion (0, 0, -1).
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
def _numbers(value):
"""Assert `value` is a real numeric sequence and return its x, y.
Accepts a 2- or 3-component point: the arity of the dump is the tool
author's call, but "it is a sequence of numbers" is not. `bool` is excluded
because `isinstance(True, int)` would otherwise wave a coerced flag through.
"""
assert isinstance(value, (list, tuple)), f"expected a point, got {value!r}"
assert len(value) in (2, 3), f"expected a 2D or 3D point, got {value!r}"
for component in value:
assert isinstance(component, (int, float)) and not isinstance(component, bool), (
f"point component {component!r} is not a number"
)
return float(value[0]), float(value[1])
async def _one_of_each(backend):
"""A LINE, LWPOLYLINE, INSERT, TEXT and CIRCLE — every Vec3-carrying type."""
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
return {
"LINE": (await backend.entity_create_line(0, 0, 100, 50)).handle,
"LWPOLYLINE": (
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
).handle,
"INSERT": (await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)).handle,
"TEXT": (await backend.entity_create_text("PART A", 40, 15, 3.0)).handle,
"CIRCLE": (await backend.entity_create_circle(30, 20, 5)).handle,
}
# ── the wire boundary ───────────────────────────────────────────────────────
@pytest.mark.parametrize("dxftype", ["LINE", "LWPOLYLINE", "INSERT", "TEXT", "CIRCLE"])
async def test_the_dump_survives_json_encoding(backend, dxftype):
"""Measured: raw `dxfattribs()` raises `Object of type Vec3 is not JSON serializable`."""
handles = await _one_of_each(backend)
result = await backend.analysis_list_properties(handles[dxftype])
encoded = json.dumps(result)
assert json.loads(encoded) == result, "the payload changed shape on the way through JSON"
async def test_a_lines_endpoints_are_numbers_not_stringified_vectors(backend):
"""`str(Vec3(...))` would satisfy json.dumps and hand the caller a string."""
line = await backend.entity_create_line(0, 0, 100, 50)
dump = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert _numbers(dump["start"]) == pytest.approx((0.0, 0.0))
assert _numbers(dump["end"]) == pytest.approx((100.0, 50.0))
async def test_a_texts_insertion_point_is_numbers_not_a_stringified_vector(backend):
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
assert _numbers(dump["insert"]) == pytest.approx((40.0, 15.0))
assert dump["text"] == "PART A"
assert dump["height"] == pytest.approx(3.0)
async def test_an_inserts_insertion_point_is_numbers_not_a_stringified_vector(backend):
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
insert = await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)
dump = (await backend.analysis_list_properties(insert.handle))["dxf_attributes"]
assert _numbers(dump["insert"]) == pytest.approx((10.0, 20.0))
assert dump["name"] == "BOLT"
assert dump["xscale"] == pytest.approx(2.0)
assert dump["yscale"] == pytest.approx(3.0)
# ── coordinates are WCS ─────────────────────────────────────────────────────
async def test_a_mirrored_circles_centre_is_reported_in_wcs(backend):
"""Stored (30, 20) with extrusion -Z; drawn at (-30, 20). The dump says drawn.
Echoing `dxfattribs()` puts a 60 mm contradiction inside one payload, because
`entity_get` on the same handle reports (-30, 20).
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
assert backend._doc.entitydb.get(mirrored.handle).dxf.center.x == pytest.approx(30.0), (
"precondition: the DXF really does store the un-negated x"
)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
assert _numbers(dump["center"]) == pytest.approx((-30.0, 20.0))
info = await backend.entity_get(mirrored.handle)
assert _numbers(dump["center"]) == pytest.approx(tuple(info.properties["center"]))
async def test_the_dump_still_names_the_frame_it_translated_from(backend):
"""Drop the extrusion and a caller cannot tell a translated point from a raw one."""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
assert list(dump["extrusion"]) == pytest.approx([0.0, 0.0, -1.0])
assert dump["radius"] == pytest.approx(5.0), "radius is frame-invariant in a flat frame"
# ── what entity_get deliberately does not carry ─────────────────────────────
async def test_a_polylines_vertices_are_in_the_dump(backend):
"""Measured: `dxfattribs()` for an LWPOLYLINE is flags/handle/layer/owner only.
Dumping it verbatim describes a polyline without saying where it is.
"""
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
assert "points" not in backend._doc.entitydb.get(poly.handle).dxfattribs(), (
"precondition: ezdxf keeps the vertices out of the attribute dict"
)
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
assert [_numbers(pt) for pt in dump["points"]] == pytest.approx(
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
)
async def test_a_polyline_vertex_carries_its_bulge(backend):
"""`entity_get` gives bare [x, y]; rebuilding a bulged outline from those
loses 28.2% of the area. The full dump is where the bulge has to live."""
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
backend._doc.entitydb.get(poly.handle).set_points(
[(0, 0, 0.0), (10, 0, 0.5), (10, 10, 0.0)], format="xyb"
)
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
bulges = [pt[2] for pt in dump["points"]]
assert len(bulges) == 3, "every vertex reports a bulge, including the straight ones"
assert bulges == pytest.approx([0.0, 0.5, 0.0])
async def test_text_style_and_obliquity_reach_the_caller(backend):
"""Neither appears anywhere in `entity_get`, and both change what is plotted."""
backend._doc.styles.new("ISO", dxfattribs={"font": "isocp.shx"})
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
entity = backend._doc.entitydb.get(text.handle)
entity.dxf.style = "ISO"
entity.dxf.oblique = 15.0
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
assert dump["style"] == "ISO"
assert dump["oblique"] == pytest.approx(15.0)
info = await backend.entity_get(text.handle)
assert "style" not in info.properties, "precondition: entity_get is the curated view"
async def test_the_dump_reports_what_is_stored_not_schema_defaults(backend):
"""A dump padded with defaults cannot answer "did the drafter set this?".
That is the only question a raw attribute dump exists to answer, so an
unset `thickness` must be absent rather than reported as 0.0.
"""
line = await backend.entity_create_line(0, 0, 100, 50)
before = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert "thickness" not in before
assert "ltscale" not in before
entity = backend._doc.entitydb.get(line.handle)
entity.dxf.thickness = 2.0
entity.dxf.ltscale = 0.5
after = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert after["thickness"] == pytest.approx(2.0)
assert after["ltscale"] == pytest.approx(0.5)
# ── the top level ───────────────────────────────────────────────────────────
async def test_the_type_and_layer_sit_at_the_top_level(backend):
"""A caller asking "what is this?" should not have to dig into the dump."""
circle = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
result = await backend.analysis_list_properties(circle.handle)
assert result["ok"] is True
assert result["handle"] == circle.handle
assert result["type"] == "CIRCLE"
assert result["layer"] == "GEOMETRY"
assert result["dxf_attributes"]["layer"] == "GEOMETRY", "the two must not disagree"
async def test_the_summary_does_not_contradict_entity_get(backend):
"""`properties` restates the curated view, so it must at least agree with it.
This is the test that argues for deleting the key: a second copy of
`entity_get`'s output can only ever be identical or wrong.
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
result = await backend.analysis_list_properties(mirrored.handle)
info = await backend.entity_get(mirrored.handle)
assert result["properties"]["center"] == pytest.approx(info.properties["center"])
# ── the refusal ─────────────────────────────────────────────────────────────
async def test_an_unknown_handle_is_refused_not_answered_with_an_empty_dump(backend):
"""`{}` would read as "this entity has no properties" for a nonexistent entity."""
with pytest.raises(RuntimeError, match="FFFFFF"):
await backend.analysis_list_properties("FFFFFF")
async def test_a_deleted_handle_is_refused_too(backend):
"""`entitydb.get` does not filter destroyed entities, so the guard must."""
line = await backend.entity_create_line(0, 0, 100, 50)
await backend.entity_delete(line.handle)
with pytest.raises(RuntimeError, match=line.handle):
await backend.analysis_list_properties(line.handle)
# ── round trip ──────────────────────────────────────────────────────────────
async def test_the_dump_describes_what_actually_lands_in_the_file(backend, tmp_path):
"""The dump is a translation of the file, not a snapshot of in-memory state.
The reloaded DXF still stores the OCS centre; the dump reported the WCS one.
Both readings of the same circle must remain consistent after a save.
"""
import ezdxf
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
path = tmp_path / "listed.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(mirrored.handle)
assert reloaded.dxf.center.x == pytest.approx(30.0), "the file keeps the OCS x"
assert list(reloaded.dxf.extrusion) == pytest.approx(list(dump["extrusion"]))
assert reloaded.dxf.radius == pytest.approx(dump["radius"])
assert _numbers(dump["center"]) == pytest.approx((-reloaded.dxf.center.x, 20.0))
async def test_a_texts_dump_round_trips_through_a_saved_file(backend, tmp_path):
import ezdxf
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
path = tmp_path / "listed_text.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(text.handle)
assert reloaded.dxf.text == dump["text"]
assert reloaded.dxf.height == pytest.approx(dump["height"])
assert _numbers(dump["insert"]) == pytest.approx((reloaded.dxf.insert.x, reloaded.dxf.insert.y))
+371
View File
@@ -0,0 +1,371 @@
"""Honesty guarantees for the headless ezdxf backend.
T0.1 — the DWG lie. ezdxf has no DWG writer, so any save that would put DXF
bytes behind a ``.dwg`` name must refuse with the machine-readable
``{"ok": False, "error": ..., "capability": ...}`` shape instead, and the
boundary must be discoverable up-front through the capability map.
T0.3 — the audit lie, which ran the *other* way. ``Drawing.audit()`` repairs the
document in place, so ``auditor.fixes`` is a log of mutations already
applied. ``drawing_audit`` threw that list away, never marked the document
dirty (so ``drawing_close(save=True)`` discarded the repairs it had just
made), and rendered the errors it did report as ``<ErrorEntry object at
0x...>``. The tool said "0 errors" about a file it had silently altered.
T0.6 — no per-call timeout. Every ezdxf method funnels through ``_async``, which
serialises on a single ``asyncio.Lock``; one hung sync call used to hold
that lock forever and deadlock every later tool call on the server.
"""
from __future__ import annotations
import asyncio
import threading
import time
import types
import pytest
import config
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── T0.1 — DWG refusal ──────────────────────────────────────────────────────
async def test_save_as_dwg_refuses_with_capability_tagged_shape(backend, tmp_path):
target = tmp_path / "part.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save_as(str(target), "dwg")
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert payload["capability"] == "dwg"
assert excinfo.value.capability == "dwg"
message = payload["error"]
assert message == str(excinfo.value)
lowered = message.lower()
assert "dwg" in lowered
assert ".dxf" in lowered # points at the headless alternative
assert "com" in lowered # ...and at the live backend
assert not target.exists(), "refusal must not leave a mislabelled file behind"
async def test_save_as_refuses_dwg_extension_even_when_fmt_says_dxf(backend, tmp_path):
"""The bytes must match the name: a .dwg path is a DWG request, whatever fmt says."""
target = tmp_path / "sneaky.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save_as(str(target), "dxf")
assert excinfo.value.capability == "dwg"
assert not target.exists()
async def test_drawing_save_refuses_dwg_path(backend, tmp_path):
"""drawing_save(path) took any path and wrote DXF bytes into it."""
target = tmp_path / "saved.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save(str(target))
assert excinfo.value.capability == "dwg"
assert not target.exists()
async def test_dxf_save_still_works(backend, tmp_path):
import ezdxf
await backend.entity_create_line(0, 0, 10, 0)
as_path = tmp_path / "ok_saveas.dxf"
result = await backend.drawing_save_as(str(as_path), "dxf")
assert result["ok"] is True
assert result["format"] == "dxf"
assert ezdxf.readfile(str(as_path)) is not None
save_path = tmp_path / "ok_save.dxf"
assert (await backend.drawing_save(str(save_path)))["ok"] is True
assert save_path.exists()
export_path = tmp_path / "ok_export.dxf"
assert (await backend.drawing_export_dxf(str(export_path)))["ok"] is True
assert export_path.exists()
async def test_capability_map_reports_the_dwg_boundary(backend):
features = backend.capabilities().to_dict()["features"]
assert features["dxf"]["supported"] is True
assert features["dwg"]["supported"] is False
assert features["dwg"]["reason"]
async def test_both_backends_declare_the_same_capability_keys():
"""A capability only helps clients if both maps answer the same question."""
ezdxf_keys = set(EzdxfBackend().capabilities().features)
com_keys = set(ComBackend().capabilities().features)
assert ezdxf_keys == com_keys
assert "dwg" in com_keys
assert ComBackend().capabilities().features["dwg"].supported is True
# ── T0.6 — per-call timeout / anti-deadlock ─────────────────────────────────
async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
entered = threading.Event()
def _hang():
entered.set()
time.sleep(2.0)
return "too late"
started = time.monotonic()
with pytest.raises(RuntimeError) as excinfo:
await backend._async(_hang)
assert entered.is_set()
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
message = str(excinfo.value)
assert "EZDXF_CALL_TIMEOUT" in message
assert "0.2" in message
# The anti-deadlock guarantee: the NEXT call must not queue behind the
# abandoned worker thread (which is still sleeping).
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
info = await asyncio.wait_for(backend.drawing_info(), timeout=3)
assert info.backend == "ezdxf"
line = await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3)
assert line.handle
async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch):
"""A live call owns its lock: queueing past the timeout must not abandon it."""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
live = backend._lock
await live.acquire() # stand in for a call that is still running
try:
started = time.monotonic()
with pytest.raises(RuntimeError, match="busy"):
await backend._async(lambda: "never runs")
assert time.monotonic() - started < 1.0
assert backend._lock is live, "the in-flight call's lock must not be swapped out"
finally:
live.release()
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
assert await asyncio.wait_for(backend._async(lambda: "after"), timeout=2) == "after"
async def test_zero_timeout_disables_the_check(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0)
def _slow():
time.sleep(0.3)
return "finished"
assert await backend._async(_slow) == "finished"
async def test_async_still_serialises_calls(backend):
"""The timeout rewrite must not drop mutual exclusion on the ezdxf document."""
trace: list[str] = []
def _work(tag):
trace.append(f"enter:{tag}")
time.sleep(0.05)
trace.append(f"exit:{tag}")
return tag
results = await asyncio.gather(*(backend._async(_work, tag) for tag in "abc"))
assert sorted(results) == ["a", "b", "c"]
assert trace == [step for tag in results for step in (f"enter:{tag}", f"exit:{tag}")]
async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backend, monkeypatch):
"""A TimeoutError *from the call* is a normal failure, not our deadline.
``TimeoutError`` is ``asyncio.TimeoutError`` on 3.11+ and an ``OSError``
subclass, so an ezdxf read off a dead network share raises exactly what
``wait_for`` raises. Blaming the deadline for it would tell the user their
drawing "may be left mid-write" and orphan a lock, for a call that never
ran long at all.
"""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30)
live = backend._lock
def _boom():
raise TimeoutError("the network share went away")
with pytest.raises(TimeoutError, match="network share"):
await backend._async(_boom)
assert backend._lock is live, "a call that merely failed must not abandon its lock"
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
async def test_errors_release_the_lock(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
def _boom():
raise ValueError("nope")
with pytest.raises(ValueError):
await backend._async(_boom)
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
# ── T0.3 — audit reports what it repaired, and keeps it ─────────────────────
def _write_damaged_dxf(path) -> None:
"""A DXF referencing a linetype and a text style that were never defined.
Both are defects ezdxf's ``Auditor`` *repairs* — UNDEFINED_LINETYPE (100)
and UNDEFINED_TEXT_STYLE (102) — by discarding the dangling attribute. The
names are chosen to survive as literal bytes in the saved file so a test can
prove the repair reached disk.
"""
import ezdxf
doc = ezdxf.new("R2010")
msp = doc.modelspace()
msp.add_line((0, 0), (10, 0), dxfattribs={"linetype": "GHOSTLINE"})
msp.add_text("hello", dxfattribs={"style": "NOSUCHSTYLE"})
doc.saveas(str(path))
def _write_block_cycle_dxf(path) -> None:
"""Two blocks that insert each other — a defect the Auditor CANNOT repair.
This is the only way to get entries into ``auditor.errors`` (as opposed to
``auditor.fixes``), which is what makes the error-rendering assertion
possible at all.
"""
import ezdxf
doc = ezdxf.new("R2010")
block_a = doc.blocks.new("BLK_A")
block_b = doc.blocks.new("BLK_B")
block_a.add_blockref("BLK_B", (0, 0))
block_b.add_blockref("BLK_A", (0, 0))
doc.modelspace().add_blockref("BLK_A", (0, 0))
doc.saveas(str(path))
async def test_audit_reports_the_repairs_it_applied(backend, tmp_path):
"""``doc.audit()`` mutates; a tool that hides those mutations is lying."""
target = tmp_path / "damaged.dxf"
_write_damaged_dxf(target)
await backend.drawing_open(str(target))
result = await backend.drawing_audit()
assert result["repaired"] is True
assert result["fix_count"] == 2
assert {fix["code"] for fix in result["fixes"]} == {100, 102}
for fix in result["fixes"]:
assert isinstance(fix["message"], str) and fix["message"]
assert fix["name"]
async def test_audit_marks_the_document_dirty_when_it_repaired(backend, tmp_path):
"""The load-bearing half: a reported repair that close() throws away is worse.
``drawing_close`` saves only ``if save and self._dirty``. Reporting the fixes
without marking the document dirty would promise three repairs and then lose
every one of them.
"""
target = tmp_path / "damaged.dxf"
_write_damaged_dxf(target)
before = target.read_bytes()
assert b"GHOSTLINE" in before and b"NOSUCHSTYLE" in before
await backend.drawing_open(str(target))
await backend.drawing_audit()
info = await backend.drawing_info()
assert info.saved is False, "audit mutated the document; it is no longer 'saved'"
await backend.drawing_close(save=True)
after = target.read_bytes()
assert after != before, "the repair must reach disk, not be discarded on close"
assert b"GHOSTLINE" not in after
assert b"NOSUCHSTYLE" not in after
async def test_audit_clean_document_reports_no_repair(backend):
"""The opposite direction of the same lie: never claim a repair not made."""
result = await backend.drawing_audit()
assert result["repaired"] is False
assert result["fix_count"] == 0
assert result["fixes"] == []
assert (await backend.drawing_info()).saved is True
async def test_audit_errors_are_readable_messages_not_object_reprs(backend, tmp_path):
"""``ErrorEntry`` has no ``__str__``; ``str(e)`` yielded a memory address."""
target = tmp_path / "cycle.dxf"
_write_block_cycle_dxf(target)
await backend.drawing_open(str(target))
result = await backend.drawing_audit()
assert result["error_count"] >= 2
for err in result["errors"]:
assert isinstance(err, dict)
assert err["code"] == 104
assert "block reference cycle" in err["message"].lower()
assert "ErrorEntry object at" not in err["message"]
async def test_audit_detail_capability_is_declared_per_backend():
"""A client must be able to tell "nothing was wrong" from "we cannot see"."""
assert EzdxfBackend().capabilities().features["audit_detail"].supported is True
com_audit = ComBackend().capabilities().features["audit_detail"]
assert com_audit.supported is False
assert com_audit.reason == "audit_result_not_readable_over_com"
async def test_audit_discloses_an_entity_it_deleted(backend):
"""The most destructive repair is the one that names no handle.
``Auditor.check_owner_exist`` *deletes* an entity whose owner handle points
nowhere, and calls ``fixed_error`` without ``dxf_entity=`` — so ``handle`` is
``None`` for exactly the fix a user most needs to know about. Before the
dirty-flag fix the missing ``_mark_dirty()`` accidentally shielded people
from this; now the deletion reaches disk on the next save, so the report has
to carry it.
"""
await backend.entity_create_line(0, 0, 5, 5)
await backend.entity_create_circle(1, 1, 2)
doomed = next(iter(backend._doc.modelspace()))
doomed.dxf.owner = "DEAD" # the damage a truncated write leaves behind
result = await backend.drawing_audit()
assert result["repaired"] is True
deletions = [fix for fix in result["fixes"] if "Deleted" in fix["message"]]
assert deletions, f"a deletion must be reported, got {result['fixes']}"
assert "LINE" in deletions[0]["message"]
assert deletions[0]["handle"] is None, (
"ezdxf reports this fix without the entity, so the handle is genuinely "
"unknown — inventing one would be worse than admitting it"
)
assert len(list(backend._doc.modelspace())) == 1, "the entity really is gone"
assert (await backend.drawing_info()).saved is False
async def test_audit_entry_names_an_unknown_code_honestly():
"""A code outside AuditError must not be dressed up as a name."""
from backends.ezdxf_backend import _audit_entry
entry = types.SimpleNamespace(code=999, message="something new", entity=None)
assert _audit_entry(entry) == {
"code": 999,
"name": "UNKNOWN",
"message": "something new",
"handle": None,
}
+211 -2
View File
@@ -65,8 +65,10 @@ def test_parse_fit_code():
@pytest.mark.parametrize(
"code,nominal",
[
("K7", 20.0), # transition hole letters need the delta-rule — excluded
("P7", 20.0),
# K7/P7 used to be here: the delta rule derives them from tables already
# in the module, so they are authored as of 1.5.0 and pinned against
# published values above.
("t6", 20.0), # ISO 286 does not define shaft t below 24 mm
("x6", 20.0), # letter outside the authored subset (regex rejects)
("H3", 20.0), # grade below authored range
("H12", 20.0), # grade above authored range
@@ -116,3 +118,210 @@ def test_fit_passthrough_without_code():
"deviation",
"x",
)
# ── ISO 286 transition / interference HOLES (K, M, N, P) ────────────────────
#
# These were refused by name until 1.5.0. They are not new table data: ISO 286
# derives an upper-letter hole from the same-letter shaft by the delta rule,
#
# ES = -ei + Δ, Δ = IT(n) − IT(n−1)
#
# so every value below comes from tables already authored in this module. The
# expected numbers are the published ISO 286 ones, cross-checked by hand — the
# point of pinning them is that the *rule* is right, not that the arithmetic runs.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
("K7", 20.0, 6, -15),
("M7", 20.0, 0, -21),
("N7", 20.0, -7, -28),
("P7", 20.0, -14, -35),
("K7", 50.0, 7, -18),
("N7", 50.0, -8, -33),
("P7", 50.0, -17, -42),
("K6", 20.0, 2, -11),
("N6", 20.0, -11, -24),
("P6", 20.0, -18, -31),
],
)
def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_h7_over_p6_is_a_genuine_interference_fit():
"""The pairing check the tables exist for: the shaft must be bigger."""
hole = fit_lookup("H7", 20.0)
shaft = fit_lookup("p6", 20.0)
assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole"
assert shaft.lower_mm > 0 and hole.lower_mm == 0
def test_the_delta_collapses_above_the_grade_the_rule_covers():
"""ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei."""
from engineering.fits import _SHAFT_EI, _size_index
index = _size_index(20.0)
n9 = fit_lookup("N9", 20.0)
assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9)
p8 = fit_lookup("P8", 20.0)
assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9)
def test_grade_4_is_refused_rather_than_guessed():
"""Δ needs IT3, which is outside the authored IT4-IT11 range."""
with pytest.raises(ValueError, match="IT5"):
fit_lookup("K4", 20.0)
# ── ISO 286 interference SHAFTS (r, s, t, u) ────────────────────────────────
#
# These need their own fundamental deviations, sub-stepped more finely than the
# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than
# a rule. Authored table data is only as good as its provenance, so every value
# is cross-checked below against ISO 286-1's own derivation formulae — an
# independent source that agrees or does not. What survives is stated; what
# diverges is listed by name, so a reader with the standard checks five numbers
# instead of ninety-five.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
# ei + IT above the nominal — these are all interference shafts.
("r6", 20.0, 41, 28),
("s6", 20.0, 48, 35),
("u6", 20.0, 54, 41),
("s7", 50.0, 68, 43),
("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30
("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step
("t6", 60.0, 85, 66),
],
)
def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_the_sub_steps_are_real_and_not_flattened():
"""Above 50 mm these letters split the main steps; collapsing them is a
silent error of tens of microns."""
assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm
assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm
def test_t_is_refused_below_24_mm_because_iso_does_not_define_it():
with pytest.raises(ValueError, match="24"):
fit_lookup("t6", 20.0)
assert fit_lookup("t6", 30.0).lower_mm > 0
# -- provenance: the table against ISO 286-1's own formulae ------------------
def test_r_is_the_geometric_mean_of_p_and_s():
"""ISO 286 *defines* r that way, and p is already pinned in this module.
24/24 sub-steps agree within 1 um, which validates r and s together against
data that was trusted before either was authored.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
mid = (step[0] + step[1]) / 2.0
p = _SHAFT_EI["p"][_size_index(mid)]
s = _SHAFT_EI_FINE["s"][index]
r = _SHAFT_EI_FINE["r"][index]
assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}"
#: Steps where ISO 286's rounding rules put the published value more than 1 um
#: from the formula. Listed rather than hidden: these are the only values a
#: reader with the standard needs to check by hand.
KNOWN_FORMULA_DIVERGENCES = {
("u", (250, 280)),
("u", (400, 450)),
("u", (450, 500)),
("t", (24, 30)),
("t", (450, 500)),
}
#: Below this the formulae do not describe the published tables at all — ISO
#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at
#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the
#: check here is a statement about where the cross-check has authority, not a
#: threshold moved until the test passed.
FORMULA_VALID_FROM_MM = 18
@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)])
def test_t_and_u_track_their_iso_formula(letter, coefficient):
"""ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds.
The standard rounds its published tables, so exact equality is not the
claim — agreement within 1 um everywhere except a listed handful is.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
checked = 0
for index, step in enumerate(_FINE_SIZE_STEPS):
ei = _SHAFT_EI_FINE[letter][index]
if ei is None or step[0] < FORMULA_VALID_FROM_MM:
continue
checked += 1
mid = (step[0] + step[1]) / 2.0
it7 = _it_um(7, _size_index(mid))
formula = it7 + coefficient * math.sqrt(step[0] * step[1])
drift = abs(round(formula) - ei)
if (letter, step) in KNOWN_FORMULA_DIVERGENCES:
assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table"
else:
assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}"
assert checked >= 18, f"only {checked} steps cross-checked for {letter}"
def test_s_above_50_tracks_its_iso_formula():
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] < 50:
continue
mid = (step[0] + step[1]) / 2.0
formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1])
assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}"
def test_s_below_50_sits_in_the_iso_band_above_it8():
"""ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um."""
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
deltas = []
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] >= 50:
break
mid = (step[0] + step[1]) / 2.0
deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid)))
assert all(0 <= d <= 4 for d in deltas), deltas
assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}"
def test_h7_over_s6_is_tighter_than_h7_over_p6():
"""The reason these letters exist: progressively heavier interference."""
p6 = fit_lookup("p6", 20.0).lower_mm
r6 = fit_lookup("r6", 20.0).lower_mm
s6 = fit_lookup("s6", 20.0).lower_mm
u6 = fit_lookup("u6", 20.0).lower_mm
assert p6 < r6 < s6 < u6
+364
View File
@@ -0,0 +1,364 @@
"""F4 — hatch depth: gradient fill, in-place edit, curved boundary edges.
`entity_create_hatch` can do exactly one thing: stamp a pattern fill into a
closed *polyline* boundary. Everything a section drawing actually needs after
that — restyling the fill, changing the island rule, bounding the fill with an
arc — has no tool. These three fill that gap, and each carries a measured trap:
* **Gradient fill is a replacement, not a layer.** `set_gradient()` flips
`solid_fill` to 1, rewrites `pattern_name` to `SOLID` and drops `.pattern` to
``None``. A tool that reported "gradient applied" while leaving the caller
believing the ANSI31 hatching is still underneath would be describing a
drawing that does not exist. Every gradient assertion here goes through
saveas/readfile, because gradient data lives in its own DXF subrecord and a
gradient that vanishes on save is the classic failure mode.
* **`pattern_scale` is two numbers, not one.** ezdxf stores the scale twice:
as `dxf.pattern_scale` *and* baked into the pattern definition lines' offset
vectors. Setting only the DXF attribute is the perfect silent no-op — every
report says scale 4.0, every renderer draws scale 2.0. So the scale and angle
tests measure the pattern line *pitch* and *angle*, not the attribute.
Measured here: ANSI31 at scale 1.0 has a 3.175 mm pitch; scale 2.0 is 6.35.
ezdxf's `set_pattern_scale`/`set_pattern_angle` are absolute, not relative.
* **A partial edit must stay partial.** `hatch_edit` takes five optional
parameters; the data-loss bug is the implementation that rebuilds the hatch
from defaults and quietly resets the four the caller did not name. Note that
a hatch born from `entity_create_hatch` already has `hatch_style == 1`
(OUTER), because that is ezdxf's `set_pattern_fill` default — so "normal" is
a real change, not a no-op, and `changed` has to distinguish a parameter that
was *supplied* from an attribute that actually *moved*.
* **Flat vertex lists straighten curves.** This is the fidelity point of
`hatch_add_boundary`. A hatch boundary API that only accepts points turns
every fillet and bore into a chord and still reports a boundary. So the arc
test asserts the reloaded path is an `EdgePath` holding an `ArcEdge` with its
radius and sweep intact — a flattened arc would come back as a `PolylinePath`
and pass any "did a path appear?" check.
"""
from __future__ import annotations
import math
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]]
RED = [255, 0, 0]
BLUE = [0, 0, 255]
def _entity(backend, handle):
return backend._doc.entitydb.get(handle)
async def _reloaded(backend, tmp_path, handle, name="hatch.dxf"):
"""Round-trip the whole document and hand back the same entity."""
import ezdxf
path = tmp_path / name
await backend.drawing_save_as(str(path))
return ezdxf.readfile(str(path)).entitydb.get(handle)
def _pattern_pitch(hatch):
"""Distance between the hatch pattern's definition lines, in drawing units."""
offset = hatch.pattern.lines[0].offset
return math.hypot(offset[0], offset[1])
def _path_types(hatch):
return [type(path).__name__ for path in hatch.paths]
# ── gradient fill ───────────────────────────────────────────────────────────
async def test_a_gradient_survives_save_and_reload(backend, tmp_path):
"""Gradient data lives in its own subrecord; losing it on save is silent."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, RED, BLUE, rotation=30.0, tint=0.4, name="CURVED"
)
assert result["ok"] is True
assert result["gradient"]["name"] == "CURVED"
assert list(result["gradient"]["color1"]) == RED
reloaded = await _reloaded(backend, tmp_path, created.handle)
gradient = reloaded.gradient
assert gradient is not None, "the gradient did not survive the round-trip"
assert tuple(gradient.color1) == (255, 0, 0)
assert tuple(gradient.color2) == (0, 0, 255)
assert gradient.rotation == pytest.approx(30.0)
assert gradient.tint == pytest.approx(0.4)
assert gradient.name == "CURVED"
async def test_a_gradient_replaces_the_pattern_fill_it_lands_on(backend, tmp_path):
"""A hatch is gradient-filled *or* pattern-filled. Never both."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _entity(backend, created.handle).dxf.pattern_name == "ANSI31"
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.pattern is None, "the ANSI31 line definition is still there"
assert reloaded.dxf.solid_fill == 1
assert reloaded.dxf.pattern_name == "SOLID"
async def test_a_gradient_keeps_the_boundary_it_fills(backend, tmp_path):
"""A gradient with no boundary paints nothing at all."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath"]
assert len(reloaded.paths[0].vertices) == len(SQUARE)
async def test_a_one_colour_gradient_stores_its_tint(backend, tmp_path):
"""one_color+tint is a distinct DXF mode; dropping the tint flattens it."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, [10, 20, 30], [0, 0, 0], one_color=True, tint=0.75, name="SPHERICAL"
)
assert result["ok"] is True
gradient = (await _reloaded(backend, tmp_path, created.handle)).gradient
assert int(gradient.one_color) == 1
assert gradient.tint == pytest.approx(0.75)
assert gradient.name == "SPHERICAL"
async def test_a_gradient_on_a_line_is_refused_naming_the_type(backend):
"""Only a HATCH carries gradient data; a LINE would swallow it silently."""
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.hatch_set_gradient(line.handle, RED, BLUE)
assert result["ok"] is False
assert "LINE" in result["error"]
assert "HATCH" in result["error"]
# ── hatch edit ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("style, expected", [("normal", 0), ("outer", 1), ("ignore", 2)])
async def test_each_island_style_maps_to_its_dxf_value(backend, tmp_path, style, expected):
created = await backend.entity_create_hatch("ANSI31", SQUARE)
# Start from a different style so every case is a real move, not a no-op.
_entity(backend, created.handle).dxf.hatch_style = (expected + 1) % 3
result = await backend.hatch_edit(created.handle, style=style)
assert result["ok"] is True
assert "style" in result["changed"]
reloaded = await _reloaded(backend, tmp_path, created.handle, f"style-{style}.dxf")
assert reloaded.dxf.hatch_style == expected
async def test_editing_the_scale_leaves_every_other_attribute_alone(backend):
"""A partial edit that resets what it was not asked about is data loss."""
created = await backend.entity_create_hatch(
"ANSI31", SQUARE, scale=2.0, angle=15.0, layer="HATCH", color=3
)
style_before = _entity(backend, created.handle).dxf.hatch_style
await backend.hatch_edit(created.handle, scale=4.0)
hatch = _entity(backend, created.handle)
assert hatch.dxf.pattern_name == "ANSI31"
assert hatch.dxf.pattern_angle == pytest.approx(15.0)
assert hatch.dxf.color == 3
assert hatch.dxf.layer == "HATCH"
assert hatch.dxf.hatch_style == style_before
assert len(hatch.paths) == 1
async def test_a_new_scale_rescales_the_pattern_definition(backend, tmp_path):
"""Setting only dxf.pattern_scale is the perfect silent no-op."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _pattern_pitch(_entity(backend, created.handle)) == pytest.approx(6.35)
await backend.hatch_edit(created.handle, scale=4.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_scale == pytest.approx(4.0)
assert _pattern_pitch(reloaded) == pytest.approx(12.7), "the lines did not move apart"
async def test_a_new_angle_rotates_the_pattern_definition(backend, tmp_path):
"""Same trap as scale: the angle is stored twice and must move twice."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, angle=15.0)
assert _entity(backend, created.handle).pattern.lines[0].angle == pytest.approx(60.0)
await backend.hatch_edit(created.handle, angle=30.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_angle == pytest.approx(30.0)
assert reloaded.pattern.lines[0].angle == pytest.approx(75.0), "the lines did not rotate"
async def test_a_new_pattern_and_colour_round_trip(backend, tmp_path):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, color=3)
result = await backend.hatch_edit(created.handle, pattern="ANSI32", color=5)
assert result["ok"] is True
assert set(result["changed"]) == {"pattern", "color"}
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_name == "ANSI32"
assert reloaded.dxf.color == 5
assert reloaded.dxf.pattern_scale == pytest.approx(2.0), "the scale was reset"
async def test_changed_lists_only_the_attributes_that_moved(backend):
""" "I was asked to set it" and "it is now different" are different answers."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, angle=15.0)
result = await backend.hatch_edit(created.handle, scale=4.0, angle=15.0)
assert result["ok"] is True
assert set(result["changed"]) == {"scale"}
async def test_an_edit_that_moves_nothing_reports_nothing(backend):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
result = await backend.hatch_edit(created.handle, scale=2.0)
assert result["ok"] is True
assert result["changed"] == []
@pytest.mark.parametrize("bad", [0.0, -1.0])
async def test_a_non_positive_pattern_scale_is_refused(backend, bad):
"""Scale 0 collapses the definition offsets to zero: an infinite line count."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, scale=bad)
assert result["ok"] is False
assert "scale" in result["error"]
async def test_an_unknown_island_style_is_refused_naming_the_valid_ones(backend):
"""Silently falling back to `normal` would fill islands nobody asked to fill."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, style="outermost")
assert result["ok"] is False
for valid in ("normal", "outer", "ignore"):
assert valid in result["error"]
# ── boundary edges ──────────────────────────────────────────────────────────
ARC_EDGE = {
"type": "arc",
"center": [110.0, 0.0],
"radius": 10.0,
"start_angle": 0.0,
"end_angle": 180.0,
"ccw": True,
}
LINE_EDGE = {"type": "line", "start": [100.0, 0.0], "end": [120.0, 0.0]}
async def test_an_arc_boundary_edge_survives_as_an_arc(backend, tmp_path):
"""The fidelity point: a flattened arc comes back as a PolylinePath."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["ok"] is True
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath", "EdgePath"]
edges = reloaded.paths[1].edges
assert [type(edge).__name__ for edge in edges] == ["LineEdge", "ArcEdge"]
arc = edges[1]
assert arc.radius == pytest.approx(10.0)
assert tuple(arc.center)[:2] == pytest.approx((110.0, 0.0))
assert arc.start_angle == pytest.approx(0.0)
assert arc.end_angle == pytest.approx(180.0)
assert arc.ccw is True
async def test_the_added_path_is_counted_alongside_the_existing_one(backend):
"""path_count is the hatch's total, so the original boundary is still there."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["handle"] == created.handle
assert result["path_count"] == 2
assert result["edge_types"] == ["line", "arc"]
async def test_an_ellipse_boundary_edge_keeps_its_ratio(backend, tmp_path):
"""The minor/major ratio *is* the ellipse; drop it and you have a circle."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
edge = {
"type": "ellipse",
"center": [200.0, 100.0],
"major_axis": [30.0, 0.0],
"ratio": 0.4,
"start_angle": 0.0,
"end_angle": 360.0,
"ccw": True,
}
result = await backend.hatch_add_boundary(created.handle, [edge])
assert result["edge_types"] == ["ellipse"]
reloaded = await _reloaded(backend, tmp_path, created.handle)
stored = reloaded.paths[1].edges[0]
assert type(stored).__name__ == "EllipseEdge"
assert stored.ratio == pytest.approx(0.4)
assert tuple(stored.major_axis)[:2] == pytest.approx((30.0, 0.0))
assert tuple(stored.center)[:2] == pytest.approx((200.0, 100.0))
async def test_an_unknown_edge_type_is_refused_naming_the_valid_ones(backend):
"""Skipping the edge it cannot build would leave an open boundary."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle, [{"type": "bezier", "start": [0, 0], "end": [1, 1]}]
)
assert result["ok"] is False
assert "bezier" in result["error"]
for valid in ("line", "arc", "ellipse"):
assert valid in result["error"]
async def test_an_edge_missing_a_key_is_refused_naming_the_key(backend):
"""An arc defaulted to radius 0 is a boundary that encloses nothing."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle,
[{"type": "arc", "center": [0.0, 0.0], "start_angle": 0.0, "end_angle": 90.0}],
)
assert result["ok"] is False
assert "radius" in result["error"]
async def test_a_boundary_with_no_edges_is_refused(backend):
"""An empty edge path bounds nothing and renders as a hatch with a hole."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [])
assert result["ok"] is False
+392
View File
@@ -0,0 +1,392 @@
"""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
+25 -9
View File
@@ -10,6 +10,7 @@ import pytest_asyncio
import config
import server
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@@ -100,20 +101,35 @@ async def test_export_pdf_layout(backend, tmp_path):
async def test_solids_unsupported_headlessly(backend):
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
extrude = await backend.solid_extrude("FF", 10)
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
boolean = await backend.solid_boolean("FF", "FE", "union")
for result in (box, cylinder, extrude, revolve, boolean):
assert result["ok"] is False
assert result["capability"] == "solid_3d"
"""T0.7: these refuse by *raising*, so the refusal middleware can see them.
They used to return ``{"ok": False, "capability": "solid_3d"}``, which left
``is_error`` False on a call that did nothing and put the refusal outside
the one place that turns a capability boundary into a wire payload. The
payload itself is unchanged — ``to_dict()`` still yields exactly what the
old return value did.
"""
calls = [
backend.solid_box(0, 0, 0, 10, 10, 10),
backend.solid_cylinder(0, 0, 0, 5, 10),
backend.solid_extrude("FF", 10),
backend.solid_revolve("FF", 0, 0, 0, 1),
backend.solid_boolean("FF", "FE", "union"),
]
for call in calls:
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await call
assert excinfo.value.capability == "solid_3d"
assert excinfo.value.to_dict()["ok"] is False
assert excinfo.value.to_dict()["capability"] == "solid_3d"
async def test_capability_map_reports_paper_space_and_solids(backend):
features = backend.capabilities().to_dict()["features"]
assert features["paper_space"]["supported"] is True
assert features["viewport_render"]["supported"] is False
# Not False: the headless renderer does project model content through
# viewports. See tests/test_viewport_ops.py for the measurement.
assert features["viewport_render"]["supported"] is True
assert features["solid_3d"]["supported"] is False
+200
View File
@@ -0,0 +1,200 @@
"""T0.5 — measuring the drawing, not the caller's memory of it.
``analysis_measure_area`` never touched the document: it ran a shoelace over
points the *caller* typed in. So "what is the area of the polyline I just drew?"
had no answer — the model had to recall the vertices, which is precisely what
CLAUDE.md's no-coordinate-guessing rule forbids.
The only workflow available was worse than absent. ``entity_get`` reported
LWPOLYLINE vertices through the default ``get_points()`` format and kept just
x and y, silently dropping the **bulge** that makes an edge an arc. Measured on a
100x100 square with one edge bulged into an outward semicircle: shoelace over the
reported points gives 10000 against a true 13926.9908 — **28.20% low** — and the
perimeter 400 against 457.0796, **12.49% low**, with nothing in the response
saying so. ``props["length"]`` was dead code: ``LWPolyline`` has no ``length()``
method, so the line raised into a debug log and the key simply never appeared.
Bulge geometry is always a circular arc, so this needs no approximation: area is
the shoelace plus each arc's signed circular segment, and perimeter uses arc
length. The tests below check that against two independent oracles — closed-form
values worked out by hand, and ezdxf's own flattening at a fine tolerance.
"""
from __future__ import annotations
import math
import pytest
from engineering.measure import (
ellipse_area,
ellipse_perimeter_ramanujan,
is_self_intersecting,
polygon_area_perimeter,
)
#: 100x100 square, bottom edge bulged outward into a semicircle (bulge=1.0 means
#: a 180 deg sweep, so the chord of 100 is the diameter and the radius is 50).
SQUARE_WITH_SEMICIRCLE = [
(0.0, 0.0, 0.0),
(100.0, 0.0, 1.0),
(100.0, 100.0, 0.0),
(0.0, 100.0, 0.0),
]
SEMICIRCLE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0 # 13926.990817
SEMICIRCLE_PERIMETER = 300.0 + math.pi * 50.0 # 457.079633
PLAIN_SQUARE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
def _flattened_oracle(vertices, closed, segments_per_arc=200_000):
"""Area and perimeter from a dense polyline approximation of the same shape.
Independent of the implementation under test: it subdivides each arc and
runs a plain shoelace, so agreement to 6 decimal places is real evidence
rather than a formula checking itself.
"""
points: list[tuple[float, float]] = []
count = len(vertices)
limit = count if closed else count - 1
for i in range(limit):
x1, y1, bulge = vertices[i]
x2, y2, _ = vertices[(i + 1) % count]
points.append((x1, y1))
if abs(bulge) < 1e-12:
continue
theta = 4.0 * math.atan(bulge)
chord = math.hypot(x2 - x1, y2 - y1)
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
# Centre lies on the perpendicular bisector, offset by the sagitta side.
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
height = radius * math.cos(abs(theta) / 2.0)
ux, uy = (y2 - y1) / chord, -(x2 - x1) / chord
sign = 1.0 if theta > 0 else -1.0
cx, cy = mx - sign * ux * height, my - sign * uy * height
a1 = math.atan2(y1 - cy, x1 - cx)
steps = max(2, segments_per_arc // max(1, limit))
for step in range(1, steps):
angle = a1 + theta * step / steps
points.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
if not closed:
points.append((vertices[-1][0], vertices[-1][1]))
total = len(points)
shoelace = sum(
points[i][0] * points[(i + 1) % total][1] - points[(i + 1) % total][0] * points[i][1]
for i in range(total)
)
perimeter = sum(
math.dist(points[i], points[(i + 1) % total]) for i in range(total if closed else total - 1)
)
return abs(shoelace) / 2.0, perimeter
# ── the arithmetic the server was missing ───────────────────────────────────
def test_a_straight_polygon_is_the_plain_shoelace():
area, perimeter = polygon_area_perimeter(PLAIN_SQUARE, closed=True)
assert area == pytest.approx(10000.0)
assert perimeter == pytest.approx(400.0)
def test_an_outward_bulge_adds_its_circular_segment():
"""The 28.20% the old path lost, recovered exactly."""
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
assert area == pytest.approx(SEMICIRCLE_AREA, rel=1e-12)
assert perimeter == pytest.approx(SEMICIRCLE_PERIMETER, rel=1e-12)
# ...and it really is 28% more than the xy-only answer.
assert area / 10000.0 == pytest.approx(1.3926990817, rel=1e-9)
def test_an_inward_bulge_subtracts_it():
inward = [(0.0, 0.0, 0.0), (100.0, 0.0, -0.5), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
area, perimeter = polygon_area_perimeter(inward, closed=True)
oracle_area, oracle_perimeter = _flattened_oracle(inward, closed=True)
assert area == pytest.approx(oracle_area, rel=1e-9)
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
assert area < 10000.0, "an inward bulge must remove material"
def test_a_bulge_matches_a_dense_flattening_of_the_same_arc():
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
oracle_area, oracle_perimeter = _flattened_oracle(SQUARE_WITH_SEMICIRCLE, closed=True)
assert area == pytest.approx(oracle_area, rel=1e-9)
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
def test_an_open_boundary_is_closed_implicitly(bulge_free=PLAIN_SQUARE):
"""AutoCAD's AREA closes an open boundary; the caller is told it happened."""
open_area, open_perimeter = polygon_area_perimeter(bulge_free, closed=False)
assert open_area == pytest.approx(10000.0), "the area is of the closed region"
assert open_perimeter == pytest.approx(300.0), "the walked length is the open one"
# ── the trap shoelace hides ─────────────────────────────────────────────────
def test_a_self_intersecting_loop_is_detected():
"""Shoelace CANCELS crossed lobes: a bowtie of two 2500 triangles reads 0."""
bowtie = [(0.0, 0.0, 0.0), (100.0, 100.0, 0.0), (100.0, 0.0, 0.0), (0.0, 100.0, 0.0)]
area, _ = polygon_area_perimeter(bowtie, closed=True)
assert area == pytest.approx(0.0, abs=1e-9), "this is exactly why it must be flagged"
assert is_self_intersecting([(x, y) for x, y, _ in bowtie]) is True
def test_a_simple_polygon_is_not_flagged():
assert is_self_intersecting([(x, y) for x, y, _ in PLAIN_SQUARE]) is False
def test_self_intersection_reports_unknown_rather_than_guessing_on_huge_loops():
"""The check is O(n^2); above the cap it must answer None, not False."""
from engineering.measure import MAX_SELF_INTERSECT_VERTS
huge = [(float(i), float(i % 7), 0.0) for i in range(MAX_SELF_INTERSECT_VERTS + 1)]
assert is_self_intersecting([(x, y) for x, y, _ in huge]) is None
# ── ellipses ────────────────────────────────────────────────────────────────
def test_ellipse_area_is_exact_and_perimeter_is_flagged_approximate():
assert ellipse_area(10.0, 0.5) == pytest.approx(math.pi * 10.0 * 5.0)
# A circle is the one ellipse whose perimeter is closed-form, so Ramanujan
# must be exact there — a cheap check that the approximation is wired right.
assert ellipse_perimeter_ramanujan(10.0, 1.0) == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
# ── the two-vertex circle ───────────────────────────────────────────────────
def test_a_circle_stored_as_two_bulged_vertices_has_its_real_area():
"""DXF's most compact circle, and the arithmetic answered 0.0 for it.
Two semicircular arcs joined into a circle store as a closed LWPOLYLINE of
*two* vertices, each bulge 1.0 — what AutoCAD writes for a JOINed pair of
arcs, and what `boundary_from_entities` builds from any two-arc loop. The
`count < 3` guard treated that as degenerate and returned zero area, which
is the same silent-nothing this module exists to stop: the shoelace over
two points is genuinely 0, but the two circular segments are the whole
shape and they were never added.
"""
circle = [(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)]
area, perimeter = polygon_area_perimeter(circle, closed=True)
assert area == pytest.approx(math.pi * 100.0, rel=1e-12)
assert perimeter == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
def test_two_straight_vertices_still_enclose_nothing():
"""The fix must not invent area where there is none."""
area, perimeter = polygon_area_perimeter([(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)], closed=True)
assert area == 0.0
assert perimeter == pytest.approx(20.0), "there and back"
def test_a_single_vertex_is_still_degenerate():
assert polygon_area_perimeter([(0.0, 0.0, 1.0)], closed=True) == (0.0, 0.0)
+274
View File
@@ -0,0 +1,274 @@
"""T0.4 — coordinates that cross the MCP boundary are WCS, on both engines.
DXF does not store 2D geometry in world coordinates. A CIRCLE, ARC, LWPOLYLINE,
TEXT or INSERT lives in an Object Coordinate System derived from its ``extrusion``
vector by the Arbitrary Axis Algorithm. Mirroring flips the extrusion to
``(0, 0, -1)`` and leaves the stored x *unnegated*, so reading ``dxf.center``
raw returns a point the entity is not at.
The word "extrusion" appeared nowhere in this repository, so every such read was
wrong and silent. Measured: after the server's own ``entity_mirror`` across the Y
axis, a circle drawn at (30, 20) reported ``center [30.0, 20.0]`` while the *same
response's* ``bounding_box`` said ``min [-35, 15] / max [-25, 25]`` — the payload
contradicted itself by 60 mm, and ``point_from_snap``, ``entity_select_smart`` and
``entity_offset`` all inherited the error.
Two fences guard the obvious wrong fixes:
* ``test_wcs_entities_are_left_alone`` — LINE, MTEXT, ELLIPSE and SPLINE all carry
an ``extrusion`` attribute too, and are already correct because ezdxf makes
their ``ocs()`` a pass-through. Keying the fix off "has extrusion" breaks them.
* ``test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle`` — the read and
write paths were *consistently* wrong, so normalising reads without the write
inverse would turn a harmless no-op into a real 60 mm teleport.
"""
from __future__ import annotations
import pytest
from backends.base import UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
#: Mirror line = the Y axis, i.e. x -> -x. This is what produces extrusion (0,0,-1).
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
def _assert_points(actual, expected) -> None:
"""pytest.approx refuses nested sequences, so compare point by point."""
assert len(actual) == len(expected), f"{actual} != {expected}"
for got, want in zip(actual, expected, strict=True):
assert got == pytest.approx(want, abs=1e-9), f"{actual} != {expected}"
def _bbox_centre(info) -> tuple[float, float]:
"""Centre of the entity's own bounding box — an independent oracle.
``ezdxf.bbox.extents`` flattens the entity through the same renderer AutoCAD
would, so it is WCS whatever the entity's frame is. It is the field that
exposed the contradiction, and it is not computed from the OCS attributes.
"""
box = info.properties["bounding_box"]
return (
(box["min"][0] + box["max"][0]) / 2.0,
(box["min"][1] + box["max"][1]) / 2.0,
)
# ── the fence: entities that were never wrong ───────────────────────────────
async def test_wcs_entities_are_left_alone(backend):
"""LINE/MTEXT/ELLIPSE/SPLINE carry extrusion but report WCS already."""
line = await backend.entity_create_line(10, 5, 30, 5)
ellipse = await backend.entity_create_ellipse(20, 40, 10, 5)
text = await backend.entity_create_mtext("hello", 25, 60, 2.5)
for created in (line, ellipse, text):
mirrored = await backend.entity_mirror(created.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
cx, cy = _bbox_centre(info)
for key in ("start", "end", "center", "insertion"):
point = info.properties.get(key)
if point is None:
continue
assert point[0] < 0, f"{info.type}.{key} must land left of x=0 after mirroring"
if "center" in info.properties:
assert info.properties["center"] == pytest.approx([cx, cy], abs=1e-6)
# ── the defect ──────────────────────────────────────────────────────────────
async def test_entity_get_center_agrees_with_its_own_bounding_box(backend):
"""A response that contradicts itself is the clearest possible proof."""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
assert info.properties["radius"] == pytest.approx(5.0)
async def test_mirrored_arc_reports_wcs_centre_and_sweep(backend):
"""A left-handed frame reverses the sweep, so start/end also swap."""
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
mirrored = await backend.entity_mirror(arc.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
# Mirroring the first quadrant across x=0 gives the second: the bbox says the
# arc occupies x in [-35, -30], y in [20, 25], i.e. 90 deg -> 180 deg.
assert info.properties["start_angle"] == pytest.approx(90.0, abs=1e-6)
assert info.properties["end_angle"] == pytest.approx(180.0, abs=1e-6)
# Length is frame-invariant and must not change.
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 / 2, rel=1e-9)
async def test_mirrored_polyline_points_are_wcs(backend):
poly = await backend.entity_create_polyline([[10, 0], [20, 0], [20, 10]], closed=False)
mirrored = await backend.entity_mirror(poly.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
async def test_mirrored_text_insertion_is_wcs(backend):
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["insertion"] == pytest.approx([-40.0, 15.0])
# ── the trap: reads must not be normalised without the write inverse ────────
async def test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle(backend):
"""Read the centre, write it straight back: the circle must not move.
Before the fix this passed for the wrong reason — read and write were
*consistently* wrong. It is here so that fixing only the read is caught.
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
before = await backend.entity_get(mirrored.handle)
centre = before.properties["center"]
await backend.entity_edit_geometry(mirrored.handle, cx=centre[0], cy=centre[1])
after = await backend.entity_get(mirrored.handle)
assert after.properties["center"] == pytest.approx(centre)
assert after.properties["bounding_box"] == before.properties["bounding_box"]
async def test_entity_edit_geometry_moves_a_mirrored_circle_where_asked(backend):
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
await backend.entity_edit_geometry(mirrored.handle, cx=-10.0, cy=40.0)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-10.0, 40.0])
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
async def test_entity_offset_on_a_mirrored_circle_is_concentric_and_shrinks(backend):
"""The offset used to land 60 mm away *and* grow instead of shrink."""
circle = await backend.entity_create_circle(30, 20, 10)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
inner = await backend.entity_offset(mirrored.handle, 2.0, side_x=-30.0, side_y=20.0)
info = await backend.entity_get(inner.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
assert info.properties["radius"] == pytest.approx(8.0), "offset toward the centre shrinks"
# ── tilted planes: the one case 2D xy cannot address ────────────────────────
def _tilt(backend, handle, normal=(0.0, 0.6, 0.8)) -> None:
"""Put an entity in a plane that is not parallel to WCS XY."""
backend._doc.entitydb.get(handle).dxf.extrusion = normal
async def test_a_tilted_entity_omits_what_xy_cannot_express(backend):
"""A tilted circle projects to an ellipse, so `radius` has no WCS-XY meaning.
Omitting the field is silence; reporting 5.0 for a footprint of 5.0 x 3.99
would be a fresh silent wrong number of exactly the kind this release exists
to remove.
"""
circle = await backend.entity_create_circle(30, 20, 5)
_tilt(backend, circle.handle)
info = await backend.entity_get(circle.handle)
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
assert "plane_normal" in info.properties
assert info.properties["plane_normal"] == pytest.approx([0.0, 0.6, 0.8])
assert "radius" not in info.properties
async def test_a_tilted_arc_omits_its_angles(backend):
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
_tilt(backend, arc.handle)
info = await backend.entity_get(arc.handle)
assert "start_angle" not in info.properties
assert "end_angle" not in info.properties
assert "plane_normal" in info.properties
async def test_entity_edit_geometry_refuses_a_tilted_plane_with_capability_shape(backend):
circle = await backend.entity_create_circle(30, 20, 5)
_tilt(backend, circle.handle)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_edit_geometry(circle.handle, cx=1.0, cy=2.0)
assert excinfo.value.capability == "ocs_tilted_plane"
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert "entity_move" in payload["error"], "a refusal must name the way forward"
# ── the boundary is pre-checkable ───────────────────────────────────────────
async def test_ocs_capability_keys_are_declared_on_both_backends():
for features in (
EzdxfBackend().capabilities().features,
ComBackend().capabilities().features,
):
assert features["ocs_normalized"].supported is True
assert features["ocs_normalized"].reason, "the residual must be named, not implied"
assert features["ocs_tilted_plane"].supported is False
# ── COM: the one genuinely wrong read on that engine ────────────────────────
def _fake_lwpolyline(coords, normal):
"""The minimum surface ``_entity_info`` touches for a lightweight polyline."""
import types
return types.SimpleNamespace(
ObjectName="AcDbLWPolyline",
Coordinates=coords,
Normal=normal,
Elevation=0.0,
Handle="2F",
Layer="0",
Color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
Length=30.0,
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
)
async def test_com_lwpolyline_coordinates_are_translated_to_wcs():
"""ActiveX returns WCS for everything *except* polyline Coordinates (OCS).
Unit-tested against a fake COM object: this machine has a live AutoCAD and
connecting to it is forbidden, so this path ships verified only at this
level — the CHANGELOG says so.
"""
from backends.com_backend import _entity_info
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0, 20.0, 10.0), (0.0, 0.0, -1.0)))
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
async def test_com_lwpolyline_with_identity_normal_is_untouched():
from backends.com_backend import _entity_info
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0), (0.0, 0.0, 1.0)))
_assert_points(info.properties["points"], [[10, 0], [20, 0]])
+142
View File
@@ -0,0 +1,142 @@
"""M5 — drawing into the layout you selected.
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current": "A3-Sheet"}`
and then every entity created afterwards went into **model space** anyway,
because `_msp()` returned `doc.modelspace()` unconditionally on both engines.
The tool reported a state change that changed nothing about where geometry
landed — proven: a line drawn straight after selecting the layout was in
modelspace and not in the layout.
That is also why the title block could not go on a sheet: it draws through the
same `entity_create_*` calls, so `apply_iso_a3_titleblock` could only ever
produce a border around model-space geometry.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
async def _handles_in(backend, layout_name: str) -> set[str]:
doc = backend._doc
space = doc.modelspace() if layout_name == "Model" else doc.layouts.get(layout_name)
return {e.dxf.handle for e in space}
# ── the current space is where geometry goes ────────────────────────────────
async def test_entities_land_in_the_layout_that_was_selected(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
assert line.handle in await _handles_in(backend, SHEET)
assert line.handle not in await _handles_in(backend, "Model")
async def test_switching_back_to_model_puts_geometry_back_in_model_space(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
on_sheet = await backend.entity_create_circle(10, 10, 5)
await backend.layout_set_current("Model")
in_model = await backend.entity_create_circle(20, 20, 5)
assert on_sheet.handle in await _handles_in(backend, SHEET)
assert in_model.handle in await _handles_in(backend, "Model")
async def test_a_new_drawing_starts_in_model_space(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.drawing_new()
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model")
async def test_selecting_a_missing_layout_does_not_move_the_current_space(backend):
"""A refused switch must leave the caller where they were, not in limbo."""
result = await backend.layout_set_current("NOSUCH")
assert result["ok"] is False
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model")
async def test_entity_get_finds_an_entity_drawn_on_a_layout(backend):
"""Lookups must follow the geometry, or the handle becomes unusable."""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
assert info.type == "LINE"
# ── the title block, on a sheet ─────────────────────────────────────────────
async def test_titleblock_can_be_drawn_on_a_paper_space_layout(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
await backend.layout_create(SHEET)
before_model = await _handles_in(backend, "Model")
result = await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="BRACKET", drawing_no="D-001"),
layout=SHEET,
)
assert result["ok"] is True
assert result["layout"] == SHEET
on_sheet = await _handles_in(backend, SHEET)
assert len(on_sheet) > 5, "border, inner frame and the block's text all belong here"
assert await _handles_in(backend, "Model") == before_model, "model space untouched"
async def test_titleblock_restores_the_space_the_caller_was_in(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
await backend.layout_create(SHEET)
await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
layout=SHEET,
)
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model"), (
"drawing a sheet border must not silently move the caller onto the sheet"
)
async def test_titleblock_without_a_layout_still_draws_in_model_space(backend):
"""The pre-1.5.0 behaviour stays the default."""
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
result = await apply_iso_a3_titleblock(
backend, metadata=TitleBlockMetadata(title="X", drawing_no="1")
)
assert result["ok"] is True
assert result["layout"] == "Model"
assert len(await _handles_in(backend, "Model")) > 5
async def test_titleblock_on_a_missing_layout_refuses(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
with pytest.raises(Exception, match="NOSUCH"):
await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
layout="NOSUCH",
)
+44
View File
@@ -51,6 +51,50 @@ def test_preflight_normalizes_complete_spec_and_hash_is_deterministic():
assert first.spec_hash == second.spec_hash
def test_preflight_question_text_is_english():
"""drawing_preflight returns this text to every user of a public English
package; it shipped in Turkish through v1.4."""
result = preflight_drawing("L bracket", requirements={})
by_code = {q.code: q.question for q in result.questions}
assert by_code["MISSING_UNITS"] == "What drawing units should be used?"
assert by_code["MISSING_PART_TYPE"] == "What kind of part or drawing is being produced?"
assert by_code["MISSING_DIMENSIONS"] == "What are the main production dimensions?"
assert by_code["MISSING_TOLERANCE_POLICY"] == "Which tolerance policy should apply?"
def test_preflight_conflict_message_is_english():
requirements = _complete_requirements()
requirements["constraints"] = [
{"field": "dimensions.width", "value": 80},
{"field": "dimensions.width", "value": 90},
]
result = preflight_drawing("L bracket", requirements=requirements)
assert result.conflicts[0].message == ("Conflicting values were supplied for dimensions.width.")
def test_preflight_user_facing_text_is_ascii():
"""Guard against any future non-English string leaking back into the tool
surface — codes and field names stay machine-readable, prose stays ASCII."""
requirements = _complete_requirements()
requirements["constraints"] = [
{"field": "dimensions.width", "value": 80},
{"field": "dimensions.width", "value": 90},
]
empty = preflight_drawing("L bracket", requirements={})
conflicting = preflight_drawing("L bracket", requirements=requirements)
prose = [q.question for q in empty.questions]
prose += [choice for q in empty.questions for choice in q.choices]
prose += [c.message for c in conflicting.conflicts]
assert prose, "expected some user-facing text to check"
for text in prose:
assert text.isascii(), f"non-ASCII user-facing text: {text!r}"
def test_preflight_detects_conflicting_constraints():
requirements = _complete_requirements()
requirements["constraints"] = [
+7 -3
View File
@@ -2,20 +2,24 @@ from __future__ import annotations
import pytest
import config
from engineering.refiner import RepairRegistry, refine_drawing
pytestmark = pytest.mark.asyncio
async def test_transaction_stack_is_separate_from_drawing_undo(backend):
async def test_transaction_stack_is_separate_from_drawing_undo(backend, monkeypatch):
"""An undo must not consume a transaction checkpoint, or vice versa."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 10, 0)
await backend.transaction_begin()
undo = await backend.drawing_undo()
status = await backend.system_status()
assert undo["ok"] is False
assert status["transaction_depth"] == 1
assert undo["ok"] is True, "undo walks its own history"
assert status["transaction_depth"] == 1, "and leaves the checkpoint standing"
await backend.transaction_rollback()
+73 -17
View File
@@ -6,13 +6,28 @@ none of them can drift again:
- pyproject version == CHANGELOG top release == README snapshot major.minor
- version.py fallback parser returns the pyproject version
- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts
- README snapshot tool/resource/prompt counts == the generated tool inventory
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
- server.json version == pyproject, and the README carries the mcp-name marker
- every wheel ``only-include`` entry is COPY'd into the Docker image
Two of these used to count the literal text ``@mcp.tool(`` in server.py, which
made that string load-bearing in prose: an example in a docstring inflated the
count. Neither does any more.
* The README snapshot counts now come from ``docs/tool-inventory.json``, which
``tests/test_tool_inventory.py`` holds equal to the live component registry —
so the chain README -> inventory -> registry is closed without this module
having to import the server.
* The section-header gate stays (a "SECTION n" is a fact about server.py's
layout that no runtime registry can see) but counts *parsed* decorators
instead of matched text.
"""
from __future__ import annotations
import ast
import json
import re
import tomllib
from pathlib import Path
@@ -90,31 +105,76 @@ def test_readme_snapshot_matches_pyproject_minor() -> None:
def test_readme_snapshot_counts_match_registrations() -> None:
src = _server_source()
tools = len(re.findall(r"@mcp\.tool\(", src))
resources = len(re.findall(r"@mcp\.resource\(", src))
prompts = len(re.findall(r"@mcp\.prompt\(", src))
"""README snapshot == the surface, via the generated inventory.
The counts used to be a regex tally of ``@mcp.tool(`` / ``@mcp.resource(`` /
``@mcp.prompt(`` in server.py's text. They now come from
``docs/tool-inventory.json``, which is built from the live component
registry and gated against it by tests/test_tool_inventory.py — so this
still fails when a registration is added without updating the README, but
it can no longer be moved by prose that merely *mentions* a decorator.
"""
inventory = json.loads((ROOT / "docs" / "tool-inventory.json").read_text(encoding="utf-8"))
totals = inventory["totals"]
live = (totals["tools"], totals["resources"], totals["prompts"])
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), (
assert (readme_tools, readme_resources, readme_prompts) == live, (
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}"
f"(tools/resources/prompts) but the server registers {live[0]}/{live[1]}/{live[2]} "
"(per docs/tool-inventory.json; regenerate it with "
"`python scripts/generate_tool_inventory.py` if that is the stale one)"
)
def _tool_decorator_lines(src: str) -> list[int]:
"""Line number of every ``mcp.tool`` decorator, parsed rather than matched.
Parsing is what keeps the *string* ``@mcp.tool(`` from being load-bearing:
an occurrence in a docstring or comment is not a decorator node, so it
cannot inflate a section's count.
"""
lines: list[int] = []
for node in ast.walk(ast.parse(src)):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for decorator in node.decorator_list:
# Both spellings count: `@mcp.tool` and `@mcp.tool(...)`.
target = decorator.func if isinstance(decorator, ast.Call) else decorator
if (
isinstance(target, ast.Attribute)
and target.attr == "tool"
and isinstance(target.value, ast.Name)
and target.value.id == "mcp"
):
lines.append(decorator.lineno)
return sorted(lines)
def test_section_header_counts_match_decorators() -> None:
"""Kept, and not replaceable by the inventory: a section is a source-layout
fact (which tools sit under which header comment), invisible to the live
registry, and it does not line up with the tag-derived groups the inventory
records — SECTION 8 alone spans analysis + batch + templates + validation."""
src = _server_source()
lines = src.splitlines()
decorators = _tool_decorator_lines(src)
# Segment boundaries: every SECTION header (counted or not) ends a segment.
boundaries = [
number
for number, line in enumerate(lines, start=1)
if re.search(r"#[^\n]*SECTION [\w]+:", line)
]
boundaries.append(len(lines) + 1)
headers = [
(m.start(), m.group(1), m.group(2))
for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src)
(number, match.group(1), match.group(2))
for number, line in enumerate(lines, start=1)
if (match := re.search(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", line))
]
assert headers, "no counted SECTION headers found in server.py"
# Segment boundaries: every SECTION header (counted or not) ends a segment.
boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src))
boundaries.append(len(src))
mismatches: list[str] = []
for start, section_id, declared in headers:
end = min(b for b in boundaries if b > start)
actual = len(re.findall(r"@mcp\.tool\(", src[start:end]))
actual = sum(1 for line in decorators if start <= line < end)
if actual != int(declared):
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
assert not mismatches, "; ".join(mismatches)
@@ -122,8 +182,6 @@ def test_section_header_counts_match_decorators() -> None:
def test_server_json_versions_match_pyproject() -> None:
"""MCP registry manifest must ship the same version as the package."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
expected = _pyproject_version()
assert manifest["version"] == expected, (
@@ -141,8 +199,6 @@ def test_server_json_versions_match_pyproject() -> None:
def test_readme_contains_mcp_name_marker() -> None:
"""PyPI ownership validation for the MCP registry needs this marker in the
README (which ships to PyPI as the long description)."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert f"mcp-name: {manifest['name']}" in readme
+477
View File
@@ -0,0 +1,477 @@
"""Result shaping — field projection + compact rows on the collection tools.
The release made *discovery* cheap and left the other half of the token bill
untouched: what the tools hand back. On a 300-entity drawing a single default
``entity_list(limit=100)`` is ~24 kB of JSON the model has to read, and roughly
a third of it is a ``properties.bounding_box`` nobody asked for.
ONE mechanism, two parameters, spelled identically on every collection tool
(:data:`server.SHAPED_RESULT_TOOLS`)::
fields=["handle", "type", "layer"] # projection, order preserved
compact=True # columnar envelope instead of dicts
What these tests guard is the mechanism, not the byte counts:
* **Backwards compatibility is the hard constraint.** Omitting both parameters
must return exactly the 1.4.0 value. Every pre-existing test in the suite is
the real gate; the test here only pins the intent.
* **No per-tool dialect.** ``SHAPED_RESULT_TOOLS`` is checked against the live
registry in *both* directions, and the two parameters' JSON Schemas are
compared across every tool that carries them. A tool that grows a ``columns=``
or a ``brief=`` of its own fails here.
* **A typo is an error, never an empty column.** An unknown field name must come
back naming the valid fields. This is the difference between a projection and
a footgun: ``fields=["handel"]`` returning 100 rows of ``{"handel": null}``
reads as "the drawing has no handles".
* **Truncation honesty.** A silently truncated list read as complete is a
correctness bug, not a token one. ``entity_list`` pages, and in 1.4.0 the
return value carried no signal at all that more rows existed; the two
``analysis_select_by_*`` tools capped at ``MAX_LIST_LIMIT`` and said so only
in a log notification most clients never show the model. The compact envelope
is the mode that can carry ``total``/``truncated``/``next_offset``, and
``total`` is measured against the *unfiltered* matching set rather than the
page, which is why the backends gained ``entity_count``.
"""
from __future__ import annotations
import json
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
import config
import server
from backends.base import BlockInfo, EntityInfo, LayerInfo
pytestmark = pytest.mark.asyncio
ENTITY_FIELDS = ("handle", "type", "layer", "color", "linetype", "visible", "properties")
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def _draw(client, circles: int = 30, lines: int = 30) -> None:
"""A small mixed drawing: CIRCLEs carry properties LINEs do not."""
steps = []
for i in range(circles):
steps.append(
{
"tool": "entity_create_circle",
"args": {"cx": float(i), "cy": 0.0, "radius": 1.0 + i, "layer": "GEOMETRY"},
}
)
for i in range(lines):
steps.append(
{
"tool": "entity_create_line",
"args": {
"x1": 0.0,
"y1": float(i),
"x2": 10.0,
"y2": float(i),
"layer": "HIDDEN",
},
}
)
result = (await client.call_tool("cad_batch", {"steps": steps})).structured_content
assert result["failed"] == 0, result
def _rows(result) -> list | dict:
"""Unwrap FastMCP's ``{"result": ...}`` output wrapper."""
return (result.structured_content or {})["result"]
def dumps(obj) -> str:
"""Compact JSON — how a payload is actually charged for."""
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
# ---------------------------------------------------------------------------
# The mechanism is one mechanism
# ---------------------------------------------------------------------------
async def test_every_shaped_tool_carries_both_parameters():
tools = {t.name: t for t in await server._registered_tools()}
missing = [name for name in server.SHAPED_RESULT_TOOLS if name not in tools]
assert not missing, f"SHAPED_RESULT_TOOLS names unregistered tools: {missing}"
for name in server.SHAPED_RESULT_TOOLS:
properties = (tools[name].parameters or {}).get("properties", {})
for param in server.RESULT_SHAPE_PARAMS:
assert param in properties, f"{name} is listed as shaped but has no {param!r} parameter"
async def test_no_tool_carries_the_parameters_without_being_declared_shaped():
"""The closed list is checked in both directions, so it cannot silently rot."""
tools = await server._registered_tools()
carriers = {
tool.name
for tool in tools
if set(server.RESULT_SHAPE_PARAMS) & set((tool.parameters or {}).get("properties", {}))
}
assert carriers == set(server.SHAPED_RESULT_TOOLS)
async def test_a_shaped_tool_never_carries_only_half_the_mechanism():
for tool in await server._registered_tools():
properties = set((tool.parameters or {}).get("properties", {}))
present = [param for param in server.RESULT_SHAPE_PARAMS if param in properties]
assert len(present) in (0, len(server.RESULT_SHAPE_PARAMS)), (
f"{tool.name} declares {present} — the two parameters ship together or not at all"
)
async def test_the_parameter_schemas_are_identical_across_every_shaped_tool():
"""One spelling everywhere: no per-tool dialect of the same idea."""
tools = {t.name: t for t in await server._registered_tools()}
seen: dict[str, list[tuple[str, str]]] = {param: [] for param in server.RESULT_SHAPE_PARAMS}
for name in server.SHAPED_RESULT_TOOLS:
properties = (tools[name].parameters or {})["properties"]
for param in server.RESULT_SHAPE_PARAMS:
seen[param].append((name, dumps(properties[param])))
for param, entries in seen.items():
distinct = {schema for _, schema in entries}
assert len(distinct) == 1, f"{param!r} is spelled {len(distinct)} different ways: {entries}"
# ---------------------------------------------------------------------------
# Backwards compatibility
# ---------------------------------------------------------------------------
async def test_omitting_both_parameters_returns_the_untouched_record(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {}))
assert isinstance(rows, list) and len(rows) == 3
assert set(rows[0]) == set(ENTITY_FIELDS), "the default record must keep every field"
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
async def test_explicit_defaults_are_the_same_value_as_omitting_them(client):
await _draw(client, circles=4, lines=4)
omitted = _rows(await client.call_tool("entity_list", {}))
explicit = _rows(await client.call_tool("entity_list", {"fields": None, "compact": False}))
assert omitted == explicit
@pytest.mark.parametrize(
"tool,args,spec",
[
("layer_list", {}, LayerInfo),
("block_list", {}, BlockInfo),
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, EntityInfo),
("analysis_select_by_type", {"entity_type": "CIRCLE"}, EntityInfo),
("analysis_find_in_region", {"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6}, EntityInfo),
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, EntityInfo),
],
)
async def test_default_shape_is_a_list_of_full_records(client, tool, args, spec):
await _draw(client, circles=3, lines=3)
rows = _rows(await client.call_tool(tool, args))
assert isinstance(rows, list)
for row in rows:
assert set(row) == set(spec.__dataclass_fields__)
# ---------------------------------------------------------------------------
# Projection
# ---------------------------------------------------------------------------
async def test_projection_keeps_only_the_named_fields_in_order(client):
await _draw(client, circles=5, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "type"]}))
assert all(list(row) == ["handle", "type"] for row in rows)
assert all(row["type"] == "CIRCLE" for row in rows)
async def test_the_projection_order_is_the_callers_not_the_dataclasses(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["layer", "handle"]}))
assert all(list(row) == ["layer", "handle"] for row in rows)
async def test_a_repeated_field_is_projected_once(client):
"""Repeats are a caller slip, not an error — but they must not double the column."""
await _draw(client, circles=2, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "handle", "type"]}))
assert all(list(row) == ["handle", "type"] for row in rows)
async def test_a_dict_valued_field_can_still_be_taken_whole(client):
await _draw(client, circles=2, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["properties"]}))
assert all(set(row) == {"properties"} for row in rows)
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
async def test_projection_reaches_one_nested_property(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "properties.radius"]}))
assert [row["properties.radius"] for row in rows] == [1.0, 2.0, 3.0]
async def test_a_nested_key_absent_from_some_rows_is_null_not_an_error(client):
"""LINEs have no radius; the column exists because CIRCLEs in the set do."""
await _draw(client, circles=2, lines=2)
rows = _rows(await client.call_tool("entity_list", {"fields": ["type", "properties.radius"]}))
by_type = {row["type"]: row["properties.radius"] for row in rows}
assert by_type["CIRCLE"] is not None
assert by_type["LINE"] is None
async def test_an_unknown_field_is_an_error_naming_the_valid_fields(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": ["handel"]})
message = str(excinfo.value)
assert "handel" in message
for field in ENTITY_FIELDS:
assert field in message, f"the error must name {field!r} as a valid field"
async def test_an_unknown_nested_key_is_an_error_naming_the_available_keys(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": ["properties.diameter"]})
message = str(excinfo.value)
assert "diameter" in message
assert "radius" in message, "the error must list the sub-keys this result actually has"
async def test_an_empty_projection_is_an_error_not_an_empty_column(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": []})
assert "handle" in str(excinfo.value)
async def test_the_valid_field_list_follows_the_row_dataclass_not_entityinfo(client):
"""layer_list rows are LayerInfo, so its error must name LayerInfo's fields."""
with pytest.raises(ToolError) as excinfo:
await client.call_tool("layer_list", {"fields": ["handle"]})
message = str(excinfo.value)
assert "is_frozen" in message and "lineweight" in message
assert "linetype" in message
async def test_projection_works_on_every_shaped_collection_tool(client):
await _draw(client, circles=3, lines=3)
cases = [
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, ["handle", "type"]),
("analysis_select_by_type", {"entity_type": "LINE"}, ["handle", "layer"]),
(
"analysis_find_in_region",
{"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6},
["handle", "type"],
),
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, ["handle"]),
("layer_list", {}, ["name", "color"]),
("block_list", {}, ["name"]),
]
for tool, args, fields in cases:
rows = _rows(await client.call_tool(tool, {**args, "fields": fields}))
assert isinstance(rows, list), tool
for row in rows:
assert list(row) == fields, tool
# ---------------------------------------------------------------------------
# Compact envelope
# ---------------------------------------------------------------------------
async def test_compact_returns_a_columnar_envelope(client):
await _draw(client, circles=4, lines=0)
envelope = _rows(await client.call_tool("entity_list", {"compact": True}))
assert isinstance(envelope, dict)
assert set(envelope) == {
"fields",
"rows",
"count",
"offset",
"total",
"truncated",
"next_offset",
}
assert envelope["fields"] == list(ENTITY_FIELDS)
assert envelope["count"] == 4
assert all(len(row) == len(envelope["fields"]) for row in envelope["rows"])
async def test_compact_rows_carry_the_same_values_as_the_dict_form(client):
await _draw(client, circles=3, lines=2)
fields = ["handle", "type", "layer"]
dicts = _rows(await client.call_tool("entity_list", {"fields": fields}))
envelope = _rows(await client.call_tool("entity_list", {"fields": fields, "compact": True}))
assert envelope["fields"] == fields
assert envelope["rows"] == [[row[name] for name in fields] for row in dicts]
async def test_compact_is_much_cheaper_than_the_default_record(client):
"""The point of the exercise, guarded as a ratio rather than a byte count."""
await _draw(client, circles=60, lines=60)
default = dumps(_rows(await client.call_tool("entity_list", {"limit": 120})))
projected = dumps(
_rows(
await client.call_tool(
"entity_list",
{"limit": 120, "fields": ["handle", "type", "layer"], "compact": True},
)
)
)
assert len(projected) < len(default) / 5, (
f"projected+compact was {len(projected)} chars against {len(default)} — "
"the mechanism is not paying for its schema cost"
)
async def test_compact_on_a_shaped_tool_that_already_returns_a_dict(client):
"""``selection_get``'s collection is nested, so its envelope lands under it."""
result = (await client.call_tool("selection_get", {"compact": True})).structured_content
assert isinstance(result["entities"], dict)
assert result["entities"]["fields"] == list(ENTITY_FIELDS)
assert result["entities"]["rows"] == []
plain = (await client.call_tool("selection_get", {})).structured_content
assert plain["entities"] == [], "the default must stay a plain list"
# ---------------------------------------------------------------------------
# Truncation honesty
# ---------------------------------------------------------------------------
async def test_a_paged_entity_list_reports_the_total_it_did_not_return(client):
await _draw(client, circles=40, lines=40)
envelope = _rows(
await client.call_tool("entity_list", {"limit": 10, "compact": True, "fields": ["handle"]})
)
assert envelope["count"] == 10
assert envelope["total"] == 80, "total counts the matching set, not the page"
assert envelope["truncated"] is True
assert envelope["next_offset"] == 10
async def test_the_last_page_is_not_reported_as_truncated(client):
await _draw(client, circles=10, lines=0)
envelope = _rows(
await client.call_tool(
"entity_list", {"limit": 10, "offset": 5, "compact": True, "fields": ["handle"]}
)
)
assert (envelope["count"], envelope["offset"], envelope["total"]) == (5, 5, 10)
assert envelope["truncated"] is False
assert envelope["next_offset"] is None
async def test_total_honours_the_same_filters_as_the_page(client):
await _draw(client, circles=20, lines=30)
envelope = _rows(
await client.call_tool(
"entity_list",
{"type_filter": "LINE", "limit": 5, "compact": True, "fields": ["handle"]},
)
)
assert envelope["total"] == 30, "a filtered page must be measured against the filtered set"
envelope = _rows(
await client.call_tool(
"entity_list",
{"layer_filter": "GEOMETRY", "limit": 5, "compact": True, "fields": ["handle"]},
)
)
assert envelope["total"] == 20
async def test_the_max_list_limit_cap_is_reported_as_truncation(client, monkeypatch):
"""``analysis_select_by_layer`` capped silently in the value before this."""
monkeypatch.setattr(config.settings, "max_list_limit", 4)
await _draw(client, circles=9, lines=0)
envelope = _rows(
await client.call_tool(
"analysis_select_by_layer",
{"layer_name": "GEOMETRY", "compact": True, "fields": ["handle"]},
)
)
assert envelope["count"] == 4
assert envelope["total"] == 9
assert envelope["truncated"] is True
assert envelope["next_offset"] == 4
async def test_an_untruncated_selection_says_so(client):
await _draw(client, circles=6, lines=0)
envelope = _rows(
await client.call_tool(
"analysis_select_by_type",
{"entity_type": "CIRCLE", "compact": True, "fields": ["handle"]},
)
)
assert (envelope["count"], envelope["total"]) == (6, 6)
assert envelope["truncated"] is False
async def test_default_mode_still_truncates_the_way_it_always_did(client, monkeypatch):
"""The compatibility contract cuts both ways: the old value, cap included."""
monkeypatch.setattr(config.settings, "max_list_limit", 4)
await _draw(client, circles=9, lines=0)
rows = _rows(await client.call_tool("analysis_select_by_layer", {"layer_name": "GEOMETRY"}))
assert isinstance(rows, list) and len(rows) == 4
# ---------------------------------------------------------------------------
# entity_count — the backend method the honest `total` needs
# ---------------------------------------------------------------------------
async def test_entity_count_matches_a_full_listing(backend):
for i in range(7):
await backend.entity_create_circle(float(i), 0.0, 1.0, layer="GEOMETRY")
for i in range(5):
await backend.entity_create_line(0.0, float(i), 1.0, float(i), layer="HIDDEN")
assert await backend.entity_count() == 12
assert await backend.entity_count(type_filter="CIRCLE") == 7
assert await backend.entity_count(layer_filter="HIDDEN") == 5
assert await backend.entity_count(type_filter="LINE", layer_filter="GEOMETRY") == 0
async def test_entity_count_filters_case_insensitively_like_entity_list(backend):
await backend.entity_create_circle(0.0, 0.0, 1.0, layer="GEOMETRY")
assert await backend.entity_count(type_filter="circle") == 1
assert await backend.entity_count(layer_filter="geometry") == 1
async def test_entity_count_is_on_the_backend_contract():
from backends.base import AutoCADBackend
assert "entity_count" in AutoCADBackend.__abstractmethods__
async def test_entity_count_never_materialises_the_rows(backend, monkeypatch):
"""A count that paged through entity_list would defeat its own purpose."""
async def _forbidden(*args, **kwargs):
raise AssertionError("entity_count must not call entity_list")
monkeypatch.setattr(backend, "entity_list", _forbidden)
await backend.entity_create_circle(0.0, 0.0, 1.0)
assert await backend.entity_count() == 1
+94
View File
@@ -0,0 +1,94 @@
"""M5 — handle grounding: connecting the picture to the handles.
A screenshot shows the model what the drawing looks like and nothing it can act
on. Every modify tool takes a handle, and there was no way to tell which of the
hex strings from `entity_list` is the circle in the top-left — so the model
either guessed or re-derived the mapping from coordinates, which is exactly what
CLAUDE.md's no-coordinate-guessing rule exists to prevent.
The overlay labels each entity with its handle at its own bounding-box centre,
so "the circle at the top-left" and "handle 2F" become the same statement.
Off by default: labels are ink on the drawing, and on a busy sheet they would
bury the geometry they are meant to explain. When there are more entities than
labels allowed, the image says so rather than labelling a subset and letting it
read as the whole picture.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
def _png_size(data: bytes) -> tuple[int, int]:
"""Width/height straight out of the PNG IHDR — no Pillow needed."""
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
async def test_a_plain_screenshot_is_unchanged(backend):
await backend.entity_create_circle(10, 10, 5)
png = await backend.view_screenshot()
assert png is not None
assert _png_size(png)[0] > 0
async def test_the_overlay_changes_the_image(backend):
"""The labels have to actually be drawn, not merely computed."""
await backend.entity_create_circle(10, 10, 5)
await backend.entity_create_line(0, 0, 20, 0)
plain = await backend.view_screenshot()
labelled = await backend.view_screenshot(overlay_handles=True)
assert labelled is not None
assert labelled != plain, "the overlay produced a byte-identical image"
async def test_the_overlay_reports_which_handles_it_drew(backend):
circle = await backend.entity_create_circle(10, 10, 5)
line = await backend.entity_create_line(0, 0, 20, 0)
report = await backend.view_screenshot_grounded()
assert set(report["labels"]) == {circle.handle, line.handle}
assert report["labelled"] == 2
assert report["total"] == 2
assert report["truncated"] is False
assert report["png"][:8] == b"\x89PNG\r\n\x1a\n"
async def test_each_label_sits_on_its_own_entity(backend):
"""A label in the wrong place is worse than no label — it grounds a lie."""
left = await backend.entity_create_circle(-50, 0, 5)
right = await backend.entity_create_circle(50, 0, 5)
report = await backend.view_screenshot_grounded()
assert report["labels"][left.handle][0] == pytest.approx(-50, abs=1.0)
assert report["labels"][right.handle][0] == pytest.approx(50, abs=1.0)
async def test_a_crowded_drawing_says_it_only_labelled_some(backend):
for i in range(30):
await backend.entity_create_circle(i * 10, 0, 2)
report = await backend.view_screenshot_grounded(max_labels=10)
assert report["labelled"] == 10
assert report["total"] == 30
assert report["truncated"] is True, (
"labelling a subset silently would let the picture read as the whole drawing"
)
async def test_an_empty_drawing_grounds_nothing_without_failing(backend):
report = await backend.view_screenshot_grounded()
assert report["labels"] == {}
assert report["total"] == 0
assert report["png"] is not None
+117 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import os
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_command, sanitize_lisp, validate_path
from security import command_verbs, sanitize_command, sanitize_lisp, validate_path
# ---------------------------------------------------------------------------
# Path validation tests
@@ -276,3 +278,117 @@ class TestSanitizerBypassVectors:
def test_lisp_legitimate_not_overblocked(self):
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"
# ── T0.8-A — what the guards actually guard ────────────────────────────────
class TestNonLocalPathAnchors:
r"""Windows accepts several spellings of the same file that compare unequal.
``_BLOCKED_PATH_PATTERNS`` rejects the backslash forms, but ``Path.resolve()``
normalises the *forward-slash* spellings into them afterwards — so
``//?/C:/Windows/win.ini`` slipped past the pattern check and then compared
unequal to ``C:/Windows`` in the system-directory loop. Proven: it returned a
handle that ``os.path.samefile``'d ``C:\Windows\win.ini``.
"""
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_extended_length_prefix_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//?/C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_unc_forward_slash_form_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//localhost/C$/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_device_form_still_reaches_the_system_directory_check(self):
"""``//./C:/...`` normalises to a plain drive path — it must be caught
by the system-directory rule, not by the anchor rule, or the anchor
check would be masking rather than adding a guard."""
with pytest.raises(ToolError, match="system directory"):
validate_path("//./C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_an_ordinary_path_is_unaffected(self, tmp_path):
assert validate_path(str(tmp_path / "part.dxf"))
class TestLispStringLiteralsAreData:
"""A quoted literal is drawing text, not code.
Reading inside quotes made the server refuse its own domain vocabulary: an
engineering CAD server that rejects ``"Max load 500 kg"`` because the word
"load" appears in a note is broken for the job it exists to do.
"""
@pytest.mark.parametrize(
"expression",
[
'(setq desc "Max load 500 kg")',
'(entmakex (list (cons 0 "TEXT") (cons 1 "LOAD CASE 1")))',
'(setq layer "C:CENTER")',
'(setq note "Design load = 12 kN")',
],
)
def test_engineering_text_is_not_mistaken_for_code(self, expression):
assert sanitize_lisp(expression) == expression
@pytest.mark.parametrize(
"expression",
[
'(load "malicious.lsp")',
'(vl-catch-all-apply (quote command) (list "ERASE"))',
'(startapp "cmd")',
"(eval (read user-input))",
],
)
def test_the_dangerous_vectors_still_block(self, expression):
with pytest.raises(ToolError):
sanitize_lisp(expression)
class TestCommandVerbScoping:
"""Match the command, not the characters.
``\b(ERASE|...)\b`` over the whole string refused ``MTEXT 0,0 10,10 Do not
erase this part`` — annotation text, not a command — while missing spellings
AutoCAD accepts. SendCommand takes a macro: segments split on newline or
``;``, and only a segment's FIRST token is a verb.
"""
def test_the_repos_own_safe_fixture_still_passes(self):
assert sanitize_command("_ZOOM E") == "_ZOOM E"
def test_zoom_extents_as_a_macro_passes(self):
"""``E`` here is the Extents *option*, not the ERASE alias. Blocking the
canonical zoom-extents macro is why single-letter aliases stay out of
the verb set."""
assert sanitize_command("_ZOOM\nE\n") == "_ZOOM\nE\n"
def test_annotation_text_mentioning_a_verb_passes(self):
text = "MTEXT 0,0 10,10 Do not erase this part"
assert sanitize_command(text) == text
def test_a_linetype_load_macro_passes(self):
macro = "_-LINETYPE _LOAD CENTER acad.lin\n\n"
assert sanitize_command(macro) == macro
@pytest.mark.parametrize(
"command",
["ERASE ALL", "_ZOOM E\n_ERASE", "SAVE", "VBASTMT", "ARX", ".SHELL", "'SCRIPT x.scr"],
)
def test_dangerous_verbs_are_blocked_in_every_decoration(self, command):
with pytest.raises(ToolError):
sanitize_command(command)
def test_the_refusal_names_the_offending_verb(self):
"""Without this a user whose annotation was refused cannot tell why."""
with pytest.raises(ToolError, match="ERASE"):
sanitize_command("_ZOOM E\n_ERASE")
def test_command_verbs_strips_every_decorator(self):
assert command_verbs("_-PURGE") == ["PURGE"]
assert command_verbs("'_ZOOM E;.REGEN") == ["ZOOM", "REGEN"]
+546
View File
@@ -0,0 +1,546 @@
"""F1 — selection filters: window, polygon and property selection.
Three tools that answer "which entities do you mean?" without the caller having
to memorise handles. Each carries a failure mode that is silent unless it is
pinned here:
* **window vs crossing** is the whole point of the family. AutoCAD's *window*
returns only entities that are *fully contained*; *crossing* also returns the
ones that merely straddle the edge. Under the hood these are
``select.bbox_inside`` and ``select.bbox_overlap`` — one character apart in a
call, and getting them backwards produces a plausible-looking handle list that
is quietly the wrong set. Every mode test below builds one entity inside, one
straddling and one outside, and pins exactly which of the three comes back.
* **a zero-area box** (``x1 == x2`` or ``y1 == y2``) can never contain anything.
``select.Window((0, 0), (0, 100))`` returns ``[]`` — measured — so a tool that
passed it through would answer "nothing is there" to a question that was
really "you asked for nothing". It is refused instead.
* **a polygon is not its bounding box.** A triangle and a square with identical
bounding boxes must not select the same entities; the triangle test uses
exactly that pair so a lazy bbox implementation cannot pass.
* **mirrored geometry** is where stored coordinates and real position diverge. A
mirrored CIRCLE keeps ``center = (30, 20)`` on disk and carries
``extrusion = (0, 0, -1)``; it is *drawn* at (-30, 20), which is what
``entity_get`` reports. Selection must agree with the reported position, or
the caller would have to window an entity where nothing is visible.
* **``selection_filter`` takes named parameters, not a query string,** on
purpose. ``layout.query("LINE[nosuchattr=='x']")`` returns ``[]`` silently —
measured — so a typo is indistinguishable from "no matches". Named parameters
make the typo a ``TypeError`` at the tool boundary. The response carries
``filtered_by`` for the same reason: a caller must be able to tell "I filtered
by layer and found none" from "I never filtered by layer at all".
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# The selection box every window/polygon test uses.
BOX = (0.0, 0.0, 100.0, 100.0)
SQUARE_POLY = [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]]
# Same bounding box as SQUARE_POLY, half the area.
TRIANGLE_POLY = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0]]
async def _three_positions(backend):
"""One entity fully inside BOX, one straddling its edge, one fully outside.
Plus a second contained entity on another layer, so a layer filter has
something to remove without emptying the result.
"""
contained = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY")
contained_other_layer = await backend.entity_create_circle(20, 80, 5, layer="HIDDEN")
straddling = await backend.entity_create_line(90, 50, 110, 50, layer="GEOMETRY")
outside = await backend.entity_create_circle(200, 200, 5, layer="HIDDEN")
return {
"contained": contained.handle,
"contained_other_layer": contained_other_layer.handle,
"straddling": straddling.handle,
"outside": outside.handle,
}
async def _mixed_properties(backend):
"""Three entities that differ in type, layer, colour, linetype and area."""
circle = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY", color=1)
square = await backend.entity_create_polyline(
[[0, 0], [20, 0], [20, 20], [0, 20]], closed=True, layer="GEOMETRY", color=3
)
line = await backend.entity_create_line(0, 0, 50, 0, layer="HIDDEN", color=0, linetype="HIDDEN")
return {"circle": circle.handle, "square": square.handle, "line": line.handle}
# ── selection_window: the mode contract ─────────────────────────────────────
async def test_a_window_returns_only_fully_contained_entities(backend):
"""The straddling line is the one that must NOT come back."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="window")
assert result["ok"] is True
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
async def test_a_crossing_also_returns_entities_that_straddle_the_edge(backend):
"""Crossing = window + the straddlers. Never fewer, never the outside one."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
handles["straddling"],
}
async def test_neither_mode_reaches_an_entity_outside_the_box(backend):
handles = await _three_positions(backend)
window = await backend.selection_window(*BOX, mode="window")
crossing = await backend.selection_window(*BOX, mode="crossing")
assert handles["outside"] not in window["handles"]
assert handles["outside"] not in crossing["handles"]
async def test_crossing_is_a_strict_superset_of_window(backend):
"""If these two ever come back equal for this drawing, one of them is wrong."""
await _three_positions(backend)
window = set((await backend.selection_window(*BOX, mode="window"))["handles"])
crossing = set((await backend.selection_window(*BOX, mode="crossing"))["handles"])
assert window < crossing
assert len(crossing) - len(window) == 1
async def test_window_is_the_default_mode(backend):
"""The safer of the two: contained-only never over-selects."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX)
assert result["mode"] == "window"
assert handles["straddling"] not in result["handles"]
async def test_the_reported_count_matches_the_handles(backend):
await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing")
assert result["count"] == len(result["handles"]) == 3
assert result["mode"] == "crossing"
async def test_the_corners_may_be_given_in_any_order(backend):
"""(x2, y2) top-right is a convention, not a requirement of the geometry."""
await _three_positions(backend)
normal = await backend.selection_window(0, 0, 100, 100, mode="crossing")
flipped = await backend.selection_window(100, 100, 0, 0, mode="crossing")
assert set(flipped["handles"]) == set(normal["handles"])
assert flipped["count"] == normal["count"] == 3
# ── selection_window: refusals ──────────────────────────────────────────────
@pytest.mark.parametrize("bad_mode", ["inside", "fence", "WINDOWS", "", "all"])
async def test_an_unknown_window_mode_is_refused(backend, bad_mode):
"""Falling back to a default mode would answer a question nobody asked."""
await _three_positions(backend)
result = await backend.selection_window(*BOX, mode=bad_mode)
assert result["ok"] is False
assert "mode" in result["error"]
@pytest.mark.parametrize(
"box",
[
(10.0, 0.0, 10.0, 100.0), # zero width
(0.0, 40.0, 100.0, 40.0), # zero height
(5.0, 5.0, 5.0, 5.0), # a point
],
)
async def test_a_zero_area_window_is_refused(backend, box):
"""It can never contain anything, so [] would read as "nothing is there"."""
await _three_positions(backend)
result = await backend.selection_window(*box, mode="crossing")
assert result["ok"] is False
assert "area" in result["error"] or "zero" in result["error"]
# ── selection_window: filters compose with the mode ─────────────────────────
async def test_an_entity_type_filter_narrows_the_crossing_set(backend):
"""The type filter must cut the crossing set, not replace it."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", entity_type="CIRCLE")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
assert handles["straddling"] not in result["handles"]
async def test_an_entity_type_filter_can_keep_the_straddler_alone(backend):
"""Same box, same mode, LINE instead of CIRCLE: the complement comes back."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", entity_type="LINE")
assert set(result["handles"]) == {handles["straddling"]}
async def test_a_layer_filter_composes_with_crossing(backend):
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", layer="GEOMETRY")
assert set(result["handles"]) == {handles["contained"], handles["straddling"]}
async def test_a_layer_filter_composes_with_window(backend):
"""Same layer, window mode: the straddler drops out and only one is left."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="window", layer="GEOMETRY")
assert set(result["handles"]) == {handles["contained"]}
async def test_type_and_layer_filters_compose_with_each_other(backend):
handles = await _three_positions(backend)
result = await backend.selection_window(
*BOX, mode="crossing", entity_type="CIRCLE", layer="HIDDEN"
)
assert set(result["handles"]) == {handles["contained_other_layer"]}
async def test_an_empty_selection_is_ok_with_zero_handles(backend):
"""Nothing there is an answer, not an error — unlike a zero-area box."""
await _three_positions(backend)
result = await backend.selection_window(500, 500, 600, 600, mode="crossing")
assert result["ok"] is True
assert result["handles"] == []
assert result["count"] == 0
# ── selection_window: mirrored geometry ─────────────────────────────────────
async def _mirrored_circle(backend):
"""A CIRCLE stored at (30, 20) but drawn at (-30, 20).
Mirroring about the Y axis gives extrusion (0, 0, -1) and leaves the stored
centre untouched — measured. entity_get resolves it to WCS (-30, 20).
"""
source = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
return (await backend.entity_mirror(source.handle, 0, 0, 0, 10, delete_original=True)).handle
async def test_a_mirrored_entity_is_selected_where_entity_get_reports_it(backend):
handle = await _mirrored_circle(backend)
reported = await backend.entity_get(handle)
assert reported.properties["center"] == [-30.0, 20.0]
result = await backend.selection_window(-40, 10, -20, 30, mode="window")
assert set(result["handles"]) == {handle}
async def test_a_mirrored_entity_is_not_selected_at_its_stored_coordinates(backend):
"""Selecting at (30, 20) would select an entity nobody can see there."""
handle = await _mirrored_circle(backend)
result = await backend.selection_window(20, 10, 40, 30, mode="crossing")
assert handle not in result["handles"]
assert result["count"] == 0
async def test_the_mirrored_coordinates_really_do_diverge_on_disk(backend, tmp_path):
"""Proof the divergence is stored, not an artefact of the in-memory copy."""
import ezdxf
handle = await _mirrored_circle(backend)
path = tmp_path / "mirrored.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(handle)
assert tuple(reloaded.dxf.extrusion) == (0.0, 0.0, -1.0)
assert tuple(reloaded.dxf.center)[:2] == (30.0, 20.0)
selected = await backend.selection_window(-40, 10, -20, 30, mode="window")
assert selected["handles"] == [handle]
# ── selection_polygon ───────────────────────────────────────────────────────
async def test_a_polygon_window_returns_only_fully_contained_entities(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="window")
assert result["ok"] is True
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
async def test_a_polygon_crossing_also_returns_the_straddler(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
handles["straddling"],
}
assert result["mode"] == "crossing"
async def test_a_polygon_is_not_its_bounding_box(backend):
"""The triangle has exactly SQUARE_POLY's bounding box and half its area.
Measured: both contained circles sit inside the square but poke out of the
triangle's hypotenuse, so a bbox-only implementation returns two here and
fails.
"""
await _three_positions(backend)
square = await backend.selection_polygon(SQUARE_POLY, mode="window")
triangle = await backend.selection_polygon(TRIANGLE_POLY, mode="window")
assert square["count"] == 2
assert triangle["handles"] == []
async def test_the_triangle_still_reaches_them_in_crossing_mode(backend):
"""They poke out of it — which is exactly what crossing is for."""
handles = await _three_positions(backend)
result = await backend.selection_polygon(TRIANGLE_POLY, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
@pytest.mark.parametrize(
"bad",
[[], [[0.0, 0.0]], [[0.0, 0.0], [100.0, 100.0]]],
)
async def test_a_polygon_needs_at_least_three_points(backend, bad):
"""Two points enclose no area; a zero-area lasso catches nothing."""
await _three_positions(backend)
result = await backend.selection_polygon(bad, mode="crossing")
assert result["ok"] is False
assert "3" in result["error"] or "three" in result["error"].lower()
@pytest.mark.parametrize("bad_mode", ["inside", "fence", ""])
async def test_an_unknown_polygon_mode_is_refused(backend, bad_mode):
await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode=bad_mode)
assert result["ok"] is False
assert "mode" in result["error"]
async def test_a_polygon_filter_composes_with_the_mode(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing", entity_type="LINE")
assert set(result["handles"]) == {handles["straddling"]}
async def test_a_polygon_layer_filter_composes_with_the_mode(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="window", layer="HIDDEN")
assert set(result["handles"]) == {handles["contained_other_layer"]}
# ── selection_filter: what was actually filtered ────────────────────────────
async def test_no_filters_returns_everything_and_says_it_filtered_by_nothing(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter()
assert result["ok"] is True
assert set(result["handles"]) == set(handles.values())
assert result["count"] == 3
assert list(result["filtered_by"]) == []
async def test_filtering_by_layer_names_the_layer_filter_in_the_response(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY")
assert set(result["handles"]) == {handles["circle"], handles["square"]}
assert set(result["filtered_by"]) == {"layer"}
async def test_an_empty_result_is_distinguishable_from_an_unfiltered_one(backend):
"""The honesty case: "found none" and "never looked" must not both be [].
DIM exists and holds nothing, so the handle list is empty either way; only
``filtered_by`` tells the caller a layer filter ran at all.
"""
await _mixed_properties(backend)
await backend.layer_create("DIM")
filtered = await backend.selection_filter(layer="DIM")
unfiltered = await backend.selection_filter()
assert filtered["ok"] is True
assert filtered["count"] == 0
assert set(filtered["filtered_by"]) == {"layer"}
assert list(unfiltered["filtered_by"]) == []
assert unfiltered["count"] == 3
async def test_a_typo_in_a_filter_name_is_a_type_error_not_an_empty_list(backend):
"""Why this tool takes named parameters instead of a query string.
``query("LINE[nosuchattr=='x']")`` answers [] — measured — and the caller
reads it as "no matches". A wrong keyword must fail loudly instead.
"""
await _mixed_properties(backend)
with pytest.raises(TypeError):
await backend.selection_filter(layar="GEOMETRY")
async def test_every_applied_filter_is_reported_not_just_the_first(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY", entity_type="CIRCLE")
assert set(result["handles"]) == {handles["circle"]}
assert set(result["filtered_by"]) == {"layer", "entity_type"}
# ── selection_filter: the individual filters ────────────────────────────────
async def test_filtering_by_entity_type(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(entity_type="LWPOLYLINE")
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"entity_type"}
async def test_filtering_by_colour(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(color=3)
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"color"}
async def test_colour_zero_is_byblock_not_no_filter(backend):
"""ACI 0 is ByBlock, a real colour. ``if color:`` would drop this filter.
The failure it guards is the worst kind: the caller asks for ByBlock and
gets the entire drawing back, reported as a successful colour filter.
"""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(color=0)
assert set(result["handles"]) == {handles["line"]}
assert set(result["filtered_by"]) == {"color"}
async def test_filtering_by_linetype(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(linetype="HIDDEN")
assert set(result["handles"]) == {handles["line"]}
assert set(result["filtered_by"]) == {"linetype"}
async def test_min_area_keeps_only_the_shapes_that_are_big_enough(backend):
"""Measured: the square encloses 400, the circle 314.16."""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=350.0)
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"min_area"}
async def test_min_area_admits_both_shapes_when_the_threshold_is_low(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=100.0)
assert set(result["handles"]) == {handles["circle"], handles["square"]}
async def test_min_area_excludes_entities_that_enclose_no_area(backend):
"""A LINE has no area at all — it is not an entity of area zero to be kept
by a zero threshold, it is an entity the question does not apply to."""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=0.0)
assert handles["line"] not in result["handles"]
assert set(result["filtered_by"]) == {"min_area"}
async def test_a_negative_min_area_is_refused(backend):
"""No area is negative, so the threshold is either a typo or a unit error."""
await _mixed_properties(backend)
result = await backend.selection_filter(min_area=-1.0)
assert result["ok"] is False
assert "min_area" in result["error"]
async def test_the_filter_count_matches_the_handles(backend):
await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY")
assert result["count"] == len(result["handles"]) == 2
+8 -1
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import pytest
import config
pytestmark = pytest.mark.asyncio
@@ -96,8 +98,13 @@ async def test_zoom_window_reports_not_applied(backend):
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
async def test_undo_empty_stack_returns_error(backend):
async def test_undo_empty_stack_returns_error(backend, monkeypatch):
"""With history on but nothing done yet, undo declines rather than raising."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
res = await backend.drawing_undo()
assert res["ok"] is False
assert "undo" in res["error"].lower()
+306
View File
@@ -0,0 +1,306 @@
"""Every token claim in this release has to be measured, and labelled as what it is.
These tests guard the *method*, not the numbers. The numbers move with the catalog
(add a tool, the idle payload grows); the method must not move at all:
* characters are the primary, exact unit; tokens are always marked as an estimate
with the divisor and its provenance attached,
* no single flat chars/token divisor is ever applied across formats (that error
flatters whichever format the author wants to win),
* every payload is priced twice - uncached and prompt-cached - because a
never-mutating tool prefix is cacheable and every real client caches it,
* the stock-BM25 arm of the discovery A/B is *configured* with the same compact
serializer ours uses, so the comparison measures the ranker rather than the
renderer,
* the suite measures the headless engine and leaves the shared server as it
found it.
"""
from __future__ import annotations
import asyncio
import json
import os
import pytest
import config
import server
from benchmarks import token_suite as ts
# A small drawing keeps the module fast; the published run uses the default 300.
TEST_ENTITIES = 40
# Captured at import, i.e. before the suite has had a chance to pin the backend.
#: ``AUTOCAD_MCP_BACKEND`` straddling the suite run. Both are captured *around*
#: ``run_suite`` rather than at import or in the test body, because the suite is
#: the only thing being measured here: the conftest pins every test to the
#: headless backend, and — since pytest builds higher-scoped fixtures first —
#: that pin lands after this module-scoped fixture. Comparing the test-time value
#: against an import-time one measures the conftest, not the restore.
BACKEND_ENV_BEFORE: str | None = None
BACKEND_ENV_AFTER: str | None = None
@pytest.fixture(scope="module")
def report() -> dict:
"""One real run of the whole suite, shared by every test in this module.
Synchronous on purpose: ``asyncio_mode = strict`` makes a module-scoped async
fixture need its own event-loop scope, and the suite owns its loop anyway.
"""
global BACKEND_ENV_BEFORE, BACKEND_ENV_AFTER
BACKEND_ENV_BEFORE = os.environ.get(ts.BACKEND_ENV)
try:
return asyncio.run(ts.run_suite(entities=TEST_ENTITIES))
finally:
BACKEND_ENV_AFTER = os.environ.get(ts.BACKEND_ENV)
def _rows(report: dict) -> list[dict]:
"""Every measured row in the report, whatever lane it came from."""
rows = list(report["idle"])
for arm in report["discovery"]["arms"]:
rows.extend(arm["queries"])
rows.extend(report["result"]["scenarios"])
return rows
# -- the report contract ----------------------------------------------------
def test_the_report_is_versioned_and_attributable(report):
assert report["schema_version"] == ts.SCHEMA_VERSION
env = report["environment"]
for key in ("python", "implementation", "platform", "machine", "fastmcp", "ezdxf"):
assert env[key], f"environment fingerprint is missing {key}"
def test_the_report_is_json_serializable(report):
assert json.loads(json.dumps(report, ensure_ascii=False)) == report
def test_the_measured_surface_is_the_headless_engine(report):
"""A run that quietly drove live AutoCAD would be measuring a different server."""
assert report["backend"] == "ezdxf"
# -- counting ---------------------------------------------------------------
def test_the_report_states_how_it_counted(report):
counting = report["counting"]
assert counting["primary_unit"] == "characters"
assert counting["token_method"]
assert counting["tokens_are_estimates"] is True
assert counting["method_note"]
def test_no_single_flat_divisor_is_applied_across_formats():
"""The measured error of the earlier design pass, guarded."""
ratios = {name: ratio.chars_per_token for name, ratio in ts.CHARS_PER_TOKEN.items()}
assert len(set(ratios.values())) > 1, ratios
assert ratios["json_schema"] != ratios["compact_text"]
assert 4.0 not in ratios.values(), "chars/4 is the divisor this harness exists to avoid"
def test_the_same_text_costs_differently_in_different_formats():
counter = ts.RatioTokenCounter()
text = "x" * 4190
assert counter.count(text, "json_schema") != counter.count(text, "compact_text")
def test_an_unlabelled_format_is_refused_rather_than_guessed():
counter = ts.RatioTokenCounter()
with pytest.raises(KeyError):
counter.count("some payload", "prose")
def test_every_ratio_carries_its_provenance_and_confidence():
for name, ratio in ts.CHARS_PER_TOKEN.items():
assert ratio.provenance, name
assert ratio.confidence in ts.RATIO_CONFIDENCE, name
def test_characters_are_exact_and_tokens_are_marked_estimated():
counter = ts.RatioTokenCounter()
text = '{"name":"entity_create_circle"}'
row = ts.measure(text, "json_schema", counter)
assert row["chars"] == len(text)
assert row["bytes_utf8"] == len(text.encode("utf-8"))
assert row["tokens"]["estimated"] is True
assert row["tokens"]["chars_per_token"] == ts.CHARS_PER_TOKEN["json_schema"].chars_per_token
def test_an_unavailable_real_tokenizer_fails_loudly(monkeypatch):
"""The fallback that must never be silent."""
monkeypatch.setattr(ts, "_anthropic_client", lambda: None)
with pytest.raises(RuntimeError, match="anthropic"):
ts.build_counter("anthropic")
# -- cached / uncached ------------------------------------------------------
def test_every_measured_row_is_priced_cached_and_uncached(report):
for row in _rows(report):
cost = row["cost"]
assert cost["uncached_input_tokens_per_request"] >= cost["cached_input_tokens_per_request"]
assert cost["session_uncached"] > cost["session_cached"]
def test_the_cache_arithmetic_follows_the_published_multipliers():
cost = ts.cost_model(1000, turns=10)
assert cost["uncached_input_tokens_per_request"] == 1000
assert cost["cached_input_tokens_per_request"] == pytest.approx(100.0)
assert cost["session_uncached"] == pytest.approx(10_000)
# one cache write (1.25x) plus nine cache reads (0.1x each)
assert cost["session_cached"] == pytest.approx(1250 + 9 * 100)
# -- lane 1: idle cost ------------------------------------------------------
def test_the_idle_lane_covers_the_three_advertised_surfaces(report):
variants = {row["variant"]: row for row in report["idle"]}
assert set(variants) == {"default", "lean", "search"}
assert variants["default"]["advertised_tools"] > 100
assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES)
assert variants["search"]["advertised_tools"] == 2
assert variants["default"]["chars"] > variants["lean"]["chars"] > variants["search"]["chars"]
def test_the_idle_lane_separates_the_wire_payload_from_what_the_model_pays(report):
"""tools/list carries annotations, titles and _meta a client need not forward."""
for row in report["idle"]:
assert row["chars"] == row["wire_chars"]
assert 0 < row["prompt_chars"] < row["wire_chars"]
def test_the_search_mode_prefix_falls_below_the_prompt_cache_minimum(report):
"""Small enough that no model can cache it - true, and worth saying out loud."""
variants = {row["variant"]: row for row in report["idle"]}
assert variants["search"]["prompt_cache"]["cacheable"]["512"] is False
assert variants["default"]["prompt_cache"]["cacheable"]["4096"] is True
assert variants["lean"]["prompt_cache"]["cacheable"]["4096"] is True
# -- lane 2: per-discovery cost ---------------------------------------------
def test_the_stock_arm_is_configured_not_left_on_its_defaults(report):
arms = {arm["arm"]: arm for arm in report["discovery"]["arms"]}
assert set(arms) == {"cad_search", "stock_bm25_compact", "stock_bm25_default"}
assert (
arms["stock_bm25_compact"]["serializer"] == "discovery.serialize.serialize_search_results"
)
assert arms["stock_bm25_compact"]["strawman"] is False
assert arms["stock_bm25_default"]["strawman"] is True
def test_the_serializer_not_the_transform_is_what_saves_the_bytes(report):
"""The honest read of the A/B: ours wins on finding tools, not on payload size."""
summary = report["discovery"]["summary"]
ours = summary["mean_chars"]["cad_search"]
stock_compact = summary["mean_chars"]["stock_bm25_compact"]
stock_default = summary["mean_chars"]["stock_bm25_default"]
assert ours == pytest.approx(stock_compact, rel=0.25)
assert ours < stock_default / 4
def test_a_zero_hit_answer_is_recorded_as_such_not_banked_as_a_saving(report):
"""Stock BM25 finds nothing for WBLOCK; the cheapest payload is the worst answer.
This used to be asserted on BPOLY. v1.5.0's `boundary_trace` names BPOLY in
its own docstring, so stock BM25 now finds it — the gap closed for that one
command, and measuring it as still-open would have flattered the corpus.
"""
arms = {arm["arm"]: arm for arm in report["discovery"]["arms"]}
stock = {row["query"]: row for row in arms["stock_bm25_compact"]["queries"]}
ours = {row["query"]: row for row in arms["cad_search"]["queries"]}
assert stock["WBLOCK"]["hits"] == 0
assert ours["WBLOCK"]["hits"] > 0
summary = report["discovery"]["summary"]
assert summary["comparable_queries"] < summary["queries"]
assert summary["hit_rate"]["cad_search"] > summary["hit_rate"]["stock_bm25_compact"]
# -- lane 3: result-side cost -----------------------------------------------
def test_the_result_lane_measures_a_real_drawing(report):
result = report["result"]
assert result["entities_in_drawing"] == TEST_ENTITIES
assert result["entity_types"], "the drawing mix is part of the measurement"
def test_the_result_lane_prices_what_the_model_reads_and_what_the_wire_carries(report):
"""FastMCP returns the same rows twice: a text block and structured content."""
for row in report["result"]["scenarios"]:
assert row["chars"] == row["text_chars"]
assert row["structured_chars"] > 0
assert row["wire_chars"] == row["text_chars"] + row["structured_chars"]
def test_the_result_payload_scales_with_the_rows_returned(report):
scenarios = {row["limit"]: row for row in report["result"]["scenarios"]}
for limit, row in scenarios.items():
assert row["rows_returned"] == min(limit, TEST_ENTITIES)
per_entity = [row["chars_per_entity"] for row in scenarios.values()]
assert max(per_entity) < min(per_entity) * 1.15
def test_the_result_lane_names_the_cap_that_cannot_actually_be_reached(report):
projection = report["result"]["projection"]
assert projection["schema_max_limit"] == 1000
assert projection["config_max_list_limit"] == config.settings.max_list_limit
assert projection["extrapolated"] is True
assert "schema" in projection["note"]
def test_the_result_lane_shows_where_the_bytes_go(report):
"""bounding_box and full-precision floats are measured, not assumed."""
row = report["result"]["scenarios"][0]
counterfactuals = row["counterfactuals"]
assert counterfactuals["without_bounding_box"]["chars"] < row["chars"]
assert counterfactuals["floats_rounded_6dp"]["chars"] <= row["chars"]
for entry in counterfactuals.values():
assert entry["counterfactual"] is True
# -- the suite is a guest on a shared server --------------------------------
def test_a_live_autocad_backend_is_refused_rather_than_measured():
with pytest.raises(RuntimeError, match="ezdxf"):
ts.require_ezdxf({"backend": "com"})
def test_the_suite_leaves_the_shared_server_as_it_found_it(report):
assert config.settings.tool_profile == "full"
assert config.settings.discovery_mode == "off"
# Restored to whatever it was, rather than asserted absent: exporting
# AUTOCAD_MCP_BACKEND=ezdxf is exactly what a developer on a Windows box with
# AutoCAD open should do, and that must not fail this test.
assert BACKEND_ENV_AFTER == BACKEND_ENV_BEFORE
async def _names() -> set[str]:
from fastmcp import Client
async with Client(server.mcp) as client:
return {tool.name for tool in await client.list_tools()}
names = asyncio.run(_names())
assert len(names) > 100
assert "search_tools" not in names
# -- the human summary ------------------------------------------------------
def test_the_summary_states_the_method_next_to_the_numbers(report):
text = ts.summarize(report)
assert "characters" in text
assert "estimate" in text
assert "cached" in text
assert text.isascii(), "the console this runs on is cp1254"
+232
View File
@@ -0,0 +1,232 @@
"""``docs/tool-inventory.json`` must be the live surface, not a stale copy of it.
The generated inventory replaces the oldest and most fragile release gate in
this repo: counting the literal text ``@mcp.tool(`` in server.py. Text counting
cannot see a group, a cost or a summary, and it miscounts the moment someone
writes the string in prose — which is why ``cad_tool``'s own docstring has to
spell its example without parentheses. This module holds the committed snapshot
equal to what ``scripts/generate_tool_inventory.py`` derives from the live
FastMCP component registry, so the JSON can be trusted as an input by anything
else (``tests/test_release_consistency.py`` reads its totals for the README
release-snapshot line).
Three properties are pinned:
* **No drift** — the committed document equals the freshly built one, field by
field, and byte for byte. Every failure names the regeneration command.
* **Honest totals** — the counts are re-derived here from the registry by a
path the generator does not share, so a generator that miscounts is caught
by something other than itself.
* **Usable rows** — every tool row carries the group/cost/summary a consumer
reads, with the same validity rules the live ``@cad_tool`` cards obey.
"""
from __future__ import annotations
import json
from typing import Any
import pytest
import server
from discovery.serialize import MAX_SUMMARY_CHARS
from scripts import generate_tool_inventory as generator
INVENTORY_PATH = generator.INVENTORY_PATH
_HOW_TO_FIX = (
f"\n\nThis file is generated. Regenerate it with:\n {generator.REGENERATE_COMMAND}\n"
"(and commit the result). Nothing here is meant to be hand-edited."
)
def _committed() -> dict[str, Any]:
assert INVENTORY_PATH.exists(), f"{generator.INVENTORY_REL_PATH} is missing.{_HOW_TO_FIX}"
return json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
def _differences(committed: dict[str, Any], live: dict[str, Any]) -> list[str]:
"""Human-readable drift, rather than a 131-row dict diff nobody can read.
Deliberately reports field *names* and identifiers only, never a summary
string: tool names are ASCII, whereas summaries carry glyphs like the
diameter sign, and a cp1254 console would turn such a failure into a
UnicodeEncodeError instead of a readable diff.
"""
problems: list[str] = []
for key in ("schema_version", "package_version", "totals", "groups"):
if committed.get(key) != live.get(key):
problems.append(f"{key}: committed {committed.get(key)!r} != live {live.get(key)!r}")
for section, id_key in (("tools", "name"), ("resources", "uri"), ("prompts", "name")):
old = {row.get(id_key): row for row in committed.get(section, [])}
new = {row.get(id_key): row for row in live.get(section, [])}
for name in sorted(set(new) - set(old), key=str):
problems.append(f"{section}: {name} is registered but absent from the committed file")
for name in sorted(set(old) - set(new), key=str):
problems.append(f"{section}: {name} is in the committed file but is not registered")
for name in sorted(set(old) & set(new), key=str):
if old[name] != new[name]:
changed = sorted(
field
for field in set(old[name]) | set(new[name])
if old[name].get(field) != new[name].get(field)
)
problems.append(f"{section}: {name} changed fields {changed}")
return problems
# ── the drift gate ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_committed_inventory_matches_the_live_surface():
"""The gate. Adding, renaming or re-costing a tool fails here until regenerated."""
live = await generator.build_inventory()
problems = _differences(_committed(), live)
assert not problems, (
f"{generator.INVENTORY_REL_PATH} no longer describes the live server:\n "
+ "\n ".join(problems)
+ _HOW_TO_FIX
)
@pytest.mark.asyncio
async def test_committed_inventory_is_byte_identical_to_the_generator_output():
"""Formatting counts too: a hand-tweaked file must not survive review.
Only fires when the *content* already matches (that check reports a readable
diff first), so this failing means indentation/ordering/newlines drifted.
"""
expected = generator.render(await generator.build_inventory())
assert INVENTORY_PATH.read_text(encoding="utf-8") == expected, (
f"{generator.INVENTORY_REL_PATH} has the right contents but not the "
f"generator's exact formatting.{_HOW_TO_FIX}"
)
# ── the totals, re-derived independently of the generator ───────────────────
@pytest.mark.asyncio
async def test_totals_agree_with_the_live_registry():
"""Counted straight off the registry keys, a path build_inventory does not use.
Guards the generator itself: were its row builders to drop a component
category, the drift gate alone would happily lock the loss in.
"""
keys = list(server.mcp._local_provider._components)
expected = {
"tools": sum(1 for key in keys if key.startswith("tool:")),
# The README's single "resources" number covers both registry prefixes.
"resources": sum(1 for key in keys if key.startswith(("resource:", "template:"))),
"prompts": sum(1 for key in keys if key.startswith("prompt:")),
"groups": len(await server._tool_groups()),
}
assert _committed()["totals"] == expected
def test_totals_match_the_row_counts():
"""Internal consistency: the header numbers describe the body."""
inventory = _committed()
totals = inventory["totals"]
assert totals["tools"] == len(inventory["tools"])
assert totals["resources"] == len(inventory["resources"])
assert totals["prompts"] == len(inventory["prompts"])
assert totals["groups"] == len(inventory["groups"])
def test_group_counts_match_the_tool_rows():
inventory = _committed()
tallied: dict[str, int] = {}
for row in inventory["tools"]:
tallied[row["group"]] = tallied.get(row["group"], 0) + 1
assert inventory["groups"] == dict(sorted(tallied.items()))
assert sum(inventory["groups"].values()) == inventory["totals"]["tools"]
# ── the rows a consumer actually reads ──────────────────────────────────────
@pytest.mark.asyncio
async def test_every_tool_row_names_a_real_group():
groups = await server._tool_groups()
bad = sorted(row["name"] for row in _committed()["tools"] if row["group"] not in groups)
assert not bad, f"tool rows filed under a group that does not exist: {bad}"
def test_every_tool_row_carries_a_usable_cost_and_summary():
"""The minimum contract of a row: name, group, cost, one-line summary."""
bad: list[str] = []
for row in _committed()["tools"]:
summary = row.get("summary")
if not row.get("name") or not row.get("group"):
bad.append(f"{row.get('name')!r}: missing name/group")
elif row.get("cost") not in server.CAD_TOOL_COSTS:
bad.append(f"{row['name']}: cost {row.get('cost')!r} is not a CAD_TOOL_COSTS value")
elif not isinstance(summary, str) or not summary.strip() or "\n" in summary:
bad.append(f"{row['name']}: summary is empty or spans lines")
elif len(summary) > MAX_SUMMARY_CHARS:
bad.append(f"{row['name']}: summary is {len(summary)} chars, over the search budget")
assert not bad, f"unusable tool rows: {bad}"
def test_tool_rows_are_sorted_by_name():
"""A generated file whose order wobbles produces unreviewable diffs."""
names = [row["name"] for row in _committed()["tools"]]
assert names == sorted(names)
assert len(names) == len(set(names)), "duplicate tool rows"
def test_the_resource_template_is_recorded_as_templated():
"""FastMCP files templates under a separate prefix; the merge must not lose that."""
templated = [row for row in _committed()["resources"] if row["templated"]]
assert [row["uri"] for row in templated] == ["autocad://entities/{layer_name}"]
assert templated[0]["parameters"] == ["layer_name"]
def test_prompt_rows_record_their_arguments():
prompts = {row["name"]: row for row in _committed()["prompts"]}
required = [a["name"] for a in prompts["prompt_quick_drawing"]["arguments"] if a["required"]]
assert required == ["description"]
def test_package_version_is_the_pyproject_version():
"""The snapshot says which release's surface it describes."""
assert _committed()["package_version"] == generator._pyproject_version()
# ── the regeneration path itself ────────────────────────────────────────────
def test_check_mode_accepts_the_committed_file():
assert generator.main(["--check"]) == 0
def test_check_mode_reports_drift_and_names_the_regeneration_command(tmp_path, monkeypatch, capsys):
"""A developer who never opens this test file still gets told what to run."""
stale = tmp_path / "tool-inventory.json"
stale.write_text('{"totals": {"tools": 1}}\n', encoding="utf-8")
monkeypatch.setattr(generator, "INVENTORY_PATH", stale)
assert generator.main(["--check"]) == 1
assert generator.REGENERATE_COMMAND in capsys.readouterr().out
def test_check_mode_reports_a_missing_file(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(generator, "INVENTORY_PATH", tmp_path / "absent.json")
assert generator.main(["--check"]) == 1
output = capsys.readouterr().out
assert "is missing" in output
assert generator.REGENERATE_COMMAND in output
def test_writing_then_checking_round_trips(tmp_path, monkeypatch):
"""Write mode produces exactly what check mode accepts, in a scratch location."""
target = tmp_path / "nested" / "tool-inventory.json"
monkeypatch.setattr(generator, "INVENTORY_PATH", target)
assert generator.main([]) == 0
assert generator.main(["--check"]) == 0
# Committed as LF regardless of platform, so the file does not churn on Windows.
assert b"\r\n" not in target.read_bytes()
+65 -21
View File
@@ -1,7 +1,15 @@
"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior."""
"""Tool-profile (TOOL_PROFILE=lean/full) behavior.
v1.5.0 removed the `core` profile: the discovery layer (tool search + the
AutoCAD command aliases) is the answer to a crowded surface, so a third
hand-maintained deny-list had no reason to exist. `core` must still *start*
the server — it falls back to `full` with a deprecation warning that names it.
"""
from __future__ import annotations
import logging
import pytest
import pytest_asyncio
from fastmcp import Client
@@ -29,10 +37,10 @@ async def test_lean_names_all_exist_in_registry():
assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_core_exclusions_all_exist_in_registry():
registered = await _registered_names()
missing = server.CORE_EXCLUDED_TOOL_NAMES - registered
assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_only_lean_and_full_profiles_exist():
"""M1.1 — `core` is gone, deny-list and all."""
assert server.TOOL_PROFILES == ("lean", "full")
assert not hasattr(server, "CORE_EXCLUDED_TOOL_NAMES")
async def test_full_profile_hides_only_gated_solids():
@@ -41,35 +49,71 @@ async def test_full_profile_hides_only_gated_solids():
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES)
# An accepted profile is applied as requested, so there is nothing to report.
assert "requested" not in info
async def test_invalid_profile_falls_back_to_full():
info = await server._apply_tool_profile("does-not-exist")
async def test_invalid_profile_falls_back_to_full(caplog):
with caplog.at_level(logging.WARNING, logger="autocad_mcp"):
info = await server._apply_tool_profile("does-not-exist")
assert info["profile"] == "full"
assert info["requested"] == "does-not-exist"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
text = caplog.text
assert "does-not-exist" in text
# An unknown value is not a removed one; don't tell the user we deleted it.
assert "removed" not in text.lower()
async def test_removed_core_profile_falls_back_to_full_with_a_deprecation(caplog):
"""TOOL_PROFILE=core must keep the server bootable, loudly."""
with caplog.at_level(logging.WARNING, logger="autocad_mcp"):
info = await server._apply_tool_profile("core")
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
text = caplog.text
assert "core" in text
assert "removed" in text.lower()
# The warning has to say what to use instead.
assert "lean" in text and "full" in text
async def test_removed_core_profile_is_reported_by_system_about():
"""A log line on a STDIO server is easy to miss; system_about must show it."""
await server._apply_tool_profile("core")
ctx = _AboutCtx()
about = await server.system_about(ctx=ctx)
assert about["tool_profile"]["profile"] == "full"
assert about["tool_profile"]["requested"] == "core"
class _AboutCtx:
"""system_about only reads lifespan_context; no backend needed."""
def __init__(self):
self.lifespan_context = {"backend": None}
async def test_core_env_value_still_serves_the_full_surface(monkeypatch):
"""The removal must not brick an existing TOOL_PROFILE=core deployment."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") # setattr here was a no-op (T0.8-B)
monkeypatch.setattr(config.settings, "tool_profile", "core")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
registered = await _registered_names()
assert visible == registered - server.SOLID_TOOL_NAMES
# The escape hatches core used to hide are back on the wire.
assert "system_run_command" in visible
async def test_lean_profile_filters_client_view(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") # setattr here was a no-op (T0.8-B)
monkeypatch.setattr(config.settings, "tool_profile", "lean")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert visible == set(server.LEAN_TOOL_NAMES)
async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setattr(config.settings, "tool_profile", "core")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert "system_run_command" not in visible
assert "system_run_lisp" not in visible
assert "gear_draw_spur_front_view" in visible
assert "drawing_refine" in visible
registered = await _registered_names()
assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES
async def test_profile_switch_is_idempotent():
await server._apply_tool_profile("lean")
info = await server._apply_tool_profile("full")
+530
View File
@@ -0,0 +1,530 @@
"""server.py must never report something it does not have.
Three families of that rule live here.
**The registry must be read untransformed.** ``_registered_tools()`` backs three
things that all need the *real* inventory:
* ``system_status`` / ``system_about`` tool counts,
* ``_tool_groups()`` (the system_about per-group breakdown),
* ``_apply_tool_profile()`` (which tools get ``mcp.disable()``d).
FastMCP applies :class:`~fastmcp.server.transforms.Transform` chains at two
levels, and only one of them reaches ``mcp._list_tools()``. Shipped search mode
(``_apply_discovery_mode("search")``) adds a *server-level* transform: it swaps
the catalog for ``search_tools``/``call_tool`` at the wire, while
``mcp._list_tools()`` upstream of it still lists everything — so search mode on
its own moves none of the three. A *provider-level* transform is the one that
rewrites ``mcp._list_tools()`` itself, and that is what ``_TruncatingTransform``
below installs. So the property pinned here is the stronger, layer-agnostic one:
registry-truth is read below every transform, and no catalog rewrite — today's,
or a provider-level one someone mounts tomorrow — can make the server report a
surface it does not have or silently turn TOOL_PROFILE into a no-op.
**A tool must not report a document it did not open** (T0.2) — the read-side
twin of the DWG write refusal, and the reason a ``.dwg`` path is refused on a
backend whose ``dwg`` capability is false.
**Discovery metadata must not cost anything.** ``@cad_tool`` writes summary /
cost / alias metadata into the MCP ``meta`` channel; ``tags`` are load-bearing
(``_tool_groups()`` buckets every tool by them for system_about), so the
grouping is pinned to the tags literally written in server.py's source. The
channel is also gated for *coverage*: every registered tool must carry a card,
and no card may contradict the tool's own annotations.
"""
from __future__ import annotations
import ast
import shutil
from pathlib import Path
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.transforms import Transform
import server
from backends.base import CapabilityMap, FeatureCapability
from discovery.aliases import aliases_for
from discovery.serialize import MAX_SUMMARY_CHARS
pytestmark = pytest.mark.asyncio
class _TruncatingTransform(Transform):
"""Stand-in for a catalog-replacing transform: collapses the list to two tools.
Mirrors the shape of the problem rather than any shipped transform — a
transform that replaces the tool list wholesale instead of decorating it,
mounted (see the fixture) at the level that reaches ``mcp._list_tools()``.
"""
def __init__(self, keep: int = 2):
self.keep = keep
async def list_tools(self, tools):
return list(tools)[: self.keep]
@pytest.fixture
def truncating_transform():
"""Install the transform where FastMCP actually applies it, then remove it.
``mcp._list_tools()`` aggregates ``provider.list_tools()``, and
``Provider.list_tools`` is what runs the provider's transforms — so a
provider-level transform is exactly what rewrites that call's output.
"""
transform = _TruncatingTransform()
server.mcp.local_provider.add_transform(transform)
try:
yield transform
finally:
server.mcp.local_provider._transforms.remove(transform)
@pytest_asyncio.fixture(autouse=True)
async def _restore_full_profile():
"""These tests call _apply_tool_profile, which mutates global enablement."""
yield
await server._apply_tool_profile("full")
async def test_transform_really_rewrites_the_public_listing(truncating_transform):
"""Guard the guard: prove the fixture actually breaks the public accessor.
Without this the other tests would pass even if the transform silently did
nothing.
"""
assert len(await server.mcp._list_tools()) == 2
async def test_registered_tools_unaffected_by_a_transform(truncating_transform):
names = {t.name for t in await server._registered_tools()}
assert len(names) > 100
assert "system_about" in names
assert "gear_draw_spur_front_view" in names
async def test_registered_tool_count_unaffected_by_a_transform(truncating_transform):
count = await server._registered_tool_count()
assert count is not None
assert count > 100
async def test_tool_groups_unaffected_by_a_transform(truncating_transform):
groups = await server._tool_groups()
# The transform collapses the catalog to two drawing tools, which would
# leave a single "drawing" bucket.
assert len(groups) > 5
assert groups["transactions"] == [
"transaction_begin",
"transaction_commit",
"transaction_rollback",
]
assert "gear_draw_spur_front_view" in groups["engineering"]
async def test_tool_profile_still_disables_under_a_transform(truncating_transform):
"""TOOL_PROFILE must keep working when a catalog-rewriting transform is mounted.
Reading the transformed catalog would make ``registered`` tiny, so
``registered - enabled`` collapses and ``mcp.disable()`` never fires.
"""
info = await server._apply_tool_profile("lean")
assert info["registered_count"] > 100
assert info["enabled_count"] == len(server.LEAN_TOOL_NAMES)
assert info["disabled_count"] > 50
# The escape hatches really are hidden, not merely counted as hidden.
assert "system_run_command" in info["disabled_tools"]
async def test_registered_tools_returns_empty_when_registry_unknown(monkeypatch):
"""Contract: never raises, and reports [] / None rather than a bogus count."""
class _Broken:
@property
def _components(self):
raise RuntimeError("registry layout changed")
async def _list_tools(self):
raise RuntimeError("registry layout changed")
async def _boom():
raise RuntimeError("public accessor gone")
monkeypatch.setattr(server.mcp, "_local_provider", _Broken(), raising=False)
monkeypatch.setattr(server.mcp, "_list_tools", _boom, raising=False)
assert await server._registered_tools() == []
assert await server._registered_tool_count() is None
# ── T0.2 — drawing_open must not report a document it did not open ──────────
class _Ctx:
"""Minimal async context for calling a tool function directly."""
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
async def warning(self, message):
pass
async def report_progress(self, progress, total):
pass
async def _saved_dxf(backend, tmp_path):
dxf = tmp_path / "part.dxf"
await backend.drawing_save_as(str(dxf), "dxf")
return dxf
async def test_drawing_open_opens_a_dxf(backend, tmp_path):
"""Baseline: the honest path keeps working."""
dxf = await _saved_dxf(backend, tmp_path)
result = await server.drawing_open(str(dxf), ctx=_Ctx(backend))
assert result["ok"] is True
assert result["name"] == "part.dxf"
async def test_drawing_open_refuses_a_dwg_the_backend_cannot_read(backend, tmp_path):
"""DXF bytes wearing a .dwg name must not be reported as an opened DWG.
ezdxf sniffs content, not extensions, so it happily parses a mislabelled
file (exactly what pre-1.5.0 drawing_save produced) and the tool then
answers ``{"ok": True, "name": "part.dwg"}`` — a document that does not
exist in that format.
"""
dxf = await _saved_dxf(backend, tmp_path)
disguised = tmp_path / "part.dwg"
shutil.copy(dxf, disguised)
with pytest.raises(ToolError) as excinfo:
await server.drawing_open(str(disguised), ctx=_Ctx(backend))
message = str(excinfo.value).lower()
assert "dwg" in message
# Blames the backend's missing capability and names the escape hatch,
# rather than ezdxf's bare "is not a DXF file".
assert "ezdxf" in message
assert "com" in message
async def test_drawing_open_refuses_a_real_dwg_without_leaking_a_parser_error(backend, tmp_path):
real_dwg = tmp_path / "real.dwg"
real_dwg.write_bytes(b"AC1032" + b"\x00" * 256)
with pytest.raises(ToolError) as excinfo:
await server.drawing_open(str(real_dwg), ctx=_Ctx(backend))
assert "is not a DXF file" not in str(excinfo.value)
async def test_drawing_open_refusal_leaves_the_current_document_untouched(backend, tmp_path):
dxf = await _saved_dxf(backend, tmp_path)
ctx = _Ctx(backend)
await server.drawing_open(str(dxf), ctx=ctx)
disguised = tmp_path / "other.dwg"
shutil.copy(dxf, disguised)
with pytest.raises(ToolError):
await server.drawing_open(str(disguised), ctx=ctx)
info = await backend.drawing_info()
assert info.name == "part.dxf"
class _DwgCapableBackend:
"""Stand-in for the live COM backend, which reads DWG natively."""
name = "com"
def __init__(self):
self.opened: list[str] = []
def capabilities(self) -> CapabilityMap:
return CapabilityMap(
backend="com",
features={"dwg": FeatureCapability(True, "native")},
)
async def drawing_open(self, path: str) -> dict:
self.opened.append(path)
return {"ok": True, "name": "part.dwg", "path": path}
async def test_drawing_open_allows_dwg_when_the_backend_supports_it(tmp_path):
"""The refusal is capability-driven, not an ezdxf-shaped hardcode."""
fake = _DwgCapableBackend()
result = await server.drawing_open(str(tmp_path / "part.dwg"), ctx=_Ctx(fake))
assert result["ok"] is True
assert len(fake.opened) == 1
async def test_drawing_open_does_not_advertise_unconditional_dwg():
"""The lie was also in the docs the model reads before calling."""
tool = next(t for t in await server._registered_tools() if t.name == "drawing_open")
text = (tool.description or "").lower()
for param in (tool.parameters or {}).get("properties", {}).values():
text += " " + str(param.get("description", "")).lower()
assert "dwg" in text, "DWG support should still be discoverable"
assert "com" in text, "but qualified by the backend that actually has it"
# ── @cad_tool — the discovery metadata channel ──────────────────────────────
CAD_TOOL_SECTION = ("transaction_begin", "transaction_commit", "transaction_rollback")
def _component(name: str):
for tool in server._local_tool_components():
if tool.name == name:
return tool
raise AssertionError(f"{name} is not registered")
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
async def test_cad_tool_publishes_discovery_meta(name):
cad = (_component(name).meta or {})["cad"]
assert cad["summary"] and "\n" not in cad["summary"]
assert cad["cost"] in server.CAD_TOOL_COSTS
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
async def test_cad_tool_pulls_aliases_from_the_discovery_corpus(name):
"""The corpus is the single source of truth; server.py must not restate it."""
record = aliases_for(name)
cad = (_component(name).meta or {})["cad"]
assert cad["acad"] == list(record.acad)
assert cad["synonyms"] == list(record.synonyms)
assert cad["synonyms"], "the transaction tools do carry synonyms"
async def test_cad_tool_meta_reaches_the_wire(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
cad = tools["transaction_rollback"].meta["cad"]
assert cad["cost"] == "destructive"
assert "rollback" in cad["synonyms"]
async def test_cad_tool_rejects_an_unknown_cost():
with pytest.raises(ValueError, match="cost"):
server.cad_tool(summary="x", cost="somewhat-dangerous")
async def test_cad_tool_requires_the_registration_below_it():
"""A misordered decorator must fail loudly at import, not silently no-op."""
decorator = server.cad_tool(summary="x", cost="read")
async def never_registered(ctx=None) -> dict:
return {}
with pytest.raises(RuntimeError, match="not registered"):
decorator(never_registered)
# ── @cad_tool coverage: the whole surface, not a sample ─────────────────────
#
# The twin of the alias-corpus gate in tests/test_discovery_aliases.py. That one
# proves every registered tool has an alias record; this one proves every
# registered tool has a discovery card, and that the card agrees with the
# annotations the same tool already publishes. Both derive their subject from
# the live registry, so a tool added without the metadata fails here instead of
# shipping with a summary silently scraped off its docstring.
def _cad(tool) -> dict | None:
cad = (tool.meta or {}).get("cad")
return cad if isinstance(cad, dict) else None
async def test_every_registered_tool_carries_cad_meta():
"""Registering a tool without @cad_tool must fail this gate."""
missing = sorted(t.name for t in await server._registered_tools() if _cad(t) is None)
assert not missing, f"tools with no @cad_tool metadata: {missing}"
async def test_every_cad_summary_is_a_single_non_empty_line():
"""A card is one line; a summary with a newline in it would break the format."""
bad: list[str] = []
for tool in await server._registered_tools():
summary = (_cad(tool) or {}).get("summary")
if not isinstance(summary, str) or not summary.strip() or "\n" in summary:
bad.append(f"{tool.name}:{summary!r}")
assert not bad, f"summaries that are not one usable line: {bad}"
async def test_no_authored_summary_is_long_enough_to_be_truncated():
"""A hand-written summary that overruns the budget would ship rendered as "...".
The serializer's truncation exists for the docstring fallback, not as a
licence to write a paragraph into @cad_tool.
"""
overlong = sorted(
(t.name, len((_cad(t) or {}).get("summary") or ""))
for t in await server._registered_tools()
if len((_cad(t) or {}).get("summary") or "") > MAX_SUMMARY_CHARS
)
assert not overlong, f"summaries the serializer would truncate: {overlong}"
async def test_every_cad_cost_is_a_known_value():
bad = sorted(
f"{t.name}:{(_cad(t) or {}).get('cost')!r}"
for t in await server._registered_tools()
if (_cad(t) or {}).get("cost") not in server.CAD_TOOL_COSTS
)
assert not bad, f"costs outside CAD_TOOL_COSTS: {bad}"
async def test_a_read_only_tool_is_costed_read():
"""``readOnlyHint`` is what a client gates on; the cost must not contradict it."""
bad: list[str] = []
for tool in await server._registered_tools():
annotations = tool.annotations
if annotations is not None and annotations.readOnlyHint:
cost = (_cad(tool) or {}).get("cost")
if cost != "read":
bad.append(f"{tool.name}:{cost!r}")
assert not bad, f"readOnlyHint tools not costed 'read': {bad}"
async def test_a_destructive_tool_is_never_costed_read_or_safe():
"""The other half of the agreement: an erase must not look harmless."""
bad: list[str] = []
for tool in await server._registered_tools():
annotations = tool.annotations
if annotations is not None and annotations.destructiveHint:
cost = (_cad(tool) or {}).get("cost")
if cost in ("read", "safe"):
bad.append(f"{tool.name}:{cost!r}")
assert not bad, f"destructiveHint tools costed as harmless: {bad}"
async def test_only_the_raw_passthroughs_are_costed_escape():
"""'escape' means 'this hands your string to AutoCAD', and nothing else."""
escapes = sorted(
t.name for t in await server._registered_tools() if (_cad(t) or {}).get("cost") == "escape"
)
assert escapes == ["system_run_command", "system_run_lisp"]
async def test_no_tool_restates_its_aliases_in_server_py():
"""@cad_tool copies the corpus; server.py must never hand-write acad/synonyms."""
bad: list[str] = []
for tool in await server._registered_tools():
cad = _cad(tool) or {}
record = aliases_for(tool.name)
expected_acad = list(record.acad) if record else []
expected_synonyms = list(record.synonyms) if record else []
if cad.get("acad") != expected_acad or cad.get("synonyms") != expected_synonyms:
bad.append(tool.name)
assert not bad, f"cad metadata out of step with discovery.aliases: {bad}"
# ── tags are load-bearing: _tool_groups() feeds system_about ────────────────
def _tags_declared_in_source() -> dict[str, set[str]]:
"""Every tool's tags as literally written in the @mcp.tool(...) call.
An independent derivation of the input to _tool_groups(): if @cad_tool (or
anything else) rewrote a registered tool's tags, the live grouping would
stop matching what server.py declares.
"""
tree = ast.parse(Path(server.__file__).read_text(encoding="utf-8"))
declared: dict[str, set[str]] = {}
for node in ast.walk(tree):
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call):
continue
func = decorator.func
if not (
isinstance(func, ast.Attribute)
and func.attr == "tool"
and isinstance(func.value, ast.Name)
and func.value.id == "mcp"
):
continue
name = node.name
tags: set[str] = set()
for keyword in decorator.keywords:
if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
name = keyword.value.value
if keyword.arg == "tags" and isinstance(keyword.value, ast.Set):
tags = {
element.value
for element in keyword.value.elts
if isinstance(element, ast.Constant)
}
declared[name] = tags
return declared
async def test_registered_tags_are_exactly_the_ones_written_in_source():
declared = _tags_declared_in_source()
live = {tool.name: set(tool.tags) for tool in await server._registered_tools()}
assert live == declared
async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
"""_tool_groups() must be reproducible from server.py's own `tags={...}`."""
expected: dict[str, list[str]] = {}
for name, tags in _tags_declared_in_source().items():
label = "other"
for tag in server._GROUP_TAG_PRIORITY:
if tag in tags:
label = server._GROUP_TAG_LABELS[tag]
break
expected.setdefault(label, []).append(name)
for names in expected.values():
names.sort()
assert await server._tool_groups() == dict(sorted(expected.items()))
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (154 tools, 19 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
`layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group
gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
rather than splitting a ninth tool off into entity_modification. 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 == {
"analysis": 12,
"batch": 3,
"blocks": 8,
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 15,
"entity_query": 8,
"layers": 14,
"layouts": 12,
"premium": 12,
"solids": 5,
"system": 7,
"templates": 2,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 154
+173
View File
@@ -0,0 +1,173 @@
"""M5 — undo/redo on the headless backend, and what it costs.
The plan called this "add ezdxf redo". The larger half was that **undo did not
work either**: `_undo_stack` was declared, read and cleaned up, but nothing ever
appended to it, so `drawing_undo` could only ever answer "Nothing to undo". Four
places promised otherwise — the `@cad_tool` summary ("Step back one operation"),
the tool title, the docstring, and the alias corpus, which routes `UNDO` and
"take that back" straight at it. The existing test asserted
``isinstance(result, dict)``, which a permanent failure satisfies.
Why it is opt-in. ezdxf has no journal; the only way to restore a previous state
is a full DXF snapshot. Measured on this machine:
10 entities -> 3.1 ms, 19 KB
100 entities -> 5.6 ms, 31 KB
1000 entities -> 28.4 ms, 146 KB
5000 entities -> 129.7 ms, 658 KB
Snapshotting on every mutation would make each new entity in a 5000-entity
drawing cost 130 ms. So `EZDXF_UNDO_DEPTH` defaults to 0 (off) and the tools
refuse with a capability that names the setting, rather than the server quietly
either lying or getting slow.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
import config
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def undoable(monkeypatch):
"""A backend with undo switched on, as a user opting in would have it."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 8)
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
yield backend
await backend.disconnect()
async def _count(backend) -> int:
return len(list(backend._doc.modelspace()))
# ── the promise, kept ───────────────────────────────────────────────────────
async def test_undo_actually_removes_the_last_entity(undoable):
await undoable.entity_create_line(0, 0, 10, 0)
await undoable.entity_create_circle(5, 5, 2)
assert await _count(undoable) == 2
result = await undoable.drawing_undo()
assert result["ok"] is True
assert await _count(undoable) == 1, "the circle must be gone"
async def test_redo_puts_it_back(undoable):
await undoable.entity_create_line(0, 0, 10, 0)
await undoable.entity_create_circle(5, 5, 2)
await undoable.drawing_undo()
result = await undoable.drawing_redo()
assert result["ok"] is True
assert await _count(undoable) == 2
async def test_undo_and_redo_walk_the_whole_history(undoable):
for i in range(4):
await undoable.entity_create_line(0, i, 10, i)
assert await _count(undoable) == 4
for expected in (3, 2, 1):
await undoable.drawing_undo()
assert await _count(undoable) == expected
for expected in (2, 3, 4):
await undoable.drawing_redo()
assert await _count(undoable) == expected
async def test_undo_stops_at_the_start_without_claiming_success(undoable):
await undoable.entity_create_line(0, 0, 10, 0)
await undoable.drawing_undo()
result = await undoable.drawing_undo()
assert result["ok"] is False
assert await _count(undoable) == 0
async def test_redo_with_nothing_to_redo_says_so(undoable):
await undoable.entity_create_line(0, 0, 10, 0)
result = await undoable.drawing_redo()
assert result["ok"] is False
# ── the branch that must not be reachable ───────────────────────────────────
async def test_drawing_after_an_undo_discards_the_redo_branch(undoable):
"""Redoing onto a timeline the user left would restore a state that never
existed — geometry they had deleted reappearing beside geometry they drew
afterwards."""
await undoable.entity_create_line(0, 0, 10, 0)
await undoable.entity_create_circle(5, 5, 2)
await undoable.drawing_undo() # circle gone
await undoable.entity_create_arc(0, 0, 5, 0, 90) # new branch
result = await undoable.drawing_redo()
assert result["ok"] is False, "the circle's future is no longer reachable"
types = sorted(e.dxftype() for e in undoable._doc.modelspace())
assert types == ["ARC", "LINE"], f"the circle must not come back: {types}"
async def test_history_is_bounded_by_the_configured_depth(monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 2)
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
try:
for i in range(5):
await backend.entity_create_line(0, i, 10, i)
undone = 0
while (await backend.drawing_undo())["ok"]:
undone += 1
assert undone == 2, f"depth 2 must buy exactly two steps back, got {undone}"
finally:
await backend.disconnect()
# ── off by default, and honest about it ─────────────────────────────────────
async def test_undo_is_off_by_default_and_refuses_with_the_setting_named(backend):
assert config.settings.ezdxf_undo_depth == 0
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_undo()
assert excinfo.value.capability == "undo_history"
assert "EZDXF_UNDO_DEPTH" in str(excinfo.value), "the refusal must name the way in"
async def test_redo_refuses_the_same_way_when_history_is_off(backend):
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_redo()
assert excinfo.value.capability == "undo_history"
async def test_the_capability_is_declared_on_both_backends():
from backends.com_backend import ComBackend
ezdxf_feature = EzdxfBackend().capabilities().features["undo_history"]
assert ezdxf_feature.supported is False
assert "EZDXF_UNDO_DEPTH" in (ezdxf_feature.reason or "")
# AutoCAD keeps its own undo journal, so the live backend needs no setting.
assert ComBackend().capabilities().features["undo_history"].supported is True
async def test_switching_it_on_flips_the_capability(monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
assert EzdxfBackend().capabilities().features["undo_history"].supported is True
+393
View File
@@ -0,0 +1,393 @@
"""M6 — inspecting and editing the viewports on a sheet.
`viewport_create` shipped in v1.4 and there was no way to see, rescale, lock or
remove what it made. Four things in the DXF viewport model make these more than
attribute pokes, and all four fail silently if ignored:
* **The main viewport is not a drafting viewport.** Every page-set-up layout
carries an `id == 1` entity representing the tab's own pan/zoom state. Its
`view_height` is not a scale, so writing one reports success and changes
nothing on the sheet; deleting it leaves `layout.dxf.viewport_handle` pointing
at a dead handle, which ezdxf's Auditor reports as zero errors — only AutoCAD
notices. It is reported with `is_main`, never hidden: "I deleted every
viewport and one is still there" has to be explainable.
* **R12 has no slot for either attribute.** `view_height` and `flags` are
dropped on export, and `get_scale()` then returns a fabricated number (150.0
measured, for a viewport that stores no scale at all). So the writers refuse
on R12 and the reader reports `None` rather than a number it invented.
* **`flags` is a shared bitfield.** A bare `flags = VSF_LOCK_ZOOM` wipes the
UCS-icon and grid bits the main viewport carries (`0x88020`) and still saves
cleanly. Read-modify-write only.
* **A deleted entity stays in `entitydb`** with `dxftype()` still answering
`"VIEWPORT"`, so a resolver that type-checks before checking liveness passes
on a corpse.
Viewports are addressed by handle. `dxf.id` is exposed as metadata only: ezdxf's
first user viewport gets id 3 rather than 2, and ids are never renumbered.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
LOCK_BIT = 0x4000
async def _sheet(backend, name: str = SHEET):
"""A page-set-up A3 layout, which is what gives it a main viewport."""
await backend.layout_create(name)
layout = backend._doc.layouts.get(name)
layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
return layout
async def _r12_backend(tmp_path):
"""An R12 document opened through the backend, with one viewport on a sheet."""
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
doc = ezdxf.new("R12")
doc.layouts.get("Layout1").add_viewport(
center=(210, 148.5), size=(200, 150), view_center_point=(50, 25), view_height=300
)
path = tmp_path / "r12.dxf"
doc.saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_open(str(path))
return backend
# ── viewport_list ───────────────────────────────────────────────────────────
async def test_listing_reports_the_viewport_that_was_created(backend):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=0.5)
result = await backend.viewport_list(SHEET)
assert result["ok"] is True
row = next(vp for vp in result["viewports"] if vp["handle"] == created["handle"])
assert row["layout"] == SHEET
assert row["is_main"] is False
assert row["scale"] == pytest.approx(0.5)
assert row["view_height"] == pytest.approx(300.0)
assert row["width"] == pytest.approx(200.0)
assert row["locked"] is False
async def test_the_main_viewport_is_reported_and_labelled_not_hidden(backend):
"""Hiding it makes "I deleted them all and one remains" unexplainable."""
layout = await _sheet(backend)
main = layout.main_viewport()
assert main is not None, "fixture assumption: page_setup creates a main viewport"
result = await backend.viewport_list(SHEET)
rows = {vp["handle"]: vp for vp in result["viewports"]}
assert rows[main.dxf.handle]["is_main"] is True
assert sum(1 for vp in rows.values() if vp["is_main"]) == 1
async def test_a_fresh_layout_has_no_phantom_viewport(backend):
"""The main viewport appears at page_setup, not at layout_create."""
await backend.layout_create(SHEET)
result = await backend.viewport_list(SHEET)
assert result["viewports"] == []
assert result["count"] == 0
async def test_listing_without_a_layout_covers_every_sheet(backend):
await _sheet(backend, "S1")
await _sheet(backend, "S2")
await backend.viewport_create("S1", 100, 100, 50, 50, 0, 0)
await backend.viewport_create("S2", 100, 100, 50, 50, 0, 0)
result = await backend.viewport_list()
assert {vp["layout"] for vp in result["viewports"]} >= {"S1", "S2"}
async def test_listing_a_missing_or_blank_layout_refuses(backend):
"""ezdxf's `layouts.get("")` quietly returns the active paper space."""
for name in ("NOSUCH", "", " "):
result = await backend.viewport_list(name)
assert result["ok"] is False, f"viewport_list({name!r}) must refuse"
async def test_listing_model_space_refuses(backend):
result = await backend.viewport_list("Model")
assert result["ok"] is False
async def test_r12_reports_no_scale_rather_than_inventing_one(tmp_path):
"""`get_scale()` returns a fabricated number when view_height is absent."""
backend = await _r12_backend(tmp_path)
try:
result = await backend.viewport_list("Layout1")
assert result["ok"] is True
row = result["viewports"][0]
assert row["scale"] is None
assert row["locked"] is None
assert "R12" in result["note"]
finally:
await backend.disconnect()
# ── viewport_set_scale ──────────────────────────────────────────────────────
async def test_setting_the_scale_changes_the_view_height_and_nothing_else(backend):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=1.0)
before = backend._doc.entitydb.get(created["handle"])
center, width, height = before.dxf.center, before.dxf.width, before.dxf.height
view_center = before.dxf.view_center_point
result = await backend.viewport_set_scale(created["handle"], 0.5)
assert result["ok"] is True
assert result["scale"] == pytest.approx(0.5)
assert result["view_height"] == pytest.approx(300.0)
after = backend._doc.entitydb.get(created["handle"])
assert after.dxf.center == center
assert (after.dxf.width, after.dxf.height) == (width, height)
assert after.dxf.view_center_point == view_center
async def test_the_reported_scale_is_read_back_not_echoed(backend):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
result = await backend.viewport_set_scale(created["handle"], 0.25)
stored = backend._doc.entitydb.get(created["handle"]).get_scale()
assert result["scale"] == pytest.approx(stored)
@pytest.mark.parametrize("bad", [0, -1, -0.5])
async def test_a_non_positive_scale_is_refused(backend, bad):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
result = await backend.viewport_set_scale(created["handle"], bad)
assert result["ok"] is False
async def test_setting_the_scale_of_the_main_viewport_is_refused(backend):
"""Its view_height is the tab's pan/zoom state, not a drafting scale."""
layout = await _sheet(backend)
result = await backend.viewport_set_scale(layout.main_viewport().dxf.handle, 0.5)
assert result["ok"] is False
assert "main" in result["error"].lower()
async def test_setting_the_scale_on_r12_is_refused_rather_than_lost(tmp_path):
backend = await _r12_backend(tmp_path)
try:
handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"]
result = await backend.viewport_set_scale(handle, 0.5)
assert result["ok"] is False
assert "R12" in result["error"]
finally:
await backend.disconnect()
async def test_a_handle_that_is_not_a_viewport_is_refused(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.viewport_set_scale(line.handle, 0.5)
assert result["ok"] is False
assert "LINE" in result["error"]
async def test_an_unknown_handle_is_refused(backend):
result = await backend.viewport_set_scale("DEADBEEF", 0.5)
assert result["ok"] is False
# ── viewport_lock ───────────────────────────────────────────────────────────
async def test_locking_and_unlocking_round_trips(backend, tmp_path):
import ezdxf
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
assert (await backend.viewport_lock(created["handle"], True))["locked"] is True
path = tmp_path / "locked.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
assert reloaded.dxf.flags & LOCK_BIT
assert (await backend.viewport_lock(created["handle"], False))["locked"] is False
assert not (backend._doc.entitydb.get(created["handle"]).dxf.flags & LOCK_BIT)
async def test_locking_preserves_the_other_flag_bits(backend):
"""A bare assignment wipes the UCS-icon and grid bits and still saves."""
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
entity = backend._doc.entitydb.get(created["handle"])
entity.dxf.flags = 0x88020
await backend.viewport_lock(created["handle"], True)
assert backend._doc.entitydb.get(created["handle"]).dxf.flags == 0x88020 | LOCK_BIT
async def test_the_reported_lock_state_is_read_back_from_the_flags(backend):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
await backend.viewport_lock(created["handle"], True)
row = next(
vp
for vp in (await backend.viewport_list(SHEET))["viewports"]
if vp["handle"] == created["handle"]
)
assert row["locked"] is True
async def test_locking_on_r12_is_refused_rather_than_lost(tmp_path):
backend = await _r12_backend(tmp_path)
try:
handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"]
result = await backend.viewport_lock(handle, True)
assert result["ok"] is False
assert "R12" in result["error"]
finally:
await backend.disconnect()
# ── viewport_delete ─────────────────────────────────────────────────────────
async def test_deleting_a_viewport_removes_it_from_the_layout(backend):
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
result = await backend.viewport_delete(created["handle"])
assert result["ok"] is True
assert result["layout"] == SHEET
assert result["was_main"] is False
handles = {vp["handle"] for vp in (await backend.viewport_list(SHEET))["viewports"]}
assert created["handle"] not in handles
async def test_deleting_the_main_viewport_needs_force(backend):
layout = await _sheet(backend)
main = layout.main_viewport()
result = await backend.viewport_delete(main.dxf.handle)
assert result["ok"] is False
assert layout.main_viewport() is not None
async def test_forcing_the_main_viewport_away_does_not_leave_a_dangling_pointer(backend):
"""No auditor reports this; only AutoCAD does."""
layout = await _sheet(backend)
main = layout.main_viewport()
result = await backend.viewport_delete(main.dxf.handle, force=True)
assert result["ok"] is True
assert result["was_main"] is True
pointer = layout.dxf.get("viewport_handle", None)
if pointer is not None:
target = backend._doc.entitydb.get(pointer)
assert target is not None and target.is_alive
async def test_a_deleted_viewport_handle_is_refused_afterwards(backend):
"""The corpse stays in entitydb and still answers dxftype() == VIEWPORT."""
await _sheet(backend)
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
await backend.viewport_delete(created["handle"])
result = await backend.viewport_set_scale(created["handle"], 0.5)
assert result["ok"] is False
async def test_deleting_a_handle_that_is_not_a_viewport_is_refused(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.viewport_delete(line.handle)
assert result["ok"] is False
assert line.handle in {e.dxf.handle for e in backend._doc.modelspace()}
# ── what the headless renderer can actually do ──────────────────────────────
async def _ink(backend, layout: str, path) -> int:
"""Dark pixels in a rendered layout — a proxy for "something was drawn"."""
from PIL import Image
result = await backend.drawing_export_pdf(str(path), layout=layout)
assert result["ok"] is True, result
image = Image.open(str(path)).convert("L")
pixels = image.load()
width, height = image.size
return sum(1 for y in range(height) for x in range(width) if pixels[x, y] < 200)
async def test_the_headless_renderer_does_project_model_content_through_viewports(
backend, tmp_path
):
"""v1.4 declared this COM-only. It is not, and has not been since ezdxf 1.4.
The claim appeared in three places at once — the `viewport_render`
capability, the note in every `drawing_export_pdf` response, and the README
table — and it sent callers to the live backend for something the headless
one already does. Understating a capability is the same class of untruth as
overstating one: the response says something about the server that is not
so.
Proven by subtraction rather than by pixel-peeping: a sheet whose only
contents are viewports renders *less* ink once the model geometry it looks
at is gone.
"""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
pytest.importorskip("PIL", reason="rendering needs the [pdf] extra")
circle = await backend.entity_create_circle(100, 50, 40)
await _sheet(backend)
await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 100, 50, scale=1.0)
assert not [e for e in backend._doc.layouts.get(SHEET) if e.dxftype() != "VIEWPORT"], (
"the sheet must hold nothing but viewports, or the ink could be its own"
)
with_model = await _ink(backend, SHEET, tmp_path / "with.png")
await backend.entity_delete(circle.handle)
without_model = await _ink(backend, SHEET, tmp_path / "without.png")
assert with_model > without_model, (
"the sheet renders the same with and without the model geometry its "
"viewport looks at — projection really is not happening"
)
async def test_the_capability_map_no_longer_calls_viewport_rendering_com_only(backend):
features = backend.capabilities().to_dict()["features"]
assert features["viewport_render"]["supported"] is True
async def test_the_export_note_does_not_send_callers_to_com_for_this(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _sheet(backend)
result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET)
assert "COM-only" not in (result.get("note") or "")
async def test_exporting_a_layout_finds_it_whatever_case_it_is_typed_in(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _sheet(backend)
result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET.upper())
assert result["ok"] is True