Files
autocad-mcp-pro/tests/test_styles.py
T
Umut CANandClaude Opus 5 03a5067d83 fix(task-11): live text styles write TrueType by typeface, refuse missing fonts and arrowhead blocks before any write, un-narrow the dimension walker
Four review findings against the COM styles block, all measured on AutoCAD 2026
(scratch documents from Documents.Add, closed without saving):

1. textstyle_create: `TextStyle.fontFile = "arial.ttf"` raises 'Filer error' for
   every bare TrueType name (bundled presets included) after TextStyles.Add has
   run, leaving a stub with an empty font that a retry meets as "already exists";
   a full path is accepted but the STYLE record then carries the machine path.
   `SetFont("Arial", bold, italic, 0, 0)` — the STYLE dialog's own route — stores
   the clean `arial.ttf`. The font is now located before Add (`_com_locate_font`:
   support path, then the Windows font folders), a TrueType is written by the
   family/subfamily read from its `name` table (`_truetype_face`), an SHX by the
   name given, an unlocatable file is refused with nothing written, and a write
   ActiveX still refuses after Add deletes the entry it added.
2. _ensure_com_textstyle goes through the same helper, so DIMTXSTY / text_style
   ARIAL and ISOCPEUR are created on the first call with a real font instead of
   failing once and then silently binding a fontless style.
3. dimstyle_create / dimstyle_modify check a user DIMBLK/DIMBLK1/DIMBLK2 against
   doc.Blocks before any write (`_com_require_arrowhead_blocks`, the headless
   `_require_arrowhead_blocks` twin, same message and table-spelling rewrite);
   a write that fails after DimStyles.Add restores the previous ActiveDimStyle
   (discarding the unsaved overrides) and deletes the half-made style and any
   preset text style the call created.
4. _com_dimensions_using un-narrows each `Layouts.Item(i).Block.Item(p)` —
   declared IAcadEntity*, so `.StyleName` raised AttributeError under a makepy
   cache and the blanket except reported `[]` — and swallows nothing.

The fake in tests/test_styles.py now models the measured ActiveX rules (bare
TrueType fontFile refused, SetFont by installed typeface, SetVariable refusing an
unknown arrowhead, IAcadEntity narrowing on block items, Delete) so the old code
cannot pass; nine tests cover the four findings. SECTION 18 docstrings state the
live engine's refusal rule for fonts and the pre-write arrowhead check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:17:30 +03:00

1511 lines
68 KiB
Python

