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

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

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

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

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

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

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

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

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

233 lines
10 KiB
Python

"""``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()