SECTION 24 grows to six tools: revision_add, bom_extract, bom_table,
balloon_add.
bom_extract reads. It walks the INSERTs through the generic members already in
the contract -- entity_list, block_get_attributes, entity_get_xdata -- and
returns parts-list rows from the ACADMCP_MECH std_part payload, falling back to
the block's own ATTRIBs and reporting which of the two each row came from. A
block with neither is skipped rather than guessed at, and the tool never writes
to the drawing.
bom_table draws the rows as a real TABLE, 180 mm wide (ISO 7573: the item list
is as wide as the title block), growing upwards from directly above it with the
heading row against the block and item 1 the row above -- which is what ISO 7573
asks for on a drawing. A TABLE is drawn downwards from its insertion point, so
the upward list is inserted a table height above its anchor with the heading as
the last row; one table_layout feeds both the real TABLE and the pure
table_prims, so the two renderings cannot disagree.
balloon_add draws the ISO 6433 reference -- numeral, leader, dot on the item --
and writes the balloon payload naming its targets. A rerun over the same
targets renumbers that balloon instead of drawing a second one, and an item
number already used for different targets is refused by name with the other
balloon's handle.
revision_add appends a row to the revision block, is idempotent per revision
code (a repeat draws nothing at all), and optionally clouds the changed regions
and tags them with the triangle. Clouds are refused up front, carrying
capability "revcloud", on an engine that has no revcloud member -- the COM
backend -- rather than leaving half a revision behind.
Declared: ISO 7200 does not standardise the revision block's geometry and
ISO 7573 does not fix the item list's column widths or row height, so both
reuse this repository's 180 mm / 7 mm ruling and say so in their SOURCE
docstrings. ISO 6433's "numeral at least twice the dimension character height"
is met by the numeral being 1.4x the balloon radius: the 4 mm default clears
2.5 mm dimension text, and balloon_add's docstring tells a sheet dimensioned at
3.5 mm to pass radius=5.0.
Three corrections to what the plan wrote, each measured rather than assumed:
* extract_records read `block_get_attributes(...)["attributes"]`. Both engines
return the flat {tag: value} mapping itself (ezdxf_backend.py:6687 builds it
from `ent.attribs`, com_backend.py:5703 from `ref.GetAttributes()` as
{TagString: TextString}), so every attribute fallback would have been
silently empty -- measured headlessly, where a PLATE insert carrying only a
DESIGNATION ATTRIB extracted to nothing.
* draw_bom_table now reports `entity_type` / `representation` /
`child_handles`. `entity_create_table` returns EntityInfo(type="TABLE") whose
handle is the first CHILD of a composite on the headless engine, so reading
that handle back gives a rule LINE, not an ACAD_TABLE -- the contract
tests/test_table_leader.py already pins. A caller that means to edit the
table afterwards has to be told which of the two it got.
* add_revision reports one `tags` entry per tag asked for ({at, handles}), not
one per entity: a tag is two primitives, so a flat handle list claimed twice
as many tags as the caller requested.
Discovery: `bom_extract` / `bom_table` / `balloon_add` each entered the index
heavy with "parts"/"list" tokens and took top-1 for the bare query "parts list"
away from `entity_create_table` (a BM25 tie broken alphabetically). Narrowed on
my side only -- the reader speaks of the bill of materials, the drawer names a
table, the balloon names an item -- never by touching the golden case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
570 lines
22 KiB
Python
570 lines
22 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 (210 tools, 23 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
|
|
24th group.
|
|
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`.
|
|
Every other number here has been unchanged
|
|
since the snapshot was taken.
|
|
"""
|
|
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": 16,
|
|
"entity_creation": 18,
|
|
"entity_modification": 16,
|
|
"entity_query": 9,
|
|
"environment": 22,
|
|
"layers": 14,
|
|
"layouts": 12,
|
|
"page_setup": 6,
|
|
"pid": 9,
|
|
"premium": 12,
|
|
"solids": 5,
|
|
"styles": 10,
|
|
"system": 7,
|
|
"templates": 2,
|
|
"transactions": 3,
|
|
"validation": 1,
|
|
"view": 4,
|
|
}
|
|
assert sum(sizes.values()) == 210
|