From b458923e19143cff6447b3ca3040f6c259034a02 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:48:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(templates):=20drawing=5Ftemplate=5Fsave=20?= =?UTF-8?q?on=20both=20engines=20=E2=80=94=20dwt=5Fwrite=20is=20a=20declar?= =?UTF-8?q?ed=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live AutoCAD writes a real .dwt through SaveAs(path, ac2018_Template = 66) and puts the description into the summary Comments; headlessly a .dxf template is written through drawing_save_as and a .dwt request is refused with capability dwt_write, naming the DXF route and the COM route. drawing_save_as's "dwt" entry was 5 (acR13_dxf): a DWT request wrote an R13 DXF under a .dwt name. Probed live on AutoCAD 2026 (typelib: ac2018_Template = 66, acR13_dxf = 5, acTemplateDwg absent; the saved file starts AC1032, the document is rebound, Comments takes). SECTION 19 is now 6 tools; SAVEAS joins SHARED_ACAD_COMMANDS; templates/README.md documents the two files per template. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 48 +++++++++++++-- backends/ezdxf_backend.py | 49 ++++++++++++++-- discovery/aliases.py | 13 ++++ docs/tool-inventory.json | 18 +++++- server.py | 40 ++++++++++++- templates/README.md | 45 ++++++++++++++ tests/test_templates.py | 114 ++++++++++++++++++++++++++++++++++++ tests/test_tool_registry.py | 9 +-- 9 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 templates/README.md 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