"""Dimension, text and multileader styles on both engines.
Headless: real ezdxf tables, and the *rendered* dimension block as the witness
that a current style reaches the next dimension (the header alone was never
the thing that could lie — see tests/test_dimension_header_vars.py). Live:
a fake ActiveX surface that records the call shapes the COM block relies on
and models the one AutoCAD rule it depends on — making a style current
restores that style's saved DIM* settings.
"""
from __future__ import annotations
import types
from pathlib import Path
import ezdxf
import pytest
import config
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
from engineering.standards.mleaderstyles import resolve_mleaderstyle
pytestmark = pytest.mark.asyncio
def _rendered(backend, handle):
"""(text, text height, arrow xscales) out of the dimension's geometry block."""
dim = backend._doc.entitydb.get(handle)
block = backend._doc.blocks.get(dim.dxf.geometry)
text = height = None
arrows = []
for entity in block:
if entity.dxftype() == "MTEXT" and text is None:
text, height = entity.text, round(float(entity.dxf.char_height), 4)
elif entity.dxftype() == "TEXT" and text is None:
text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
elif entity.dxftype() == "INSERT":
arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
if text is None:
raise AssertionError("the dimension block carries no text")
return text, height, sorted(set(arrows))
# ── headless engine ─────────────────────────────────────────────────────────
async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend):
rows = await backend.dimstyle_list()
assert [r["name"] for r in rows] == ["Standard"]
assert rows[0]["current"] is True and rows[0]["values_available"] is True
assert tuple(rows[0]["values"]) == PRESET_VARIABLES
assert rows[0]["values"]["DIMTXT"] == 2.5
assert rows[0]["values"]["DIMDSEP"] == ".", "drawing_new's ISO point"
assert rows[0]["values"]["DIMTXSTY"] == "Standard", "unstored code reports the schema default"
async def test_dimstyle_create_iso25_writes_every_value_and_creates_isocp(backend):
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
assert result["ok"] is True and result["name"] == "ISO-25"
assert result["textstyle_created"] is True and result["current"] is False
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
assert result["written"] == sorted(PRESET_VARIABLES)
style = backend._doc.dimstyles.get("ISO-25")
assert style.dxf.dimtxt == 2.5 and style.dxf.dimdsep == 44 and style.dxf.dimtad == 1
assert style.dxf.dimtxsty == "ISOCP" and style.dxf.dimlwd == -2 and style.dxf.dimblk == ""
assert backend._doc.styles.get("ISOCP").dxf.font == "isocp.shx"
# ezdxf.new() writes $DIMSTYLE "ISO-25" with no such entry; creating the
# entry must not make it current by accident.
assert backend._doc.header["$DIMSTYLE"] == "Standard"
assert _current_dimstyle_name(backend._doc) == "Standard"
async def test_set_current_syncs_the_header_and_the_next_dimension_renders_with_it(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
header = backend._doc.header
assert header["$DIMSTYLE"] == "ISO-25"
assert header["$DIMDSEP"] == 44 and header["$DIMTXT"] == 2.5 and header["$DIMTXSTY"] == "ISOCP"
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert backend._doc.entitydb.get(dim.handle).dxf.dimstyle == "ISO-25"
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
dim2 = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
assert backend._doc.entitydb.get(dim2.handle).dxf.dimstyle == "ANSI"
assert _rendered(backend, dim2.handle) == ("33.33", 3.0, [3.0])
for creator, args in (
(backend.dimension_aligned, (0, 0, 10, 10, 5, 15)),
(backend.dimension_radius, (50, 50, 60, 50)),
(backend.dimension_diameter, (70, 0, 90, 0)),
(backend.dimension_angular, (0, 0, 10, 0, 0, 10, 5, 5)),
):
created = await creator(*args)
assert backend._doc.entitydb.get(created.handle).dxf.dimstyle == "ANSI", creator.__name__
async def test_dimstyle_modify_reports_only_what_moved_and_the_dimensions_using_it(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
dim = await backend.dimension_linear(0, 0, 10, 0, 5, -10)
result = await backend.dimstyle_modify("iso-25", {"DIMTXT": 2.5, "DIMDEC": 3, "dimrnd": 0})
assert result["name"] == "ISO-25"
assert result["changed"] == {"DIMDEC": [2, 3]}
assert result["dimensions_using_style"] == [dim.handle]
assert result["rerender_required"] is True
assert backend._doc.header["$DIMDEC"] == 3, "the current style's header twin follows"
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimrnd"), (
"DIMRND 0 is 'no rounding' and must be discarded, not stored (xround(x, 0.0) rounds)"
)
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
assert again["changed"] == {} and again["rerender_required"] is False
on_standard = await backend.dimstyle_modify("Standard", {"DIMDEC": 4})
assert on_standard["dimensions_using_style"] == [] and on_standard["rerender_required"] is False
assert backend._doc.header["$DIMDEC"] == 3, "a non-current style does not touch the header"
async def test_refusals_happen_before_anything_is_written(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="already exists"):
await backend.dimstyle_create("iso-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMTXSTY"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
assert not backend._doc.dimstyles.has_entry("X")
with pytest.raises(ValueError, match="DIMFOO"):
await backend.dimstyle_create("Y", {"DIMFOO": 1})
assert not backend._doc.dimstyles.has_entry("Y")
with pytest.raises(ValueError, match="DIMDEC"):
await backend.dimstyle_modify("ISO-25", {"DIMDEC": 9})
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
with pytest.raises(ValueError, match="empty"):
await backend.dimstyle_modify("ISO-25", {})
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_set_current("NOPE")
assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2
# DIMDSEP is stored as ``ord(value)``: a control character or a code point
# above the 16-bit group 278 must stop here, not reach the table.
with pytest.raises(ValueError, match="DIMDSEP"):
await backend.dimstyle_create("Z", {"DIMDSEP": "\n"})
assert not backend._doc.dimstyles.has_entry("Z")
with pytest.raises(ValueError, match="DIMDSEP"):
await backend.dimstyle_modify("ISO-25", {"DIMDSEP": "\U0001f600"})
assert backend._doc.dimstyles.get("ISO-25").dxf.dimdsep == 44
async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path):
"""`validate_overrides` hands the canonical `_OBLIQUE`; stored verbatim ezdxf
raises at render and at `doc.write`. The witness is the rendered INSERT and
a save/reopen that reports the same name before and after."""
values = resolve_dimstyle(
"iso-25", {"DIMBLK": "oblique", "DIMSAH": 1, "DIMBLK1": "_DOT", "DIMBLK2": "."}
)
result = await backend.dimstyle_create("ISO-25", values, set_current=True)
assert result["values"]["DIMBLK"] == "_OBLIQUE"
style = backend._doc.dimstyles.get("ISO-25")
assert style.dxf.dimblk == "OBLIQUE" and style.dxf.dimblk1 == "DOT" and style.dxf.dimblk2 == ""
dim = await backend.dimension_linear(0, 0, 20, 0, 10, -10)
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
assert {e.dxf.name for e in block if e.dxftype() == "INSERT"} == {"_DOT", "_CLOSEDFILLED"}, (
"DIMSAH on: the two separate arrowheads win over DIMBLK"
)
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):
with pytest.raises(ValueError, match="DIMBLK: block 'MYARROW' does not exist"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMBLK": "MYARROW"}))
assert not backend._doc.dimstyles.has_entry("X")
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMBLK1: block 'MYARROW' does not exist"):
await backend.dimstyle_modify("ISO-25", {"DIMSAH": 1, "DIMBLK1": "MYARROW"})
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimsah"), "nothing written"
backend._doc.blocks.new("MYARROW").add_line((0, 0), (1, 1))
result = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "myarrow"})
assert result["changed"] == {"DIMBLK": ["", "MYARROW"]}, "the block table's own spelling"
assert backend._doc.dimstyles.get("ISO-25").dxf.dimblk == "MYARROW"
async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
result = await backend.dimstyle_set_current("iso-25")
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
same = await backend.dimstyle_set_current("ISO-25")
assert same["changed"] is False and same["previous"] == "ISO-25"
path = str(tmp_path / "styles.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
rows = await backend.dimstyle_list()
assert [r["name"] for r in rows if r["current"]] == ["ISO-25"]
assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == ","
# The table's report was never the thing that could lie; the render is.
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
async def test_a_user_dimstyle_survives_every_dxf_round_trip_without_rounding(
backend, tmp_path, monkeypatch
):
"""ezdxf's exporter writes DIMRND 0.0 for *every* DIMSTYLE, and a stored 0.0
makes the renderer round every dimension to a whole unit. Measured before
`_normalise_dimstyle_rounding` walked the whole table: ISO-25 came back from
a save/open, a `transaction_rollback` and a `drawing_undo` rendering 33.333
as "33" and R6.35 as "R6" while `dimstyle_list` still reported DIMDEC 2."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new() # a history baseline with undo switched on
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None))
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
def _rounding_stored() -> dict[str, bool]:
return {s.dxf.name: s.dxf.hasattr("dimrnd") for s in backend._doc.dimstyles}
path = str(tmp_path / "roundtrip.dxf")
await backend.drawing_save_as(path)
assert ezdxf.readfile(path).dimstyles.get("ISO-25").dxf.get("dimrnd") == 0.0, (
"the premise: the file on disk carries the 0.0 sentinel for a user style"
)
await backend.drawing_open(path)
assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False}
reopened = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
assert _rendered(backend, reopened.handle) == ("33,33", 2.5, [2.5])
radius = await backend.dimension_radius(50, 50, 56.35, 50)
assert _rendered(backend, radius.handle)[0] == "R6,35"
await backend.dimstyle_set_current("ANSI")
ansi = await backend.dimension_linear(0, 0, 12.75, 0, 6, -90)
assert _rendered(backend, ansi.handle)[0] == "12.75"
await backend.dimstyle_set_current("ISO-25")
await backend.transaction_begin()
await backend.entity_create_line(0, 0, 10, 10)
await backend.transaction_rollback()
assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False}
rolled_back = await backend.dimension_linear(0, 0, 33.333, 0, 16, -120)
assert _rendered(backend, rolled_back.handle) == ("33,33", 2.5, [2.5])
await backend.entity_create_line(0, 0, 20, 20)
assert (await backend.drawing_undo())["ok"] is True
assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False}
undone = await backend.dimension_linear(0, 0, 33.333, 0, 16, -150)
assert _rendered(backend, undone.handle) == ("33,33", 2.5, [2.5])
await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5})
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert backend._doc.dimstyles.get("ISO-25").dxf.dimrnd == 0.5, (
"only the 0.0 sentinel is discarded; a rounding the drafter asked for survives"
)
async def test_drawing_purge_keeps_what_the_style_tables_and_header_reference(backend, tmp_path):
"""`drawing_purge` followed only entity `.dxf.style`; a text style that only
a DIMSTYLE, an MLEADERSTYLE or the header referenced was deleted, the next
dimension silently rendered in Standard, and `drawing_save_as` raised
`DXFTableEntryError` after leaving a truncated file on disk."""
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
await backend.mleaderstyle_create("ANSI", resolve_mleaderstyle("ansi", None))
await backend.textstyle_create("Notes", "arial.ttf", set_current=True)
await backend.textstyle_create("Junk", "isocpeur.ttf")
await backend.block_define("MYARROW", [{"type": "line", "x1": 0, "y1": 0, "x2": -1, "y2": 0.2}])
await backend.dimstyle_create("ARROWED", resolve_dimstyle("ansi", {"DIMBLK": "MYARROW"}))
result = await backend.drawing_purge()
assert result["purged"]["text_styles"] == 1, "only the style nothing references goes"
assert result["purged"]["blocks"] == 0
names = {row["name"] for row in await backend.textstyle_list()}
assert {"Standard", "ISOCP", "ROMANS", "Notes"} <= names and "Junk" not in names
assert [r["name"] for r in await backend.textstyle_list() if r["current"]] == ["Notes"]
assert "MYARROW" in backend._doc.blocks
leader_styles = {r["name"]: r["text_style"] for r in await backend.mleaderstyle_list()}
assert leader_styles["ANSI"] == "ROMANS"
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
path = str(tmp_path / "purged.dxf")
await backend.drawing_save_as(path)
reopened = ezdxf.readfile(path)
assert reopened.dimstyles.get("ISO-25").dxf.dimtxsty == "ISOCP"
assert reopened.header["$TEXTSTYLE"] == "Notes"
async def test_set_current_restores_every_dim_variable_not_just_the_stored_ones(backend, tmp_path):
"""A live seat's `-DIMSTYLE Restore` replaces *all* DIM* variables. Writing
only what the target style stores left the previous style's values in the
header: measured, ISO-25 with DIMZIN 0 / DIMSCALE 2.0 then
`dimstyle_set_current("Standard")` rendered the next dimension at 5.0 mm as
"33.30" and the saved file carried those next to `$DIMSTYLE Standard`."""
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST
header = backend._doc.header
fresh = {f"${var}": header.get(f"${var}", None) for var in DIM_VARIABLE_WHITELIST}
baseline = await backend.dimension_linear(0, 0, 33.3, 0, 16, -30)
values = resolve_dimstyle("iso-25", {"DIMZIN": 0, "DIMSCALE": 2.0})
await backend.dimstyle_create("ISO-25", values, set_current=True)
assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0
assert header["$DIMTXSTY"] == "ISOCP"
result = await backend.dimstyle_set_current("Standard")
assert result["current"] == "Standard"
assert {key: header.get(key, None) for key in fresh} == fresh, (
"restoring Standard must reproduce the header drawing_new made"
)
dim = await backend.dimension_linear(0, 0, 33.3, 0, 16, -60)
expected = ("33.3", 2.5, [2.5])
assert _rendered(backend, dim.handle) == _rendered(backend, baseline.handle) == expected
assert backend._doc.entitydb.get(dim.handle).override().dimstyle_attribs == {}, (
"nothing stale is folded onto the dimension"
)
# DIMRND 0 discards the style attribute; the header twin must follow.
await backend.dimstyle_set_current("ISO-25")
assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0
await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5})
assert header["$DIMRND"] == 0.5
cleared = await backend.dimstyle_modify("ISO-25", {"DIMRND": 0})
assert cleared["changed"] == {"DIMRND": [0.5, 0]}
assert header["$DIMRND"] == 0.0
await backend.dimstyle_set_current("Standard")
path = str(tmp_path / "restored.dxf")
await backend.drawing_save_as(path)
saved = ezdxf.readfile(path).header
assert saved["$DIMSTYLE"] == "Standard"
assert {key: saved.get(key, None) for key in fresh} == fresh
async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend):
result = await backend.textstyle_create("ISOCP", "isocp", set_current=True)
assert result == {
"ok": True,
"name": "ISOCP",
"font": "isocp.shx",
"font_resolved": True,
"current": True,
}
rows = await backend.textstyle_list()
assert [r["name"] for r in rows] == ["ISOCP", "Standard"]
assert rows[0] == {
"name": "ISOCP",
"font": "isocp.shx",
"height": 0.0,
"width_factor": 1.0,
"oblique_deg": 0.0,
"current": True,
}
assert backend._doc.header["$TEXTSTYLE"] == "ISOCP"
assert _current_textstyle_name(backend._doc) == "ISOCP"
text = await backend.entity_create_text("note", 0, 0)
mtext = await backend.entity_create_mtext("note", 0, 10)
assert backend._doc.entitydb.get(text.handle).dxf.style == "ISOCP"
assert backend._doc.entitydb.get(mtext.handle).dxf.style == "ISOCP"
back = await backend.textstyle_set_current("standard")
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
assert (
backend._doc.entitydb.get((await backend.entity_create_text("x", 0, 0)).handle).dxf.style
== "Standard"
)
async def test_textstyle_unknown_font_is_written_but_reported_unresolved(backend):
result = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15)
assert result["font_resolved"] is False and result["current"] is False
style = backend._doc.styles.get("ODD")
assert style.dxf.font == "acadmcp-missing-font.ttf"
assert style.dxf.height == 3.5 and style.dxf.width == 0.8 and style.dxf.oblique == 15.0
async def test_textstyle_refusals_write_nothing(backend):
await backend.textstyle_create("ISOCP", "isocp.shx")
with pytest.raises(ValueError, match="already exists"):
await backend.textstyle_create("isocp", "isocp.shx")
with pytest.raises(ValueError, match="width_factor"):
await backend.textstyle_create("Z", "arial.ttf", width_factor=0)
with pytest.raises(ValueError, match="oblique_deg"):
await backend.textstyle_create("Z", "arial.ttf", oblique_deg=90)
with pytest.raises(ValueError, match="height"):
await backend.textstyle_create("Z", "arial.ttf", height=-1)
with pytest.raises(ValueError, match="font"):
await backend.textstyle_create("Z", "")
assert not backend._doc.styles.has_entry("Z")
with pytest.raises(ValueError, match="does not exist"):
await backend.textstyle_set_current("NOPE")
async def test_mleaderstyle_create_iso_and_list(backend):
result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
assert result["ok"] is True and result["textstyle_created"] is True
assert result["values"] == {
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5,
}
style = backend._doc.mleader_styles.get("ISO")
assert style.dxf.arrow_head_size == 2.5 and style.dxf.landing_gap_size == 1.0
assert backend._doc.entitydb[style.dxf.text_style_handle].dxf.name == "ISOCP"
rows = await backend.mleaderstyle_list()
assert rows == [
{
"name": "ISO",
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5,
"values_available": True,
},
{
"name": "Standard",
"arrow_size": 4.0,
"landing_gap": 2.0,
"text_style": "Standard",
"text_height": 4.0,
"values_available": True,
},
]
async def test_mleaderstyle_refusals_write_nothing(backend):
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
with pytest.raises(ValueError, match="already exists"):
await backend.mleaderstyle_create("iso", 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})
assert not backend._doc.mleader_styles.has_entry("X")
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 "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 ──────────────────────────────
#: The font files the fake seat's font folder holds: the SHX presets (empty
#: files — the SHX loader is never read here) and the TrueType presets as
#: minimal sfnt files whose ``name`` table carries the family, plus a bold
#: face and txt.shx. The ``com_backend`` fixture writes them to a tmp folder.
FAKE_SHX_FONTS = ("isocp.shx", "romans.shx", "txt.shx")
FAKE_TTF_FONTS = {
"arial.ttf": ("Arial", "Regular"),
"arialbd.ttf": ("Arial", "Bold"),
"isocpeur.ttf": ("ISOCPEUR", "Regular"),
"orphan.ttf": ("Orphan Face", "Regular"), # a file Windows has not installed
}
#: The typefaces the fake Windows font system has installed — what SetFont accepts.
FAKE_INSTALLED_FACES = {"Arial": "arial", "ISOCPEUR": "isocpeur"}
def minimal_sfnt(family: str, subfamily: str) -> bytes:
"""A TrueType file reduced to its table directory and a ``name`` table
with Windows/Unicode (platform 3, en-US) family and subfamily records —
enough for ``_truetype_face`` and nothing else."""
import struct
records, strings = b"", b""
for name_id, text in ((1, family), (2, subfamily)):
raw = text.encode("utf-16-be")
records += struct.pack(">HHHHHH", 3, 1, 0x409, name_id, len(raw), len(strings))
strings += raw
header = struct.pack(">HHH", 0, 2, 6 + len(records))
name_table = header + records + strings
directory = struct.pack(">IHHHH", 0x00010000, 1, 16, 0, 0)
directory += b"name" + struct.pack(">III", 0, 12 + 16, len(name_table))
return directory + name_table
class _FilerError(RuntimeError):
"""``TextStyle.fontFile`` / ``SetVariable("DIMBLK", ...)`` failing the way
ActiveX does: ``com_error (-2147352567, 'Exception occurred.', (0, None,
'Filer error', ...))`` — a generic error the caller cannot catch by type."""
class _FakeStyle:
"""An AcadDimStyle / AcadTextStyle: ``Name``, ``CopyFrom`` and — for a text
style — ``fontFile``, ``Width``, ``ObliqueAngle``, ``Height``. The
``fontFile`` setter models what AutoCAD 2026 measured: a bare SHX name on
the support path is accepted and stored as given; a bare *TrueType* name
is refused with a Filer error even when the file exists on the support
path (``"arial.ttf"``, ``"isocpeur.ttf"``); a full path to an existing
file is accepted; a file nobody has is refused. A refused write leaves
the previous value (``""`` on a fresh entry) — the stub state the
backend must never leave behind."""
support_folder: Path | None = None # set by the com_backend fixture
installed_faces: dict[str, str] = FAKE_INSTALLED_FACES # typeface -> file stem
def __init__(self, name, calls):
self.Name = name
self._calls = calls
self._font = "txt.shx"
self.Width = 1.0
self.ObliqueAngle = 0.0
self.Height = 0.0
self.deleted = False
self.saved: dict | None = None # the DIM* values this style holds, once saved
@property
def fontFile(self):
return self._font
@fontFile.setter
def fontFile(self, value):
self._calls.append(("fontFile", self.Name, value))
path = Path(value)
if path.is_absolute():
accepted = path.is_file()
elif path.suffix.lower() in (".ttf", ".ttc", ".otf"):
accepted = False # a bare TrueType name: Filer error, measured
else:
folder = _FakeStyle.support_folder
accepted = folder is not None and (folder / value).is_file()
if not accepted:
raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))")
self._font = value
def SetFont(self, typeface, bold, italic, charset, pitch):
# AcadTextStyle.SetFont: a typeface Windows has installed is accepted
# and fontFile reads back as its file name (measured: SetFont("Arial")
# -> 'arial.ttf'); anything else is 'Invalid input'.
self._calls.append(("SetFont", self.Name, typeface, bold, italic, charset, pitch))
stem = self.installed_faces.get(typeface)
if stem is None:
raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Invalid input'))")
self._font = stem + ("bd" if bold else "") + ("i" if italic else "") + ".ttf"
def CopyFrom(self, source):
# AcadDimStyle.CopyFrom(document) saves the document's current
# dimension settings into this style.
self._calls.append(("CopyFrom", self.Name, source))
self.saved = dict(source.variables)
def Delete(self):
# IAcadObject.Delete: the owning collection drops the entry.
self._calls.append(("Delete", self.Name))
self.deleted = True
class _FakeCollection:
def __init__(self, kind, calls, *names):
self._kind = kind
self._calls = calls
self._all = [_FakeStyle(name, calls) for name in names]
@property
def entries(self):
return [style for style in self._all if not style.deleted]
@property
def Count(self):
return len(self.entries)
def Item(self, index):
return self.entries[index]
def Add(self, name):
self._calls.append((f"{self._kind}.Add", name))
style = _FakeStyle(name, self._calls)
self._all.append(style)
return style
class _FakeBlocks:
"""``doc.Blocks``: Count / Item / Name over the block definitions."""
def __init__(self, *names):
self.entries = [types.SimpleNamespace(Name=name) for name in names]
@property
def Count(self):
return len(self.entries)
def Item(self, index):
return self.entries[index]
class _FakeBlock:
"""A layout's ``Block``: ``IAcadBlock.Item`` is declared ``IAcadEntity*``,
so each member comes back narrowed to the entity base (see
``_NarrowedToIAcadEntity``) — ``StyleName`` is a dimension member and is
unreachable until un-narrowed."""
def __init__(self, *entities):
self.entities = list(entities)
@property
def Count(self):
return len(self.entities)
def Item(self, index):
return _NarrowedToIAcadEntity(self.entities[index])
class _FakeLayouts:
def __init__(self, *blocks):
self.layouts = [types.SimpleNamespace(Block=block) for block in blocks]
@property
def Count(self):
return len(self.layouts)
def Item(self, index):
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 _NarrowedToIAcadObject:
"""What pywin32 hands back for ``Dictionaries.Item``, ``Dictionary.Item``
and ``Dictionary.AddObject`` once a makepy cache for the AutoCAD type
library exists: acax25enu.tlb declares all three as returning
``IAcadObject*``, so the result is the gen_py ``IAcadObject`` class, which
exposes only IAcadObject members and raises AttributeError for everything
the runtime class (``IAcadDictionary``, ``IAcadMLeaderStyle``) adds — on a
read *and* on a property write. The full object is reachable only through
``_oleobj_``, which is how the backend must un-narrow it (this is exactly
what happened live on AutoCAD 2026 before the fix)."""
IACADOBJECT_MEMBERS = frozenset(
{
"Application",
"Database",
"Document",
"Handle",
"HasExtensionDictionary",
"ObjectID",
"ObjectName",
"OwnerID",
"Delete",
"Erase",
"GetExtensionDictionary",
"GetXData",
"SetXData",
}
)
def __init__(self, raw):
object.__setattr__(self, "_oleobj_", raw)
def __getattr__(self, key):
if key in self.IACADOBJECT_MEMBERS:
return getattr(self._oleobj_, key)
raise AttributeError(
"'<win32com.gen_py.AutoCAD 2025 Type Library.IAcadObject instance>' "
f"object has no attribute {key!r}"
)
def __setattr__(self, key, value):
# gen_py's DispatchBaseClass.__setattr__ consults _prop_map_put_, and
# IAcadObject's is empty: every property write raises.
raise AttributeError(
"'<win32com.gen_py.AutoCAD 2025 Type Library.IAcadObject instance>' "
f"object has no attribute {key!r}"
)
class _NarrowedToIAcadEntity(_NarrowedToIAcadObject):
"""What pywin32 hands back for ``IAcadBlock.Item`` with a makepy cache:
the gen_py ``IAcadEntity`` class — IAcadObject's members plus the entity
base's — and nothing of the runtime class. On a dimension, ``StyleName``
raises ``AttributeError: 'IAcadEntity' object has no attribute
'StyleName'`` (measured live on AutoCAD 2026)."""
IACADENTITY_MEMBERS = frozenset(
{
"Color",
"EntityTransparency",
"Hyperlinks",
"Layer",
"Linetype",
"LinetypeScale",
"Lineweight",
"Material",
"PlotStyleName",
"TrueColor",
"Visible",
"ArrayPolar",
"ArrayRectangular",
"Copy",
"GetBoundingBox",
"Highlight",
"IntersectWith",
"Mirror",
"Mirror3D",
"Move",
"Rotate",
"Rotate3D",
"ScaleEntity",
"TransformBy",
"Update",
}
)
def __getattr__(self, key):
if key in self.IACADENTITY_MEMBERS:
return getattr(self._oleobj_, key)
if key in self.IACADOBJECT_MEMBERS:
return getattr(self._oleobj_, key)
raise AttributeError(f"'IAcadEntity' object has no attribute {key!r}")
class _FakeDictionary:
"""The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects
and AddObject(keyword, class_name), the documented VBA route. Item and
AddObject hand their result over narrowed to IAcadObject, as pywin32
does."""
def __init__(self, calls, *names):
self._calls = calls
self.ObjectName = "AcDbDictionary"
self.objects = [_FakeMLeaderStyle(name, calls) for name in names]
@property
def Count(self):
return len(self.objects)
def Item(self, index):
return _NarrowedToIAcadObject(self.objects[index])
def GetName(self, obj):
# ActiveX takes the object itself; a narrowed wrapper resolves to the
# same object, so both spellings name it.
raw = getattr(obj, "_oleobj_", obj)
assert raw in self.objects, "GetName: object is not in this dictionary"
return raw.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 _NarrowedToIAcadObject(style)
class _FakeDictionaries:
"""``doc.Dictionaries``: Item is declared IAcadObject* (narrowed); Add is
declared IAcadDictionary* and comes back with its full surface."""
def __init__(self, calls, entries):
self._calls = calls
self.entries = entries
def Item(self, name):
if name not in self.entries:
raise RuntimeError(f"no dictionary {name}")
return _NarrowedToIAcadObject(self.entries[name])
def Add(self, name):
self._calls.append(("Dictionaries.Add", name))
if name in self.entries:
raise RuntimeError(f"dictionary {name} already exists")
self.entries[name] = _FakeDictionary(self._calls)
return self.entries[name]
CURRENT_DIMVARS = {
"DIMTXT": 2.5,
"DIMASZ": 2.5,
"DIMEXE": 1.25,
"DIMEXO": 0.625,
"DIMGAP": 0.625,
"DIMTAD": 1,
"DIMTIH": 0,
"DIMTOH": 0,
"DIMDEC": 2,
"DIMDSEP": ".",
"DIMLUNIT": 2,
"DIMZIN": 8,
"DIMBLK": "",
"DIMTXSTY": "Standard",
"DIMLWD": -2,
"DIMLWE": -2,
"DIMSCALE": 1.0,
}
class _FakeDocument:
def __init__(self):
self.calls: list[tuple] = []
self.DimStyles = _FakeCollection("DimStyles", self.calls, "Standard")
self.TextStyles = _FakeCollection("TextStyles", self.calls, "Standard")
self.variables = dict(CURRENT_DIMVARS)
self._active_dim = self.DimStyles.entries[0]
self._active_dim.saved = dict(CURRENT_DIMVARS)
self._active_text = self.TextStyles.entries[0]
dim = types.SimpleNamespace(
ObjectName="AcDbRotatedDimension", StyleName="ISO-25", Handle="2A"
)
line = types.SimpleNamespace(ObjectName="AcDbLine", Handle="2B")
sheet_dim = types.SimpleNamespace(
ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C"
)
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
self.Blocks = _FakeBlocks("*Model_Space", "*Paper_Space", "MyArrow")
self.Dictionaries = _FakeDictionaries(
self.calls,
{"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")},
)
@property
def mleader_dictionary(self) -> _FakeDictionary:
"""The raw ACAD_MLEADERSTYLE dictionary, for test setup and assertions
(the backend only ever sees it narrowed through ``Dictionaries.Item``)."""
return self.Dictionaries.entries["ACAD_MLEADERSTYLE"]
def GetVariable(self, name):
return self.variables[name]
def SetVariable(self, name, value):
self.calls.append(("SetVariable", name, value))
if name in ("DIMBLK", "DIMBLK1", "DIMBLK2") and value and not value.startswith("_"):
# AutoCAD refuses a user arrowhead naming no block, mid-write
# (measured: DISP_E_EXCEPTION from SetVariable("DIMBLK", ...)).
if value.lower() not in {b.Name.lower() for b in self.Blocks.entries}:
raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))")
self.variables[name] = value
@property
def ActiveDimStyle(self):
return self._active_dim
@ActiveDimStyle.setter
def ActiveDimStyle(self, style):
# Making a style current restores its saved settings as the current
# DIM* variables and discards unsaved overrides (AutoCAD's -DIMSTYLE
# Restore rule; scripts/smoke_settings_com.py confirms it live). A
# style never saved (fresh DimStyles.Add) keeps the current variables.
self.calls.append(("ActiveDimStyle", style.Name))
self._active_dim = style
if style.saved is not None:
self.variables.update(style.saved)
@property
def ActiveTextStyle(self):
return self._active_text
@ActiveTextStyle.setter
def ActiveTextStyle(self, style):
self.calls.append(("ActiveTextStyle", style.Name))
self._active_text = style
@pytest.fixture
def com_backend(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
# The fake seat's only font folder: the four presets plus txt.shx, as
# empty files. The host's Windows Fonts folder is deliberately out of
# reach so the tests measure the backend's resolution, not the machine.
support = tmp_path / "Fonts"
support.mkdir()
for font in FAKE_SHX_FONTS:
(support / font).write_bytes(b"")
for font, (family, subfamily) in FAKE_TTF_FONTS.items():
(support / font).write_bytes(minimal_sfnt(family, subfamily))
monkeypatch.setattr(_FakeStyle, "support_folder", support)
document = _FakeDocument()
app = types.SimpleNamespace(
Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support)))
)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_regen", lambda: None)
monkeypatch.setattr(module, "_com_font_folders", lambda: [support])
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_create_sets_variables_then_copies_from_the_document(com_backend):
backend, document = com_backend
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
assert result["ok"] is True and result["current"] is False
assert result["textstyle_created"] is True
names = [call[0] for call in document.calls]
assert names.index("TextStyles.Add") < names.index("DimStyles.Add"), (
"the text style exists before DIMTXSTY names it"
)
assert document.calls[names.index("DimStyles.Add") + 1] == ("ActiveDimStyle", "ISO-25")
sets = [call for call in document.calls if call[0] == "SetVariable"]
assert len(sets) == 17
assert ("SetVariable", "DIMDSEP", ",") in sets, "DIMDSEP is a string on ActiveX"
assert ("SetVariable", "DIMTXSTY", "ISOCP") in sets
assert ("SetVariable", "DIMTXT", 2.5) in sets and ("SetVariable", "DIMTAD", 1) in sets
copy_index = names.index("CopyFrom")
assert copy_index > names.index("SetVariable")
assert document.calls[copy_index] == ("CopyFrom", "ISO-25", document)
assert document.calls[-1] == ("ActiveDimStyle", "Standard"), "restored: set_current was False"
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
isocp = document.TextStyles.entries[-1]
assert isocp.Name == "ISOCP" and isocp.fontFile == "isocp.shx"
async def test_com_create_set_current_keeps_the_new_style_active(com_backend):
backend, document = com_backend
result = await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
assert result["current"] is True
activations = [c for c in document.calls if c[0] == "ActiveDimStyle"]
assert activations == [("ActiveDimStyle", "ANSI")]
assert document.ActiveDimStyle.Name == "ANSI"
async def test_com_create_refuses_before_touching_activex(com_backend):
backend, document = com_backend
with pytest.raises(ValueError, match="already exists"):
await backend.dimstyle_create("standard", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMTXSTY"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
with pytest.raises(ValueError, match="DIMDEC"):
await backend.dimstyle_create("X", {"DIMDEC": 9})
assert document.calls == []
async def test_com_list_reports_values_for_the_current_style_only(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
rows = await backend.dimstyle_list()
assert [(r["name"], r["current"], r["values_available"]) for r in rows] == [
("ISO-25", False, False),
("Standard", True, True),
]
assert rows[0]["values"] is None
assert rows[1]["values"]["DIMDSEP"] == "." and tuple(rows[1]["values"]) == PRESET_VARIABLES
async def test_com_modify_switches_saves_and_switches_back(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
document.calls.clear()
result = await backend.dimstyle_modify("iso-25", {"DIMDEC": 3, "DIMTXT": 2.5})
assert result["name"] == "ISO-25"
assert result["changed"] == {"DIMDEC": [2, 3]}
assert result["dimensions_using_style"] == ["2A"], "model and paper space, dimensions only"
assert result["rerender_required"] is False, "AutoCAD re-renders on regen"
assert document.calls == [
("ActiveDimStyle", "ISO-25"),
("SetVariable", "DIMDEC", 3),
("CopyFrom", "ISO-25", document),
("ActiveDimStyle", "Standard"),
]
document.calls.clear()
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
assert again["changed"] == {}
assert [c[0] for c in document.calls] == ["ActiveDimStyle", "ActiveDimStyle"], (
"no CopyFrom when nothing moved"
)
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
async def test_com_set_current(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
result = await backend.dimstyle_set_current("ISO-25")
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
assert document.ActiveDimStyle.Name == "ISO-25"
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_set_current("NOPE")
async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
backend, document = com_backend
result = await backend.textstyle_create("ISOCP", "isocp", 0.0, 1.0, 0.0, set_current=True)
assert result == {
"ok": True,
"name": "ISOCP",
"font": "isocp.shx",
"font_resolved": True,
"current": True,
}
style = document.TextStyles.entries[-1]
assert (style.fontFile, style.Width, style.ObliqueAngle, style.Height) == (
"isocp.shx",
1.0,
0.0,
0.0,
)
assert document.ActiveTextStyle.Name == "ISOCP"
odd = await backend.textstyle_create("ODD", "romans.shx", 3.5, 0.8, 15.0)
assert odd["font_resolved"] is True
assert document.TextStyles.entries[-1].ObliqueAngle == pytest.approx(0.2617993878), "radians"
rows = await backend.textstyle_list()
assert [(r["name"], r["current"]) for r in rows] == [
("ISOCP", True),
("ODD", False),
("Standard", False),
]
assert rows[1]["oblique_deg"] == pytest.approx(15.0)
with pytest.raises(ValueError, match="already exists"):
await backend.textstyle_create("isocp", "isocp.shx")
back = await backend.textstyle_set_current("standard")
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend):
backend, document = com_backend
dictionary = document.mleader_dictionary
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": 3.0,
"landing_gap": 2.0,
"text_style": "ISOCP",
"text_height": 4.0,
"values_available": True,
},
{
"name": "Standard",
"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_adds_the_object_then_writes_its_properties(com_backend):
backend, document = com_backend
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.mleader_dictionary
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"]
async def test_com_dictionary_route_is_narrowed_to_iacadobject_until_unwrapped(com_backend):
"""The failure mode as it happened live (AutoCAD 2026, pywin32 with a
makepy cache): every object the dictionary route returns is wrapped as
the DECLARED type, IAcadObject, so Count / Item / GetName on the
dictionary and ArrowSize on a style raise AttributeError. The fake
models that narrowing, and `_com_unnarrow` is the one door through it —
a raw non-COM `_oleobj_` is handed back as-is, an object without one is
returned unchanged."""
from backends.com_backend import _com_unnarrow
backend, document = com_backend
narrowed = document.Dictionaries.Item("ACAD_MLEADERSTYLE")
assert narrowed.ObjectName == "AcDbDictionary", "IAcadObject members still answer"
with pytest.raises(AttributeError, match="no attribute 'Count'"):
getattr(narrowed, "Count") # noqa: B009 — the read itself is what raises
with pytest.raises(AttributeError, match="no attribute 'Item'"):
narrowed.Item(0)
raw = document.mleader_dictionary
assert _com_unnarrow(narrowed) is raw
assert _com_unnarrow(raw) is raw, "an object without _oleobj_ is returned unchanged"
item = raw.Item(0)
assert item.ObjectName == "AcDbMLeaderStyle"
with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"):
getattr(item, "ArrowSize") # noqa: B009
with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"):
item.ArrowSize = 2.5
assert _com_unnarrow(item) is raw.objects[0]
added = raw.AddObject("LDR-A", "AcDbMLeaderStyle")
with pytest.raises(AttributeError, match="no attribute 'TextStyle'"):
added.TextStyle = "ISOCP"
assert _com_unnarrow(added) is raw.objects[-1]
assert raw.GetName(added) == raw.GetName(raw.objects[-1]) == "LDR-A"
async def test_com_mleaderstyle_create_adds_the_dictionary_to_a_drawing_without_one(com_backend):
"""A foreign drawing may lack ACAD_MLEADERSTYLE: list reports no styles
(and never adds the dictionary), create adds it through Dictionaries.Add
— whose IAcadDictionary* result is un-narrowed like the rest — and then
the style."""
backend, document = com_backend
del document.Dictionaries.entries["ACAD_MLEADERSTYLE"]
assert await backend.mleaderstyle_list() == []
assert document.calls == [] and "ACAD_MLEADERSTYLE" not in document.Dictionaries.entries
result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
assert result["ok"] is True and result["name"] == "ISO"
names = [call[0] for call in document.calls]
assert names.index("Dictionaries.Add") < names.index("Dictionary.AddObject")
assert ("Dictionaries.Add", "ACAD_MLEADERSTYLE") in document.calls
assert [obj.Name for obj in document.mleader_dictionary.objects] == ["ISO"]
rows = await backend.mleaderstyle_list()
assert [row["name"] for row in rows] == ["ISO"] and rows[0]["arrow_size"] == 2.5
# ── the MCP tools (SECTION 18) ──────────────────────────────────────────────
STYLE_TOOLS = {
"dimstyle_list",
"dimstyle_create",
"dimstyle_modify",
"dimstyle_set_current",
"textstyle_list",
"textstyle_create",
"textstyle_set_current",
"mleaderstyle_list",
"mleaderstyle_create",
}
class _Ctx:
"""Enough of a fastmcp Context for a direct tool call (tests/test_capabilities.py)."""
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *_args, **_kwargs):
return None
async def test_style_tools_are_registered_and_grouped_under_styles():
import server
names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)}
assert STYLE_TOOLS <= names
groups = await server._tool_groups()
assert STYLE_TOOLS <= set(groups["styles"]), "the style tag must win over query/create/modify"
for other in ("layers", "drawing", "entity_query", "entity_creation", "entity_modification"):
assert not STYLE_TOOLS & set(groups.get(other, [])), other
async def test_dimstyle_tools_resolve_the_preset_then_write(backend):
import server
ctx = _Ctx(backend)
created = await server.dimstyle_create(
name="ISO-25", preset="iso-25", overrides={"dimdec": 3}, set_current=True, ctx=ctx
)
assert created["current"] is True and created["values"]["DIMDEC"] == 3
listed = await server.dimstyle_list(ctx=ctx)
assert listed["count"] == 2 and listed["current"] == "ISO-25"
modified = await server.dimstyle_modify(name="ISO-25", overrides={"DIMTXT": 3.5}, ctx=ctx)
assert modified["changed"] == {"DIMTXT": [2.5, 3.5]}
switched = await server.dimstyle_set_current(name="Standard", ctx=ctx)
assert switched["previous"] == "ISO-25" and switched["current"] == "Standard"
async def test_dimstyle_create_tool_refuses_an_unknown_override_before_writing(backend):
import server
ctx = _Ctx(backend)
with pytest.raises(ValueError, match="DIMFOO"):
await server.dimstyle_create(name="X", overrides={"DIMFOO": 1}, ctx=ctx)
with pytest.raises(ValueError, match="preset"):
await server.dimstyle_create(name="X", preset="din", ctx=ctx)
assert (await server.dimstyle_list(ctx=ctx))["count"] == 1
async def test_textstyle_and_mleaderstyle_tools_round_trip(backend):
import server
ctx = _Ctx(backend)
made = await server.textstyle_create(name="ISOCP", font="isocp", set_current=True, ctx=ctx)
assert made["font_resolved"] is True
assert (await server.textstyle_list(ctx=ctx))["current"] == "ISOCP"
assert (await server.textstyle_set_current(name="Standard", ctx=ctx))["previous"] == "ISOCP"
leader = await server.mleaderstyle_create(
name="ISO", preset="iso", overrides={"arrow_size": 3}, ctx=ctx
)
assert leader["values"]["arrow_size"] == 3.0 and leader["textstyle_created"] is False
assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2
with pytest.raises(ValueError, match="preset"):
await server.mleaderstyle_create(name="X", preset="din", ctx=ctx)
# ── live engine: the four Task 11 review findings ────────────────────────────
def _com_names(collection):
return [entry.Name for entry in collection.entries]
async def test_com_textstyle_create_writes_truetype_by_typeface_and_shx_by_name(com_backend):
"""Measured on AutoCAD 2026: `fontFile = "arial.ttf"` raises 'Filer error'
(bundled presets included) and a full path stores the machine path in the
STYLE record, while `SetFont("Arial", ...)` stores the clean `arial.ttf`.
The fake refuses a bare TrueType name the same way, so a backend that
still wrote one could not pass this."""
backend, document = com_backend
arial = await backend.textstyle_create("ARIAL", "arial")
assert arial == {
"ok": True,
"name": "ARIAL",
"font": "arial.ttf",
"font_resolved": True,
"current": False,
}
eur = await backend.textstyle_create("ISOCPEUR", "isocpeur.ttf")
bold = await backend.textstyle_create("HEAD", "arialbd.ttf", height=5.0)
shx = await backend.textstyle_create("ISOCP", "ISOCP")
assert eur["font"] == "isocpeur.ttf" and bold["font"] == "arialbd.ttf"
font_writes = [c for c in document.calls if c[0] in ("fontFile", "SetFont")]
assert font_writes == [
("SetFont", "ARIAL", "Arial", False, False, 0, 0),
("SetFont", "ISOCPEUR", "ISOCPEUR", False, False, 0, 0),
("SetFont", "HEAD", "Arial", True, False, 0, 0),
("fontFile", "ISOCP", "isocp.shx"),
], "TrueType by typeface (bold from the subfamily), SHX by the name given"
rows = {row["name"]: row["font"] for row in await backend.textstyle_list()}
assert rows == {
"ARIAL": "arial.ttf",
"ISOCPEUR": "isocpeur.ttf",
"HEAD": "arialbd.ttf",
"ISOCP": "isocp.shx",
"Standard": "txt.shx",
}, "what the STYLE records carry: file names, never a machine path"
assert shx["font_resolved"] is True
async def test_com_textstyle_create_refuses_an_unlocatable_font_before_add(com_backend):
"""Before this fix `TextStyles.Add` ran first, `fontFile` then raised, and
the table kept the name with an empty font — so a retry with a good font
was refused 'already exists'. The refusal now precedes the Add, nothing is
written, and the retry succeeds."""
backend, document = com_backend
with pytest.raises(ValueError, match=r"font: font file 'nosuchfont\.shx' is not on") as info:
await backend.textstyle_create("PROBE", "nosuchfont.shx")
assert "before any write" in str(info.value) and "isocp.shx" in str(info.value)
with pytest.raises(ValueError, match="font"):
await backend.textstyle_create("PROBE", r"C:\nosuch\folder\arial.ttf")
assert document.calls == [], "nothing reached ActiveX"
assert _com_names(document.TextStyles) == ["Standard"]
retry = await backend.textstyle_create("PROBE", "romans.shx")
assert retry["ok"] is True and document.TextStyles.entries[-1].fontFile == "romans.shx"
async def test_com_textstyle_create_removes_its_entry_when_the_write_still_fails(com_backend):
"""A TrueType file that exists but whose face Windows has not installed
is the one refusal `SetFont` can still raise after the Add ('Invalid
input'). The error propagates — never a silent stub — and the entry the
backend added is deleted, so the name is free again."""
backend, document = com_backend
with pytest.raises(_FilerError, match="Invalid input"):
await backend.textstyle_create("ORPHAN", "orphan.ttf")
assert _com_names(document.TextStyles) == ["Standard"]
assert ("Delete", "ORPHAN") in document.calls
again = await backend.textstyle_create("ORPHAN", "arial.ttf")
assert again["ok"] is True, "the failed create left no 'already exists' behind"
garbage = _FakeStyle.support_folder / "broken.ttf"
garbage.write_bytes(b"not an sfnt at all")
with pytest.raises(
ValueError, match=r"font: font file 'broken\.ttf' .* not a readable TrueType"
):
await backend.textstyle_create("BROKEN", "broken.ttf")
assert _com_names(document.TextStyles) == ["Standard", "ORPHAN"], (
"an unreadable TrueType file is refused before the Add"
)
async def test_com_dimstyle_create_creates_the_truetype_presets_for_dimtxsty(com_backend):
"""Finding 2: `DIMTXSTY: ARIAL` used to raise on the live engine and leave
an ARIAL entry with no font, so the *second* call reported ok with a
dimension style whose text style had no font. Every preset is created
through the same typeface route now, first time, and a preset whose file
the seat lacks is refused by DIMTXSTY before any write."""
backend, document = com_backend
result = await backend.dimstyle_create(
"ANSI-AR", resolve_dimstyle("ansi", {"DIMTXSTY": "ARIAL"})
)
assert result["ok"] is True and result["textstyle_created"] is True
assert result["values"]["DIMTXSTY"] == "ARIAL"
arial = document.TextStyles.entries[-1]
assert (arial.Name, arial.fontFile) == ("ARIAL", "arial.ttf")
assert ("SetFont", "ARIAL", "Arial", False, False, 0, 0) in document.calls
eur = await backend.mleaderstyle_create(
"LDR", resolve_mleaderstyle("iso", {"text_style": "ISOCPEUR"})
)
assert eur["textstyle_created"] is True and eur["values"]["text_style"] == "ISOCPEUR"
assert document.TextStyles.entries[-1].fontFile == "isocpeur.ttf"
(_FakeStyle.support_folder / "romans.shx").unlink()
document.calls.clear()
with pytest.raises(ValueError, match=r"DIMTXSTY: font file 'romans\.shx' is not on"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "ROMANS"}))
assert document.calls == [] and _com_names(document.DimStyles) == ["Standard", "ANSI-AR"]
assert "ROMANS" not in _com_names(document.TextStyles), "no stub text style either"
async def test_com_dimstyle_create_refuses_a_missing_arrowhead_block_before_any_write(com_backend):
"""Finding 3: `SetVariable("DIMBLK", "NOSUCHBLOCK")` raises mid-write on
the live engine, after Add and ActiveDimStyle — the half-made style was
left as the operator's current style. Refused first now, as headlessly."""
backend, document = com_backend
with pytest.raises(ValueError, match=r"DIMBLK: block 'NOSUCHBLOCK' does not exist"):
await backend.dimstyle_create("BAD", resolve_dimstyle("iso-25", {"DIMBLK": "NOSUCHBLOCK"}))
with pytest.raises(ValueError, match="DIMBLK2"):
await backend.dimstyle_create("BAD", {"DIMSAH": 1, "DIMBLK2": "Nope"})
assert document.calls == [] and _com_names(document.DimStyles) == ["Standard"]
assert document.ActiveDimStyle.Name == "Standard"
ok = await backend.dimstyle_create(
"ARROWS", resolve_dimstyle("iso-25", {"DIMBLK": "myarrow", "DIMBLK1": "_oblique"})
)
assert ok["values"]["DIMBLK"] == "MyArrow", "the block table's own spelling, as headlessly"
assert ("SetVariable", "DIMBLK", "MyArrow") in document.calls
assert ("SetVariable", "DIMBLK1", "_OBLIQUE") in document.calls, "a built-in needs no block"
async def test_com_dimstyle_create_rolls_back_a_write_that_fails_after_add(
com_backend, monkeypatch
):
"""Whatever ActiveX refuses after DimStyles.Add (here: one SetVariable),
the drawing is left as it was found — previous style current with its
saved values, no half-made dimension style, no preset text style the
call created for it — and the error is the caller's, not swallowed."""
backend, document = com_backend
real_set = document.SetVariable
def failing_set(name, value):
if name == "DIMSCALE":
raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))")
real_set(name, value)
monkeypatch.setattr(document, "SetVariable", failing_set)
with pytest.raises(_FilerError):
await backend.dimstyle_create("HALF", resolve_dimstyle("iso-25", None), set_current=True)
assert document.ActiveDimStyle.Name == "Standard"
assert document.variables == CURRENT_DIMVARS, "Restore discarded the unsaved overrides"
assert _com_names(document.DimStyles) == ["Standard"]
assert _com_names(document.TextStyles) == ["Standard"], "the ISOCP it created is gone too"
tail = [c for c in document.calls if c[0] in ("ActiveDimStyle", "Delete")]
assert tail == [
("ActiveDimStyle", "HALF"),
("ActiveDimStyle", "Standard"),
("Delete", "HALF"),
("Delete", "ISOCP"),
]
async def test_com_dimstyle_modify_refuses_a_missing_arrowhead_and_restores_on_failure(
com_backend, monkeypatch
):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
document.calls.clear()
with pytest.raises(ValueError, match=r"DIMBLK1: block 'Nope' does not exist"):
await backend.dimstyle_modify("ISO-25", {"DIMBLK1": "Nope"})
assert document.calls == [], "refused before the style was even made current"
spelled = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "MYARROW"})
assert spelled["changed"] == {"DIMBLK": ["", "MyArrow"]}
real_set = document.SetVariable
def failing_set(name, value):
if name == "DIMTXT":
raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))")
real_set(name, value)
monkeypatch.setattr(document, "SetVariable", failing_set)
document.calls.clear()
with pytest.raises(_FilerError):
await backend.dimstyle_modify("ISO-25", {"DIMDEC": 4, "DIMTXT": 9.0})
assert document.ActiveDimStyle.Name == "Standard"
assert document.variables["DIMDEC"] == 2, "Restore discarded the DIMDEC override"
assert [c[0] for c in document.calls] == ["ActiveDimStyle", "SetVariable", "ActiveDimStyle"], (
"DIMDEC was written, DIMTXT refused, the previous style restored; no CopyFrom"
)
async def test_com_dimensions_using_style_reads_through_the_iacadentity_narrowing(com_backend):
"""Finding 4: `IAcadBlock.Item` is declared `IAcadEntity*`, so with a
makepy cache `.StyleName` on a dimension raises AttributeError, and the
old blanket `except` reported `[]` for a style a dimension used. The fake
narrows the same way; the walker un-narrows and swallows nothing."""
from backends.com_backend import _com_dimensions_using
backend, document = com_backend
narrowed = document.Layouts.Item(0).Block.Item(0)
assert narrowed.ObjectName == "AcDbRotatedDimension" and narrowed.Handle == "2A"
with pytest.raises(AttributeError, match="'IAcadEntity' object has no attribute 'StyleName'"):
getattr(narrowed, "StyleName") # noqa: B009 — the read itself is what raises
assert _com_dimensions_using(document, "iso-25") == ["2A"]
assert _com_dimensions_using(document, "Standard") == ["2C"]
assert _com_dimensions_using(document, "NOPE") == []
class _BrokenDimension:
ObjectName = "AcDbAlignedDimension"
Handle = "2D"
@property
def StyleName(self):
raise RuntimeError("RPC_E_CALL_REJECTED")
document.Layouts.Item(1).Block.entities.append(_BrokenDimension())
with pytest.raises(RuntimeError, match="RPC_E_CALL_REJECTED"):
_com_dimensions_using(document, "Standard")
async def test_truetype_face_reads_family_and_subfamily(tmp_path):
import struct
from backends.com_backend import _com_font_path, _truetype_face
regular = tmp_path / "r.ttf"
regular.write_bytes(minimal_sfnt("Segoe UI Semibold", "Regular"))
assert _truetype_face(regular) == ("Segoe UI Semibold", False, False)
bold_italic = tmp_path / "bi.ttf"
bold_italic.write_bytes(minimal_sfnt("Arial", "Bold Italic"))
assert _truetype_face(bold_italic) == ("Arial", True, True)
collection = tmp_path / "c.ttc"
inner = bytearray(minimal_sfnt("Cambria", "Regular"))
struct.pack_into(">I", inner, 12 + 8, 16 + 12 + 16) # table offsets are file-absolute
collection.write_bytes(b"ttcf" + struct.pack(">HHII", 1, 0, 1, 16) + inner)
assert _truetype_face(collection) == ("Cambria", False, False)
(tmp_path / "junk.ttf").write_bytes(b"\x00\x01\x00\x00" + b"\xff" * 40)
assert _truetype_face(tmp_path / "junk.ttf") is None
assert _truetype_face(tmp_path / "missing.ttf") is None
windows_arial = Path(r"C:\Windows\Fonts\arial.ttf")
if windows_arial.is_file():
assert _truetype_face(windows_arial) == ("Arial", False, False)
assert _com_font_path(str(regular)) == regular
assert _com_font_path(str(tmp_path / "missing.ttf")) is None