The review of Task 9 left four minor findings. Three change what a user reads: - A LINE drawn back to front and moved was reported as a geometry change. The signature already ignores a LINE's direction, so its movement now does too: the reversed order is tried before the entity is called reshaped. - An INSERT carrying one attribute tag twice was read as a dict. An edit of the first value could disappear, and since the pair then had nothing to report, it counted as unchanged. A repeated tag is now compared as the list of its values; a tag carried once keeps plain from / to. - cap_report capped the three lists but not the clusters. A widespread edit of a large drawing could return an unbounded cluster list. It is capped like the others, with truncated["clusters"]; the markup still clouds every cluster. The fourth bounds work: when the whole drawing moves, every entity reaches the position stage, and a generous move_tol gave each one hundreds of candidates, which is n x m memory. Only the 32 nearest per entity are kept now. A 200-line test shows each line still pairs with its own moved copy. The first three tests failed against the previous module (the attribute test with no changed row at all). The whole diff suite passes: 38 tests. 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, "clusters": 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
|