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>
478 lines
20 KiB
Python
478 lines
20 KiB
Python
"""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
|