Files
autocad-mcp-pro/tests/test_pid_deliverables.py
T
Umut CANandClaude Opus 5 346eac4ac6 fix(task-15): instrument connected_to follows junction ends and never names its own wire
instrument_index only resolved {"node"} ends of a signal line, so a
transmitter tapping the middle of a pipe (a {"junction"} end) reported
connected_to="" and an instrument on untagged equipment fell back to the
signal line's own number — which Task 13 always stamps, so the handle
fallback was unreachable and every untagged mounting was labelled with
its wire. Spec 9.5's "line numbers" clause is exactly the junction case.

_far_ends now names the far node's tag (its handle when untagged) and,
at a junction, the line number of every line the junction joins (that
line's handle when unnumbered). Two tests exercise both mountings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:02:11 +03:00

215 lines
7.7 KiB
Python

"""Instrument index, line list and equipment list are derived from the graph."""
from __future__ import annotations
import csv
import pytest
from fastmcp.exceptions import ToolError
from engineering.pid.deliverables import (
deliverable,
equipment_list,
instrument_index,
line_list,
natural_key,
)
from engineering.pid.drawlines import draw_line
from engineering.pid.graph import build_graph
from engineering.pid.insert import place_symbol
pytestmark = pytest.mark.asyncio
INDEX_KEYS = [
"tag",
"func",
"loop",
"description",
"type",
"location",
"connected_to",
"signal_lines",
"handle",
"confidence",
]
LINE_KEYS = [
"line_number",
"line_class",
"size",
"service",
"spec",
"insulation",
"from_tag",
"from_port",
"to_tag",
"to_port",
"length_mm",
"handle",
"number_source",
]
EQUIP_KEYS = [
"tag",
"kind",
"symbol",
"description",
"ports",
"connected_lines",
"unconnected_ports",
"handle",
"confidence",
]
async def _pid(backend):
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101", desc="Feed pump")
vessel = await place_symbol(backend, "vertical_vessel", 220, 120, tag="V-201")
cv = await place_symbol(backend, "globe", 160, 106, tag="FCV-101", actuator="diaphragm")
fic = await place_symbol(
backend, "instrument", 160, 160, tag="FIC-101", type="dcs", location="primary"
)
await place_symbol(
backend, "instrument", 100, 160, tag="PT-10", type="discrete", location="field"
)
await draw_line(
backend,
{"handle": pump["handle"], "port": "discharge"},
{"handle": cv["handle"], "port": "in"},
size="100",
service="P",
spec="CS1",
)
await draw_line(
backend,
{"handle": cv["handle"], "port": "out"},
{"handle": vessel["handle"], "port": "N3"},
size="100",
service="P",
spec="CS1",
)
await draw_line(
backend,
{"handle": fic["handle"]},
{"handle": cv["handle"], "port": "signal"},
line_class="pneumatic",
)
# A hand-drawn line off V-201's N4 nozzle: no XDATA, no label. The reader
# takes it as an edge (it ends on a port) with ``line_number: None`` — the
# untagged row spec 9.5 says sorts last.
n4 = vessel["ports"]["N4"]
await backend.entity_create_line(
n4["x"], n4["y"], n4["x"] + 40, n4["y"], layer="PROCESS-PIPING-MAIN"
)
return await build_graph(backend)
async def test_instrument_index_rows(backend):
graph = await _pid(backend)
rows = instrument_index(graph)
assert [list(r) for r in rows] == [INDEX_KEYS] * 2
assert [r["tag"] for r in rows] == ["FIC-101", "PT-10"]
fic = rows[0]
assert fic["description"] == "Flow Indicating Controller"
assert fic["type"] == "dcs" and fic["location"] == "primary"
assert fic["connected_to"] == "FCV-101" and fic["signal_lines"] == 1
assert rows[1]["signal_lines"] == 0 and rows[1]["connected_to"] == ""
async def test_line_list_rows(backend):
graph = await _pid(backend)
rows = line_list(graph)
assert [list(r) for r in rows] == [LINE_KEYS] * 4
numbered = [r for r in rows if r["line_number"]]
# The signal line carries no size/service/spec, so its default-format
# number is the bare sequence "3" (empty fields drop out, spec 6.4) — and
# a natural sort files 3 before 100.
assert [r["line_number"] for r in numbered] == ["3", "100-P-1-CS1", "100-P-2-CS1"]
signal = numbered[0]
assert signal["line_class"] == "pneumatic" and signal["number_source"] == "xdata"
assert signal["from_tag"] == "FIC-101" and signal["to_tag"] == "FCV-101"
assert signal["to_port"] == "signal"
first = numbered[1]
assert first["from_tag"] == "P-101" and first["from_port"] == "discharge"
assert first["to_tag"] == "FCV-101" and first["length_mm"] > 0
assert rows[-1]["line_number"] is None, "untagged rows sort last"
assert rows[-1]["from_tag"] == "V-201" and rows[-1]["from_port"] == "N4"
assert rows[-1]["to_tag"] is None and rows[-1]["number_source"] is None
async def test_equipment_list_rows(backend):
graph = await _pid(backend)
rows = equipment_list(graph)
assert [list(r) for r in rows] == [EQUIP_KEYS] * 3
assert [r["tag"] for r in rows] == ["FCV-101", "P-101", "V-201"]
pump = next(r for r in rows if r["tag"] == "P-101")
assert pump["description"] == "Feed pump" and pump["ports"] == 2
assert pump["connected_lines"] == 1
assert pump["unconnected_ports"] == "suction"
def test_natural_sort():
assert sorted(["P-10", "P-2", "P-1A"], key=natural_key) == ["P-1A", "P-2", "P-10"]
assert natural_key(None) > natural_key("Z-999"), "untagged rows sort last"
async def test_csv_is_written_inside_allowed_paths(backend, tmp_path, monkeypatch):
import config
monkeypatch.setattr(config.settings, "allowed_paths", [tmp_path.resolve()])
await _pid(backend)
target = tmp_path / "index.csv"
result = await deliverable(backend, "instrument_index", csv_path=str(target))
assert result["count"] == 2 and result["csv_path"] == str(target.resolve())
with target.open(newline="", encoding="utf-8") as fh:
reader = list(csv.reader(fh))
assert reader[0] == INDEX_KEYS and reader[1][0] == "FIC-101"
outside = tmp_path.parent / "outside.csv"
with pytest.raises(ToolError, match="not inside any allowed directory"):
await deliverable(backend, "line_list", csv_path=str(outside))
assert not outside.exists(), "a refused path is never written"
async def test_instrument_tapping_a_pipe_reports_the_pipe_line_number(backend):
# A transmitter whose signal line lands on the middle of a process line
# (not on a port) — the far end is a junction, and spec 9.5 says the row
# names the line number of what the junction joins.
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101")
vessel = await place_symbol(backend, "vertical_vessel", 300, 120, tag="V-201")
pipe = await draw_line(
backend,
{"handle": pump["handle"], "port": "discharge"},
{"handle": vessel["handle"], "port": "N3"},
size="100",
service="P",
spec="CS1",
)
pt = await place_symbol(backend, "instrument", 193.5, 168, tag="PT-101", location="field")
await draw_line(
backend, {"handle": pt["handle"]}, {"x": 193.5, "y": 128}, line_class="pneumatic"
)
graph = await build_graph(backend)
signal = next(e for e in graph["edges"] if e["line_class"] == "pneumatic")
assert "junction" in (signal["to"] or {}), "the fixture must produce a junction end"
rows = instrument_index(graph)
row = next(r for r in rows if r["tag"] == "PT-101")
assert row["connected_to"] == pipe["line_number"] == "100-P-1-CS1"
assert row["signal_lines"] == 1
async def test_instrument_on_untagged_equipment_reports_its_handle(backend):
# The vessel has no tag, so the only honest identity is its handle — never
# the signal line's own number (which Task 13 always stamps, so a fallback
# to it would silently mislabel every untagged mounting).
vessel = await place_symbol(backend, "vertical_vessel", 300, 120)
lt = await place_symbol(backend, "instrument", 300, 200, tag="LT-201", location="field")
signal = await draw_line(
backend,
{"handle": lt["handle"]},
{"handle": vessel["handle"], "port": "N1"},
line_class="electric",
)
graph = await build_graph(backend)
row = next(r for r in instrument_index(graph) if r["tag"] == "LT-201")
assert row["connected_to"] == vessel["handle"]
assert row["connected_to"] != signal["line_number"]
assert row["signal_lines"] == 1