Files
autocad-mcp-pro/tests/test_understand_takeoff_tools.py
T
Umut CANandClaude Opus 5.5 c4759e696d fix(understand): the pipe network's tolerance follows the P&ID's unit, and a dropped stub is said
pipe_takeoff merged line ends closer than tol = 1.0 drawing units and
silently dropped any segment shorter than that. On a millimetre P&ID that is
1 mm. On a metre drawing it is a metre: ends a metre apart were merged and
every pipe under a metre disappeared from the totals, with no warning. The
review of the network engine found this.

- tol now defaults to 1 mm in the unit the P&ID's geometry implies: 1.0 on
  a millimetre drawing, 0.001 on a metre one. A tol the caller gives is
  used as given.
- build_network counts the segments that collapse to a point
  (stats.segments_below_tol), and pipe_rows turns a non-zero count into a
  warning naming the tolerance.

A new wire test builds a metre P&ID with a 0.6 m pipe and a 5 m pipe. Before
this change the takeoff said 5.0 m; now it says 5.6 m. The network test
checks that a sub-tolerance stub is counted. Registry, packs, aliases and
every track H suite: 472 passed. The inventory matches the live surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:51:10 +03:00

162 lines
6.4 KiB
Python

"""pipe_takeoff and cable_takeoff over the MCP wire, on the synthetic plant pair - never on a
client drawing."""
from __future__ import annotations
from pathlib import Path
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from tests.fixtures.plant_pair import build_plant_pair
pytestmark = pytest.mark.asyncio
CSV_NAMES = [
"takeoff_metraj.csv",
"takeoff_ozet.csv",
"takeoff_kontrol.csv",
"takeoff_metodoloji.csv",
]
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on the headless engine - 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"
yield connected
@pytest.fixture
def plant(tmp_path):
return build_plant_pair(tmp_path)
async def test_a_metre_pid_keeps_a_pipe_shorter_than_one_drawing_unit(client, tmp_path):
# The junction tolerance defaults to 1 mm in the unit the P&ID's geometry
# implies. A fixed 1.0 would be a metre here: the 0.6 m pipe would collapse
# to a point and vanish from the totals.
import ezdxf
doc = ezdxf.new(setup=False)
doc.header["$INSUNITS"] = 6 # metres
msp = doc.modelspace()
msp.add_line((0.0, 0.0), (0.6, 0.0), dxfattribs={"layer": "PRODUCT"})
msp.add_line((10.0, 0.0), (15.0, 0.0), dxfattribs={"layer": "PRODUCT"})
msp.add_text("NOTES", height=0.25, dxfattribs={"layer": "TEXT"}).set_placement((0.0, 1.0))
path = tmp_path / "metre_pid.dxf"
doc.saveas(path)
result = (await client.call_tool("pipe_takeoff", {"pid_path": str(path)})).structured_content
assert result["units"]["pid"]["inferred"] == "m"
assert result["totals"]["net_m"] == pytest.approx(5.6) # 0.6 + 5.0
async def test_pipe_takeoff_measures_on_the_layout_and_writes_the_workbook(client, plant, tmp_path):
output = tmp_path / "takeoff.xlsx"
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(output),
"lang": "tr",
}
result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
assert result["length_source"] == "layout"
assert result["scale"]["verdict"] == plant["scale_verdict"]
files = result["files"]
assert [Path(p).name for p in files["csv"]] == CSV_NAMES
assert all(Path(p).exists() for p in files["csv"])
if files["xlsx"] is None:
assert files["refused"]["capability"] == "xlsx_write"
else:
assert Path(files["xlsx"]) == output
assert output.exists()
async def test_pid_lengths_on_the_schematic_pid_are_refused_without_force(client, plant):
arguments = {"pid_path": plant["pid"], "layout_path": plant["layout"], "length_source": "pid"}
result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
text = result.content[0].text
assert "the scale check calls this P&ID schematic" in text
assert "force=True" in text
async def test_force_measures_on_the_pid_and_marks_it_unverified(client, plant):
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"length_source": "pid",
"force": True,
}
result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
assert (result["length_source"], result["scale_verified"]) == ("pid", False)
assert result["files"] is None # no output named, nothing written
async def test_an_unknown_language_is_refused_before_anything_is_read(client, tmp_path):
arguments = {"pid_path": str(tmp_path / "never_read.dxf"), "lang": "de"}
result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
assert "lang: 'de' unknown; choose from tr, en, ru" in result.content[0].text
async def test_the_drawings_are_never_modified(client, plant, tmp_path):
before = {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")}
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(tmp_path / "t.xlsx"),
}
await client.call_tool("pipe_takeoff", arguments)
assert {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")} == before
async def test_the_tool_says_it_writes_a_file_and_destroys_nothing(client):
tools = {tool.name: tool for tool in await client.list_tools()}
annotations = tools["pipe_takeoff"].annotations
assert annotations.readOnlyHint is False
assert annotations.destructiveHint is False
async def test_cable_takeoff_gives_every_synthetic_load_its_rounded_cable(client, plant, tmp_path):
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(tmp_path / "cables.xlsx"),
"lang": "ru",
}
result = (await client.call_tool("cable_takeoff", arguments)).structured_content
rows = {row["tag"]: row for row in result["rows"]}
for expected in plant["cable_rows"]:
assert rows[expected["tag"]]["cable_m"] == expected["roundup_m"], expected["tag"]
assert Path(result["files"]["csv"][0]).name == "cables_metraj.csv"
async def test_cable_takeoff_without_a_layout_leaves_every_length_empty(client, plant):
result = (
await client.call_tool("cable_takeoff", {"pid_path": plant["pid"]})
).structured_content
assert result["rows"], "the synthetic P&ID carries electrical loads"
assert all(row["cable_m"] is None for row in result["rows"])
no_layout = {item["tag"] for item in result["open_items"] if item["item"] == "no_layout"}
assert no_layout == {row["tag"] for row in result["rows"]}
async def test_a_malformed_section_rule_is_refused_by_path(client, plant):
arguments = {"pid_path": plant["pid"], "section_rules": [{"max_kw": 5}]}
result = await client.call_tool("cable_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
assert "section_rules[0].section: expected a non-empty text" in result.content[0].text
async def test_the_cable_tool_says_it_writes_a_file_and_destroys_nothing(client):
tools = {tool.name: tool for tool in await client.list_tools()}
annotations = tools["cable_takeoff"].annotations
assert annotations.readOnlyHint is False
assert annotations.destructiveHint is False