Three blocking review findings on drawing_diff.
1. An unedited entity reported as changed with changes == {} and clouded.
The signature snaps values to the tol grid, so a value 1 ulp across a
half-grid point lands in another bucket. AutoCAD writes 16 significant
digits (0.14 + 0.005 = 0.14500000000000002 comes back as 0.145), so a
round trip split the pair, which was matched later and counted as
changed. A matched pair is now compared by difference (more than half a
tol, around the circle for angles) instead of by grid bucket, and a pair
with nothing to report counts as unchanged, with no row and no cluster.
Measured live on AutoCAD 2026 (Documents.Open read-only, select all,
Export DXF, Close(False)): before, summary changed=1, a row with
changes {} and one cluster; after, unchanged=21, changed=0, no cluster.
Over 12000 computed coordinates (a*0.01 + {0.005, 0.015, 0.105, 0.305})
795 signatures flip; before, 795 were changed, and after, 0.
2. With zero exact matches, handles were declared stable, so strangers from
two fresh files that share handles were paired and given wrong moves.
The handle stage now needs at least one exact match. The report adds
handle_evidence {exact_matches, kept_handle}.
3. A truncated DXF raised StopIteration from ezdxf's tag reader.
asyncio.to_thread cannot hand that exception to an awaiting caller, so
drawing_diff hung instead of refusing. read_revision now turns every
parse or walk failure (not MemoryError) into ValueError "not a readable
DXF (...)". tol is validated first, so its refusal keeps its own name.
Tests: grid splits by handle and by position, radius/height/rotation/bulge
split at tol=1, a real edit next to a split, no-evidence handles, a
truncated file directly and over the MCP wire with a 30 s timeout. The six
new tests fail against the previous module.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
145 lines
5.4 KiB
Python
145 lines
5.4 KiB
Python
"""drawing_diff over the MCP wire, on the headless engine, against the synthetic revisions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastmcp import Client
|
|
|
|
import server
|
|
from engineering.sheet.frames import SHEET_LAYER
|
|
from tests.fixtures.revisions import build_revision_pair
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(monkeypatch):
|
|
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
|
|
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 _total(client) -> int:
|
|
stats = (await client.call_tool("analysis_entity_stats", {})).structured_content
|
|
return stats["total_entities"]
|
|
|
|
|
|
async def test_the_current_document_against_its_older_revision(client, tmp_path):
|
|
pair = build_revision_pair(tmp_path)
|
|
await client.call_tool("drawing_open", {"path": pair["new"]})
|
|
before = await _total(client)
|
|
result = (
|
|
await client.call_tool(
|
|
"drawing_diff", {"old_path": pair["old"], "move_tol": pair["move_tol"]}
|
|
)
|
|
).structured_content
|
|
assert (result["summary"]["changed"], result["summary"]["added"]) == (3, 1)
|
|
assert result["summary"]["removed"] == 1
|
|
assert {row["old_handle"] for row in result["changed"]} == {
|
|
pair["moved"],
|
|
pair["text"],
|
|
pair["insert"],
|
|
}
|
|
assert result["blocks"]["compared"] is True and result["blocks"]["changed"] == []
|
|
assert result["truncated"] == {"changed": 0, "added": 0, "removed": 0}
|
|
assert await _total(client) == before, "a diff without markup writes nothing"
|
|
|
|
|
|
async def test_two_paths_and_the_list_cap(client, tmp_path):
|
|
pair = build_revision_pair(tmp_path)
|
|
result = (
|
|
await client.call_tool(
|
|
"drawing_diff",
|
|
{
|
|
"old_path": pair["old"],
|
|
"new_path": pair["new"],
|
|
"move_tol": pair["move_tol"],
|
|
"limit": 1,
|
|
},
|
|
)
|
|
).structured_content
|
|
assert len(result["changed"]) == 1
|
|
assert result["truncated"]["changed"] == 2
|
|
assert result["summary"]["changed"] == 3
|
|
|
|
|
|
async def test_markup_clouds_every_model_space_cluster(client, tmp_path):
|
|
pair = build_revision_pair(tmp_path)
|
|
await client.call_tool("drawing_open", {"path": pair["new"]})
|
|
result = (
|
|
await client.call_tool(
|
|
"drawing_diff",
|
|
{"old_path": pair["old"], "move_tol": pair["move_tol"], "markup": True, "rev": "b"},
|
|
)
|
|
).structured_content
|
|
model = [c for c in result["clusters"] if c["space"] == "Model"]
|
|
markup = result["markup"]
|
|
assert markup["created"] is True and markup["rev"] == "B"
|
|
assert len(markup["clouds"]) == len(model) >= 1
|
|
for handle in markup["clouds"]:
|
|
cloud = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
|
assert (cloud["type"], cloud["layer"]) == ("LWPOLYLINE", SHEET_LAYER)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("arguments", "fragment"),
|
|
[
|
|
(
|
|
{"markup": True, "rev": "B", "new_path": "NEW"},
|
|
"markup draws revision clouds on the current",
|
|
),
|
|
({"markup": True}, "markup needs `rev`"),
|
|
({"markup": True, "rev": "TOO-LONG"}, "markup needs `rev`"),
|
|
],
|
|
)
|
|
async def test_markup_refusals_come_before_any_read(client, tmp_path, arguments, fragment):
|
|
pair = build_revision_pair(tmp_path)
|
|
if arguments.get("new_path") == "NEW":
|
|
arguments = {**arguments, "new_path": pair["new"]}
|
|
result = await client.call_tool(
|
|
"drawing_diff", {"old_path": pair["old"], **arguments}, raise_on_error=False
|
|
)
|
|
assert result.is_error is True
|
|
assert fragment in result.content[0].text
|
|
|
|
|
|
async def test_a_missing_revision_is_refused_by_parameter(client, tmp_path):
|
|
result = await client.call_tool(
|
|
"drawing_diff", {"old_path": str(tmp_path / "nowhere.dxf")}, raise_on_error=False
|
|
)
|
|
assert result.is_error is True
|
|
assert "old_path:" in result.content[0].text and "does not exist" in result.content[0].text
|
|
|
|
|
|
async def test_a_revision_that_is_neither_dxf_nor_dwg_is_refused_by_parameter(client, tmp_path):
|
|
notes = tmp_path / "notes.txt"
|
|
notes.write_text("not a drawing\n", encoding="utf-8")
|
|
result = await client.call_tool("drawing_diff", {"old_path": str(notes)}, raise_on_error=False)
|
|
assert result.is_error is True
|
|
text = result.content[0].text
|
|
assert "old_path:" in text and "reads a .dxf or a .dwg" in text
|
|
|
|
|
|
async def test_a_truncated_revision_is_refused_not_hung(client, tmp_path):
|
|
import asyncio
|
|
|
|
half = tmp_path / "half.dxf"
|
|
half.write_bytes(b" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1015\n")
|
|
result = await asyncio.wait_for(
|
|
client.call_tool("drawing_diff", {"old_path": str(half)}, raise_on_error=False),
|
|
timeout=30,
|
|
)
|
|
assert result.is_error is True
|
|
text = result.content[0].text
|
|
assert "old_path:" in text and "not a readable DXF" in text
|
|
|
|
|
|
async def test_the_diff_is_advertised_as_non_destructive(client):
|
|
tools = {tool.name: tool for tool in await client.list_tools()}
|
|
assert tools["drawing_diff"].annotations.destructiveHint is False
|