Files
autocad-mcp-pro/tests/test_arch_critique.py
Umut CANandClaude Opus 5.5 6e004cc658 feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts
The three track-F branches are merged; this is the integration that makes the
twelve SECTION 25 tools one surface.

- engineering/arch/spec.py: a whole plan from one document - walls with their
  openings, stairs, room labels measured off the walls just drawn, the
  exterior chains and the schedules - inside one transaction. Everything the
  model, the wall engine or the stair symbol can refuse is refused before the
  transaction opens, by path; a room point in no closed face rolls it back,
  and so does a cancelled request, under the anyio shield of pid/spec.py.
  EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor,
  computed by hand in the tests.
- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm,
  one issue per pair of walls, measured to the wall body so an L corner and a
  T stem both read as joined) and arch_opening_clash. One index per critique
  run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus
  has a positive and a negative fixture.
- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains
  arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64;
  the arch tag ranks after mech, so SECTION 25 files as one `architecture`
  group - 241 tools in 26 groups.
- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias
  record, the shared-command allowlist reconciled with what the merged records
  claim, the inventory regenerated, the registry snapshot retaken, and the
  README figures taken from check_doc_numbers rather than typed.
- Two more Turkish golden cases close the reconciliation's alias gaps
  ("vaziyet planı çiz" -> arch_plan_from_spec, "oda alanlarını hesapla" ->
  arch_rooms_detect); the arch records carry vaziyet / alan / oda / kat planı
  in diacritic and ASCII-folded spellings, because the search tokenizer splits
  on a non-ASCII letter. BOUNDARY and AREA stay with boundary_trace and
  analysis_measure_entity on purpose, recorded in the arch alias comment.
  Tuning top-1 73/84, top-3 83/84; holdout top-3 15/15.
- tests/test_token_suite.py: TOOL_PACKS=core,mech now drops the arch pack
  too (measured 193 = 236 - 9 - 22 - 12).

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

266 lines
10 KiB
Python

