Files
autocad-mcp-pro/tests/test_tool_registry.py
T
Umut CANandClaude Opus 5.5 3b033033f9 feat(mech): six mech_* critique focuses, the mech pack, lean 60, aliases and the counts
The four track-B/G branches are merged; this is the integration that makes the
25 new tools one surface instead of four.

- engineering/mech/critique.py: the six focuses of spec 13, dispatched the way
  the P&ID ones are. One index per critique run, shared through run_critique's
  context, so six focuses cost one read of the drawing; a drawing with no
  ACADMCP_MECH payload yields nothing at all, which is what keeps focus=None
  cheap for a P&ID sheet or a plain DXF. Each focus has a positive and a
  negative fixture.
  Two narrowings are in the module docstring rather than hidden:
  mech_view_misaligned checks the projection axis and not the quadrant,
  because the pinned payload carries no projection angle to check it against,
  and mech_duplicate_dimension catches two dimensions in the same place rather
  than a semantic restatement, because no engine exposes what a dimension
  measured.
- The index reads what the tools actually write, not only the plan's fixture
  shape. mech_part_draw anchors its part payload on a POINT (layer MECH) and
  lists every drawn view under "views"; the plan's index read INSERT/CIRCLE
  only and one "view" per payload, so it was silent on every part the server
  draws. It now reads POINT too, expands "views" into one entry per drawn view
  (the section check looks in the section's own WCS box, the alignment check
  compares frame_bbox centres), and pages entity_list instead of one call.
  Test: a part drawn by draw_part + add_view is read and passes all six; the
  same part with its hatch erased is reported.
- Live type names, measured on AutoCAD 2026 (2026-09-23, scratch document,
  closed): ObjectName AcDbBlockReference -> BLOCKREFERENCE, AcDbRotatedDimension
  -> ROTATEDDIMENSION, AcDbAlignedDimension -> ALIGNEDDIMENSION, AcDbPoint ->
  POINT with GetBoundingBox = the point. The index reads BLOCKREFERENCE as an
  INSERT and any *DIMENSION as a dimension; a wrapper test reports the ezdxf
  entities under exactly those measured names.
- mech_thread_unrepresented uses the view engine's own MINOR_RATIO, and checks
  the thin line ISO 6410 draws: the minor diameter of an external thread, the
  major of an internal one.
- mech_bom_balloon_mismatch: identical parts share one balloon (ISO 6433
  balloons a parts-list row), so a second M12x60 is covered by the first one's
  balloon instead of reported.
- TOOL_PACK_NAMES has "mech" (the 14 tools of SECTIONS 21-23). SECTION 24
  stays core: a frame, a title block, a parts list and an xref are universal
  drafting, not a vertical. Lean gains five and reaches 60 (54 with
  TOOL_PACKS=core).
- _GROUP_TAG_PRIORITY gains sheet directly after mech; the mech group label
  becomes "mechanical". SECTION 24's last four tools (xref_attach,
  xref_manage, image_attach, drawing_export_dwg) now carry the sheet tag, so
  group, pack and SECTION are one list: 229 tools in 25 groups, and
  engineering/blocks/entity_creation/drawing return to their pre-SECTION-24
  sizes.
- Ten golden tuning cases, two of them Turkish (with the diacritic spellings
  added to the alias records next to the ASCII-folded ones). The shared
  AutoCAD command set needed no change: the merge had already resolved TABLE,
  REVCLOUD, SECTIONPLANE, INSERT, ARRAY and SAVEAS, and nothing is stale.
- Inventory regenerated, the frozen group-size snapshot retaken from the live
  registry, and the README release-snapshot / pack / lean figures taken from
  scripts/check_doc_numbers.py rather than typed.

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

614 lines
25 KiB
Python

