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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ad5a32a34a
commit
6e004cc658
@@ -1,6 +1,6 @@
|
||||
"""Golden query set for tool discovery: English in, one right tool out.
|
||||
|
||||
Ninety-one cases (76 tuning + 15 holdout) split into a **tuning** set and a
|
||||
Ninety-nine cases (84 tuning + 15 holdout) split into a **tuning** set and a
|
||||
**holdout** set. The split is
|
||||
the point of the file. Ranking work is measured against
|
||||
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
||||
@@ -190,6 +190,20 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
|
||||
GoldenCase("put an m12 hex bolt on the drawing", "std_part_insert", "paraphrase"),
|
||||
GoldenCase("draw the parts list table on the sheet", "bom_table", "paraphrase"),
|
||||
GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"),
|
||||
# v1.6 track F - the architectural plan. Written before the merged alias
|
||||
# records were measured against them; a miss is fixed by adding vocabulary
|
||||
# to that tool's record in discovery/aliases.py, never by moving the
|
||||
# expectation. Turkish queries again: this shop searches in Turkish, and
|
||||
# the last two close the reconciliation's gaps - a site plan ("vaziyet")
|
||||
# and the room areas ("alan"), neither of which any record carried.
|
||||
GoldenCase("draw the walls of a floor plan", "arch_wall", "paraphrase"),
|
||||
GoldenCase("put a door in this wall", "arch_opening", "paraphrase"),
|
||||
GoldenCase("label the room with its measured area", "arch_room", "paraphrase"),
|
||||
GoldenCase("draw the whole floor plan from a spec", "arch_plan_from_spec", "paraphrase"),
|
||||
GoldenCase("kapı pencere listesi", "arch_schedule", "synonym"),
|
||||
GoldenCase("merdiven çiz", "arch_stair", "synonym"),
|
||||
GoldenCase("vaziyet planı çiz", "arch_plan_from_spec", "synonym"),
|
||||
GoldenCase("oda alanlarını hesapla", "arch_rooms_detect", "synonym"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""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
|
||||
@@ -0,0 +1,162 @@
|
||||
"""``arch_plan_from_spec``: validation by path, one transaction, the shielded rollback.
|
||||
|
||||
Headless ezdxf against a real document. The areas are computed by hand from
|
||||
``EXAMPLE_SPEC``: the exterior ring runs on axis (0,0)-(9000,6000) at 250 mm,
|
||||
so its inner faces are x = 125 / 8875 and y = 125 / 5875; the interior wall
|
||||
runs on x = 5000 at 100 mm, faces x = 4950 / 5050. Room 01 is therefore
|
||||
(4950 - 125) x (5875 - 125) = 4825 x 5750 = 27 743 750 mm², room 02
|
||||
(8875 - 5050) x 5750 = 3825 x 5750 = 21 993 750 mm² - the net floor, not the
|
||||
axis area.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec, validate_spec
|
||||
|
||||
ROOM_01 = 4825.0 * 5750.0
|
||||
ROOM_02 = 3825.0 * 5750.0
|
||||
|
||||
|
||||
def _spec(**over) -> dict:
|
||||
spec = copy.deepcopy(EXAMPLE_SPEC)
|
||||
spec.update(over)
|
||||
return spec
|
||||
|
||||
|
||||
async def _count(backend) -> int:
|
||||
return len(await backend.entity_list(limit=5000))
|
||||
|
||||
|
||||
# -- validation, before anything is drawn ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("over", "message"),
|
||||
[
|
||||
({"colour": "red"}, r"^spec: unknown key\(s\) colour; keys are sheet"),
|
||||
({"walls": []}, r"^walls: name at least one wall"),
|
||||
({"lang": "de"}, r"^lang: unknown language 'de'; languages are en, tr"),
|
||||
({"sheet": {"scale": 0}}, r"^sheet\.scale: must be a finite number greater than zero"),
|
||||
({"chains": {"sides": ["north"]}}, r"^chains\.sides: \['north'\]"),
|
||||
(
|
||||
{"schedules": [{"kind": "stairs", "at": [0, 0]}]},
|
||||
r"^schedules\[0\]\.kind: 'stairs' is not a schedule",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_document_is_refused_by_path(backend, over, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
await draw_plan_from_spec(backend, _spec(**over))
|
||||
assert await _count(backend) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bad_wall_is_named_by_its_index(backend):
|
||||
spec = _spec()
|
||||
spec["walls"][1]["thickness"] = -100
|
||||
with pytest.raises(ValueError, match=r"^walls\[1\]\.thickness: must be greater than zero"):
|
||||
await draw_plan_from_spec(backend, spec)
|
||||
assert await _count(backend) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_overlapping_openings_are_refused_naming_both(backend):
|
||||
spec = _spec()
|
||||
spec["openings"][0]["offset"] = 1000
|
||||
spec["openings"].append(
|
||||
{"id": "D9", "wall": "EXT", "kind": "door", "offset": 1500, "width": 900}
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"openings D1 and D9 overlap on wall EXT"):
|
||||
await draw_plan_from_spec(backend, spec)
|
||||
assert await _count(backend) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_typed_room_area_is_refused(backend):
|
||||
spec = _spec()
|
||||
spec["rooms"][0]["area"] = 27.0e6
|
||||
with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured"):
|
||||
await draw_plan_from_spec(backend, spec)
|
||||
assert await _count(backend) == 0
|
||||
|
||||
|
||||
def test_validate_spec_turns_dicts_into_the_model():
|
||||
norm = validate_spec(EXAMPLE_SPEC)
|
||||
assert [w.id for w in norm["walls"]] == ["EXT", "INT"]
|
||||
assert [o.id for o in norm["openings"]] == ["D1", "D2", "W1", "W2"]
|
||||
assert norm["scale"] == 50.0 and norm["lang"] == "en"
|
||||
assert norm["chains"] == ("bottom", "left")
|
||||
assert [s["kind"] for s in norm["schedules"]] == ["doors", "windows", "rooms"]
|
||||
|
||||
|
||||
# -- the whole plan --------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_example_plan_draws_in_one_transaction_and_measures_its_rooms(backend):
|
||||
from engineering.arch.draw import read_plan
|
||||
|
||||
result = await draw_plan_from_spec(backend, EXAMPLE_SPEC)
|
||||
assert result["walls"] == ["EXT", "INT"]
|
||||
assert result["openings"] == ["D1", "D2", "W1", "W2"]
|
||||
assert result["omitted"] == []
|
||||
assert len(result["rooms"]) == 2 and len(result["schedules"]) == 3
|
||||
assert result["critique"] == []
|
||||
plan = await read_plan(backend)
|
||||
areas = {room.number: room.area for room in plan["rooms"]}
|
||||
assert areas["01"] == pytest.approx(ROOM_01, abs=0.5)
|
||||
assert areas["02"] == pytest.approx(ROOM_02, abs=0.5)
|
||||
assert backend.get_plan_spec()["layer_set_id"] == "arch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_room_outside_every_face_rolls_the_whole_plan_back(backend):
|
||||
spec = _spec()
|
||||
spec["rooms"][1]["at"] = [20000, 20000]
|
||||
with pytest.raises(ValueError, match=r"^rooms\[1\]: "):
|
||||
await draw_plan_from_spec(backend, spec)
|
||||
assert await _count(backend) == 0
|
||||
begun = await backend.transaction_begin()
|
||||
assert begun["ok"] is True, "the rollback must have closed the transaction"
|
||||
await backend.transaction_rollback()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cancelled_request_still_rolls_back(backend, monkeypatch):
|
||||
"""The MCP SDK cancels a request by cancelling an anyio scope; the rollback
|
||||
runs under a shield, so the cancelled plan leaves nothing behind."""
|
||||
from engineering.arch import rooms
|
||||
|
||||
async def hang(*_args, **_kwargs):
|
||||
await anyio.sleep_forever()
|
||||
|
||||
monkeypatch.setattr(rooms, "label_room", hang)
|
||||
with anyio.move_on_after(0.5):
|
||||
await draw_plan_from_spec(backend, EXAMPLE_SPEC)
|
||||
assert await _count(backend) == 0
|
||||
begun = await backend.transaction_begin()
|
||||
assert begun["ok"] is True
|
||||
await backend.transaction_rollback()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_is_registered_and_refuses_through_tool_error():
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
|
||||
async with Client(server.mcp) as client:
|
||||
await client.call_tool("drawing_new", {})
|
||||
result = (
|
||||
await client.call_tool("arch_plan_from_spec", {"spec": EXAMPLE_SPEC})
|
||||
).structured_content
|
||||
assert result["critique"] == []
|
||||
with pytest.raises(ToolError, match=r"walls: name at least one wall"):
|
||||
await client.call_tool("arch_plan_from_spec", {"spec": {"walls": []}})
|
||||
@@ -163,14 +163,14 @@ def test_the_cache_arithmetic_follows_the_published_multipliers():
|
||||
|
||||
def test_the_idle_lane_covers_the_four_advertised_surfaces(report):
|
||||
"""Default, lean, search - and TOOL_PACKS=core,mech, the mechanical client
|
||||
that never opens a P&ID or a live seat: the default surface minus exactly
|
||||
the pid and settings packs."""
|
||||
that never opens a P&ID, a live seat or a floor plan: the default surface
|
||||
minus exactly the pid, settings and arch packs (track F added `arch`)."""
|
||||
variants = {row["variant"]: row for row in report["idle"]}
|
||||
assert set(variants) == {"default", "lean", "packs_core_mech", "search"}
|
||||
assert variants["default"]["advertised_tools"] > 100
|
||||
assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES)
|
||||
assert variants["search"]["advertised_tools"] == 2
|
||||
dropped = len(server.PACK_TOOL_NAMES["pid"]) + len(server.PACK_TOOL_NAMES["settings"])
|
||||
dropped = sum(len(server.PACK_TOOL_NAMES[pack]) for pack in ("pid", "settings", "arch"))
|
||||
assert variants["packs_core_mech"]["advertised_tools"] == (
|
||||
variants["default"]["advertised_tools"] - dropped
|
||||
)
|
||||
|
||||
@@ -11,6 +11,11 @@ SECTION 22's three standard-parts tools and SECTION 23's five annotation
|
||||
symbols - 14 in all. SECTION 24 (the sheet: frames, title blocks, revisions,
|
||||
the parts list, balloons, xrefs, images, DWG) stays in `core` - universal
|
||||
drafting, not a vertical - and files under its own `sheet` group.
|
||||
|
||||
v1.6 track F adds the `arch` pack: SECTION 25's twelve architectural tools -
|
||||
the wall network, openings, stairs, rooms and their reader, schedules, the
|
||||
grid and symbols, the furniture and sanitary catalogue, the exterior chains
|
||||
and the whole plan from one spec. Lean gains four of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -115,6 +120,28 @@ SHEET_TOOLS = {
|
||||
"drawing_export_dwg",
|
||||
}
|
||||
|
||||
ARCH_TOOLS = {
|
||||
"arch_wall",
|
||||
"arch_opening",
|
||||
"arch_stair",
|
||||
"arch_room",
|
||||
"arch_rooms_detect",
|
||||
"arch_schedule",
|
||||
"arch_grid",
|
||||
"arch_symbol",
|
||||
"arch_catalogue_list",
|
||||
"arch_catalogue_insert",
|
||||
"arch_dimension_chains",
|
||||
"arch_plan_from_spec",
|
||||
}
|
||||
|
||||
LEAN_ARCH_ESSENTIALS = {
|
||||
"arch_wall",
|
||||
"arch_opening",
|
||||
"arch_room",
|
||||
"arch_plan_from_spec",
|
||||
}
|
||||
|
||||
LEAN_MECH_ESSENTIALS = {
|
||||
"mech_part_draw",
|
||||
"mech_view_add",
|
||||
@@ -132,7 +159,7 @@ LEAN_SETTINGS_ESSENTIALS = {
|
||||
}
|
||||
|
||||
#: Declaration order - what `tool_packs["available"]` reports.
|
||||
ALL_PACKS = ["core", "pid", "settings", "mech"]
|
||||
ALL_PACKS = ["core", "pid", "settings", "mech", "arch"]
|
||||
#: `tool_packs["enabled"]` is sorted, which is no longer the same list.
|
||||
ALL_PACKS_ENABLED = sorted(ALL_PACKS)
|
||||
|
||||
@@ -145,15 +172,19 @@ async def _restore(monkeypatch):
|
||||
|
||||
|
||||
def test_pack_registry_names_real_tools_and_only_them():
|
||||
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
|
||||
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech", "arch")
|
||||
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
|
||||
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
|
||||
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
|
||||
assert server.PACK_TOOL_NAMES["arch"] == frozenset(ARCH_TOOLS)
|
||||
assert len(SETTINGS_TOOLS) == 22
|
||||
assert len(MECH_TOOLS) == 14
|
||||
assert len(ARCH_TOOLS) == 12
|
||||
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
|
||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
|
||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
|
||||
for pack in ("pid", "settings", "mech"):
|
||||
assert not (server.PACK_TOOL_NAMES["arch"] & server.PACK_TOOL_NAMES[pack]), pack
|
||||
assert not (SHEET_TOOLS & set().union(*server.PACK_TOOL_NAMES.values())), (
|
||||
"SECTION 24 is core drafting, not a vertical pack"
|
||||
)
|
||||
@@ -173,6 +204,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
|
||||
assert PID_TOOLS <= disabled
|
||||
assert SETTINGS_TOOLS <= disabled
|
||||
assert MECH_TOOLS <= disabled
|
||||
assert ARCH_TOOLS <= disabled
|
||||
assert not (SHEET_TOOLS & disabled)
|
||||
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
|
||||
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
|
||||
@@ -193,7 +225,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "tool_packs", "all")
|
||||
info = await server._apply_tool_profile("full")
|
||||
disabled = set(info["disabled_tools"])
|
||||
assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS) & disabled)
|
||||
assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS | ARCH_TOOLS) & disabled)
|
||||
assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED
|
||||
|
||||
|
||||
@@ -222,19 +254,24 @@ async def test_lean_intersects_with_packs(monkeypatch):
|
||||
async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Spec §8.2: lean = 60 (55 + the five mechanical/sheet essentials). The
|
||||
five settings essentials are core-pack tools, so `TOOL_PACKS=core` leaves
|
||||
them on a lean surface; no environment tool is lean."""
|
||||
"""Spec §8.2: lean = 64 (55 + the five mechanical/sheet essentials + track
|
||||
F's four architectural ones). The five settings essentials are core-pack
|
||||
tools, so `TOOL_PACKS=core` leaves them on a lean surface; no environment
|
||||
tool is lean."""
|
||||
assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
||||
assert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
||||
assert LEAN_ARCH_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
||||
assert server.LEAN_TOOL_NAMES & ARCH_TOOLS == LEAN_ARCH_ESSENTIALS
|
||||
assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES)
|
||||
assert len(server.LEAN_TOOL_NAMES) == 60
|
||||
assert len(server.LEAN_TOOL_NAMES) == 64
|
||||
monkeypatch.setattr(config.settings, "tool_packs", "core")
|
||||
info = await server._apply_tool_profile("lean")
|
||||
disabled = set(info["disabled_tools"])
|
||||
assert not (LEAN_SETTINGS_ESSENTIALS & disabled)
|
||||
assert not ({"sheet_frame", "titleblock_apply"} & disabled), "sheet tools are core"
|
||||
assert info["enabled_count"] == 60 - 6, "lean minus the three pid and three mech tools"
|
||||
assert info["enabled_count"] == 64 - 10, (
|
||||
"lean minus the three pid, three mech and four arch tools"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_three_track_e_sections_file_under_their_own_groups():
|
||||
@@ -246,7 +283,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
|
||||
assert set(groups["environment"]) == SETTINGS_TOOLS
|
||||
assert set(groups["mechanical"]) == MECH_TOOLS
|
||||
assert set(groups["sheet"]) == SHEET_TOOLS
|
||||
assert len(groups) == 26 # + architecture (SECTION 25, track F)
|
||||
assert set(groups["architecture"]) == ARCH_TOOLS
|
||||
assert len(groups) == 26
|
||||
|
||||
|
||||
async def test_system_about_reports_packs():
|
||||
|
||||
+12
-31
@@ -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 (240 tools, 26 groups).
|
||||
"""Frozen snapshot of the surface (241 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
|
||||
@@ -582,39 +582,20 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
|
||||
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
|
||||
|
||||
`architecture` appeared with v1.6's track F: SECTION 25 opened with the
|
||||
plan model's wall engine (`arch_wall` / `arch_opening` / `arch_stair` /
|
||||
`arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in
|
||||
`_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather
|
||||
than under their secondary `create` / `dimension` tags.
|
||||
|
||||
Track F group R opened SECTION 25 with the rooms: `arch_room` and
|
||||
`arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch`
|
||||
and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they
|
||||
file under their secondary tags: `entity_creation` 18 -> 20,
|
||||
`entity_query` 9 -> 10. This snapshot is branch-local: the track F merge
|
||||
task recomputes it once the walls, rooms and catalogue branches have landed.
|
||||
|
||||
Track F group C opened SECTION 25 with the furniture and sanitary
|
||||
catalogue: `arch_catalogue_list` is tagged `arch` and `query`,
|
||||
`arch_catalogue_insert` `arch` and `create`. `arch` is not in
|
||||
`_GROUP_TAG_PRIORITY` on this branch, so they file under their secondary
|
||||
tags: `entity_query` 9 -> 10, `entity_creation` 18 -> 19. This snapshot is
|
||||
branch-local: the track F merge task recomputes it once the walls, rooms
|
||||
and catalogue branches have landed.
|
||||
|
||||
Group C's second task added the structural grid and the plan symbols
|
||||
(`arch_grid` / `arch_symbol`), both tagged `arch` and `create`:
|
||||
`entity_creation` 19 -> 21, for the same branch-local reason.
|
||||
|
||||
After the track F merge all eleven SECTION 25 tools file under
|
||||
`architecture` (the `arch` tag outranks `create` / `query` / `dimension`),
|
||||
so `entity_creation` and `entity_query` are back at 18 and 9.
|
||||
`architecture` appeared with v1.6's track F (12, SECTION 25: the wall
|
||||
network, openings, stairs, rooms and their reader, schedules, grid,
|
||||
symbols, the catalogue, the exterior chains and the whole plan from one
|
||||
spec). The `arch` tag is ranked directly after `mech` in
|
||||
`_GROUP_TAG_PRIORITY`, so a SECTION 25 tool that also carries `create`,
|
||||
`query` or `dimension` files under its own section; `architecture` is
|
||||
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.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 12,
|
||||
"architecture": 11,
|
||||
"architecture": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
@@ -640,4 +621,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 240
|
||||
assert sum(sizes.values()) == 241
|
||||
|
||||
Reference in New Issue
Block a user