merge: track H group D
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:
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,
|
||||
}
|
||||
@@ -599,10 +599,20 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
`drawing_scale_check`, tagged the same, followed it (13 -> 14). This
|
||||
snapshot is branch-local: the track H merge task recomputes it once the
|
||||
understand, network and diff branches have landed.
|
||||
|
||||
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
|
||||
`drawing_diff` and `drawing_topology_check`, both tagged `analysis` and
|
||||
`query`; `analysis` outranks `query` in `_GROUP_TAG_PRIORITY`, so both file
|
||||
under `analysis` (12 -> 14). This snapshot is branch-local: the track H
|
||||
merge recomputes it once the understand, network and diff branches have
|
||||
landed.
|
||||
|
||||
Merged, the two SECTION 26 halves file all four readers under `analysis`
|
||||
(12 -> 16).
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 14,
|
||||
"analysis": 16,
|
||||
"architecture": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
@@ -629,4 +639,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 243
|
||||
assert sum(sizes.values()) == 245
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""The three topo_* focuses: a positive and a negative fixture each, and silence
|
||||
on every drawing this server makes.
|
||||
|
||||
The network layer is the synthetic plant's product-piping layer, read from the
|
||||
fixture's truth dict (``tests/fixtures/plant_pair.py``), so the test draws on a
|
||||
layer the vocabulary classifies the way a foreign drawing's layer would be
|
||||
classified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.arch.layers import ARCH_ROLE_LAYER
|
||||
from engineering.critique import run_critique
|
||||
from engineering.pid.lines import PID_LINE_LAYERS
|
||||
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
|
||||
from engineering.understand.critique import (
|
||||
SEMANTIC_LAYERS,
|
||||
TOPO_DISPATCH,
|
||||
TOPO_FOCUSES,
|
||||
build_index,
|
||||
focus_layers,
|
||||
issues_for,
|
||||
)
|
||||
from engineering.understand.snapshot import EntityRecord, Snapshot
|
||||
from engineering.understand.vocab import classify_layer
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pipe_layer(tmp_path_factory) -> str:
|
||||
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
|
||||
layer = next(
|
||||
name for name, service in sorted(truth["layer_services"].items()) if service == "product"
|
||||
)
|
||||
meaning = classify_layer(layer)
|
||||
assert meaning["discipline"] == "piping" and meaning["confidence"] >= 0.9, meaning
|
||||
return layer
|
||||
|
||||
|
||||
async def _lines(backend, layer, *segments):
|
||||
handles = []
|
||||
for x1, y1, x2, y2 in segments:
|
||||
entity = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
|
||||
handles.append(entity.handle)
|
||||
return handles
|
||||
|
||||
|
||||
async def _issues(backend, focus):
|
||||
return issues_for(focus, await build_index(backend))
|
||||
|
||||
|
||||
# -- the closed enum and the exclusions ------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
|
||||
assert TOPO_FOCUSES == ("topo_dangling_endpoint", "topo_near_miss", "topo_interior_crossing")
|
||||
for focus in TOPO_FOCUSES:
|
||||
assert focus in ALL_CRITIQUE_FOCUSES
|
||||
assert TOPO_DISPATCH[focus].needs_shared is True
|
||||
|
||||
|
||||
async def test_pid_line_layers_and_arch_layers_are_excluded():
|
||||
assert set(PID_LINE_LAYERS) <= SEMANTIC_LAYERS
|
||||
assert ARCH_ROLE_LAYER["wall"] in SEMANTIC_LAYERS
|
||||
assert "0" not in SEMANTIC_LAYERS
|
||||
snap = Snapshot(
|
||||
source="test",
|
||||
insunits=4,
|
||||
extmin=None,
|
||||
extmax=None,
|
||||
layers={"PROCESS-PIPING-MAIN": {}, "A-WALL-E-N": {}},
|
||||
layouts=(),
|
||||
records=(
|
||||
EntityRecord("1", "LINE", "PROCESS-PIPING-MAIN", "Model", ((0, 0), (10, 0))),
|
||||
EntityRecord("2", "LINE", "A-WALL-E-N", "Model", ((0, 5), (10, 5))),
|
||||
),
|
||||
)
|
||||
assert focus_layers(snap) == []
|
||||
|
||||
|
||||
# -- topo_dangling_endpoint ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dangling_fires_on_a_lone_pipe(backend, pipe_layer):
|
||||
(handle,) = await _lines(backend, pipe_layer, (0, 0, 1000, 0))
|
||||
issues = await _issues(backend, "topo_dangling_endpoint")
|
||||
assert [(i.severity, i.handles, i.detail["end"]) for i in issues] == [
|
||||
("warning", [handle], "end"),
|
||||
("warning", [handle], "start"),
|
||||
]
|
||||
|
||||
|
||||
async def test_dangling_is_quiet_on_a_closed_loop(backend, pipe_layer):
|
||||
await _lines(
|
||||
backend,
|
||||
pipe_layer,
|
||||
(0, 0, 1000, 0),
|
||||
(1000, 0, 1000, 1000),
|
||||
(1000, 1000, 0, 1000),
|
||||
(0, 1000, 0, 0),
|
||||
)
|
||||
assert await _issues(backend, "topo_dangling_endpoint") == []
|
||||
|
||||
|
||||
# -- topo_near_miss --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_near_miss_fires_on_a_3_unit_gap(backend, pipe_layer):
|
||||
# box 0..2003 wide: default gap 0.002 x 2003 = 4.006 > 3
|
||||
first, second = await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1003, 0, 2003, 0))
|
||||
(issue,) = await _issues(backend, "topo_near_miss")
|
||||
assert issue.severity == "error"
|
||||
assert issue.handles == sorted([first, second])
|
||||
assert issue.detail["kind"] == "end_end"
|
||||
assert issue.detail["gap"] == pytest.approx(3.0)
|
||||
assert "stops 3 short of the end of" in issue.message
|
||||
|
||||
|
||||
async def test_near_miss_is_quiet_when_the_lines_join(backend, pipe_layer):
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1000, 0, 2000, 0))
|
||||
assert await _issues(backend, "topo_near_miss") == []
|
||||
|
||||
|
||||
# -- topo_interior_crossing ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_crossing_fires_on_two_pipes_crossing_mid_span(backend, pipe_layer):
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (500, -500, 500, 500))
|
||||
(issue,) = await _issues(backend, "topo_interior_crossing")
|
||||
assert issue.severity == "info"
|
||||
assert issue.detail["at"] == [500.0, 0.0]
|
||||
|
||||
|
||||
async def test_crossing_is_quiet_on_a_t_junction(backend, pipe_layer):
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (500, 0, 500, 500))
|
||||
assert await _issues(backend, "topo_interior_crossing") == []
|
||||
|
||||
|
||||
# -- silence on this server's own drawings ---------------------------------------------
|
||||
|
||||
|
||||
async def test_a_pid_from_spec_sheet_gets_no_topo_issue(backend):
|
||||
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
|
||||
|
||||
await run_spec(backend, copy.deepcopy(EXAMPLE_SPEC))
|
||||
assert await run_critique(backend, list(TOPO_FOCUSES)) == []
|
||||
everything = await run_critique(backend, None)
|
||||
assert not [i for i in everything if i.focus.startswith("topo_")]
|
||||
|
||||
|
||||
async def test_an_arch_plan_from_spec_gets_no_topo_issue(backend):
|
||||
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec
|
||||
|
||||
await draw_plan_from_spec(backend, copy.deepcopy(EXAMPLE_SPEC))
|
||||
assert await run_critique(backend, list(TOPO_FOCUSES)) == []
|
||||
|
||||
|
||||
async def test_a_drawing_without_a_network_layer_is_never_snapshotted(backend, monkeypatch):
|
||||
import engineering.understand.snapshot as snapshot
|
||||
|
||||
async def refuse(*_args, **_kwargs):
|
||||
raise AssertionError("the snapshot was taken")
|
||||
|
||||
monkeypatch.setattr(snapshot, "take_snapshot", refuse)
|
||||
await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
|
||||
assert await build_index(backend) == {"layers": [], "findings": None, "error": None}
|
||||
|
||||
|
||||
async def test_a_snapshot_that_fails_is_reported_not_passed(backend, pipe_layer, monkeypatch):
|
||||
import engineering.understand.snapshot as snapshot
|
||||
|
||||
async def broken(*_args, **_kwargs):
|
||||
raise RuntimeError("export failed")
|
||||
|
||||
monkeypatch.setattr(snapshot, "take_snapshot", broken)
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0))
|
||||
(issue,) = await _issues(backend, "topo_near_miss")
|
||||
assert issue.severity == "info" and "export failed" in issue.message
|
||||
|
||||
|
||||
# -- the dispatch ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_three_focuses_share_one_index_per_run(backend, pipe_layer):
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0))
|
||||
shared: dict = {}
|
||||
for focus in TOPO_FOCUSES:
|
||||
await TOPO_DISPATCH[focus](backend, shared)
|
||||
assert list(shared) == ["topo_index"]
|
||||
|
||||
|
||||
async def test_run_critique_reaches_the_topo_focuses(backend, pipe_layer):
|
||||
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1003, 0, 2003, 0))
|
||||
(issue,) = await run_critique(backend, ["topo_near_miss"])
|
||||
assert issue.focus == "topo_near_miss" and issue.severity == "error"
|
||||
@@ -0,0 +1,445 @@
|
||||
"""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_no_exact_match_is_no_evidence_that_handles_are_stable():
|
||||
# two fresh files hand out handles from one seed; every circle moved +1000 in x
|
||||
# and the new file added them in reverse order, so equal handles are strangers
|
||||
old = snap(
|
||||
*(rec(f"{0x20 + i:X}", "CIRCLE", "EQ", ((0, 200 * i),), radius=10.0) for i in range(8))
|
||||
)
|
||||
new = snap(
|
||||
*(
|
||||
rec(f"{0x20 + k:X}", "CIRCLE", "EQ", ((1000, 200 * i),), radius=10.0)
|
||||
for k, i in enumerate(reversed(range(8)))
|
||||
)
|
||||
)
|
||||
result = diff_snapshots(old, new)
|
||||
assert result["handles_stable"] is False
|
||||
assert result["handle_evidence"] == {"exact_matches": 0, "kept_handle": 0}
|
||||
assert result["changed"] == []
|
||||
assert (result["summary"]["added"], result["summary"]["removed"]) == (8, 8)
|
||||
# given a move tolerance that reaches, the position stage finds the true move
|
||||
moved = diff_snapshots(old, new, move_tol=1001.0)
|
||||
assert {row["matched_by"] for row in moved["changed"]} == {"position"}
|
||||
assert {tuple(row["changes"]["moved_by"]) for row in moved["changed"]} == {(1000.0, 0.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"] == []
|
||||
|
||||
|
||||
# -- a value split by the signature grid ----------------------------------------------
|
||||
|
||||
|
||||
def test_a_value_one_ulp_across_a_half_grid_point_is_unchanged():
|
||||
# AutoCAD writes 16 significant digits: 0.14 + 0.005 = 0.14500000000000002 comes
|
||||
# back as 0.145, and at tol=0.01 the two snap to buckets 15 and 14
|
||||
assert 0.14 + 0.005 != 0.145
|
||||
edge = 0.14 + 0.005
|
||||
assert signature(line("L", edge, 300, 50, 300)) != signature(line("L", 0.145, 300, 50, 300))
|
||||
old = snap(*STILL, line("L", edge, 300, 50, 300))
|
||||
for new in (
|
||||
snap(*STILL, line("L", 0.145, 300, 50, 300)), # paired by handle
|
||||
snap(*RENUMBERED, line("1L", 0.145, 300, 50, 300)), # paired by position
|
||||
):
|
||||
result = diff_snapshots(old, new, move_tol=10.0)
|
||||
assert result["summary"]["unchanged"] == 4
|
||||
assert (result["summary"]["changed"], result["summary"]["added"]) == (0, 0)
|
||||
assert result["summary"]["removed"] == 0
|
||||
assert result["changed"] == [] and result["clusters"] == []
|
||||
assert result["by_layer"] == {} and result["by_type"] == {}
|
||||
|
||||
|
||||
def test_numbers_one_ulp_across_a_grid_boundary_are_not_changes():
|
||||
# at tol=1, 12.500000000000002 and 12.5 snap to 13 and 12
|
||||
edge = 12.500000000000002
|
||||
old = snap(
|
||||
*STILL,
|
||||
rec("R", "CIRCLE", "PIPE", ((edge, 0),), radius=edge),
|
||||
rec("T", "TEXT", "NOTES", ((10, 10),), text="X", height=edge, rotation=edge),
|
||||
rec("P", "LWPOLYLINE", "PIPE", ((0, 0), (10, 0)), bulges=(edge, 0.0)),
|
||||
)
|
||||
new = snap(
|
||||
*STILL,
|
||||
rec("R", "CIRCLE", "PIPE", ((12.5, 0),), radius=12.5),
|
||||
rec("T", "TEXT", "NOTES", ((10, 10),), text="X", height=12.5, rotation=12.5),
|
||||
rec("P", "LWPOLYLINE", "PIPE", ((0, 0), (10, 0)), bulges=(12.5, 0.0)),
|
||||
)
|
||||
result = diff_snapshots(old, new, tol=1.0)
|
||||
assert result["summary"]["unchanged"] == 6
|
||||
assert result["changed"] == [] and result["clusters"] == []
|
||||
|
||||
|
||||
def test_a_real_edit_next_to_a_grid_split_is_still_reported():
|
||||
old = snap(
|
||||
*STILL,
|
||||
line("L", 0.14 + 0.005, 300, 50, 300),
|
||||
rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=10.0),
|
||||
)
|
||||
new = snap(
|
||||
*STILL, line("L", 0.145, 300, 50, 300), rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=10.2)
|
||||
)
|
||||
result = diff_snapshots(old, new)
|
||||
(row,) = result["changed"]
|
||||
assert row["old_handle"] == "R"
|
||||
assert row["changes"] == {"radius": {"from": 10.0, "to": 10.2}}
|
||||
assert result["summary"]["unchanged"] == 4
|
||||
assert [c["count"] for c in result["clusters"]] == [1]
|
||||
|
||||
|
||||
# -- 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
|
||||
# five lines 1000 apart are five clusters: the list is capped like the others
|
||||
assert len(capped["clusters"]) == 2
|
||||
assert capped["truncated"] == {"changed": 0, "added": 3, "removed": 0, "clusters": 3}
|
||||
assert capped["summary"]["added"] == 5
|
||||
|
||||
|
||||
def test_a_line_drawn_back_to_front_and_moved_is_a_move():
|
||||
# the signature ignores a LINE's direction; so does its movement
|
||||
old = snap(*STILL, line("D", 0, 200, 100, 200))
|
||||
new = snap(*STILL, line("D", 150, 250, 50, 250)) # reversed, then moved by (50, 50)
|
||||
(row,) = diff_snapshots(old, new, move_tol=100.0)["changed"]
|
||||
assert row["changes"] == {"moved_by": [pytest.approx(50.0), pytest.approx(50.0)]}
|
||||
|
||||
|
||||
def test_an_attribute_tag_carried_twice_cannot_hide_a_change():
|
||||
# as a dict, both revisions read NOTE = "B" and the edit of the first NOTE vanished
|
||||
before = (("NOTE", "A"), ("NOTE", "B"))
|
||||
after = (("NOTE", "X"), ("NOTE", "B"))
|
||||
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": "NOTE", "from": ["A", "B"], "to": ["X", "B"]}]}
|
||||
|
||||
|
||||
def test_a_dense_drawing_moved_as_a_whole_pairs_each_entity_with_its_own_copy():
|
||||
# 200 lines 10 apart on one layer, all moved (3, 0) under new handles: no exact
|
||||
# match, so no handle stage, and with move_tol 500 each line has about a hundred
|
||||
# candidates. The position stage keeps only the nearest few per entity, and each
|
||||
# line must still pair with its own moved copy.
|
||||
old = snap(*(line(f"O{i}", 0, 10 * i, 5, 10 * i) for i in range(200)))
|
||||
new = snap(*(line(f"N{i}", 3, 10 * i, 8, 10 * i) for i in range(200)))
|
||||
result = diff_snapshots(old, new, move_tol=500.0)
|
||||
assert result["handles_stable"] is False
|
||||
assert (result["summary"]["changed"], result["summary"]["added"]) == (200, 0)
|
||||
assert result["summary"]["removed"] == 0
|
||||
assert all(
|
||||
row["changes"] == {"moved_by": [pytest.approx(3.0), pytest.approx(0.0)]}
|
||||
and row["old_handle"][1:] == row["new_handle"][1:]
|
||||
for row in result["changed"]
|
||||
)
|
||||
|
||||
|
||||
# -- 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))
|
||||
|
||||
|
||||
def test_read_revision_refuses_a_truncated_dxf(tmp_path):
|
||||
# an interrupted save: ezdxf's tag reader runs out and raises StopIteration,
|
||||
# which must surface as the refusal, not escape (it would hang asyncio.to_thread)
|
||||
path = tmp_path / "half.dxf"
|
||||
path.write_bytes(b" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1015\n")
|
||||
with pytest.raises(ValueError, match=r"half\.dxf: not a readable DXF \(the file ends early"):
|
||||
read_revision(str(path))
|
||||
|
||||
|
||||
def test_read_revision_refuses_a_non_positive_tol_by_name(tmp_path):
|
||||
path = tmp_path / "empty.dxf"
|
||||
ezdxf.new().saveas(path)
|
||||
with pytest.raises(ValueError, match=r"^tol must be a positive number"):
|
||||
read_revision(str(path), tol=0)
|
||||
|
||||
|
||||
# -- 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,144 @@
|
||||
"""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
|
||||
@@ -0,0 +1,317 @@
|
||||
"""The topology engine: ends, joins, near misses, crossings - by hand-computed geometry.
|
||||
|
||||
Every record is built by hand, so every coordinate in an assertion is the
|
||||
answer. The network layer is called PIPE here; which layers are network layers
|
||||
by default is ``network_layers``' job and is tested on the synthetic plant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.understand.snapshot import EntityRecord, Snapshot
|
||||
from engineering.understand.topology import (
|
||||
Piece,
|
||||
cap_findings,
|
||||
entity_pieces,
|
||||
network_layers,
|
||||
topology_findings,
|
||||
)
|
||||
from engineering.understand.vocab import classify_layer
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
L = "PIPE"
|
||||
|
||||
|
||||
def rec(handle, type_, layer=L, points=(), **kw):
|
||||
space = kw.pop("space", "Model")
|
||||
return EntityRecord(
|
||||
handle=handle, type=type_, layer=layer, space=space, points=tuple(points), **kw
|
||||
)
|
||||
|
||||
|
||||
def line(handle, x1, y1, x2, y2, layer=L, space="Model"):
|
||||
return rec(handle, "LINE", layer, ((x1, y1), (x2, y2)), space=space)
|
||||
|
||||
|
||||
def snap(*records):
|
||||
return Snapshot(
|
||||
source="test",
|
||||
insunits=4,
|
||||
extmin=None,
|
||||
extmax=None,
|
||||
layers={r.layer: {"color": 7, "linetype": "Continuous"} for r in records},
|
||||
layouts=("Layout1",),
|
||||
records=tuple(records),
|
||||
)
|
||||
|
||||
|
||||
def run(*records, gap=5.0, layers=(L,)):
|
||||
return topology_findings(snap(*records), layers=list(layers), gap=gap)
|
||||
|
||||
|
||||
def ends(rows):
|
||||
return {(row["handle"], row["end"]) for row in rows}
|
||||
|
||||
|
||||
# -- pieces ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_bulge_of_one_is_the_semicircle_below_its_chord():
|
||||
# bulge +1 from (0,0) to (200,0): counter-clockwise, centre (100,0), radius 100,
|
||||
# through (100,-100)
|
||||
pieces, entity_ends = entity_pieces(
|
||||
rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(1.0, 0.0))
|
||||
)
|
||||
(arc,) = pieces
|
||||
assert arc.centre == (pytest.approx(100.0), pytest.approx(0.0, abs=1e-9))
|
||||
assert arc.radius == pytest.approx(100.0)
|
||||
foot = arc.nearest((100.0, -150.0))
|
||||
assert foot == (pytest.approx(100.0), pytest.approx(-100.0))
|
||||
assert entity_ends == ((0.0, 0.0), (200.0, 0.0))
|
||||
|
||||
|
||||
def test_a_closed_polyline_has_no_ends_and_a_closing_piece():
|
||||
pieces, entity_ends = entity_pieces(
|
||||
rec("SQ", "LWPOLYLINE", L, ((0, 0), (10, 0), (10, 10), (0, 10)), closed=True)
|
||||
)
|
||||
assert entity_ends is None and len(pieces) == 4
|
||||
assert (pieces[3].a, pieces[3].b) == ((0.0, 10.0), (0.0, 0.0))
|
||||
|
||||
|
||||
def test_a_straight_piece_nearest_point_is_clamped_to_its_ends():
|
||||
piece = Piece("A", L, 0, (0.0, 0.0), (10.0, 0.0))
|
||||
assert piece.nearest((5.0, 3.0)) == (5.0, 0.0)
|
||||
assert piece.nearest((-4.0, 3.0)) == (0.0, 0.0)
|
||||
|
||||
|
||||
# -- dangling and joined ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_lone_line_dangles_at_both_ends():
|
||||
result = run(line("A", 0, 0, 1000, 0))
|
||||
assert ends(result["dangling"]) == {("A", "start"), ("A", "end")}
|
||||
assert result["near_miss"] == [] and result["crossing"] == []
|
||||
|
||||
|
||||
def test_a_t_junction_joins_the_stem():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 0, 500, 500))
|
||||
assert ends(result["dangling"]) == {("A", "start"), ("A", "end"), ("B", "end")}
|
||||
|
||||
|
||||
def test_a_closed_square_of_lines_has_no_finding():
|
||||
square = [
|
||||
line("A", 0, 0, 1000, 0),
|
||||
line("B", 1000, 0, 1000, 1000),
|
||||
line("C", 1000, 1000, 0, 1000),
|
||||
line("D", 0, 1000, 0, 0),
|
||||
]
|
||||
result = run(*square)
|
||||
assert (result["dangling"], result["near_miss"], result["crossing"]) == ([], [], [])
|
||||
|
||||
|
||||
def test_an_end_on_an_arc_and_on_an_arc_body_is_joined():
|
||||
# ARC centre (0,0) r 100 from 0 to 90 degrees: ends (100,0) and (0,100)
|
||||
arc = rec("ARC1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
|
||||
on_end = line("L1", 100, 0, 500, 0)
|
||||
on_body = line("L2", 70.71067811865476, 70.71067811865476, 300, 300)
|
||||
result = run(arc, on_end, on_body)
|
||||
assert ends(result["dangling"]) == {("ARC1", "end"), ("L1", "end"), ("L2", "end")}
|
||||
|
||||
|
||||
def test_an_end_on_a_bulged_segment_is_joined():
|
||||
arc = rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(1.0, 0.0))
|
||||
result = run(arc, line("L", 100, -100, 100, -500))
|
||||
assert ends(result["dangling"]) == {("PL", "start"), ("PL", "end"), ("L", "end")}
|
||||
|
||||
|
||||
def test_an_end_inside_an_insert_box_is_attached():
|
||||
valve = rec("V1", "INSERT", "VALVES", ((1000, 0),), block="VALVE", bbox=(995, -5, 1005, 5))
|
||||
result = run(line("A", 0, 0, 995, 0), valve)
|
||||
assert ends(result["dangling"]) == {("A", "start")}
|
||||
|
||||
|
||||
def test_an_end_on_a_curve_of_another_layer_is_attached():
|
||||
tank = rec(
|
||||
"TK",
|
||||
"LWPOLYLINE",
|
||||
"EQUIPMENT",
|
||||
((1000, -500), (2000, -500), (2000, 500), (1000, 500)),
|
||||
closed=True,
|
||||
)
|
||||
result = run(line("A", 0, 0, 1000, 0), tank)
|
||||
assert ends(result["dangling"]) == {("A", "start")}
|
||||
|
||||
|
||||
def test_model_space_and_a_layout_never_join():
|
||||
result = run(line("A", 0, 0, 100, 0), line("B", 100, 0, 200, 0, space="Layout1"))
|
||||
assert ends(result["dangling"]) == {("A", "start"), ("A", "end"), ("B", "start"), ("B", "end")}
|
||||
|
||||
|
||||
# -- lines drawn twice -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_pipe_drawn_twice_still_dangles_at_all_four_ends():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("A2", 0, 0, 1000, 0))
|
||||
assert ends(result["dangling"]) == {
|
||||
("A", "start"),
|
||||
("A", "end"),
|
||||
("A2", "start"),
|
||||
("A2", "end"),
|
||||
}
|
||||
assert result["near_miss"] == [] and result["crossing"] == []
|
||||
|
||||
|
||||
def test_a_line_over_a_polyline_joins_only_where_the_polyline_turns():
|
||||
polyline = rec("PL", "LWPOLYLINE", L, ((0, 0), (1000, 0), (1000, 1000)))
|
||||
result = run(polyline, line("OV", 0, 0, 1000, 0))
|
||||
assert ends(result["dangling"]) == {("OV", "start"), ("PL", "end"), ("PL", "start")}
|
||||
|
||||
|
||||
def test_an_arc_drawn_twice_still_dangles_at_all_four_ends():
|
||||
# the arc form of a pipe drawn twice: a bend drawn twice must not join itself
|
||||
first = rec("A1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
|
||||
second = rec("A2", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
|
||||
result = run(first, second)
|
||||
assert ends(result["dangling"]) == {
|
||||
("A1", "start"),
|
||||
("A1", "end"),
|
||||
("A2", "start"),
|
||||
("A2", "end"),
|
||||
}
|
||||
assert result["near_miss"] == []
|
||||
|
||||
|
||||
def test_two_arcs_of_one_circle_butted_end_to_end_join():
|
||||
# 0-90 then 90-180 on the same circle: continuing the other way is a join
|
||||
first = rec("A1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
|
||||
second = rec("A2", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(90.0, 180.0))
|
||||
assert ends(run(first, second)["dangling"]) == {("A1", "start"), ("A2", "end")}
|
||||
|
||||
|
||||
def test_a_bulge_of_minus_one_is_the_semicircle_above_its_chord():
|
||||
((arc,), _ends) = entity_pieces(
|
||||
rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(-1.0, 0.0))
|
||||
)
|
||||
assert arc.radius == pytest.approx(100.0)
|
||||
assert arc.nearest((100.0, 150.0)) == (pytest.approx(100.0), pytest.approx(100.0))
|
||||
|
||||
|
||||
def test_a_side_drawn_twice_inside_a_closed_loop_is_harmless():
|
||||
square = [
|
||||
line("A", 0, 0, 1000, 0),
|
||||
line("A2", 0, 0, 1000, 0),
|
||||
line("B", 1000, 0, 1000, 1000),
|
||||
line("C", 1000, 1000, 0, 1000),
|
||||
line("D", 0, 1000, 0, 0),
|
||||
]
|
||||
result = run(*square)
|
||||
assert (result["dangling"], result["near_miss"], result["crossing"]) == ([], [], [])
|
||||
|
||||
|
||||
# -- near misses -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_two_ends_3_apart_are_one_end_end_near_miss():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("B", 1003, 0, 2000, 0))
|
||||
(row,) = result["near_miss"]
|
||||
assert row["handles"] == ["A", "B"] and row["kind"] == "end_end"
|
||||
assert row["gap"] == pytest.approx(3.0)
|
||||
assert (row["at"], row["to"]) == ([1000.0, 0.0], [1003.0, 0.0])
|
||||
assert ends(result["dangling"]) == {("A", "start"), ("B", "end")}
|
||||
|
||||
|
||||
def test_an_end_4_above_a_line_is_an_end_segment_near_miss():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 4, 500, 500))
|
||||
(row,) = result["near_miss"]
|
||||
assert row["handles"] == ["B", "A"] and row["kind"] == "end_segment"
|
||||
assert row["gap"] == pytest.approx(4.0) and row["to"] == [500.0, 0.0]
|
||||
|
||||
|
||||
def test_an_end_beyond_the_gap_dangles_instead():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 6, 500, 500))
|
||||
assert result["near_miss"] == []
|
||||
assert ("B", "start") in ends(result["dangling"])
|
||||
|
||||
|
||||
def test_a_polyline_that_nearly_closes_on_itself_is_one_near_miss():
|
||||
ring = rec("RING", "LWPOLYLINE", L, ((0, 0), (1000, 0), (1000, 1000), (0, 1000), (0, 3)))
|
||||
result = run(ring)
|
||||
(row,) = result["near_miss"]
|
||||
assert row["handles"] == ["RING", "RING"] and row["kind"] == "end_end"
|
||||
assert row["gap"] == pytest.approx(3.0)
|
||||
assert result["dangling"] == []
|
||||
|
||||
|
||||
# -- crossings -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_two_lines_of_one_layer_crossing_mid_span_are_a_crossing():
|
||||
result = run(line("A", 0, 0, 1000, 0), line("B", 500, -500, 500, 500))
|
||||
assert result["crossing"] == [
|
||||
{"handles": ["A", "B"], "layer": L, "at": [500.0, 0.0], "space": "Model"}
|
||||
]
|
||||
|
||||
|
||||
def test_two_layers_crossing_and_a_t_junction_are_not_crossings():
|
||||
other = line("B", 500, -500, 500, 500, layer="CABLE")
|
||||
assert run(line("A", 0, 0, 1000, 0), other, layers=(L, "CABLE"))["crossing"] == []
|
||||
assert run(line("A", 0, 0, 1000, 0), line("B", 500, 0, 500, 500))["crossing"] == []
|
||||
|
||||
|
||||
def test_a_bow_tie_polyline_crosses_itself():
|
||||
bow = rec("BOW", "LWPOLYLINE", L, ((0, 0), (100, 100), (100, 0), (0, 100)))
|
||||
(row,) = run(bow)["crossing"]
|
||||
assert row["handles"] == ["BOW", "BOW"] and row["at"] == [50.0, 50.0]
|
||||
|
||||
|
||||
# -- defaults and refusals -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_default_gap_is_0_2_percent_of_the_network_diagonal():
|
||||
# box 0..3000 x 0..4000: diagonal 5000, gap 10
|
||||
result = topology_findings(snap(line("A", 0, 0, 3000, 0), line("B", 0, 0, 0, 4000)), layers=[L])
|
||||
assert result["gap"] == pytest.approx(10.0)
|
||||
assert result["stats"] == {"entities": 2, "ends": 4, "skipped": {}}
|
||||
|
||||
|
||||
def test_an_arc_without_a_centre_is_skipped_and_counted():
|
||||
result = run(line("A", 0, 0, 10, 0), rec("ARC9", "ARC", L, (), radius=5.0, angles=(0, 90)))
|
||||
assert result["stats"]["skipped"] == {"ARC": 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "message"),
|
||||
[
|
||||
({"layers": []}, r"^layers: name at least one layer to check"),
|
||||
({"layers": [L], "tol": 0}, r"^tol must be a positive number; got 0"),
|
||||
({"layers": [L], "gap": -1}, r"^gap must be a positive number; got -1"),
|
||||
({"layers": [L], "gap": 0.005}, r"^gap \(0\.005\) must exceed tol \(0\.01\)"),
|
||||
],
|
||||
)
|
||||
def test_refusals_come_before_any_work(kwargs, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
topology_findings(snap(line("A", 0, 0, 1, 0)), **kwargs)
|
||||
|
||||
|
||||
def test_cap_findings_counts_everything_and_cuts_the_lists():
|
||||
lines = [line(f"L{i}", 0, 1000 * i, 100, 1000 * i) for i in range(3)]
|
||||
capped = cap_findings(run(*lines), limit=2)
|
||||
assert capped["counts"] == {"dangling": 6, "near_miss": 0, "crossing": 0}
|
||||
assert capped["truncated"] == {"dangling": 4, "near_miss": 0, "crossing": 0}
|
||||
assert len(capped["dangling"]) == 2
|
||||
|
||||
|
||||
# -- the default layers on the synthetic plant -----------------------------------------
|
||||
|
||||
|
||||
def test_the_plant_piping_layers_are_network_layers(tmp_path):
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
|
||||
truth = build_plant_pair(tmp_path)
|
||||
pid = read_snapshot(truth["pid"])
|
||||
found = {row["layer"] for row in network_layers(pid)}
|
||||
for layer, service in truth["layer_services"].items():
|
||||
if layer in pid.layers and service in ("product", "cip_supply", "cip_return"):
|
||||
assert layer in found, (layer, classify_layer(layer))
|
||||
assert all(row["confidence"] >= 0.9 for row in network_layers(pid))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""drawing_topology_check over the MCP wire, on the headless engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pipe_layer(tmp_path_factory) -> str:
|
||||
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
|
||||
return next(
|
||||
name for name, service in sorted(truth["layer_services"].items()) if service == "product"
|
||||
)
|
||||
|
||||
|
||||
@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 _line(client, layer, x1, y1, x2, y2):
|
||||
await client.call_tool(
|
||||
"entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": layer}
|
||||
)
|
||||
|
||||
|
||||
async def _total(client) -> int:
|
||||
stats = (await client.call_tool("analysis_entity_stats", {})).structured_content
|
||||
return stats["total_entities"]
|
||||
|
||||
|
||||
async def test_the_default_layers_are_the_classified_network_layers(client, pipe_layer):
|
||||
await _line(client, pipe_layer, 0, 0, 1000, 0)
|
||||
await _line(client, pipe_layer, 1003, 0, 2003, 0)
|
||||
before = await _total(client)
|
||||
result = (await client.call_tool("drawing_topology_check", {})).structured_content
|
||||
assert result["layers"] == [pipe_layer]
|
||||
assert [row["layer"] for row in result["classification"]] == [pipe_layer]
|
||||
assert result["counts"] == {"dangling": 2, "near_miss": 1, "crossing": 0}
|
||||
assert result["near_miss"][0]["gap"] == pytest.approx(3.0)
|
||||
assert await _total(client) == before, "the check never writes"
|
||||
|
||||
|
||||
async def test_a_dxf_path_is_read_without_opening_it(client, pipe_layer, tmp_path):
|
||||
doc = ezdxf.new()
|
||||
doc.layers.add(pipe_layer)
|
||||
msp = doc.modelspace()
|
||||
msp.add_line((0, 0), (1000, 0), dxfattribs={"layer": pipe_layer})
|
||||
msp.add_line((500, -500), (500, 500), dxfattribs={"layer": pipe_layer})
|
||||
path = tmp_path / "crossing.dxf"
|
||||
doc.saveas(path)
|
||||
result = (
|
||||
await client.call_tool("drawing_topology_check", {"path": str(path), "gap": 5.0})
|
||||
).structured_content
|
||||
assert result["counts"]["crossing"] == 1
|
||||
assert result["crossing"][0]["at"] == [500.0, 0.0]
|
||||
assert await _total(client) == 0, "the current document is untouched"
|
||||
|
||||
|
||||
async def test_an_unknown_layer_is_refused_with_the_drawings_layers(client, pipe_layer):
|
||||
await _line(client, pipe_layer, 0, 0, 10, 0)
|
||||
result = await client.call_tool(
|
||||
"drawing_topology_check", {"layers": ["NOPE"]}, raise_on_error=False
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "layers: 'NOPE' is not a layer of this drawing" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_drawing_with_no_network_layer_is_refused_not_passed(client):
|
||||
await _line(client, "GEOMETRY", 0, 0, 10, 0)
|
||||
result = await client.call_tool("drawing_topology_check", {}, raise_on_error=False)
|
||||
assert result.is_error is True
|
||||
assert "no layer of this drawing is classified piping or electrical" in result.content[0].text
|
||||
|
||||
|
||||
async def test_gap_must_exceed_tol(client, pipe_layer):
|
||||
await _line(client, pipe_layer, 0, 0, 10, 0)
|
||||
result = await client.call_tool(
|
||||
"drawing_topology_check", {"gap": 0.005, "tol": 0.01}, raise_on_error=False
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "gap (0.005) must exceed tol (0.01)" in result.content[0].text
|
||||
|
||||
|
||||
async def test_the_check_is_advertised_read_only(client):
|
||||
tools = {tool.name: tool for tool in await client.list_tools()}
|
||||
assert tools["drawing_topology_check"].annotations.readOnlyHint is True
|
||||
Reference in New Issue
Block a user