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:
Umut CAN
2026-09-23 23:20:05 +03:00
co-authored by Claude Opus 5.5
parent ad5a32a34a
commit 6e004cc658
14 changed files with 1440 additions and 57 deletions
+15 -1
View File
@@ -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"),
)
+265
View File
@@ -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
+162
View File
@@ -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": []}})
+3 -3
View File
@@ -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
)
+47 -9
View File
@@ -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
View File
@@ -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