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:
co-authored by
Claude Opus 5
parent
d5a0ff5109
commit
b03a7615b1
@@ -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."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Static fixtures for the test suite."""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == ""
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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]])
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"] = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user