diff --git a/backends/com_backend.py b/backends/com_backend.py index 3316d4c..e2f54d9 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -341,6 +341,39 @@ def _com_font_on_support_path(font_file: str) -> bool: return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder) +_MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE" +_MLEADERSTYLE_CLASS = "AcDbMLeaderStyle" + + +def _com_mleaderstyle_dictionary(doc, *, create: bool = False): + """The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none. + + ActiveX exposes no ``MLeaderStyles`` *collection*, but the dictionary's + items are full ``IAcadMLeaderStyle`` objects (acax25enu.tlb: ``Name``, + ``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and + ``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the + documented VBA route — creates one. Every drawing AutoCAD makes carries + the dictionary; ``create`` adds it to a foreign file that does not. The + route is verified against the AutoCAD 2026 type library and fake-tested; + its live execution is scripts/smoke_settings_com.py's job. + """ + try: + return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) + except Exception: + if not create: + return None + return doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY) + + +def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]: + """``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.""" + rows = [] + for index in range(dictionary.Count): + style = dictionary.Item(index) + rows.append((str(dictionary.GetName(style)), style)) + return rows + + def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]: """``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused.""" from engineering.standards.textstyles import TEXT_PRESETS @@ -923,13 +956,6 @@ class ComBackend(AutoCADBackend): "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), - "mleaderstyle": FeatureCapability( - False, - reason=( - "no_activex_mleaderstyle_collection;" - "use_leader_create_mleader_per_leader_parameters" - ), - ), }, ) @@ -4420,21 +4446,19 @@ class ComBackend(AutoCADBackend): async def mleaderstyle_list(self) -> list[dict]: def _sync(): doc = _acad_doc() - try: - dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE") - except Exception: + dictionary = _com_mleaderstyle_dictionary(doc) + if dictionary is None: return [] rows = [] - for index in range(dictionary.Count): + for name, style in _com_mleaderstyle_rows(dictionary): rows.append( { - "name": str(dictionary.GetName(dictionary.Item(index))), - # ActiveX exposes no MLeaderStyle object: names only. - "arrow_size": None, - "landing_gap": None, - "text_style": None, - "text_height": None, - "values_available": False, + "name": name, + "arrow_size": float(style.ArrowSize), + "landing_gap": float(style.LandingGap), + "text_style": str(style.TextStyle), + "text_height": float(style.TextHeight), + "values_available": True, } ) rows.sort(key=lambda row: row["name"].lower()) @@ -4442,6 +4466,40 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + async def mleaderstyle_create(self, name: str, values: dict) -> dict: + from engineering.standards.mleaderstyles import validate_mleaderstyle + + clean = sanitize_symbol_name(name, kind="mleaderstyle") + typed = validate_mleaderstyle(values) + + def _sync(): + doc = _acad_doc() + dictionary = _com_mleaderstyle_dictionary(doc, create=True) + wanted = clean.lower() + if any( + existing.lower() == wanted for existing, _ in _com_mleaderstyle_rows(dictionary) + ): + raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") + # The text style must exist before TextStyle can name it — and a + # name that is neither present nor a preset is refused here, + # before AddObject, so a refusal leaves no half-made style behind. + text_style_name, textstyle_created = _ensure_com_textstyle( + doc, typed["text_style"], refusal_key="text_style" + ) + style = dictionary.AddObject(clean, _MLEADERSTYLE_CLASS) + style.ArrowSize = typed["arrow_size"] + style.LandingGap = typed["landing_gap"] + style.TextHeight = typed["text_height"] + style.TextStyle = text_style_name + return { + "ok": True, + "name": clean, + "values": {**typed, "text_style": text_style_name}, + "textstyle_created": textstyle_created, + } + + return await self._run(_sync) + # ── corner ops ────────────────────────────────────────────────────────── @staticmethod diff --git a/backends/contracts/styles.py b/backends/contracts/styles.py index f0bdbf7..04d0830 100644 --- a/backends/contracts/styles.py +++ b/backends/contracts/styles.py @@ -14,8 +14,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from backends.capability import capability - class StylesContract(ABC): @abstractmethod @@ -115,29 +113,24 @@ class StylesContract(ABC): async def mleaderstyle_list(self) -> list[dict]: """``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``. - Headless the values are read from the MLEADERSTYLE object (schema - defaults for an unset field). ActiveX exposes no MLeaderStyle object, - so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE`` - dictionary and reports the four values ``None`` with - ``values_available: False``. + Both engines report the four values (``values_available: True``). + Headless they are read from the MLEADERSTYLE object (schema defaults + for an unset field); live from the ``AcadMLeaderStyle`` objects the + ``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap`` + / ``TextHeight`` in drawing units, ``TextStyle`` a name) — ActiveX + exposes no *collection* property for them, but the dictionary items + are full ``IAcadMLeaderStyle`` objects. """ ... - # B027 (empty method, no @abstractmethod) is what this pattern looks like - # from the outside: the decorator replaces the `...` body with the refusing - # default, so the source really is empty on purpose. - @capability( # noqa: B027 - "mleaderstyle", - reason=( - "ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's " - "per-leader parameters (arrow size, landing, text height) or the headless " - "backend (AUTOCAD_MCP_BACKEND=ezdxf)" - ), - ) + @abstractmethod async def mleaderstyle_create(self, name: str, values: dict) -> dict: """Create a multileader style from a resolved ``values`` dict (``engineering.standards.mleaderstyles.resolve_mleaderstyle``). - ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the - text style follows the ``dimstyle_create`` rule (exists or preset, - else refused by name); a clash is refused.""" + Both engines: ezdxf on ``doc.mleader_styles``; live through + ``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, + "AcDbMLeaderStyle")`` and the object's property writes. Returns + ``{ok, name, values, textstyle_created}``; the text style follows the + ``dimstyle_create`` rule (exists or preset, else refused by name); a + clash is refused — before anything is written.""" ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 7729517..1bd0796 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -830,8 +830,18 @@ def _sync_header_to_dimstyle(doc, style) -> None: `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` reports. + + The arrowheads cross a vocabulary boundary here. The DIMSTYLE entry holds + ezdxf's writer spelling (``OBLIQUE``, see `_write_dimstyle_values`); the + ``$DIMBLK`` / ``$DIMBLK1`` / ``$DIMBLK2`` header variables are DXF-facing + and spell a built-in with its underscore (``_OBLIQUE``, what a live seat + stores and what `system_get_variable("DIMBLK")` returns on COM). Copied + raw, the saved file carried ``$DIMBLK "OBLIQUE"`` next to blocks named + ``_OBLIQUE`` — a current-override naming a block that does not exist — + and the two engines answered the same read two ways. `reported_arrowhead` + is the same read-side rule `_dimstyle_value` applies. """ - from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST + from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST, reported_arrowhead for var in sorted(DIM_VARIABLE_WHITELIST): attr = var.lower() @@ -840,6 +850,8 @@ def _sync_header_to_dimstyle(doc, style) -> None: value = _dimstyle_schema_default(var) if value is None and var == "DIMTXSTY": value = "Standard" + if var in _ARROWHEAD_VARS: + value = reported_arrowhead(value) key = f"${var}" if value is None: try: @@ -1342,7 +1354,6 @@ class EzdxfBackend(AutoCADBackend): False, reason="acis_generation_requires_live_autocad" ), "lisp": FeatureCapability(False, reason="live_com_only"), - "mleaderstyle": FeatureCapability(True, "native"), }, ) @@ -6079,38 +6090,29 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def mleaderstyle_create(self, name: str, values: dict) -> dict: - from engineering.standards.mleaderstyles import MLEADER_KEYS + from engineering.standards.mleaderstyles import validate_mleaderstyle from security import sanitize_symbol_name clean = sanitize_symbol_name(name, kind="mleaderstyle") - missing = [key for key in MLEADER_KEYS if key not in values] - if missing: - raise ValueError( - f"mleaderstyle_create: values is missing {missing}; resolve a preset first" - ) + typed = validate_mleaderstyle(values) def _sync(): doc = self._require_doc() if doc.mleader_styles.has_entry(clean): raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") text_style_name, textstyle_created = _ensure_textstyle( - doc, str(values["text_style"]), refusal_key="text_style" + doc, typed["text_style"], refusal_key="text_style" ) style = doc.mleader_styles.new(clean) - style.dxf.arrow_head_size = float(values["arrow_size"]) - style.dxf.landing_gap_size = float(values["landing_gap"]) - style.dxf.char_height = float(values["text_height"]) + style.dxf.arrow_head_size = typed["arrow_size"] + style.dxf.landing_gap_size = typed["landing_gap"] + style.dxf.char_height = typed["text_height"] style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle self._mark_dirty() return { "ok": True, "name": clean, - "values": { - "arrow_size": float(values["arrow_size"]), - "landing_gap": float(values["landing_gap"]), - "text_style": text_style_name, - "text_height": float(values["text_height"]), - }, + "values": {**typed, "text_style": text_style_name}, "textstyle_created": textstyle_created, } diff --git a/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md b/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md index b5404bd..62b19ab 100644 --- a/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md +++ b/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md @@ -118,7 +118,7 @@ name. - `dimstyle_modify(name, overrides)` — `changed` reports only variables that actually moved (the `hatch_edit` rule). Dimensions already using the style are listed in `dimensions_using_style` with `rerender_required: true` on the headless engine: AutoCAD re-renders them on the next regen, but a headless DIMENSION carries its rendered block, so the drawing shows the old style until the dimension is redrawn — stated rather than silent. - `dimstyle_set_current(name)` — `$DIMSTYLE`; the dimension tools then use it (the existing per-dimension override path stays as is). - `textstyle_list()`, `textstyle_create(name, font: "isocp.shx"|"isocpeur.ttf"|"arial.ttf"|"romans.shx"|, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)` — `$TEXTSTYLE`; a font file that is neither a bundled preset nor present in the engine's font path is written but reported `font_resolved: false` (AutoCAD substitutes at open; the headless renderer falls back), never refused, because DXF only stores the name. -- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height on ezdxf's `doc.mleader_styles`. ActiveX exposes no MLeaderStyle collection, so the COM engine refuses both with `capability: "mleaderstyle"` (the reason names `leader_create_mleader`'s existing per-leader parameters as the route); `mleaderstyle_list` on COM still reads the names from the `ACAD_MLEADERSTYLE` dictionary. +- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height, both engines: ezdxf on `doc.mleader_styles`; COM through the `ACAD_MLEADERSTYLE` dictionary, whose items are `IAcadMLeaderStyle` objects (`ArrowSize` / `LandingGap` / `TextHeight` in drawing units, `TextStyle` a name — verified against the AutoCAD 2026 type library), created with `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")` followed by the property writes. ActiveX has no `MLeaderStyles` *collection* property, which is not an engine boundary — no `mleaderstyle` capability key exists. - `drawing_apply_standard(standard: "iso"|"ansi", layers: bool = True, units: bool = True)` — one call: the dimstyle preset (created and set current), its text style, `drawing_settings` (units mm, decimal linear and angular units, LTSCALE 1, DIMSCALE 1; the decimal separator comes from the preset) and, with `layers`, the `mech` layer set for both standards (ANSI layer naming is company-specific; the set reuses the mech names with ISO 128 lineweights — only styles and the separator differ between the two). Returns, per item, whether it was created or was already present. ## 5. Settings (SECTION 20 shares these; 3 tools + facade growth) @@ -187,9 +187,10 @@ A state is the layer table snapshot `{layer: {on, frozen, locked, color, linetyp ### 8.1 Capability keys (added to **both** maps) -`mleaderstyle` (COM false), `dwgprops` (ezdxf false: summary fields), `dwt_write` -(ezdxf false), `live_application`, `preferences`, `interactive_prompt` (ezdxf -false). `tests/test_capability_contract.py` enforces both maps. +`dwgprops` (ezdxf false: summary fields), `dwt_write` (ezdxf false), +`live_application`, `preferences`, `interactive_prompt` (ezdxf false). +`tests/test_capability_contract.py` enforces both maps. (`mleaderstyle` was +listed here as COM false; the ActiveX route exists — §4.2 — so there is no key.) ### 8.2 Packs, profiles, discovery @@ -219,7 +220,9 @@ shop-floor synonyms; the discovery golden set gains ≥ 10 queries for them (A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog sysvar has type+meaning+engines. - Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call - shapes: `DimStyles.Add`, `TextStyles.Add`, `Layout.ConfigName/…`, + shapes: `DimStyles.Add`, `TextStyles.Add`, + `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")`, + `Layout.ConfigName/…`, `Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`, `UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`, `Utility.GetPoint`). diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py index abb8477..115a25f 100644 --- a/engineering/standards/__init__.py +++ b/engineering/standards/__init__.py @@ -23,7 +23,11 @@ from engineering.standards.dimstyles import ( resolve_dimstyle, validate_overrides, ) -from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle +from engineering.standards.mleaderstyles import ( + MLEADER_PRESETS, + resolve_mleaderstyle, + validate_mleaderstyle, +) from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle __all__ = [ @@ -42,6 +46,7 @@ __all__ = [ "resolve_dimstyle", "resolve_font", "resolve_mleaderstyle", + "validate_mleaderstyle", "validate_overrides", "validate_textstyle", ] diff --git a/engineering/standards/mleaderstyles.py b/engineering/standards/mleaderstyles.py index 48a386d..e944aba 100644 --- a/engineering/standards/mleaderstyles.py +++ b/engineering/standards/mleaderstyles.py @@ -2,8 +2,10 @@ Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows and lettering; ANSI: 3 mm), so a leader note and a dimension on the same -sheet read at one size. Only ezdxf can author an MLEADERSTYLE object; -ActiveX exposes no collection for it (see ``backends/contracts/styles.py``). +sheet read at one size. Both engines author the style: ezdxf on +``doc.mleader_styles``, ActiveX through the ``ACAD_MLEADERSTYLE`` dictionary's +``AddObject(name, "AcDbMLeaderStyle")`` and the ``IAcadMLeaderStyle`` +properties (see ``backends/contracts/styles.py``). """ from __future__ import annotations @@ -13,7 +15,7 @@ from typing import Any from engineering.standards.names import check_name -__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] +__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle", "validate_mleaderstyle"] MLEADER_PRESETS: dict[str, dict[str, Any]] = { "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, @@ -57,3 +59,27 @@ def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str else: values[name] = _positive(name, value) return values + + +def validate_mleaderstyle(values: Any) -> dict[str, Any]: + """The four ``MLEADER_KEYS`` of ``values``, typed, or a refusal naming the key. + + What both engines' ``mleaderstyle_create`` run before touching a table: + a missing key is ``ValueError`` (``resolve a preset first``), and each + value is checked exactly as :func:`resolve_mleaderstyle` checks an + override — so a hand-built dict is held to the preset's rules too. + Unknown keys are ignored: the row shape is the contract, not the input. + """ + if not isinstance(values, dict): + raise TypeError(f"values: expected a mapping, got {type(values).__name__}") + missing = [key for key in MLEADER_KEYS if key not in values] + if missing: + raise ValueError( + f"mleaderstyle_create: values is missing {missing}; resolve a preset first" + ) + return { + "arrow_size": _positive("arrow_size", values["arrow_size"]), + "landing_gap": _positive("landing_gap", values["landing_gap"], allow_zero=True), + "text_style": check_name("text_style", values["text_style"], what="text style name"), + "text_height": _positive("text_height", values["text_height"]), + } diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index 95dcd10..7c6d102 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -30,6 +30,7 @@ from engineering.standards.mleaderstyles import ( MLEADER_KEYS, MLEADER_PRESETS, resolve_mleaderstyle, + validate_mleaderstyle, ) from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle @@ -564,3 +565,28 @@ def test_resolve_mleaderstyle_applies_overrides(): def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment): with pytest.raises(exc, match=fragment): resolve_mleaderstyle(preset, overrides) + + +def test_validate_mleaderstyle_types_the_four_keys_and_refuses_by_name(): + """What both engines' ``mleaderstyle_create`` run before any write: a + hand-built dict is held to the same rules as a preset override.""" + assert validate_mleaderstyle(resolve_mleaderstyle("iso")) == MLEADER_PRESETS["iso"] + typed = validate_mleaderstyle( + {"arrow_size": 3, "landing_gap": 0, "text_style": " arial ", "text_height": 2, "extra": 1} + ) + assert typed == { + "arrow_size": 3.0, + "landing_gap": 0.0, + "text_style": "arial", + "text_height": 2.0, + } + with pytest.raises(ValueError, match=r"missing \['landing_gap', 'text_style', 'text_height'\]"): + validate_mleaderstyle({"arrow_size": 2.5}) + with pytest.raises(ValueError, match="arrow_size"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "arrow_size": 0}) + with pytest.raises(ValueError, match="text_style"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_style": "a|b"}) + with pytest.raises(TypeError, match="text_height"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_height": "2.5"}) + with pytest.raises(TypeError, match="values"): + validate_mleaderstyle(["arrow_size"]) diff --git a/tests/test_styles.py b/tests/test_styles.py index cd73c70..8fd8c7c 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -16,7 +16,6 @@ import ezdxf import pytest import config -from backends.base import UnsupportedCapabilityError from backends.capability import is_capability_default from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle @@ -162,13 +161,31 @@ async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, t ) again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"}) assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)" + # The style is current, so Restore loaded it into the $DIM* header. The + # header is DXF-facing: a built-in is spelled with its underscore there + # (what a live seat stores and what COM's system_get_variable returns), + # never ezdxf's writer spelling the DIMSTYLE entry holds. + header = backend._doc.header + assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("_OBLIQUE", "_DOT", "") + assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE", "the same answer as COM" path = str(tmp_path / "arrows.dxf") await backend.drawing_save_as(path) + saved = ezdxf.readfile(path) + assert saved.header["$DIMBLK"] == "_OBLIQUE" and saved.header["$DIMBLK1"] == "_DOT", ( + "the file names the block it actually holds, not a block that does not exist" + ) + assert "_OBLIQUE" in saved.blocks and "_DOT" in saved.blocks await backend.drawing_open(path) rows = {r["name"]: r for r in await backend.dimstyle_list()} assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling" + assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE" reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"}) assert reopened["changed"] == {} + await backend.dimstyle_set_current("Standard") + header = backend._doc.header # drawing_open replaced the document + assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("", "", ""), ( + "closed filled is the empty string in the header, as in a fresh drawing" + ) async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend): @@ -434,12 +451,20 @@ async def test_mleaderstyle_refusals_write_nothing(backend): assert not backend._doc.mleader_styles.has_entry("X") -async def test_both_capability_maps_declare_mleaderstyle(backend): +async def test_mleaderstyle_is_not_a_capability_boundary_on_either_engine(backend): + """The v1.6 plan declared `mleaderstyle` COM-unsupported ("ActiveX exposes + no MLeaderStyle collection"). The type library says otherwise: the + ACAD_MLEADERSTYLE dictionary items are IAcadMLeaderStyle objects and + AddObject(name, "AcDbMLeaderStyle") creates one. A refusal resting on a + false premise is exactly what CLAUDE.md forbids `@capability` for, so + neither map declares the key and neither engine inherits a default.""" from backends.com_backend import ComBackend - assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True - com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"] - assert com["supported"] is False and "leader_create_mleader" in com["reason"] + assert "mleaderstyle" not in backend.capabilities().to_dict()["features"] + assert "mleaderstyle" not in ComBackend().capabilities().to_dict()["features"] + for engine in (type(backend), ComBackend): + assert not is_capability_default(engine, "mleaderstyle_create") + assert not is_capability_default(engine, "mleaderstyle_list") # ── live engine, against a fake ActiveX surface ────────────────────────────── @@ -506,9 +531,32 @@ class _FakeLayouts: return self.layouts[index] +class _FakeMLeaderStyle: + """An AcadMLeaderStyle: the properties acax25enu.tlb's IAcadMLeaderStyle + exposes that the backend reads or writes (doubles in drawing units, the + text style by name), with AutoCAD's defaults for a fresh style.""" + + PROPERTIES = ("ArrowSize", "LandingGap", "TextHeight", "TextStyle") + + def __init__(self, name, calls): + self.Name = name + self.ObjectName = "AcDbMLeaderStyle" + self.ArrowSize, self.LandingGap, self.TextHeight, self.TextStyle = 4.0, 2.0, 4.0, "Standard" + self._calls = calls # last: property writes are recorded from here on + + def __setattr__(self, key, value): + if key in self.PROPERTIES and "_calls" in vars(self): + self._calls.append(("MLeaderStyle." + key, self.Name, value)) + super().__setattr__(key, value) + + class _FakeDictionary: - def __init__(self, *names): - self.objects = [types.SimpleNamespace(mleader_name=name) for name in names] + """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects + and AddObject(keyword, class_name), the documented VBA route.""" + + def __init__(self, calls, *names): + self._calls = calls + self.objects = [_FakeMLeaderStyle(name, calls) for name in names] @property def Count(self): @@ -518,7 +566,15 @@ class _FakeDictionary: return self.objects[index] def GetName(self, obj): - return obj.mleader_name + return obj.Name + + def AddObject(self, keyword, class_name): + self._calls.append(("Dictionary.AddObject", keyword, class_name)) + if class_name != "AcDbMLeaderStyle": + raise RuntimeError(f"cannot create {class_name} here") + style = _FakeMLeaderStyle(keyword, self._calls) + self.objects.append(style) + return style class _FakeDictionaries: @@ -570,7 +626,7 @@ class _FakeDocument: ) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) self.Dictionaries = _FakeDictionaries( - {"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")} + {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")} ) def GetVariable(self, name): @@ -751,36 +807,74 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend): assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True} -async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend): +async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend): backend, document = com_backend + dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary.objects[1].ArrowSize = 3.0 + dictionary.objects[1].TextStyle = "ISOCP" + document.calls.clear() # fixture setup, not backend calls rows = await backend.mleaderstyle_list() assert rows == [ { "name": "Annotative", - "arrow_size": None, - "landing_gap": None, - "text_style": None, - "text_height": None, - "values_available": False, + "arrow_size": 3.0, + "landing_gap": 2.0, + "text_style": "ISOCP", + "text_height": 4.0, + "values_available": True, }, { "name": "Standard", - "arrow_size": None, - "landing_gap": None, - "text_style": None, - "text_height": None, - "values_available": False, + "arrow_size": 4.0, + "landing_gap": 2.0, + "text_style": "Standard", + "text_height": 4.0, + "values_available": True, }, ] + assert document.calls == [], "a list reads; it writes nothing" -async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend): - from backends.com_backend import ComBackend - +async def test_com_mleaderstyle_create_adds_the_object_then_writes_its_properties(com_backend): backend, document = com_backend - assert is_capability_default(ComBackend, "mleaderstyle_create") - with pytest.raises(UnsupportedCapabilityError) as excinfo: - await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) - assert excinfo.value.capability == "mleaderstyle" - assert "leader_create_mleader" in str(excinfo.value) - assert document.calls == [] + result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + assert result == { + "ok": True, + "name": "ISO", + "values": { + "arrow_size": 2.5, + "landing_gap": 1.0, + "text_style": "ISOCP", + "text_height": 2.5, + }, + "textstyle_created": True, + } + names = [call[0] for call in document.calls] + assert names.index("TextStyles.Add") < names.index("Dictionary.AddObject"), ( + "the text style exists before TextStyle names it" + ) + assert ("Dictionary.AddObject", "ISO", "AcDbMLeaderStyle") in document.calls + writes = [call for call in document.calls if call[0].startswith("MLeaderStyle.")] + assert writes == [ + ("MLeaderStyle.ArrowSize", "ISO", 2.5), + ("MLeaderStyle.LandingGap", "ISO", 1.0), + ("MLeaderStyle.TextHeight", "ISO", 2.5), + ("MLeaderStyle.TextStyle", "ISO", "ISOCP"), + ] + rows = {row["name"]: row for row in await backend.mleaderstyle_list()} + assert rows["ISO"] == {**result["values"], "name": "ISO", "values_available": True} + + +async def test_com_mleaderstyle_refusals_write_nothing(com_backend): + backend, document = com_backend + dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + with pytest.raises(ValueError, match="already exists"): + await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None)) + with pytest.raises(ValueError, match="text_style"): + await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"})) + with pytest.raises(ValueError, match="missing"): + await backend.mleaderstyle_create("X", {"arrow_size": 2.5}) + with pytest.raises(ValueError, match="arrow_size"): + await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0}) + assert document.calls == [], "nothing reached ActiveX" + assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"]