"""Three architectural focuses, each with a positive and a negative fixture.
All three are silent on a drawing with no ACADMCP_ARCH record, so
`focus=None` stays cheap - and honest - on a mechanical sheet, a P&ID or a
foreign plan of plain lines.
A fixture writes the records onto entities of the layers they live on in a
real plan (a wall record on a wall line, an opening record on a door line, a
room record on its label), then asks the index. The geometry of the gaps and
clashes is computed by hand in each test.
"""
from __future__ import annotations
import pytest
from engineering.arch.critique import (
ARCH_FOCUSES,
GAP_MM,
body_distance,
build_index,
has_arch_content,
issues_for,
opening_clashes,
point_in_loop,
wall_gaps,
)
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.model import (
APP_ID,
Room,
opening_from_dict,
to_payload,
to_values,
wall_from_dict,
)
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
WALL_LAYER = ARCH_ROLE_LAYER["wall"]
def _wall(wall_id, axis, thickness=200.0, **kw):
return wall_from_dict({"id": wall_id, "axis": axis, "thickness": thickness, **kw})
def _opening(opening_id, wall, offset, width, kind="door"):
return opening_from_dict(
{"id": opening_id, "wall": wall, "kind": kind, "offset": offset, "width": width}
)
async def _record(backend, obj, layer, at=(0.0, 0.0)):
"""A short line on ``layer`` carrying ``obj``'s ACADMCP_ARCH record."""
ent = await backend.entity_create_line(at[0], at[1], at[0] + 10.0, at[1], layer=layer)
await backend.entity_set_xdata(ent.handle, APP_ID, to_values(to_payload(obj)))
return ent.handle
async def _square(backend, x0, y0, x1, y1, layer=WALL_LAYER):
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
handles = []
for a, b in zip(corners, corners[1:] + corners[:1], strict=True):
ent = await backend.entity_create_line(a[0], a[1], b[0], b[1], layer=layer)
handles.append(ent.handle)
return handles
# -- the closed enum and the silence rule ---------------------------------------
def test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
assert ARCH_FOCUSES == ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash")
for focus in ARCH_FOCUSES:
assert focus in ALL_CRITIQUE_FOCUSES
assert GAP_MM == 50.0
@pytest.mark.asyncio
async def test_every_focus_is_silent_on_a_drawing_with_no_arch_record(backend):
"""A closed room of plain lines on a WALL layer, nothing labelled, two
wall-like lines 30 mm apart - every trigger present, and not one issue,
because nothing on the drawing claims to be our plan."""
await _square(backend, 0.0, 0.0, 4000.0, 4000.0, layer="WALLS")
await backend.entity_create_line(5000.0, 0.0, 9000.0, 0.0, layer="WALLS")
await backend.entity_create_line(9030.0, 0.0, 12000.0, 0.0, layer="WALLS")
index = await build_index(backend)
assert has_arch_content(index) is False
for focus in ARCH_FOCUSES:
assert issues_for(focus, index) == []
# -- arch_wall_gap -----------------------------------------------------------------
def test_body_distance_is_zero_inside_and_on_the_edge():
# a 200 mm centre wall from (0, 0) to (4000, 0): body x 0..4000, y -100..100
band = (-100.0, 100.0)
assert body_distance((2000.0, 50.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0
assert body_distance((4000.0, 100.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0
assert body_distance((4040.0, 0.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(40.0)
# 30 past the end and 40 past the face: the corner distance, 50
assert body_distance((4030.0, 140.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(50.0)
def test_two_collinear_walls_40_mm_apart_are_one_gap():
walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4040, 0], [8000, 0]])]
(row,) = wall_gaps(walls)
assert row["walls"] == ("W1", "W2")
assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9)
def test_an_l_corner_and_a_t_junction_are_joined():
l_corner = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4000, 0], [4000, 3000]])]
# the stem ends on the crossing wall's axis, inside its body
t_junction = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 0], [3000, 3000]], 100)]
assert wall_gaps(l_corner) == []
assert wall_gaps(t_junction) == []
def test_a_stem_stopping_30_mm_short_of_the_crossing_face_is_a_gap():
# crossing wall body y -100..100; the stem starts at y = 130
walls = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 130], [3000, 3000]], 100)]
(row,) = wall_gaps(walls)
assert row["end"] == "W2.start" and row["other"] == "W1"
assert row["gap_mm"] == pytest.approx(30.0, abs=1e-9)
def test_a_wall_further_than_50_mm_away_is_not_a_gap():
walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4051, 0], [8000, 0]])]
assert wall_gaps(walls) == []
def test_a_wall_that_nearly_closes_on_itself_leaks():
# the last point stops 40 mm above the first segment's face (y = 100)
ring = _wall("W1", [[0, 0], [4000, 0], [4000, 3000], [0, 3000], [0, 140]])
(row,) = wall_gaps([ring])
assert row["walls"] == ("W1", "W1") and row["end"] == "W1.end"
assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9)
@pytest.mark.asyncio
async def test_wall_gap_fires_on_the_drawing(backend):
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0))
issues = issues_for("arch_wall_gap", await build_index(backend))
assert len(issues) == 1
assert issues[0].severity == "error"
assert issues[0].detail["gap_mm"] == pytest.approx(40.0)
assert len(issues[0].handles) == 2
@pytest.mark.asyncio
async def test_wall_gap_is_quiet_on_a_joined_pair(backend):
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
await _record(backend, _wall("W2", [[4000, 0], [4000, 3000]]), WALL_LAYER, at=(4000.0, 0.0))
assert issues_for("arch_wall_gap", await build_index(backend)) == []
# -- arch_opening_clash ------------------------------------------------------------
def test_overlap_and_overrun_are_both_reported():
wall = _wall("W1", [[0, 0], [5000, 0]])
rows = opening_clashes(
[wall],
[
_opening("D1", "W1", 1000, 900),
_opening("W2", "W1", 1500, 1200, "window"),
_opening("D3", "W1", 4500, 900),
],
)
assert [row["openings"] for row in rows] == [("D1", "W2"), ("D3",)]
assert "overlap (1000-1900 and 1500-2700)" in rows[0]["reason"]
assert "runs 400 mm past the end" in rows[1]["reason"]
def test_an_opening_across_an_axis_vertex_and_one_on_a_missing_wall():
wall = _wall("W1", [[0, 0], [5000, 0], [5000, 4000]])
rows = opening_clashes([wall], [_opening("D1", "W1", 4500, 900), _opening("D2", "W9", 0, 900)])
assert "across the axis vertex at 5000" in rows[0]["reason"]
assert "'W9' has no record" in rows[1]["reason"]
@pytest.mark.asyncio
async def test_opening_clash_fires_on_the_drawing(backend):
await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER)
await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"])
await _record(backend, _opening("D2", "W1", 1500, 900), ARCH_ROLE_LAYER["door"], (1500.0, 0.0))
(issue,) = issues_for("arch_opening_clash", await build_index(backend))
assert issue.severity == "error"
assert issue.detail == {"openings": ["D1", "D2"], "wall": "W1"}
assert len(issue.handles) == 2
@pytest.mark.asyncio
async def test_opening_clash_is_quiet_on_two_openings_that_only_touch(backend):
await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER)
await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"])
await _record(backend, _opening("D2", "W1", 1900, 900), ARCH_ROLE_LAYER["door"], (1900.0, 0.0))
assert issues_for("arch_opening_clash", await build_index(backend)) == []
# -- arch_room_unlabelled --------------------------------------------------------------
def test_point_in_loop():
loop = [[0, 0], [4000, 0], [4000, 3000], [0, 3000]]
assert point_in_loop((2000.0, 1500.0), loop) is True
assert point_in_loop((4000.0, 1500.0), loop) is True # on an edge
assert point_in_loop((4100.0, 1500.0), loop) is False
async def _ring(backend):
"""A 200 mm ring on axis (0,0)-(4000,4000): its faces drawn as lines on the
wall layer, its record carried by the first outer face line - where a real
plan carries it. Interior 3800 x 3800."""
outer = await _square(backend, -100.0, -100.0, 4100.0, 4100.0)
await _square(backend, 100.0, 100.0, 3900.0, 3900.0)
ring = _wall("W1", [[0, 0], [4000, 0], [4000, 4000], [0, 4000]], closed=True)
await backend.entity_set_xdata(outer[0], APP_ID, to_values(to_payload(ring)))
@pytest.mark.asyncio
async def test_room_unlabelled_fires_on_a_closed_room_with_no_label(backend):
await _ring(backend)
index = await build_index(backend)
(issue,) = issues_for("arch_room_unlabelled", index)
assert issue.severity == "warning"
assert issue.detail["area_mm2"] == pytest.approx(3800.0 * 3800.0)
assert "14.44 m²" in issue.message
@pytest.mark.asyncio
async def test_room_unlabelled_is_quiet_once_the_room_carries_a_label(backend):
await _ring(backend)
room = Room(id="R1", name="ROOM", number="01", at=(2000.0, 2000.0), area=3800.0 * 3800.0)
await _record(backend, room, ARCH_ROLE_LAYER["room"], at=(2000.0, 2000.0))
assert issues_for("arch_room_unlabelled", await build_index(backend)) == []
# -- the dispatch --------------------------------------------------------------------
@pytest.mark.asyncio
async def test_the_dispatch_entries_share_one_index_per_run(backend):
from engineering.arch.critique import ARCH_DISPATCH
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
shared: dict = {}
for focus in ARCH_FOCUSES:
check = ARCH_DISPATCH[focus]
assert check.needs_shared is True
await check(backend, shared)
assert list(shared) == ["arch_index"]
@pytest.mark.asyncio
async def test_run_critique_reaches_the_arch_focuses(backend):
from engineering.critique import run_critique
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0))
(issue,) = await run_critique(backend, ["arch_wall_gap"])
assert issue.focus == "arch_wall_gap" and issue.severity == "error"
assert "stops 40 mm short of wall" in issue.message