diff --git a/README.md b/README.md
index dacc503..21ab84c 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 171 tools · 8 resources · 5 prompt templates · 2423 collected tests.
+> **v1.5 release snapshot:** 172 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
-| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list` |
+| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
@@ -114,7 +114,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-171 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+172 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/backends/com_backend.py b/backends/com_backend.py
index d1819f2..bec6896 100644
--- a/backends/com_backend.py
+++ b/backends/com_backend.py
@@ -1117,8 +1117,10 @@ class ComBackend(AutoCADBackend):
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict:
def _sync():
doc = _acad_doc()
- # AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010
- fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5}
+ # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template.
+ # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF
+ # under a .dwt name.
+ fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66}
acad_fmt = fmt_map.get(fmt.lower(), 12)
doc.SaveAs(path, acad_fmt)
return {"ok": True, "path": path, "format": fmt}
@@ -2136,6 +2138,10 @@ class ComBackend(AutoCADBackend):
# scripts/smoke_settings_com.py (Task 24).
_AC_NO_ROTATION = 0
+ #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64,
+ #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum.
+ _AC_SAVEAS_TEMPLATE = 66
+ _AC_SAVEAS_DXF = 61
#: Layout properties a ConfigName write can move as a side effect (measured
#: live for CanonicalMediaName; units / rotation are re-applied on the same
#: evidence-before-trust basis) — snapshotted before the device switch and
@@ -2442,10 +2448,40 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def drawing_template_save(self, path, name=None, description=None) -> dict:
- # Completed in Task 20; declared here so the ABC instantiates.
- raise UnsupportedCapabilityError(
- "dwt_write", "drawing_template_save: not implemented yet (Task 20)"
- )
+ """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``.
+
+ AutoCAD's template description is its summary-info Comments field, so
+ ``description`` is written there first (``description_written`` says
+ whether it took). SaveAs rebinds the active document to the new file,
+ as the SAVEAS command does — reported as ``document_rebound``.
+ Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``.
+ """
+ suffix = Path(path).suffix.lower()
+ if suffix not in (".dwt", ".dxf"):
+ raise ValueError(f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}")
+
+ def _sync():
+ doc = _acad_doc()
+ description_written = False
+ if description is not None:
+ try:
+ doc.SummaryInfo.Comments = str(description)
+ description_written = True
+ except Exception as exc:
+ log.debug("SummaryInfo.Comments write failed: %s", exc)
+ code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF
+ doc.SaveAs(path, code)
+ return {
+ "ok": True,
+ "path": path,
+ "format": suffix[1:],
+ "backend": "com",
+ "name": name or Path(path).stem,
+ "description_written": description_written,
+ "document_rebound": True,
+ }
+
+ return await self._run(_sync)
# ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─
diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py
index 66119df..d10cf0b 100644
--- a/backends/ezdxf_backend.py
+++ b/backends/ezdxf_backend.py
@@ -123,6 +123,18 @@ _DWG_WRITE_REFUSAL = (
)
+# A DWT is a DWG container with a template flag. ezdxf cannot write it, and a
+# DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists
+# to prevent. The DXF route works headlessly end to end: drawing_new(template=)
+# opens a .dxf template.
+_DWT_WRITE_REFUSAL = (
+ "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG "
+ "container, and ezdxf has no DWG writer. Save the template as .dxf instead "
+ "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM "
+ "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt."
+)
+
+
def _is_dwg_path(path: str) -> bool:
"""True when `path` names a DWG file. The extension is authoritative (N2)."""
return Path(path).suffix.lower() == ".dwg"
@@ -2687,10 +2699,39 @@ class EzdxfBackend(AutoCADBackend):
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
async def drawing_template_save(self, path, name=None, description=None) -> dict:
- # Completed in Task 20; declared here so the ABC instantiates.
- raise UnsupportedCapabilityError(
- "dwt_write", "drawing_template_save: not implemented yet (Task 20)"
- )
+ """Save the current drawing as a template — DXF only, headlessly.
+
+ ``.dwt`` is refused with capability ``dwt_write`` before anything is
+ written; ``.dxf`` goes through ``drawing_save_as`` (the document is
+ rebound to the new path, as AutoCAD's SAVEAS does). A description has
+ no home in a DXF header — it is a DWG/DWT summary property — so it is
+ reported ``description_written: False`` rather than dropped silently.
+ """
+ suffix = Path(path).suffix.lower()
+ if suffix == ".dwt":
+ raise UnsupportedCapabilityError(
+ "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem)
+ )
+ if suffix != ".dxf":
+ raise ValueError(
+ f"drawing_template_save: path must end in .dxf (headless) or .dwt (live "
+ f"AutoCAD), got {path!r}"
+ )
+ saved = await self.drawing_save_as(path, "dxf")
+ result = {
+ "ok": True,
+ "path": saved["path"],
+ "format": "dxf",
+ "backend": "ezdxf",
+ "name": name or Path(path).stem,
+ "description_written": False,
+ }
+ if description is not None:
+ result["description_note"] = (
+ "DXF has no template description field; it is a DWG/DWT summary property "
+ "(capability 'dwgprops')"
+ )
+ return result
# ── 3D solids (unsupported headlessly — honest capability boundary) ─────
diff --git a/discovery/aliases.py b/discovery/aliases.py
index bd77614..87cdfcd 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -76,6 +76,8 @@ class ToolAliases:
# (page_setup_list vs page_setup_apply).
# PLOT -- one sheet to PDF (drawing_export_pdf) or every sheet in one go
# (batch_plot); a drafter typing it could mean either.
+# SAVEAS -- the same command saves a copy (drawing_save_as) or a template
+# (drawing_template_save, its "Drawing Template" file type).
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -91,6 +93,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"PLINE",
"PLOT",
"QSELECT",
+ "SAVEAS",
"SETVAR",
"ZOOM",
}
@@ -1462,6 +1465,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"dwt list",
),
),
+ "drawing_template_save": ToolAliases(
+ acad=("SAVEAS",),
+ synonyms=(
+ "save as template",
+ "make a dwt",
+ "template from this drawing",
+ "save as dwt",
+ "reuse this sheet setup",
+ ),
+ ),
"page_setup_apply": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 62e22ca..4891dd0 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 171,
+ "tools": 172,
"resources": 8,
"prompts": 5,
"groups": 20
@@ -25,7 +25,7 @@
"premium": 12,
"solids": 5,
"system": 7,
- "templates": 3,
+ "templates": 4,
"transactions": 3,
"validation": 1,
"view": 4
@@ -741,6 +741,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "drawing_template_save",
+ "group": "templates",
+ "cost": "mutate",
+ "summary": "Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
+ "tags": [
+ "drawing",
+ "template"
+ ],
+ "annotations": {
+ "title": "Save As Template",
+ "destructiveHint": false
+ }
+ },
{
"name": "drawing_undo",
"group": "drawing",
diff --git a/server.py b/server.py
index 58cce6f..17b954e 100644
--- a/server.py
+++ b/server.py
@@ -6855,7 +6855,7 @@ async def pid_tag_parse(
# ---------------------------------------------------------------------------
-# ── SECTION 19: Page Setup & Templates (5 tools) ────────────────────────────
+# ── SECTION 19: Page Setup & Templates (6 tools) ────────────────────────────
# ---------------------------------------------------------------------------
@@ -7044,6 +7044,44 @@ async def drawing_template_list(ctx: Context = None) -> dict:
}
+@cad_tool(
+ summary="Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Save As Template", "destructiveHint": False},
+ tags={"template", "drawing"},
+)
+async def drawing_template_save(
+ path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."],
+ name: Annotated[str | None, "Template name for the report (default: the file stem)."] = None,
+ description: Annotated[
+ str | None, "Template description → DWT summary Comments on the live engine."
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """AutoCAD's SAVEAS → Drawing Template, on both engines.
+
+ Live AutoCAD writes a real `.dwt` (`SaveAs(path, ac2018_Template)`) and
+ puts `description` into the template's summary Comments; the active
+ document is rebound to the new file, as SAVEAS does. Headlessly a `.dxf`
+ template is written — `drawing_new(template=)` opens it — and a
+ `.dwt` request is refused with capability `dwt_write` (the message names
+ the DXF route and the COM route); a description is reported
+ `description_written: false` because DXF has nowhere to keep it. Any other
+ suffix, and any path outside the allowed directories, is refused before
+ a byte is written.
+
+ Pack: core · lean: no (`drawing_save_as` is not lean either).
+ """
+ validated = validate_path(path, allow_write=True)
+ await ctx.info(f"Saving template: {validated}")
+ try:
+ return await _backend(ctx).drawing_template_save(str(validated), name, description)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/templates/README.md b/templates/README.md
new file mode 100644
index 0000000..9528d5a
--- /dev/null
+++ b/templates/README.md
@@ -0,0 +1,45 @@
+# Bundled drawing templates
+
+Five templates, built by the server's own tools — never hand-edited — and
+listed by `drawing_template_list`. Start from one with
+`drawing_new(template="iso_a3_mech")`.
+
+| name | standard | sheet | layer set | styles | page setup |
+|---|---|---|---|---|---|
+| `iso_a3_mech` | ISO | A3 landscape, ISO 5457 frame + ISO 7200 title block | mech | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
+| `iso_a1_arch` | ISO | A1 landscape | iso13567 | ISO-25 / ISOCP, annotation scale 1:50 | ISO_A1, monochrome.ctb, fit |
+| `iso_a3_pid` | ISO | A3 landscape, ISO 5457 frame + title block | pid | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
+| `ansi_b_mech` | ANSI (metric) | ANSI B landscape | mech | ANSI / ROMANS | ANSI_B, monochrome.ctb, 1:1 |
+| `ansi_d_arch` | ANSI (metric) | ANSI D landscape | iso13567 | ANSI / ROMANS | ANSI_D, monochrome.ctb, fit |
+
+## The two files per template
+
+- `.dxf` — the headless twin. Produced by
+ `uv run --frozen python scripts/build_templates.py`, which runs
+ `drawing_new → apply_layer_set → styles → drawing_settings → layout rename →
+ titleblock_apply_iso_a3 (A3 sheets) → page_setup_apply → drawing_save_as`.
+ `scripts/build_templates.py --check` rebuilds into a temporary folder and
+ fails if any committed DXF differs (the save-time stamps `$TDCREATE`,
+ `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID` and the two `EZDXF_META`
+ entries are masked; everything else must be byte-identical).
+ `tests/test_templates.py` runs the same check.
+- `.dwt` — the live twin, used by `drawing_new(template=…)` on the COM
+ backend. AutoCAD's DWT is a DWG container, which no headless library
+ writes, so these are produced **on the live machine** by
+ `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's
+ smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)`
+ (`SaveAs(path, ac2018_Template)`), then the files are committed once. While
+ a `.dwt` is missing, `resolve_template` falls back to the DXF and reports
+ `source: "bundled_dxf"`; `drawing_template_list` shows `files.dwt.present`.
+ The live engine does not open that DXF blank: `Documents.Add` accepts only
+ a genuine DWT (measured on AutoCAD 2026), so the COM `drawing_new` converts
+ the DXF through `SaveAs(…, ac2018_Template)` once, caches the result under
+ the temp folder keyed on path, size and mtime, and reports
+ `template_dwt: {path, cached}`.
+
+Line endings: ezdxf writes CRLF and `.gitattributes` pins `templates/*.dxf`
+to CRLF so a checkout on any platform equals a fresh build.
+
+Rebuild after changing anything the build depends on (layer sets, the title
+block, the dimension presets, the settings facade) and commit the result
+together with the change — the reproducibility test is the gate.
diff --git a/tests/test_templates.py b/tests/test_templates.py
index 7a98b29..2ba62ac 100644
--- a/tests/test_templates.py
+++ b/tests/test_templates.py
@@ -10,6 +10,7 @@ import pytest
from fastmcp.exceptions import ToolError
import server
+from backends.base import UnsupportedCapabilityError
from engineering.standards.templates import (
TEMPLATE_CATALOG,
TEMPLATES_DIR,
@@ -371,3 +372,116 @@ async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new):
assert documents.calls[-1] == ("Add", template["dwt"])
else:
assert "dwt" not in template and documents.calls == [("Add", template["path"])]
+
+
+# ── drawing_template_save ───────────────────────────────────────────────────
+
+
+async def test_template_save_dxf_headless_round_trips(backend, tmp_path):
+ await backend.layer_create("MINE", color=3)
+ out = tmp_path / "mine_template.dxf"
+ result = await backend.drawing_template_save(str(out), name="Mine", description="my sheet")
+ assert result == {
+ "ok": True,
+ "path": str(out),
+ "format": "dxf",
+ "backend": "ezdxf",
+ "name": "Mine",
+ "description_written": False,
+ "description_note": (
+ "DXF has no template description field; it is a DWG/DWT summary property "
+ "(capability 'dwgprops')"
+ ),
+ }
+ assert out.is_file()
+ await server.drawing_new(template=str(out), ctx=_Ctx(backend))
+ assert "MINE" in {layer.name for layer in await backend.layer_list()}
+
+
+async def test_template_save_dwt_headless_is_refused_with_the_capability(backend, tmp_path):
+ out = tmp_path / "mine.dwt"
+ with pytest.raises(UnsupportedCapabilityError) as excinfo:
+ await backend.drawing_template_save(str(out))
+ assert excinfo.value.capability == "dwt_write"
+ message = str(excinfo.value)
+ assert ".dxf" in message and "com" in message.lower()
+ assert not out.exists()
+
+
+async def test_template_save_refuses_other_suffixes(backend, tmp_path):
+ with pytest.raises(ValueError, match=r"\.dxf"):
+ await backend.drawing_template_save(str(tmp_path / "mine.txt"))
+
+
+async def test_template_save_tool_validates_the_path(backend, tmp_path):
+ with pytest.raises(ToolError):
+ await server.drawing_template_save("../../outside.dxf", ctx=_Ctx(backend))
+ result = await server.drawing_template_save(str(tmp_path / "t.dxf"), ctx=_Ctx(backend))
+ assert result["ok"] and result["name"] == "t"
+
+
+class _SaveRecorder:
+ def __init__(self):
+ self.calls = []
+ self.SummaryInfo = types.SimpleNamespace()
+ self.Name = "Drawing1.dwg"
+
+ def SaveAs(self, path, fmt=None):
+ self.calls.append(("SaveAs", path, fmt))
+ self.Name = Path(path).name
+
+
+@pytest.fixture
+def com_backend(monkeypatch):
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends import com_backend as module
+
+ document = _SaveRecorder()
+ monkeypatch.setattr(module, "_acad_doc", lambda: document)
+ backend = module.ComBackend()
+
+ async def _run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "_run", _run_inline)
+ return backend, document
+
+
+async def test_com_template_save_dwt_uses_ac2018_template(com_backend, tmp_path):
+ backend, document = com_backend
+ out = str(tmp_path / "mine.dwt")
+ result = await backend.drawing_template_save(out, description="A3 mech")
+ assert document.calls == [("SaveAs", out, 66)]
+ assert document.SummaryInfo.Comments == "A3 mech"
+ assert result == {
+ "ok": True,
+ "path": out,
+ "format": "dwt",
+ "backend": "com",
+ "name": "mine",
+ "description_written": True,
+ "document_rebound": True,
+ }
+
+
+async def test_com_template_save_dxf_uses_the_dxf_code(com_backend, tmp_path):
+ backend, document = com_backend
+ out = str(tmp_path / "mine.dxf")
+ result = await backend.drawing_template_save(out)
+ assert document.calls == [("SaveAs", out, 61)]
+ assert result["format"] == "dxf" and result["description_written"] is False
+
+
+async def test_com_save_as_dwt_no_longer_writes_an_r13_dxf(com_backend, tmp_path):
+ """drawing_save_as mapped 'dwt' to 5, which is AcSaveAsType.acR13_dxf."""
+ backend, document = com_backend
+ out = str(tmp_path / "x.dwt")
+ await backend.drawing_save_as(out, "dwt")
+ assert document.calls == [("SaveAs", out, 66)]
+
+
+async def test_com_template_save_refuses_other_suffixes_before_saving(com_backend, tmp_path):
+ backend, document = com_backend
+ with pytest.raises(ValueError, match=r"\.dwt"):
+ await backend.drawing_template_save(str(tmp_path / "x.dwg"))
+ assert document.calls == []
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 1970ce7..cd547ae 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -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 (171 tools, 20 groups).
+ """Frozen snapshot of the surface (172 tools, 20 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
@@ -523,7 +523,8 @@ async def test_tool_group_sizes_are_unchanged():
— all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above
`query`/`export`, so they file with the sheets they set up. `templates`
moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
- (tagged `template`, which ranks above `drawing`). Every other number
+ (tagged `template`, which ranks above `drawing`), then 3 -> 4 when
+ `drawing_template_save` joined. 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()}
@@ -544,9 +545,9 @@ async def test_tool_group_sizes_are_unchanged():
"premium": 12,
"solids": 5,
"system": 7,
- "templates": 3,
+ "templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 171
+ assert sum(sizes.values()) == 172