feat(understand): drawing_diff - two revisions matched by geometric signature
engineering/understand/diff.py reads each revision once into entity records and matches them in three stages: exact signature (type, layer, geometry rounded to tol, text, block, rotation, scale, attributes; handles ignored), then handle while at least half of the exact matches kept theirs, then type + layer + nearest anchor within move_tol (default 2 % of the 1st-99th percentile diagonal). The report counts changes by layer and type, says what changed per entity (moved by, text, attribute, radius ... from -> to), compares named block definitions by content and groups the changes into clusters. drawing_diff opens group D's copy of SECTION 26. It is read-only unless markup=True, which files revision clouds around the model-space clusters on the current document through add_revision - refused before any read with new_path, without a valid rev, or on an engine without revcloud. A revision path that is neither .dxf nor .dwg is refused naming the parameter (old_path: / new_path:), like every other path refusal. Tests: the synthetic plant P&ID edited five ways (a move, a text, an attribute, an add, a delete) under renumbered handles reports exactly those five edits; renumbering alone reports nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
704a44b190
commit
54325e6de0
@@ -0,0 +1,130 @@
|
||||
"""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_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
|
||||
Reference in New Issue
Block a user