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
Vendored
+96
@@ -0,0 +1,96 @@
|
||||
"""Two revisions of the synthetic plant P&ID with known edits, for ``drawing_diff``.
|
||||
|
||||
Built from ``build_plant_pair`` (``tests/fixtures/plant_pair.py``) with
|
||||
ezdxf. The older revision is the P&ID plus one tagged block reference; the
|
||||
newer one moves a line by half the move tolerance, rewrites a text, changes
|
||||
the block's attribute, deletes one line, adds a circle - and then gives every
|
||||
model-space entity a new handle, the way a save through another program
|
||||
renumbers them. What was edited is
|
||||
picked from the older revision's own records (entities whose signature is
|
||||
unique, so a double-drawn line can never be the one edited) and returned with
|
||||
the paths: the expected answer is read off the fixture, never typed in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.diff import diff_snapshots, read_revision, robust_box, signature
|
||||
from engineering.understand.snapshot import records_from_doc
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
TAG_BLOCK = "DIFF_TAG"
|
||||
TAG_LAYER = "DIFF-TAGS"
|
||||
ADDED_LAYER = "DIFF-ADDED"
|
||||
NEW_TEXT = "REV-B NOTE"
|
||||
TAG_BEFORE = "X-1"
|
||||
TAG_AFTER = "X-2"
|
||||
|
||||
|
||||
def renumber_modelspace(doc) -> None:
|
||||
"""Every model-space entity replaced by a copy under a fresh handle."""
|
||||
msp = doc.modelspace()
|
||||
for entity in list(msp):
|
||||
clone = entity.copy()
|
||||
msp.add_entity(clone)
|
||||
msp.delete_entity(entity)
|
||||
|
||||
|
||||
def build_revision_pair(directory, *, edits: bool = True) -> dict:
|
||||
directory = Path(directory)
|
||||
truth = build_plant_pair(directory / "plant")
|
||||
base = ezdxf.readfile(truth["pid"])
|
||||
box = robust_box(records_from_doc(base, source="base"))
|
||||
centre = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
|
||||
block = base.blocks.new(TAG_BLOCK)
|
||||
block.add_circle((0.0, 0.0), 5.0)
|
||||
block.add_attdef("TAG", (0.0, -8.0), dxfattribs={"height": 2.5})
|
||||
base.layers.add(TAG_LAYER)
|
||||
tag = base.modelspace().add_blockref(TAG_BLOCK, centre, dxfattribs={"layer": TAG_LAYER})
|
||||
tag.add_auto_attribs({"TAG": TAG_BEFORE})
|
||||
old_path = directory / "rev_a.dxf"
|
||||
base.saveas(old_path)
|
||||
|
||||
old_snap, _blocks = read_revision(str(old_path))
|
||||
move_tol = diff_snapshots(old_snap, old_snap)["move_tol"]
|
||||
counts = Counter(signature(r) for r in old_snap.records)
|
||||
unique = [r for r in old_snap.records if r.space == "Model" and counts[signature(r)] == 1]
|
||||
moved = next(r for r in unique if r.type == "LINE")
|
||||
text = next(r for r in unique if r.type in ("TEXT", "MTEXT"))
|
||||
insert = next(r for r in old_snap.records if r.block == TAG_BLOCK)
|
||||
deleted = next(
|
||||
r
|
||||
for r in unique
|
||||
if r.type in ("LINE", "LWPOLYLINE") and (r.type, r.layer) != (moved.type, moved.layer)
|
||||
)
|
||||
|
||||
new = ezdxf.readfile(old_path)
|
||||
msp = new.modelspace()
|
||||
if edits:
|
||||
new.entitydb[moved.handle].translate(move_tol / 2.0, 0.0, 0.0)
|
||||
target = new.entitydb[text.handle]
|
||||
if target.dxftype() == "MTEXT":
|
||||
target.text = NEW_TEXT
|
||||
else:
|
||||
target.dxf.text = NEW_TEXT
|
||||
new.entitydb[insert.handle].get_attrib("TAG").dxf.text = TAG_AFTER
|
||||
msp.delete_entity(new.entitydb[deleted.handle])
|
||||
new.layers.add(ADDED_LAYER)
|
||||
msp.add_circle(centre, move_tol / 10.0, dxfattribs={"layer": ADDED_LAYER})
|
||||
renumber_modelspace(new)
|
||||
new_path = directory / "rev_b.dxf"
|
||||
new.saveas(new_path)
|
||||
return {
|
||||
"old": str(old_path),
|
||||
"new": str(new_path),
|
||||
"move_tol": move_tol,
|
||||
"old_count": len(old_snap.records),
|
||||
"moved": moved.handle,
|
||||
"text": text.handle,
|
||||
"text_before": text.text,
|
||||
"insert": insert.handle,
|
||||
"deleted": deleted.handle,
|
||||
}
|
||||
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
||||
|
||||
|
||||
async def test_tool_group_sizes_are_unchanged():
|
||||
"""Frozen snapshot of the surface (241 tools, 26 groups).
|
||||
"""Frozen snapshot of the surface (242 tools, 26 groups).
|
||||
|
||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||
@@ -591,10 +591,16 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
exactly `PACK_TOOL_NAMES["arch"]`, and every group the track-F branches
|
||||
had grown while the tag was unranked on them returned to its
|
||||
pre-track-F value.
|
||||
|
||||
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
|
||||
`drawing_diff`, tagged `analysis` and `query`; `analysis` outranks `query`
|
||||
in `_GROUP_TAG_PRIORITY`, so it files under `analysis` (12 -> 13). This
|
||||
snapshot is branch-local: the track H merge recomputes it once the
|
||||
understand, network and diff branches have landed.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 12,
|
||||
"analysis": 13,
|
||||
"architecture": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
@@ -621,4 +627,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 241
|
||||
assert sum(sizes.values()) == 242
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""drawing_diff's engine: signatures, the three matching stages, the report.
|
||||
|
||||
Pure tests build ``EntityRecord``s by hand, so every coordinate is the
|
||||
answer. The known-edits tests build two revisions of the synthetic plant P&ID
|
||||
(``tests/fixtures/revisions.py``) and assert exactly the edits made - a move,
|
||||
a text change, an attribute change, an add, a delete, under renumbered
|
||||
handles - and nothing else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from engineering.understand.diff import (
|
||||
block_changes,
|
||||
block_signatures,
|
||||
cap_report,
|
||||
diff_snapshots,
|
||||
read_revision,
|
||||
robust_box,
|
||||
signature,
|
||||
)
|
||||
from engineering.understand.snapshot import EntityRecord, Snapshot
|
||||
from tests.fixtures.revisions import (
|
||||
ADDED_LAYER,
|
||||
NEW_TEXT,
|
||||
TAG_AFTER,
|
||||
TAG_BEFORE,
|
||||
build_revision_pair,
|
||||
)
|
||||
|
||||
|
||||
def rec(handle, type_, layer="PIPE", points=(), **kw):
|
||||
space = kw.pop("space", "Model")
|
||||
return EntityRecord(
|
||||
handle=handle, type=type_, layer=layer, space=space, points=tuple(points), **kw
|
||||
)
|
||||
|
||||
|
||||
def snap(*records):
|
||||
return Snapshot(
|
||||
source="test",
|
||||
insunits=4,
|
||||
extmin=None,
|
||||
extmax=None,
|
||||
layers={},
|
||||
layouts=(),
|
||||
records=tuple(records),
|
||||
)
|
||||
|
||||
|
||||
def line(handle, x1, y1, x2, y2, layer="PIPE"):
|
||||
return rec(handle, "LINE", layer, ((x1, y1), (x2, y2)))
|
||||
|
||||
|
||||
STILL = [line("A", 0, 0, 100, 0), line("B", 0, 50, 100, 50), line("C", 0, 100, 100, 100)]
|
||||
RENUMBERED = [line("1A", 0, 0, 100, 0), line("1B", 0, 50, 100, 50), line("1C", 0, 100, 100, 100)]
|
||||
|
||||
|
||||
# -- signatures ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_signature_is_rounded_to_tol_and_ignores_a_lines_direction():
|
||||
a = line("A", 0.0, 0.0, 10.0, 0.0)
|
||||
b = line("B", 10.001, 0.004, 0.0, 0.0)
|
||||
assert signature(a, 0.01) == signature(b, 0.01)
|
||||
assert signature(a, 0.01) != signature(line("C", 0.0, 0.0, 10.02, 0.0), 0.01)
|
||||
|
||||
|
||||
def test_identical_revisions_have_no_change_and_no_cluster():
|
||||
result = diff_snapshots(snap(*STILL), snap(*STILL))
|
||||
assert result["summary"] == {
|
||||
"old_entities": 3,
|
||||
"new_entities": 3,
|
||||
"unchanged": 3,
|
||||
"changed": 0,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
}
|
||||
assert result["clusters"] == [] and result["handles_stable"] is True
|
||||
|
||||
|
||||
# -- the three stages ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_move_under_renumbered_handles_is_matched_by_position():
|
||||
old = snap(*STILL, line("D", 0, 200, 100, 200))
|
||||
new = snap(*RENUMBERED, line("1D", 3, 200, 103, 200))
|
||||
result = diff_snapshots(old, new, move_tol=10.0)
|
||||
assert result["handles_stable"] is False
|
||||
(row,) = result["changed"]
|
||||
assert (row["old_handle"], row["new_handle"], row["matched_by"]) == ("D", "1D", "position")
|
||||
assert row["changes"] == {"moved_by": [pytest.approx(3.0), pytest.approx(0.0)]}
|
||||
assert result["summary"]["unchanged"] == 3
|
||||
|
||||
|
||||
def test_a_text_change_is_reported_from_and_to():
|
||||
old = snap(*STILL, rec("T", "TEXT", "NOTES", ((10, 10),), text="OLD", height=2.5))
|
||||
new = snap(*STILL, rec("T", "TEXT", "NOTES", ((10, 10),), text="NEW", height=2.5))
|
||||
(row,) = diff_snapshots(old, new)["changed"]
|
||||
assert row["matched_by"] == "handle"
|
||||
assert row["changes"] == {"text": {"from": "OLD", "to": "NEW"}}
|
||||
|
||||
|
||||
def test_an_attribute_change_is_reported_by_tag():
|
||||
before = (("TAG", "A-1"), ("SERVICE", "CIP"))
|
||||
after = (("TAG", "A-2"), ("SERVICE", "CIP"))
|
||||
old = snap(*STILL, rec("I", "INSERT", "TAGS", ((500, 500),), block="TB", attribs=before))
|
||||
new = snap(*STILL, rec("I", "INSERT", "TAGS", ((500, 500),), block="TB", attribs=after))
|
||||
(row,) = diff_snapshots(old, new)["changed"]
|
||||
assert row["changes"] == {"attribs": [{"tag": "TAG", "from": "A-1", "to": "A-2"}]}
|
||||
|
||||
|
||||
def test_a_radius_change_is_reported_from_and_to():
|
||||
old = snap(*STILL, rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=10.0))
|
||||
new = snap(*STILL, rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=12.0))
|
||||
(row,) = diff_snapshots(old, new)["changed"]
|
||||
assert row["changes"] == {"radius": {"from": 10.0, "to": 12.0}}
|
||||
|
||||
|
||||
def test_the_handle_stage_catches_a_layer_change_the_position_stage_cannot():
|
||||
old = snap(*STILL, line("L", 0, 300, 100, 300, layer="P1"))
|
||||
new = snap(*STILL, line("L", 0, 300, 100, 300, layer="P2"))
|
||||
(row,) = diff_snapshots(old, new)["changed"]
|
||||
assert row["matched_by"] == "handle"
|
||||
assert row["changes"] == {"layer": {"from": "P1", "to": "P2"}}
|
||||
|
||||
|
||||
def test_stable_handles_pair_a_far_move_by_handle():
|
||||
old = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((0, 0),), radius=5.0))
|
||||
new = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0))
|
||||
(row,) = diff_snapshots(old, new, move_tol=10.0)["changed"]
|
||||
assert row["matched_by"] == "handle"
|
||||
assert row["changes"] == {"moved_by": [pytest.approx(5000.0), pytest.approx(5000.0)]}
|
||||
|
||||
|
||||
def test_renumbered_handles_never_pair_strangers_that_share_a_handle():
|
||||
old = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((0, 0),), radius=5.0))
|
||||
new = snap(*RENUMBERED, rec("20", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0))
|
||||
result = diff_snapshots(old, new, move_tol=10.0)
|
||||
assert result["handles_stable"] is False
|
||||
assert result["changed"] == []
|
||||
assert [r["handle"] for r in result["removed"]] == ["20"]
|
||||
assert [r["at"] for r in result["added"]] == [[5000.0, 5000.0]]
|
||||
|
||||
|
||||
def test_beyond_move_tol_a_line_is_removed_and_another_added():
|
||||
old = snap(*STILL, line("D", 0, 200, 100, 200))
|
||||
new = snap(*STILL, line("E", 0, 700, 100, 700))
|
||||
result = diff_snapshots(old, new, move_tol=10.0)
|
||||
assert (result["summary"]["added"], result["summary"]["removed"]) == (1, 1)
|
||||
assert result["changed"] == []
|
||||
|
||||
|
||||
# -- the move tolerance ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_default_move_tol_is_2_percent_of_the_robust_diagonal():
|
||||
# 100 points on the diagonal of a 2970 x 3960 box, and one stray 10 km away:
|
||||
# the 1st-99th percentile box is (30, 40)-(2970, 3960), diagonal 4900.
|
||||
points = [rec(f"P{i}", "POINT", "PTS", ((i * 30.0, i * 40.0),)) for i in range(100)]
|
||||
stray = rec("FAR", "POINT", "PTS", ((1.0e7, 1.0e7),))
|
||||
drawing = snap(*points, stray)
|
||||
assert robust_box(drawing) == (30.0, 40.0, 2970.0, 3960.0)
|
||||
assert diff_snapshots(drawing, drawing)["move_tol"] == pytest.approx(98.0)
|
||||
|
||||
|
||||
def test_a_non_positive_tolerance_is_refused_by_name():
|
||||
with pytest.raises(ValueError, match=r"^tol must be a positive number; got 0"):
|
||||
diff_snapshots(snap(), snap(), tol=0)
|
||||
with pytest.raises(ValueError, match=r"^move_tol must be a positive number; got -1"):
|
||||
diff_snapshots(snap(), snap(), move_tol=-1)
|
||||
|
||||
|
||||
# -- the report ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_changes_are_counted_by_layer_and_type_and_clustered():
|
||||
old = snap(
|
||||
line("U", 0, -500, 100, -500),
|
||||
line("D", 0, 200, 100, 200),
|
||||
rec("T", "TEXT", "NOTES", ((10, 10),), text="OLD", height=2.5),
|
||||
)
|
||||
new = snap(
|
||||
line("U", 0, -500, 100, -500),
|
||||
line("D", 3, 200, 103, 200),
|
||||
rec("T", "TEXT", "NOTES", ((10, 10),), text="NEW", height=2.5),
|
||||
rec("N1", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0, bbox=(4995, 4995, 5005, 5005)),
|
||||
)
|
||||
result = diff_snapshots(old, new, move_tol=10.0)
|
||||
assert result["by_layer"] == {
|
||||
"NOTES": {"changed": 1, "added": 0, "removed": 0},
|
||||
"PIPE": {"changed": 1, "added": 1, "removed": 0},
|
||||
}
|
||||
assert result["by_type"] == {
|
||||
"CIRCLE": {"changed": 0, "added": 1, "removed": 0},
|
||||
"LINE": {"changed": 1, "added": 0, "removed": 0},
|
||||
"TEXT": {"changed": 1, "added": 0, "removed": 0},
|
||||
}
|
||||
# pad = max(0.25 x 10, 10 x 0.01) = 2.5 around each cluster
|
||||
assert result["clusters"] == [
|
||||
{"space": "Model", "box": [-2.5, 197.5, 105.5, 202.5], "count": 1},
|
||||
{"space": "Model", "box": [7.5, 7.5, 12.5, 12.5], "count": 1},
|
||||
{"space": "Model", "box": [4992.5, 4992.5, 5007.5, 5007.5], "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_cap_report_cuts_every_list_and_says_how_much():
|
||||
old = snap(*STILL)
|
||||
new = snap(*STILL, *(line(f"N{i}", 0, 1000 * (i + 1), 100, 1000 * (i + 1)) for i in range(5)))
|
||||
capped = cap_report(diff_snapshots(old, new, move_tol=10.0), limit=2)
|
||||
assert len(capped["added"]) == 2
|
||||
assert capped["truncated"] == {"changed": 0, "added": 3, "removed": 0}
|
||||
assert capped["summary"]["added"] == 5
|
||||
|
||||
|
||||
# -- block definitions -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _blocks(radius: float, extra: bool):
|
||||
doc = ezdxf.new()
|
||||
doc.blocks.new("B1").add_circle((0, 0), radius)
|
||||
doc.blocks.new("B2").add_line((0, 0), (10, 0))
|
||||
if extra:
|
||||
doc.blocks.new("B3").add_line((0, 0), (0, 10))
|
||||
doc.blocks.new_anonymous_block().add_line((0, 0), (1, 1))
|
||||
return doc
|
||||
|
||||
|
||||
def test_block_definitions_are_compared_by_content():
|
||||
old = block_signatures(_blocks(5.0, extra=False))
|
||||
new = block_signatures(_blocks(6.0, extra=True))
|
||||
assert sorted(old) == ["B1", "B2"] # the anonymous *U block is left out
|
||||
assert block_changes(old, new) == {
|
||||
"compared": True,
|
||||
"added": ["B3"],
|
||||
"removed": [],
|
||||
"changed": ["B1"],
|
||||
}
|
||||
assert block_changes(None, new)["compared"] is False
|
||||
|
||||
|
||||
# -- reading a revision ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_revision_refuses_a_file_over_the_size_limit(tmp_path, monkeypatch):
|
||||
path = tmp_path / "big.dxf"
|
||||
ezdxf.new().saveas(path)
|
||||
monkeypatch.setattr(config.settings, "max_dxf_bytes", 10)
|
||||
with pytest.raises(ValueError, match=r"over the MAX_DXF_BYTES limit of 10"):
|
||||
read_revision(str(path))
|
||||
|
||||
|
||||
def test_read_revision_refuses_a_file_that_is_not_a_dxf(tmp_path):
|
||||
path = tmp_path / "notes.dxf"
|
||||
path.write_text("not a drawing\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match=r"not a readable DXF"):
|
||||
read_revision(str(path))
|
||||
|
||||
|
||||
# -- the known edits on the synthetic plant --------------------------------------------
|
||||
|
||||
|
||||
def test_the_known_edits_and_nothing_else(tmp_path):
|
||||
pair = build_revision_pair(tmp_path)
|
||||
old, old_blocks = read_revision(pair["old"])
|
||||
new, new_blocks = read_revision(pair["new"])
|
||||
result = diff_snapshots(old, new, move_tol=pair["move_tol"])
|
||||
total = pair["old_count"]
|
||||
assert result["summary"] == {
|
||||
"old_entities": total,
|
||||
"new_entities": total,
|
||||
"unchanged": total - 4,
|
||||
"changed": 3,
|
||||
"added": 1,
|
||||
"removed": 1,
|
||||
}
|
||||
changed = {row["old_handle"]: row for row in result["changed"]}
|
||||
assert set(changed) == {pair["moved"], pair["text"], pair["insert"]}
|
||||
assert {row["matched_by"] for row in result["changed"]} == {"position"}
|
||||
moved = changed[pair["moved"]]["changes"]
|
||||
assert list(moved) == ["moved_by"]
|
||||
assert moved["moved_by"][0] == pytest.approx(pair["move_tol"] / 2.0, abs=1e-9)
|
||||
assert moved["moved_by"][1] == pytest.approx(0.0, abs=1e-9)
|
||||
assert changed[pair["text"]]["changes"] == {
|
||||
"text": {"from": pair["text_before"], "to": NEW_TEXT}
|
||||
}
|
||||
assert changed[pair["insert"]]["changes"] == {
|
||||
"attribs": [{"tag": "TAG", "from": TAG_BEFORE, "to": TAG_AFTER}]
|
||||
}
|
||||
assert [row["handle"] for row in result["removed"]] == [pair["deleted"]]
|
||||
assert [(row["type"], row["layer"]) for row in result["added"]] == [("CIRCLE", ADDED_LAYER)]
|
||||
assert block_changes(old_blocks, new_blocks) == {
|
||||
"compared": True,
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": [],
|
||||
}
|
||||
|
||||
|
||||
def test_renumbered_handles_alone_are_no_change(tmp_path):
|
||||
pair = build_revision_pair(tmp_path, edits=False)
|
||||
old, _ = read_revision(pair["old"])
|
||||
new, _ = read_revision(pair["new"])
|
||||
summary = diff_snapshots(old, new)["summary"]
|
||||
assert (summary["changed"], summary["added"], summary["removed"]) == (0, 0, 0)
|
||||
assert summary["unchanged"] == pair["old_count"]
|
||||
@@ -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