"""server.py must never report something it does not have.
Three families of that rule live here.
**The registry must be read untransformed.** ``_registered_tools()`` backs three
things that all need the *real* inventory:
* ``system_status`` / ``system_about`` tool counts,
* ``_tool_groups()`` (the system_about per-group breakdown),
* ``_apply_tool_profile()`` (which tools get ``mcp.disable()``d).
FastMCP applies :class:`~fastmcp.server.transforms.Transform` chains at two
levels, and only one of them reaches ``mcp._list_tools()``. Shipped search mode
(``_apply_discovery_mode("search")``) adds a *server-level* transform: it swaps
the catalog for ``search_tools``/``call_tool`` at the wire, while
``mcp._list_tools()`` upstream of it still lists everything — so search mode on
its own moves none of the three. A *provider-level* transform is the one that
rewrites ``mcp._list_tools()`` itself, and that is what ``_TruncatingTransform``
below installs. So the property pinned here is the stronger, layer-agnostic one:
registry-truth is read below every transform, and no catalog rewrite — today's,
or a provider-level one someone mounts tomorrow — can make the server report a
surface it does not have or silently turn TOOL_PROFILE into a no-op.
**A tool must not report a document it did not open** (T0.2) — the read-side
twin of the DWG write refusal, and the reason a ``.dwg`` path is refused on a
backend whose ``dwg`` capability is false.
**Discovery metadata must not cost anything.** ``@cad_tool`` writes summary /
cost / alias metadata into the MCP ``meta`` channel; ``tags`` are load-bearing
(``_tool_groups()`` buckets every tool by them for system_about), so the
grouping is pinned to the tags literally written in server.py's source. The
channel is also gated for *coverage*: every registered tool must carry a card,
and no card may contradict the tool's own annotations.
"""
from __future__ import annotations
import ast
import shutil
from pathlib import Path
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.transforms import Transform
import server
from backends.base import CapabilityMap, FeatureCapability
from discovery.aliases import aliases_for
from discovery.serialize import MAX_SUMMARY_CHARS
pytestmark = pytest.mark.asyncio
class _TruncatingTransform(Transform):
"""Stand-in for a catalog-replacing transform: collapses the list to two tools.
Mirrors the shape of the problem rather than any shipped transform — a
transform that replaces the tool list wholesale instead of decorating it,
mounted (see the fixture) at the level that reaches ``mcp._list_tools()``.
"""
def __init__(self, keep: int = 2):
self.keep = keep
async def list_tools(self, tools):
return list(tools)[: self.keep]
@pytest.fixture
def truncating_transform():
"""Install the transform where FastMCP actually applies it, then remove it.
``mcp._list_tools()`` aggregates ``provider.list_tools()``, and
``Provider.list_tools`` is what runs the provider's transforms — so a
provider-level transform is exactly what rewrites that call's output.
"""
transform = _TruncatingTransform()
server.mcp.local_provider.add_transform(transform)
try:
yield transform
finally:
server.mcp.local_provider._transforms.remove(transform)
@pytest_asyncio.fixture(autouse=True)
async def _restore_full_profile():
"""These tests call _apply_tool_profile, which mutates global enablement."""
yield
await server._apply_tool_profile("full")
async def test_transform_really_rewrites_the_public_listing(truncating_transform):
"""Guard the guard: prove the fixture actually breaks the public accessor.
Without this the other tests would pass even if the transform silently did
nothing.
"""
assert len(await server.mcp._list_tools()) == 2
async def test_registered_tools_unaffected_by_a_transform(truncating_transform):
names = {t.name for t in await server._registered_tools()}
assert len(names) > 100
assert "system_about" in names
assert "gear_draw_spur_front_view" in names
async def test_registered_tool_count_unaffected_by_a_transform(truncating_transform):
count = await server._registered_tool_count()
assert count is not None
assert count > 100
async def test_tool_groups_unaffected_by_a_transform(truncating_transform):
groups = await server._tool_groups()
# The transform collapses the catalog to two drawing tools, which would
# leave a single "drawing" bucket.
assert len(groups) > 5
assert groups["transactions"] == [
"transaction_begin",
"transaction_commit",
"transaction_rollback",
]
assert "gear_draw_spur_front_view" in groups["engineering"]
async def test_tool_profile_still_disables_under_a_transform(truncating_transform):
"""TOOL_PROFILE must keep working when a catalog-rewriting transform is mounted.
Reading the transformed catalog would make ``registered`` tiny, so
``registered - enabled`` collapses and ``mcp.disable()`` never fires.
"""
info = await server._apply_tool_profile("lean")
assert info["registered_count"] > 100
assert info["enabled_count"] == len(server.LEAN_TOOL_NAMES)
assert info["disabled_count"] > 50
# The escape hatches really are hidden, not merely counted as hidden.
assert "system_run_command" in info["disabled_tools"]
async def test_registered_tools_returns_empty_when_registry_unknown(monkeypatch):
"""Contract: never raises, and reports [] / None rather than a bogus count."""
class _Broken:
@property
def _components(self):
raise RuntimeError("registry layout changed")
async def _list_tools(self):
raise RuntimeError("registry layout changed")
async def _boom():
raise RuntimeError("public accessor gone")
monkeypatch.setattr(server.mcp, "_local_provider", _Broken(), raising=False)
monkeypatch.setattr(server.mcp, "_list_tools", _boom, raising=False)
assert await server._registered_tools() == []
assert await server._registered_tool_count() is None
# ── T0.2 — drawing_open must not report a document it did not open ──────────
class _Ctx:
"""Minimal async context for calling a tool function directly."""
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
async def warning(self, message):
pass
async def report_progress(self, progress, total):
pass
async def _saved_dxf(backend, tmp_path):
dxf = tmp_path / "part.dxf"
await backend.drawing_save_as(str(dxf), "dxf")
return dxf
async def test_drawing_open_opens_a_dxf(backend, tmp_path):
"""Baseline: the honest path keeps working."""
dxf = await _saved_dxf(backend, tmp_path)
result = await server.drawing_open(str(dxf), ctx=_Ctx(backend))
assert result["ok"] is True
assert result["name"] == "part.dxf"
async def test_drawing_open_refuses_a_dwg_the_backend_cannot_read(backend, tmp_path):
"""DXF bytes wearing a .dwg name must not be reported as an opened DWG.
ezdxf sniffs content, not extensions, so it happily parses a mislabelled
file (exactly what pre-1.5.0 drawing_save produced) and the tool then
answers ``{"ok": True, "name": "part.dwg"}`` — a document that does not
exist in that format.
"""
dxf = await _saved_dxf(backend, tmp_path)
disguised = tmp_path / "part.dwg"
shutil.copy(dxf, disguised)
with pytest.raises(ToolError) as excinfo:
await server.drawing_open(str(disguised), ctx=_Ctx(backend))
message = str(excinfo.value).lower()
assert "dwg" in message
# Blames the backend's missing capability and names the escape hatch,
# rather than ezdxf's bare "is not a DXF file".
assert "ezdxf" in message
assert "com" in message
async def test_drawing_open_refuses_a_real_dwg_without_leaking_a_parser_error(backend, tmp_path):
real_dwg = tmp_path / "real.dwg"
real_dwg.write_bytes(b"AC1032" + b"\x00" * 256)
with pytest.raises(ToolError) as excinfo:
await server.drawing_open(str(real_dwg), ctx=_Ctx(backend))
assert "is not a DXF file" not in str(excinfo.value)
async def test_drawing_open_refusal_leaves_the_current_document_untouched(backend, tmp_path):
dxf = await _saved_dxf(backend, tmp_path)
ctx = _Ctx(backend)
await server.drawing_open(str(dxf), ctx=ctx)
disguised = tmp_path / "other.dwg"
shutil.copy(dxf, disguised)
with pytest.raises(ToolError):
await server.drawing_open(str(disguised), ctx=ctx)
info = await backend.drawing_info()
assert info.name == "part.dxf"
class _DwgCapableBackend:
"""Stand-in for the live COM backend, which reads DWG natively."""
name = "com"
def __init__(self):
self.opened: list[str] = []
def capabilities(self) -> CapabilityMap:
return CapabilityMap(
backend="com",
features={"dwg": FeatureCapability(True, "native")},
)
async def drawing_open(self, path: str) -> dict:
self.opened.append(path)
return {"ok": True, "name": "part.dwg", "path": path}
async def test_drawing_open_allows_dwg_when_the_backend_supports_it(tmp_path):
"""The refusal is capability-driven, not an ezdxf-shaped hardcode."""
fake = _DwgCapableBackend()
result = await server.drawing_open(str(tmp_path / "part.dwg"), ctx=_Ctx(fake))
assert result["ok"] is True
assert len(fake.opened) == 1
async def test_drawing_open_does_not_advertise_unconditional_dwg():
"""The lie was also in the docs the model reads before calling."""
tool = next(t for t in await server._registered_tools() if t.name == "drawing_open")
text = (tool.description or "").lower()
for param in (tool.parameters or {}).get("properties", {}).values():
text += " " + str(param.get("description", "")).lower()
assert "dwg" in text, "DWG support should still be discoverable"
assert "com" in text, "but qualified by the backend that actually has it"
# ── @cad_tool — the discovery metadata channel ──────────────────────────────
CAD_TOOL_SECTION = ("transaction_begin", "transaction_commit", "transaction_rollback")
def _component(name: str):
for tool in server._local_tool_components():
if tool.name == name:
return tool
raise AssertionError(f"{name} is not registered")
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
async def test_cad_tool_publishes_discovery_meta(name):
cad = (_component(name).meta or {})["cad"]
assert cad["summary"] and "\n" not in cad["summary"]
assert cad["cost"] in server.CAD_TOOL_COSTS
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
async def test_cad_tool_pulls_aliases_from_the_discovery_corpus(name):
"""The corpus is the single source of truth; server.py must not restate it."""
record = aliases_for(name)
cad = (_component(name).meta or {})["cad"]
assert cad["acad"] == list(record.acad)
assert cad["synonyms"] == list(record.synonyms)
assert cad["synonyms"], "the transaction tools do carry synonyms"
async def test_cad_tool_meta_reaches_the_wire(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
cad = tools["transaction_rollback"].meta["cad"]
assert cad["cost"] == "destructive"
assert "rollback" in cad["synonyms"]
async def test_cad_tool_rejects_an_unknown_cost():
with pytest.raises(ValueError, match="cost"):
server.cad_tool(summary="x", cost="somewhat-dangerous")
async def test_cad_tool_requires_the_registration_below_it():
"""A misordered decorator must fail loudly at import, not silently no-op."""
decorator = server.cad_tool(summary="x", cost="read")
async def never_registered(ctx=None) -> dict:
return {}
with pytest.raises(RuntimeError, match="not registered"):
decorator(never_registered)
# ── @cad_tool coverage: the whole surface, not a sample ─────────────────────
#
# The twin of the alias-corpus gate in tests/test_discovery_aliases.py. That one
# proves every registered tool has an alias record; this one proves every
# registered tool has a discovery card, and that the card agrees with the
# annotations the same tool already publishes. Both derive their subject from
# the live registry, so a tool added without the metadata fails here instead of
# shipping with a summary silently scraped off its docstring.
def _cad(tool) -> dict | None:
cad = (tool.meta or {}).get("cad")
return cad if isinstance(cad, dict) else None
async def test_every_registered_tool_carries_cad_meta():
"""Registering a tool without @cad_tool must fail this gate."""
missing = sorted(t.name for t in await server._registered_tools() if _cad(t) is None)
assert not missing, f"tools with no @cad_tool metadata: {missing}"
async def test_every_cad_summary_is_a_single_non_empty_line():
"""A card is one line; a summary with a newline in it would break the format."""
bad: list[str] = []
for tool in await server._registered_tools():
summary = (_cad(tool) or {}).get("summary")
if not isinstance(summary, str) or not summary.strip() or "\n" in summary:
bad.append(f"{tool.name}:{summary!r}")
assert not bad, f"summaries that are not one usable line: {bad}"
async def test_no_authored_summary_is_long_enough_to_be_truncated():
"""A hand-written summary that overruns the budget would ship rendered as "...".
The serializer's truncation exists for the docstring fallback, not as a
licence to write a paragraph into @cad_tool.
"""
overlong = sorted(
(t.name, len((_cad(t) or {}).get("summary") or ""))
for t in await server._registered_tools()
if len((_cad(t) or {}).get("summary") or "") > MAX_SUMMARY_CHARS
)
assert not overlong, f"summaries the serializer would truncate: {overlong}"
async def test_every_cad_cost_is_a_known_value():
bad = sorted(
f"{t.name}:{(_cad(t) or {}).get('cost')!r}"
for t in await server._registered_tools()
if (_cad(t) or {}).get("cost") not in server.CAD_TOOL_COSTS
)
assert not bad, f"costs outside CAD_TOOL_COSTS: {bad}"
async def test_a_read_only_tool_is_costed_read():
"""``readOnlyHint`` is what a client gates on; the cost must not contradict it."""
bad: list[str] = []
for tool in await server._registered_tools():
annotations = tool.annotations
if annotations is not None and annotations.readOnlyHint:
cost = (_cad(tool) or {}).get("cost")
if cost != "read":
bad.append(f"{tool.name}:{cost!r}")
assert not bad, f"readOnlyHint tools not costed 'read': {bad}"
async def test_a_destructive_tool_is_never_costed_read_or_safe():
"""The other half of the agreement: an erase must not look harmless."""
bad: list[str] = []
for tool in await server._registered_tools():
annotations = tool.annotations
if annotations is not None and annotations.destructiveHint:
cost = (_cad(tool) or {}).get("cost")
if cost in ("read", "safe"):
bad.append(f"{tool.name}:{cost!r}")
assert not bad, f"destructiveHint tools costed as harmless: {bad}"
async def test_only_the_raw_passthroughs_are_costed_escape():
"""'escape' means 'this hands your string to AutoCAD', and nothing else."""
escapes = sorted(
t.name for t in await server._registered_tools() if (_cad(t) or {}).get("cost") == "escape"
)
assert escapes == ["system_run_command", "system_run_lisp"]
async def test_no_tool_restates_its_aliases_in_server_py():
"""@cad_tool copies the corpus; server.py must never hand-write acad/synonyms."""
bad: list[str] = []
for tool in await server._registered_tools():
cad = _cad(tool) or {}
record = aliases_for(tool.name)
expected_acad = list(record.acad) if record else []
expected_synonyms = list(record.synonyms) if record else []
if cad.get("acad") != expected_acad or cad.get("synonyms") != expected_synonyms:
bad.append(tool.name)
assert not bad, f"cad metadata out of step with discovery.aliases: {bad}"
# ── tags are load-bearing: _tool_groups() feeds system_about ────────────────
def _tags_declared_in_source() -> dict[str, set[str]]:
"""Every tool's tags as literally written in the @mcp.tool(...) call.
An independent derivation of the input to _tool_groups(): if @cad_tool (or
anything else) rewrote a registered tool's tags, the live grouping would
stop matching what server.py declares.
"""
tree = ast.parse(Path(server.__file__).read_text(encoding="utf-8"))
declared: dict[str, set[str]] = {}
for node in ast.walk(tree):
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call):
continue
func = decorator.func
if not (
isinstance(func, ast.Attribute)
and func.attr == "tool"
and isinstance(func.value, ast.Name)
and func.value.id == "mcp"
):
continue
name = node.name
tags: set[str] = set()
for keyword in decorator.keywords:
if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
name = keyword.value.value
if keyword.arg == "tags" and isinstance(keyword.value, ast.Set):
tags = {
element.value
for element in keyword.value.elts
if isinstance(element, ast.Constant)
}
declared[name] = tags
return declared
async def test_registered_tags_are_exactly_the_ones_written_in_source():
declared = _tags_declared_in_source()
live = {tool.name: set(tool.tags) for tool in await server._registered_tools()}
assert live == declared
async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
"""_tool_groups() must be reproducible from server.py's own `tags={...}`."""
expected: dict[str, list[str]] = {}
for name, tags in _tags_declared_in_source().items():
label = "other"
for tag in server._GROUP_TAG_PRIORITY:
if tag in tags:
label = server._GROUP_TAG_LABELS[tag]
break
expected.setdefault(label, []).append(name)
for names in expected.values():
names.sort()
assert await server._tool_groups() == dict(sorted(expected.items()))
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (229 tools, 25 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
`layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group
gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
rather than splitting a ninth tool off into entity_modification. `blocks`
moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on
both engines) joined SECTION 7. `entity_query` moved 8 -> 9 and
`entity_modification` 15 -> 16 when v1.6's `entity_get_xdata` /
`entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the
setter `modify`, so the pair splits across two groups). `pid` appeared
(0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` /
`pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined, 3 -> 4
when the reader `pid_graph` joined and 4 -> 7 when the three graph
deliverables (`pid_instrument_index` / `pid_line_list` /
`pid_equipment_list`) joined, 7 -> 8 when `pid_from_spec` (a whole
sheet from one declarative spec, in one transaction) joined and 8 -> 9
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
joined; the `pid`
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
`pid` rather than under their secondary `query` / `create` tags.
Three groups appeared with v1.6's track E — `styles` (10, SECTION 18),
`page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each
tagged with its section name, ranked directly after `pid` in
`_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an
environment tool that also carries `layer` files under its section; the
`environment` group is exactly `PACK_TOOL_NAMES["settings"]`. (Until the
merge task tagged them, the four groups had filed those 28 tools under
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
their secondary tags; those counts returned to their pre-track-E values
when the section tags landed.)
`engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with
`sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200
title block for every size A4-A0). Both are tagged `engineering` and
`sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under
`engineering` beside `titleblock_apply_iso_a3` rather than opening a
group of their own.
It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and
`balloon_add` joined the same section; `bom_extract` also carries `query`,
but `engineering` outranks it in `_GROUP_TAG_PRIORITY`.
The section's last five tools file elsewhere on purpose: they are
whole-drawing and entity operations, not sheet furniture, and
`_GROUP_TAG_PRIORITY` files them where a caller would look.
`xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block`
outranks `drawing`, so they file under `blocks` (9 -> 11) beside
`block_insert` -- which is where an xref belongs: it is a block definition
that lives in another file. `image_attach` carries `create`
(entity_creation 18 -> 19), `data_extract` carries `engineering`
(16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12).
Every other number here has been unchanged
since the snapshot was taken.
`mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
the part model and its view engine (`mech_part_draw` / `mech_view_add` /
`mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` /
`mech_part_inspect`); the `mech` tag is ranked directly after `pid` in
`_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under
their secondary `create` / `dimension` / `query` tags.
SECTION 22 added the standard-parts catalogue (`std_part_list` /
`std_part_insert` / `std_feature_draw`) to the same group, filed under
`mech` rather than under their secondary `query` / `create` tags.
SECTION 23 added the mechanical annotation symbols (`surface_texture` /
`weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They
are tagged `engineering` and `mech`; with `mech` ranked ahead of
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
Two groups appeared with v1.6's tracks B and G — `mechanical` (14,
SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO
annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks,
revisions, the parts list, balloons, xrefs, images and DWG). Both tags are
ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that
also carries `create` and an annotation tool that also carries `dimension`
file under their own section. `mechanical` is exactly
`PACK_TOOL_NAMES["mech"]` (the `mech` label above was renamed when the
integration task gave the section its full name); `sheet` is core-pack,
because a frame, a title block, a parts list and an xref are universal
drafting. The integration also tagged SECTION 24's last four tools
(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) with
`sheet`, so pack, group and SECTION are one list again: `engineering`
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"environment": 22,
"layers": 14,
"layouts": 12,
"mechanical": 14,
"page_setup": 6,
"pid": 9,
"premium": 12,
"sheet": 11,
"solids": 5,
"styles": 10,
"system": 7,
"templates": 2,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 229