Files
Umut CANandClaude Opus 5.5 cdb1063896
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
fix(server): stop stacking a tool profile's visibility on every session
What the first Linux CI run of the 1.6 tree found.

- fastmcp 3's enable()/disable() append a Visibility transform per call,
  and a tool lookup walks the transform stack as nested calls. The
  lifespan re-applies the tool profile on every client connection, so an
  in-process server grew two layers per session until every tool call,
  system_status included, raised RecursionError (CPython 3.11 counts C
  frames against the limit). The profile's transforms now replace the
  previous ones, as _apply_discovery_mode already did; a test switches
  the profile 600 times and then calls a tool. The local suite runs in
  half the time it took with the stack growing.
- 21 fake-ActiveX tests build a real pywin32 VARIANT; they now skip
  without pywin32, like the suite's other VARIANT-dependent COM tests,
  and the Windows lane keeps running them.
- A test spelled a path through a directory it never created, which only
  Windows normalises away; it creates the directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-25 00:49:16 +03:00

1411 lines
51 KiB
Python

"""Tests for COM backend logic that runs without a live AutoCAD instance.
Strategy:
- normalize_lineweight: imported from backends.base — no COM needed.
- _safe_send_command: ComBackend is importable on non-Windows because the
win32com block is guarded by ``if _WIN32_AVAILABLE``. The static method
only calls methods on a *mock* doc object, so it runs fine on Linux/CI.
- Live ComBackend integration tests: skipped unless AutoCAD is reachable.
"""
from __future__ import annotations
import importlib.util
import math
import sys
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from backends.base import normalize_lineweight
from backends.com_backend import ComBackend
#: A path that marshals a point or an object array builds a real pywin32 VARIANT,
#: and pywin32 exists only on Windows. The Windows lane runs these; elsewhere
#: they skip, like the suite's other VARIANT-dependent COM tests.
needs_pywin32 = pytest.mark.skipif(
importlib.util.find_spec("win32com") is None, reason="pywin32 not installed"
)
@pytest.mark.asyncio
async def test_active_document_switch_clears_com_document_state(monkeypatch):
backend = ComBackend()
documents = SimpleNamespace(Count=1)
app = SimpleNamespace(
Documents=documents,
ActiveDocument=SimpleNamespace(Name="First.dwg", FullName="C:/First.dwg"),
)
async def run_inline(function, *args, **kwargs):
return function(*args, **kwargs)
monkeypatch.setattr(backend, "_run", run_inline)
monkeypatch.setattr("backends.com_backend._acad_app", lambda: app)
await backend._ensure_document_state()
backend._plan_spec = object()
backend._preflight_result = object()
backend._gdt_datums_defined = {"A"}
app.ActiveDocument = SimpleNamespace(Name="Second.dwg", FullName="C:/Second.dwg")
await backend._ensure_document_state()
assert backend._plan_spec is None
assert backend._preflight_result is None
assert backend._gdt_datums_defined == set()
# ---------------------------------------------------------------------------
# normalize_lineweight — parametrized table
# ---------------------------------------------------------------------------
NL_TABLE: list[tuple[Any, Any]] = [
# mm-float inputs (0 < v <= 2.05) → converted to hundredths
(0.13, 13),
(0.18, 18),
(0.25, 25),
(0.35, 35),
(0.50, 50),
(0.70, 70),
(1.00, 100),
(1.40, 140),
(2.00, 200),
# Already-hundredths ints (v > 2.05)
(25, 25),
(50, 50),
(100, 100),
(200, 200),
# Ambiguous bare ints (<= 2.05) — the P0 truncation hazard: treated as mm,
# NOT as hundredths (NEW-test-com-1). 2 -> 2.00mm -> 200, 1 -> 1.00mm -> 100.
(1, 100),
(2, 200),
# Sentinels: -1 = ByLayer, -2 = ByBlock, -3 = Default
(-1, -1),
(-2, -2),
(-3, -3),
# Zero passthrough
(0, 0),
# None passthrough
(None, None),
# String-encoded floats are coerced
("0.25", 25),
("50", 50),
]
@pytest.mark.parametrize("input_val, expected", NL_TABLE)
def test_normalize_lineweight(input_val, expected):
assert normalize_lineweight(input_val) == expected
def test_normalize_lineweight_boundary_2_05():
"""2.00 is treated as mm; 2.06 is treated as hundredths."""
assert normalize_lineweight(2.00) == 200
assert normalize_lineweight(2.06) == 2 # 2.06 rounds to 2 hundredths
def test_normalize_lineweight_non_numeric_passthrough():
"""Non-numeric exotic values pass through unchanged (don't raise)."""
result = normalize_lineweight("ByLayer") # type: ignore[arg-type]
assert result == "ByLayer"
# ---------------------------------------------------------------------------
# _safe_send_command — CMDACTIVE polling / timeout / var-restore
# ---------------------------------------------------------------------------
def _make_mock_doc(
cmdactive_sequence: list[int],
modelspace_handles_pre: set[str] | None = None,
modelspace_handles_post: set[str] | None = None,
) -> MagicMock:
"""Build a minimal mock AutoCAD document for _safe_send_command tests."""
doc = MagicMock()
# Simulate CMDACTIVE returning successive values on each GetVariable("CMDACTIVE") call.
# Other variables (OSMODE, SNAPMODE, CMDECHO) return 0 by default.
getvar_calls: list[int] = list(cmdactive_sequence)
def _get_variable(name: str):
if name == "CMDACTIVE":
if getvar_calls:
return getvar_calls.pop(0)
return 0
return 0 # default for OSMODE, SNAPMODE, CMDECHO
doc.GetVariable.side_effect = _get_variable
doc.SetVariable.return_value = None
doc.SendCommand.return_value = None
# Modelspace iteration for handle snapshotting
pre = modelspace_handles_pre or set()
post = modelspace_handles_post or set()
call_count: list[int] = [0]
def _iter_modelspace():
call_count[0] += 1
handles = pre if call_count[0] == 1 else post
for h in handles:
entity = MagicMock()
entity.Handle = h
yield entity
doc.ModelSpace.__iter__ = lambda _: _iter_modelspace()
return doc
def test_safe_send_command_immediate_return():
"""CMDACTIVE=0 immediately → no polling loop, returns empty new-handles list."""
doc = _make_mock_doc(cmdactive_sequence=[0])
result = ComBackend._safe_send_command(doc, "ZOOM E\n")
assert result == []
doc.SendCommand.assert_called_once()
def test_safe_send_command_waits_for_active_to_clear():
"""CMDACTIVE=1, 1, 0 → polls twice before returning."""
doc = _make_mock_doc(cmdactive_sequence=[1, 1, 0])
with patch("time.sleep"): # don't actually sleep
result = ComBackend._safe_send_command(doc, "REGEN\n")
assert result == []
def test_safe_send_command_restores_variables_on_success():
"""OSMODE/SNAPMODE/CMDECHO are saved and restored even when command succeeds."""
saved_osmode = 4159 # typical user OSMODE
doc = MagicMock()
doc.SendCommand.return_value = None
def _get_variable(name: str):
if name == "CMDACTIVE":
return 0
if name == "OSMODE":
return saved_osmode
return 0
doc.GetVariable.side_effect = _get_variable
doc.ModelSpace.__iter__ = lambda _: iter([])
ComBackend._safe_send_command(doc, "ZOOM E\n")
# SetVariable should have been called at least once with OSMODE restored to saved value
set_calls = [c for c in doc.SetVariable.call_args_list if c[0][0] == "OSMODE"]
assert any(c[0][1] == saved_osmode for c in set_calls), (
f"OSMODE {saved_osmode} not restored; SetVariable calls: {set_calls}"
)
def test_safe_send_command_restores_variables_on_timeout():
"""Variables are restored even when the command times out (RuntimeError)."""
saved_osmode = 4159
doc = MagicMock()
doc.SendCommand.return_value = None
def _get_variable(name: str):
if name == "CMDACTIVE":
return 1 # never clears → will time out
if name == "OSMODE":
return saved_osmode
return 0
doc.GetVariable.side_effect = _get_variable
doc.ModelSpace.__iter__ = lambda _: iter([])
with patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 0.0, 999.0]):
with pytest.raises(RuntimeError, match="did not finish"):
ComBackend._safe_send_command(doc, "BAD_CMD\n", deadline_s=1.0)
# OSMODE must still be restored
set_calls = [c for c in doc.SetVariable.call_args_list if c[0][0] == "OSMODE"]
assert any(c[0][1] == saved_osmode for c in set_calls)
def test_safe_send_command_timeout_sends_esc():
"""On timeout, three ESC sequences are sent to unblock the command prompt."""
doc = MagicMock()
doc.SendCommand.return_value = None
doc.GetVariable.side_effect = lambda name: 1 if name == "CMDACTIVE" else 0
doc.ModelSpace.__iter__ = lambda _: iter([])
with patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 0.0, 999.0]):
with pytest.raises(RuntimeError):
ComBackend._safe_send_command(doc, "BAD\n", deadline_s=1.0)
# Second SendCommand call should be the ESC sequence
calls = doc.SendCommand.call_args_list
assert len(calls) >= 2
assert "\x1b" in calls[-1][0][0]
def test_safe_send_command_new_handle_detection():
"""Handles created during the command appear in the return value."""
pre_handles = {"1A", "1B"}
post_handles = {"1A", "1B", "1C", "1D"}
doc = _make_mock_doc(
cmdactive_sequence=[0],
modelspace_handles_pre=pre_handles,
modelspace_handles_post=post_handles,
)
result = ComBackend._safe_send_command(doc, "LINE\n")
assert set(result) == {"1C", "1D"}
# ---------------------------------------------------------------------------
# dimension_linear — must use AddDimRotated (GH issue #3)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dimension_linear_uses_add_dim_rotated():
"""Regression for GH issue #3: the AutoCAD ActiveX API has no AddDimLinear
method — calling it raised ``<unknown>.AddDimLinear``. Linear dimensions
must go through AddDimRotated."""
backend = ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
backend._run = _run_inline # bypass the COM executor thread
mspace = MagicMock()
with (
patch("backends.com_backend._msp", return_value=mspace),
patch("backends.com_backend._apoint", side_effect=lambda *a: tuple(float(v) for v in a)),
patch("backends.com_backend._regen"),
patch("backends.com_backend._apply_dim_tolerance"),
patch("backends.com_backend._entity_info", side_effect=lambda e: e),
):
await backend.dimension_linear(0, 0, 100, 0, 50, 20, rotation=0.0)
mspace.AddDimRotated.assert_called_once()
mspace.AddDimLinear.assert_not_called()
args = mspace.AddDimRotated.call_args[0]
assert args[0] == (0.0, 0.0) # ext line 1 origin
assert args[1] == (100.0, 0.0) # ext line 2 origin
assert args[2] == (50.0, 20.0) # dim line location
assert args[3] == pytest.approx(0.0) # rotation in radians
# ---------------------------------------------------------------------------
# Live integration — skipped unless AutoCAD is reachable
# ---------------------------------------------------------------------------
_SKIP_LIVE = pytest.mark.skipif(
sys.platform != "win32",
reason="COM integration tests require Windows + AutoCAD",
)
@_SKIP_LIVE
@pytest.mark.asyncio
async def test_com_backend_connects():
"""Smoke test: connect and disconnect without errors."""
backend = ComBackend()
await backend.connect()
assert backend.is_connected
await backend.disconnect()
assert not backend.is_connected
# ── entity_measure: a good .Area must not be thrown away ────────────────────
#
# The fallback fired when `.Area` OR `.Length` was missing, and then demanded
# `.Coordinates`, which only the polyline types carry. So HATCH, REGION and
# 3DSOLID — the very types the README sells the COM backend on — discarded a
# successfully-read area and raised "AutoCAD reported neither an area nor
# usable vertices", which was false: it had the area.
#
# These profiles are ASSUMPTIONS about the ActiveX member table, not facts, and
# they are what a live seat has to confirm. What they do test is our dispatch,
# which is where the defect is: given a member profile, does the code keep the
# number it already has?
@pytest.fixture(autouse=True)
def _restore_acad_doc():
"""`_measure_backend` swaps the module-level `_acad_doc`; put it back after
every test here. Left in place, the MagicMock document leaked into every
later test of the session that resolves the document itself (the settings
facade's COM tests read sysvars through `_acad_doc()`)."""
import backends.com_backend as cb
original = cb._acad_doc
yield
cb._acad_doc = original
def _measure_backend(entity):
"""A ComBackend whose HandleToObject returns exactly this entity."""
import backends.com_backend as cb
backend = ComBackend()
async def _run(func, *args, **kwargs): # bypass the STA executor
return func(*args, **kwargs)
backend._run = _run
doc = MagicMock()
doc.HandleToObject.return_value = entity
cb._acad_doc = lambda: doc # restored by `_restore_acad_doc`
return backend
class _Entity:
"""An ActiveX entity that has only the members it is given."""
def __init__(self, object_name, **members):
self.ObjectName = object_name
for name, value in members.items():
setattr(self, name, value)
def __getattr__(self, name): # anything not set simply does not exist
raise AttributeError(name)
@pytest.mark.asyncio
async def test_a_hatch_with_an_area_and_no_length_keeps_the_area():
"""The red case: today this raises, holding a perfectly good 300.0."""
backend = _measure_backend(_Entity("AcDbHatch", Area=300.0))
result = await backend.entity_measure("2AF")
assert result["area"] == pytest.approx(300.0)
assert result["method"] == "activex_area"
@pytest.mark.asyncio
async def test_a_solid_without_a_perimeter_says_so_instead_of_inventing_one():
backend = _measure_backend(_Entity("AcDb3dSolid", Area=1250.0))
result = await backend.entity_measure("3B0")
assert result["area"] == pytest.approx(1250.0)
assert result["perimeter"] is None
assert result["perimeter_exact"] is False
@pytest.mark.asyncio
async def test_the_polyline_fallback_still_fires_when_the_area_is_the_missing_one():
"""The fallback exists for a reason and must keep working."""
entity = _Entity("AcDbPolyline", Coordinates=(0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0))
entity.GetBulge = lambda index: 0.0
entity.Closed = True
backend = _measure_backend(entity)
result = await backend.entity_measure("4C1")
assert result["method"] == "activex_fallback_analytic"
assert result["area"] == pytest.approx(100.0)
@pytest.mark.asyncio
async def test_the_refusal_is_only_used_when_there_is_genuinely_nothing():
"""The message names both halves, so it must only appear when both are true."""
backend = _measure_backend(_Entity("AcDbSpline"))
with pytest.raises(RuntimeError, match="neither an area nor usable vertices"):
await backend.entity_measure("5D2")
@pytest.mark.asyncio
async def test_a_line_is_still_refused_before_any_of_this():
backend = _measure_backend(_Entity("AcDbLine", Area=0.0))
with pytest.raises(RuntimeError, match="does not bound an area"):
await backend.entity_measure("6E3")
@pytest.mark.asyncio
async def test_a_live_bowtie_is_flagged_even_though_activex_answered():
"""`area: 0.0` with no warning is the exact failure this field prevents.
A bowtie LWPOLYLINE has both `.Area` (which AutoCAD returns as the
shoelace, cancelling the crossed lobes to ~0) and `.Length`, so it never
reached the fallback where `self_intersecting` used to be computed.
"""
entity = _Entity(
"AcDbPolyline",
Area=0.0,
Length=200.0,
Coordinates=(0.0, 0.0, 50.0, 50.0, 50.0, 0.0, 0.0, 50.0),
)
backend = _measure_backend(entity)
result = await backend.entity_measure("7F4")
assert result["method"] == "activex_area"
assert result["self_intersecting"] is True
@pytest.mark.asyncio
async def test_a_simple_polyline_is_not_flagged():
entity = _Entity(
"AcDbPolyline",
Area=2500.0,
Length=200.0,
Coordinates=(0.0, 0.0, 50.0, 0.0, 50.0, 50.0, 0.0, 50.0),
)
backend = _measure_backend(entity)
assert (await backend.entity_measure("8A5"))["self_intersecting"] is False
# ── the member profile, measured on a live AutoCAD 2026 (2026-08-06) ────────
#
# Probed on a fresh seat with a throwaway drawing, nothing saved:
# AcDbCircle Area 314.1593 Circumference 62.8319 (no Length, no Coordinates)
# AcDbPolyline Area 1600.0 Length 160.0 Coordinates present
# AcDbHatch Area 942.4778 -- r20 with an r10 island, so pi*(400-100):
# AutoCAD's hatch area DOES subtract islands
# AcDbRegion Area 1600.0 Perimeter 160.0
# AcDb3dSolid Area ABSENT Volume 1000.0
#
# The last row is the one that matters: ActiveX exposes no `.Area` on a 3DSOLID
# at all, so "REGION / 3DSOLID area" was half a claim.
@pytest.mark.asyncio
async def test_a_circle_uses_circumference_for_its_perimeter():
"""Measured: AcDbCircle has `.Circumference` and no `.Length`."""
backend = _measure_backend(_Entity("AcDbCircle", Area=314.1593, Circumference=62.8319))
result = await backend.entity_measure("C01")
assert result["area"] == pytest.approx(314.1593)
assert result["perimeter"] == pytest.approx(62.8319)
assert result["perimeter_exact"] is True
@pytest.mark.asyncio
async def test_a_region_uses_perimeter():
"""Measured: AcDbRegion has `.Perimeter` and no `.Length`."""
backend = _measure_backend(_Entity("AcDbRegion", Area=1600.0, Perimeter=160.0))
result = await backend.entity_measure("R01")
assert result["area"] == pytest.approx(1600.0)
assert result["perimeter"] == pytest.approx(160.0)
@pytest.mark.asyncio
async def test_a_3dsolid_is_refused_by_name_because_activex_has_no_area_for_it():
"""Measured: AcDb3dSolid exposes Volume and no Area at all.
The generic "neither an area nor usable vertices" would send the reader
hunting for geometry that was never the problem.
"""
backend = _measure_backend(_Entity("AcDb3dSolid", Volume=1000.0))
with pytest.raises(RuntimeError, match="no surface-area member"):
await backend.entity_measure("S01")
# ── geometry_bbox on a block reference: the drawn body, not its tag ─────────
#
# ``GetBoundingBox`` on an INSERT takes the ATTRIBs with it, so a TAG lettered
# above a valve pushes the box past the body and a line ending on the body's
# real edge reads as *inside* the box. The definition's members are read in
# block space, attribute definitions skipped, and carried through the INSERT.
class _Block:
"""An AcadBlock: ``Count`` members reachable by ``Item(i)``."""
def __init__(self, members, origin=(0.0, 0.0, 0.0)):
self._members = members
self.Count = len(members)
self.Origin = origin
def Item(self, i):
return self._members[i]
def _blockref(name, blocks, insertion=(0.0, 0.0, 0.0), sx=1.0, sy=1.0, rotation=0.0, **extra):
doc = SimpleNamespace(Blocks=SimpleNamespace(Item=lambda n: blocks[n]))
return _Entity(
"AcDbBlockReference",
Name=name,
Document=doc,
InsertionPoint=insertion,
XScaleFactor=sx,
YScaleFactor=sy,
ZScaleFactor=1.0,
Rotation=rotation,
Handle="B1",
Layer="0",
color=256,
Linetype="ByLayer",
Visible=True,
**extra,
)
def _gate_valve_block():
body_left = _Entity("AcDbPolyline", GetBoundingBox=lambda: ((-4.0, -2.0, 0.0), (0.0, 2.0, 0.0)))
body_right = _Entity("AcDbPolyline", GetBoundingBox=lambda: ((0.0, -2.0, 0.0), (4.0, 2.0, 0.0)))
tag = _Entity(
"AcDbAttributeDefinition",
GetBoundingBox=lambda: ((0.0, 4.0, 0.0), (10.75, 6.5, 0.0)),
)
return _Block([body_left, body_right, tag])
def test_a_dimension_reports_where_its_text_stands():
"""The dim_overlap critique reads `text_position`; without it a diameter
dimension's reference point was its bounding-box centre - the circle's
centre - and every outside diameter collided with its own bore on the live
engine (measured by scripts/smoke_mech_com.py, AutoCAD 2026)."""
from backends.com_backend import _entity_info
outside = _Entity(
"AcDbDiametricDimension",
GetBoundingBox=lambda: ((178.0, 170.0, 0.0), (184.0, 233.0, 0.0)),
TextPosition=(181.0, 230.0, 0.0),
Handle="1C1",
Layer="DIM",
color=256,
Linetype="ByLayer",
Visible=True,
)
props = _entity_info(outside).properties
assert props["text_position"] == [181.0, 230.0]
class IAcadEntity:
"""What pywin32 hands back for ``ModelSpace.Item(i)`` once a makepy cache
for the AutoCAD type library exists: acax25enu.tlb declares the result
``IAcadEntity*``, so the wrapper is the gen_py class of that name and it
exposes only the entity base's members. Measured on AutoCAD 2026
(2026-09-23, scripts/smoke_arch_com.py): ``type(item).__name__`` is
``"IAcadEntity"`` and ``item.StartPoint`` on an AcDbLine raises
``AttributeError: '<win32com.gen_py.AutoCAD 2025 Type Library.IAcadEntity
instance>' object has no attribute 'StartPoint'``. The concrete object is
reachable only through ``_oleobj_``."""
BASE_MEMBERS = frozenset(
{"ObjectName", "Handle", "Layer", "color", "Linetype", "Visible", "GetBoundingBox"}
)
def __init__(self, raw):
object.__setattr__(self, "_oleobj_", raw)
def __getattr__(self, key):
if key in self.BASE_MEMBERS:
return getattr(self._oleobj_, key)
raise AttributeError(
"'<win32com.gen_py.AutoCAD 2025 Type Library.IAcadEntity instance>' "
f"object has no attribute {key!r}"
)
def test_a_line_listed_through_a_narrowed_wrapper_keeps_its_endpoints():
"""``entity_list`` reads every entity through ``ModelSpace.Item``. With a
makepy cache the LINE rows lost ``start``/``end`` (only the bounding box
survived), so on the live seat the room reader found 0 wall segments and
``arch_plan_from_spec`` refused its first room label."""
from backends.com_backend import _entity_info
raw = _Entity(
"AcDbLine",
GetBoundingBox=lambda: ((125.0, 125.0, 0.0), (1000.0, 125.0, 0.0)),
StartPoint=(125.0, 125.0, 0.0),
EndPoint=(1000.0, 125.0, 0.0),
Length=875.0,
Handle="2B0",
Layer="A-WALL-E-N",
color=256,
Linetype="ByLayer",
Visible=True,
)
info = _entity_info(IAcadEntity(raw))
assert info.type == "LINE" and info.layer == "A-WALL-E-N"
assert info.properties["start"] == [125.0, 125.0]
assert info.properties["end"] == [1000.0, 125.0]
assert info.properties["length"] == 875.0
def test_com_geometry_bbox_is_the_drawn_body_without_the_attribute():
from backends.com_backend import _entity_info
ent = _blockref(
"GATE_VALVE",
{"GATE_VALVE": _gate_valve_block()},
insertion=(50.0, 50.0, 0.0),
# what ActiveX answers for the INSERT itself: the TAG is in it
GetBoundingBox=lambda: ((46.0, 48.0, 0.0), (60.75, 56.5, 0.0)),
)
props = _entity_info(ent).properties
assert props["bounding_box"] == {"min": [46.0, 48.0], "max": [60.75, 56.5]}
assert props["geometry_bbox"]["min"] == pytest.approx([46.0, 48.0])
assert props["geometry_bbox"]["max"] == pytest.approx([54.0, 52.0])
assert props["block_name"] == "GATE_VALVE" and props["insertion"] == [50.0, 50.0]
def test_com_geometry_bbox_follows_the_inserts_scale_rotation_and_block_origin():
import math
from backends.com_backend import _com_geometry_bbox
# scale 2 and a quarter turn: body x∈[-4,4] y∈[-2,2] → x∈[-4,4] y∈[-8,8]
ent = _blockref(
"GATE_VALVE",
{"GATE_VALVE": _gate_valve_block()},
insertion=(10.0, 20.0, 0.0),
sx=2.0,
sy=2.0,
rotation=math.pi / 2,
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([6.0, 12.0]) and box["max"] == pytest.approx([14.0, 28.0])
# a mirrored INSERT (YScaleFactor -1) keeps the same extents for a symmetric body
ent = _blockref("GATE_VALVE", {"GATE_VALVE": _gate_valve_block()}, sy=-1.0)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([-4.0, -2.0]) and box["max"] == pytest.approx([4.0, 2.0])
# the block's base point is what lands on the insertion point
blk = _Block(
[_Entity("AcDbLine", GetBoundingBox=lambda: ((10.0, 10.0, 0.0), (20.0, 10.0, 0.0)))],
origin=(10.0, 10.0, 0.0),
)
ent = _blockref("OFFSET", {"OFFSET": blk}, insertion=(100.0, 100.0, 0.0))
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([100.0, 100.0]) and box["max"] == pytest.approx(
[110.0, 100.0]
)
def test_com_geometry_bbox_is_omitted_when_the_block_draws_nothing_or_activex_refuses():
from backends.com_backend import _com_geometry_bbox, _entity_info
only_tag = _Block(
[
_Entity(
"AcDbAttributeDefinition", GetBoundingBox=lambda: ((0.0, 4.0, 0.0), (5.0, 6.5, 0.0))
)
]
)
assert _com_geometry_bbox(_blockref("TAG_ONLY", {"TAG_ONLY": only_tag})) is None
props = _entity_info(_blockref("TAG_ONLY", {"TAG_ONLY": only_tag})).properties
assert "geometry_bbox" not in props and props["block_name"] == "TAG_ONLY"
def _refuse(_name):
raise RuntimeError("Key not found")
ent = _blockref("MISSING", {})
ent.Document = SimpleNamespace(Blocks=SimpleNamespace(Item=_refuse))
assert _com_geometry_bbox(ent) is None
props = _entity_info(ent).properties
assert "geometry_bbox" not in props and props["rotation_deg"] == 0.0
# ── geometry_bbox off a right angle: the geometry, not the box of a box ──────
#
# Rotating a member's axis-aligned box and taking the extents of the corners
# is exact only at multiples of 90 degrees. A circle of radius 5 turned 45
# degrees is still x in [-5, 5]; its box's corners reach 7.07. The ezdxf
# engine measures the real geometry, so the live engine must too — or say
# that it could not.
def _circle(cx, cy, r):
return _Entity(
"AcDbCircle",
Center=(cx, cy, 0.0),
Radius=r,
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
)
def _line(x0, y0, x1, y1):
return _Entity(
"AcDbLine",
StartPoint=(x0, y0, 0.0),
EndPoint=(x1, y1, 0.0),
GetBoundingBox=lambda: ((min(x0, x1), min(y0, y1), 0.0), (max(x0, x1), max(y0, y1), 0.0)),
)
def _arc(cx, cy, r, start_deg, end_deg):
import math
return _Entity(
"AcDbArc",
Center=(cx, cy, 0.0),
Radius=r,
StartAngle=math.radians(start_deg),
EndAngle=math.radians(end_deg),
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
)
def _polyline(points, bulges, closed=False):
import math
coords = [c for p in points for c in p]
chord = sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False))
if closed:
chord += math.dist(points[-1], points[0])
length = chord + (1.0 if any(bulges) else 0.0) # "longer than its chord" is all that matters
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return _Entity(
"AcDbPolyline",
Coordinates=coords,
Closed=closed,
Length=length,
GetBulge=lambda i: bulges[i],
GetBoundingBox=lambda: ((min(xs), min(ys), 0.0), (max(xs), max(ys), 0.0)),
)
def test_com_geometry_bbox_of_a_rotated_circle_and_diagonal_line_is_exact():
import math
from backends.com_backend import _com_geometry_bbox
ent = _blockref(
"PUMP_C",
{"PUMP_C": _Block([_circle(0.0, 0.0, 5.0)])},
insertion=(100.0, 100.0, 0.0),
rotation=math.radians(45.0),
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([95.0, 95.0]) and box["max"] == pytest.approx([105.0, 105.0])
assert "approximate" not in box
# a diagonal line turned 45 degrees stands upright: x is exactly 0
ent = _blockref(
"DIAG", {"DIAG": _Block([_line(0.0, 0.0, 10.0, 10.0)])}, rotation=math.radians(45.0)
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([0.0, 0.0], abs=1e-9)
assert box["max"] == pytest.approx([0.0, math.sqrt(200.0)], abs=1e-9)
assert "approximate" not in box
@pytest.mark.parametrize(
("rotation_deg", "sx", "sy"),
[(37.0, 1.0, 1.0), (45.0, 1.5, 1.5), (-120.0, 1.0, -1.0), (37.0, 2.0, -2.0)],
)
def test_com_geometry_bbox_off_a_right_angle_matches_the_ezdxf_engine(rotation_deg, sx, sy):
"""Same block, same INSERT, both engines: a circle, an arc, a bulged
polyline and a diagonal line, turned and scaled and mirrored."""
import math
import ezdxf
from ezdxf import bbox as _bbox
from backends.com_backend import _com_geometry_bbox
doc = ezdxf.new()
blk = doc.blocks.new("MIXED")
blk.add_circle((3.0, 0.0), 5.0)
blk.add_arc((0.0, 8.0), 4.0, 200.0, 330.0)
blk.add_lwpolyline([(-6, -6, 0.0), (0, -6, 0.8), (0, -12, 0.0)], format="xyb")
blk.add_line((-2.0, 1.0), (7.0, 9.0))
blk.add_attdef("TAG", insert=(0, 15), text="", dxfattribs={"height": 2.5})
ref = doc.modelspace().add_blockref(
"MIXED",
(50.0, 20.0),
dxfattribs={"rotation": rotation_deg, "xscale": sx, "yscale": sy},
)
expected = _bbox.extents(
e for e in ref.virtual_entities() if e.dxftype() not in ("ATTRIB", "ATTDEF")
)
members = [
_circle(3.0, 0.0, 5.0),
_arc(0.0, 8.0, 4.0, 200.0, 330.0),
_polyline([(-6.0, -6.0), (0.0, -6.0), (0.0, -12.0)], [0.0, 0.8, 0.0]),
_line(-2.0, 1.0, 7.0, 9.0),
_Entity(
"AcDbAttributeDefinition", GetBoundingBox=lambda: ((0.0, 15.0, 0.0), (8.0, 17.5, 0.0))
),
]
ent = _blockref(
"MIXED",
{"MIXED": _Block(members)},
insertion=(50.0, 20.0, 0.0),
sx=sx,
sy=sy,
rotation=math.radians(rotation_deg),
)
box = _com_geometry_bbox(ent)
assert "approximate" not in box
# ezdxf flattens the curves (default 0.01), so agreement is to that
assert box["min"] == pytest.approx([expected.extmin.x, expected.extmin.y], abs=0.02)
assert box["max"] == pytest.approx([expected.extmax.x, expected.extmax.y], abs=0.02)
def test_com_geometry_bbox_is_marked_approximate_when_a_rotated_member_cannot_be_measured():
import math
from backends.com_backend import _com_geometry_bbox, _entity_info
hatch = _Entity("AcDbHatch", GetBoundingBox=lambda: ((-5.0, -5.0, 0.0), (5.0, 5.0, 0.0)))
blocks = {"FILLED": _Block([_circle(0.0, 0.0, 5.0), hatch])}
# at a right angle the corners are exact — no marker, no geometry reads
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.pi)
box = _com_geometry_bbox(ent)
assert box == {"min": [95.0, 95.0], "max": [105.0, 105.0]}
# off one, the hatch is carried by its corners and the box says so
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.radians(45.0))
props = _entity_info(ent).properties
box = props["geometry_bbox"]
assert box["approximate"] is True
half = 5.0 * math.sqrt(2.0)
assert box["min"] == pytest.approx([100.0 - half, 100.0 - half])
assert box["max"] == pytest.approx([100.0 + half, 100.0 + half])
# a non-uniformly scaled arc is an elliptical arc: not measured, said so
ent = _blockref(
"ARC",
{"ARC": _Block([_arc(0.0, 0.0, 4.0, 0.0, 90.0)])},
sx=2.0,
sy=1.0,
rotation=math.radians(30.0),
)
assert _com_geometry_bbox(ent)["approximate"] is True
# ---------------------------------------------------------------------------
# System variables live on AcadDocument, never on AcadApplication
# ---------------------------------------------------------------------------
#
# Measured on AutoCAD 2026 (25.1s, 2026-09-22, scratch Documents.Add()):
# ``hasattr(app, "GetVariable") is False``, ``hasattr(app, "SetVariable") is
# False``, ``doc.GetVariable("INSUNITS") -> 4``, ``doc.GetVariable("MEASUREMENT")
# -> 1``, ``doc.SetVariable("FILEDIA", 0)`` takes and ``doc.GetVariable`` reads
# it back. Fifteen call sites went through the Application inside a
# ``try/except ... log.debug``, so on a live seat they silently degraded:
# every seat was "metric", ``drawing_info`` reported ``Unknown`` units, AUDIT
# ran with the operator's AUDITCTL. The fakes below model the measured shape
# only — an application with *no* sysvar members — so the old call shape
# cannot pass.
#
# Linetype loading, measured the same day: ``SendCommand("_-LINETYPE _LOAD
# CENTER acadiso.lin\n\n")`` loads nothing with FILEDIA=0 and leaves the
# command at ``Enter an option [?/Create/Load/Set]:`` (LASTPROMPT), which
# makes the seat reject every COM call until ESC; with FILEDIA=1 it opens the
# modal "Select Linetype File" dialog. ``doc.Linetypes.Load("CENTER",
# "acadiso.lin")`` returns None and the table grows; a second call raises
# 'Duplicate record name', an unknown name 'Undefined linetype', a missing
# file 'File system error' (excepinfo descriptions). So the loaders call
# ``Linetypes.Load`` and touch no FILEDIA at all.
class _StrictApp:
"""The ActiveX Application: ``Version``, ``Documents``, ``ActiveDocument``
and nothing that reads or writes a system variable."""
Version = "25.1s (LMS Tech)"
def __init__(self, doc):
self.ActiveDocument = doc
self.Documents = SimpleNamespace(Count=1)
def __getattr__(self, name):
if name in ("GetVariable", "SetVariable"):
raise AttributeError(f"AutoCAD.Application.{name}")
raise AttributeError(name)
class _SysvarDoc:
"""An AcadDocument that records every sysvar read and write."""
def __init__(self, **variables):
self.variables = {"CMDACTIVE": 0, "FILEDIA": 1, "MEASUREMENT": 1, "AUDITCTL": 0}
self.variables.update(variables)
self.calls: list[tuple] = []
self.sent: list[str] = []
self.Name = "Drawing1.dwg"
self.FullName = ""
self.Saved = True
self.Database = SimpleNamespace(Extmin=(0.0, 0.0, 0.0), Extmax=(10.0, 20.0, 0.0))
self.ModelSpace = SimpleNamespace(Count=3)
self.ActiveLayout = SimpleNamespace(Block=self.ModelSpace)
self.Layers = SimpleNamespace(Count=2)
self.Blocks = SimpleNamespace(Count=2)
self.Linetypes = _RecordingLinetypes(self.calls)
def GetVariable(self, name):
self.calls.append(("GetVariable", name))
return self.variables[name]
def SetVariable(self, name, value):
self.calls.append(("SetVariable", name, value))
self.variables[name] = value
def SendCommand(self, cmd):
self.calls.append(("SendCommand", cmd, self.variables["FILEDIA"]))
self.sent.append(cmd)
def _com_error(description: str, scode: int):
"""A pywin32 ``com_error`` shaped like AutoCAD's: localised HRESULT text,
English excepinfo description at index 2 of ``args[2]``."""
pywintypes = pytest.importorskip("pywintypes")
return pywintypes.com_error(
-2147352567,
"Özel durum oluştu.",
(0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode),
None,
)
class _RecordingLinetypes:
"""``doc.Linetypes`` with the measured ``Load`` failures; the .lin files
it knows carry CENTER / HIDDEN / PHANTOM only."""
FILES = {"acadiso.lin", "acad.lin", "C:\\lin\\custom.lin"}
KNOWN = {"center", "hidden", "phantom"}
def __init__(self, calls):
self.names = ["ByLayer", "ByBlock", "Continuous"]
self._calls = calls
@property
def Count(self):
return len(self.names)
def Item(self, index):
return SimpleNamespace(Name=self.names[index])
def Load(self, name, file):
self._calls.append(("Linetypes.Load", name, file))
if file not in self.FILES:
raise _com_error("File system error", -2145386425)
if name.lower() in {n.lower() for n in self.names}:
raise _com_error("Duplicate record name", -2145386405)
if name.lower() not in self.KNOWN:
raise _com_error("Undefined linetype", -2145386359)
self.names.append(name.upper())
@pytest.fixture
def sysvar_seat(monkeypatch):
"""``(backend, doc)``: a ComBackend whose ``_acad_doc`` is a recording
document and whose ``_acad_app`` refuses every sysvar member."""
import backends.com_backend as cb
doc = _SysvarDoc()
app = _StrictApp(doc)
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
backend = ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, doc
def test_ensure_linetype_loaded_reads_measurement_on_the_document_and_calls_load(sysvar_seat):
import backends.com_backend as cb
_, doc = sysvar_seat
cb._ensure_linetype_loaded("CENTER")
assert doc.calls == [
("GetVariable", "MEASUREMENT"),
("Linetypes.Load", "CENTER", "acadiso.lin"),
], "MEASUREMENT (on the document) picks the .lin; Linetypes.Load does the loading"
assert "CENTER" in doc.Linetypes.names
assert doc.sent == [], "no -LINETYPE macro: nothing goes through SendCommand"
assert doc.variables["FILEDIA"] == 1 and not [c for c in doc.calls if c[0] == "SetVariable"], (
"Linetypes.Load needs no FILEDIA, so the operator's setting is never touched"
)
doc.calls.clear()
cb._ensure_linetype_loaded("center")
assert doc.calls == [], "already loaded (any case): no read, no load"
def test_ensure_linetype_loaded_picks_acad_lin_from_the_documents_measurement(sysvar_seat):
import backends.com_backend as cb
_, doc = sysvar_seat
doc.variables["MEASUREMENT"] = 0 # imperial seat
cb._ensure_linetype_loaded("HIDDEN")
assert doc.calls[-1] == ("Linetypes.Load", "HIDDEN", "acad.lin")
def test_ensure_linetype_loaded_refuses_when_the_document_cannot_say_its_measurement(sysvar_seat):
"""The old path defaulted a failed MEASUREMENT read to metric; that read
never worked (wrong object), so every seat was metric. A read the
document refuses is an error, and nothing is loaded from a guessed file."""
import backends.com_backend as cb
_, doc = sysvar_seat
del doc.variables["MEASUREMENT"]
with pytest.raises(KeyError):
cb._ensure_linetype_loaded("PHANTOM")
assert not [c for c in doc.calls if c[0] == "Linetypes.Load"]
def test_ensure_linetype_loaded_names_the_linetype_and_file_when_load_fails(sysvar_seat):
import backends.com_backend as cb
_, doc = sysvar_seat
with pytest.raises(RuntimeError, match=r"'NOSUCH' from 'acadiso.lin': Undefined linetype"):
cb._ensure_linetype_loaded("NOSUCH")
assert "NOSUCH" not in doc.Linetypes.names
@pytest.mark.asyncio
async def test_linetype_load_reads_measurement_on_the_document_and_calls_load(sysvar_seat):
backend, doc = sysvar_seat
result = await backend.linetype_load("CENTER")
assert result == {"ok": True, "name": "CENTER", "file": "acadiso.lin"}
assert doc.calls == [
("GetVariable", "MEASUREMENT"),
("Linetypes.Load", "CENTER", "acadiso.lin"),
]
assert doc.sent == [] and doc.variables["FILEDIA"] == 1
again = await backend.linetype_load("center")
assert again == {"ok": True, "name": "center", "already_loaded": True}
@pytest.mark.asyncio
async def test_linetype_load_with_an_explicit_file_reads_no_measurement(sysvar_seat, monkeypatch):
import backends.com_backend as cb
backend, doc = sysvar_seat
monkeypatch.setattr(cb, "validate_path", lambda p: p) # the allowlist is tested elsewhere
result = await backend.linetype_load("HIDDEN", "C:\\lin\\custom.lin")
assert result == {"ok": True, "name": "HIDDEN", "file": "C:\\lin\\custom.lin"}
assert doc.calls == [("Linetypes.Load", "HIDDEN", "C:\\lin\\custom.lin")]
@pytest.mark.asyncio
async def test_linetype_load_reports_an_unknown_name_and_a_missing_file_by_name(
sysvar_seat, monkeypatch
):
import backends.com_backend as cb
backend, doc = sysvar_seat
with pytest.raises(RuntimeError, match=r"'NOSUCH' from 'acadiso.lin': Undefined linetype"):
await backend.linetype_load("NOSUCH")
monkeypatch.setattr(cb, "validate_path", lambda p: p)
with pytest.raises(RuntimeError, match=r"'CENTER' from 'C:\\lin\\nope.lin': File system error"):
await backend.linetype_load("CENTER", "C:\\lin\\nope.lin")
assert doc.Linetypes.names == ["ByLayer", "ByBlock", "Continuous"]
@pytest.mark.asyncio
async def test_linetype_load_reports_a_load_that_returned_but_did_not_take(sysvar_seat):
backend, doc = sysvar_seat
doc.Linetypes.Load = lambda name, file: doc.calls.append(("Linetypes.Load", name, file))
with pytest.raises(RuntimeError, match="Failed to load linetype 'CENTER' from 'acadiso.lin'"):
await backend.linetype_load("CENTER")
@pytest.mark.asyncio
async def test_drawing_info_reports_the_documents_insunits(sysvar_seat):
backend, doc = sysvar_seat
doc.variables["INSUNITS"] = 4
info = await backend.drawing_info()
assert info.units == "mm"
assert ("GetVariable", "INSUNITS") in doc.calls
assert info.version == "25.1s (LMS Tech)" and info.backend == "com"
doc.variables["INSUNITS"] = 1
assert (await backend.drawing_info()).units == "Inches"
@pytest.mark.asyncio
async def test_drawing_info_units_are_unknown_only_when_the_document_cannot_say(sysvar_seat):
"""An INSUNITS code outside the map is ``Unknown``; a read the document
refuses is ``Unknown`` too, but that is the document's failure, never the
Application's missing member."""
backend, doc = sysvar_seat
doc.variables["INSUNITS"] = 14 # microinches: not in the map
assert (await backend.drawing_info()).units == "Unknown"
del doc.variables["INSUNITS"]
assert (await backend.drawing_info()).units == "Unknown"
@pytest.mark.asyncio
async def test_drawing_audit_sets_auditctl_on_the_document_and_restores_it(sysvar_seat):
backend, doc = sysvar_seat
result = await backend.drawing_audit()
assert result["ok"] is True and result["repaired"] is None
assert doc.calls == [
("GetVariable", "AUDITCTL"),
("SetVariable", "AUDITCTL", 1),
("SendCommand", "_AUDIT Y\n", 1),
("SetVariable", "AUDITCTL", 0),
], "AUDIT runs with the log on and the operator's AUDITCTL comes back"
assert doc.variables["AUDITCTL"] == 0
@pytest.mark.asyncio
async def test_drawing_audit_restores_auditctl_when_the_command_raises(sysvar_seat):
backend, doc = sysvar_seat
def _boom(cmd):
raise RuntimeError("SendCommand rejected")
doc.SendCommand = _boom
with pytest.raises(RuntimeError, match="rejected"):
await backend.drawing_audit()
assert doc.variables["AUDITCTL"] == 0
assert doc.calls[-1] == ("SetVariable", "AUDITCTL", 0)
# ── type filters speak the DXF names on the live engine too ─────────────────
def _typed_modelspace(monkeypatch, object_names):
"""A ComBackend whose model space holds one entity per ActiveX ObjectName."""
import backends.com_backend as cb
members = [
_Entity(
name,
Handle=f"{i + 1:X}",
Layer="0",
color=256,
Linetype="ByLayer",
Visible=True,
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
)
for i, name in enumerate(object_names)
]
space = SimpleNamespace(Count=len(members), Item=lambda i: members[i])
monkeypatch.setattr(cb, "_msp", lambda: space)
backend = ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend
# The ObjectNames measured on AutoCAD 2026 (25.1s) for each kind of entity a
# tool filters by its DXF name: a block reference, a lightweight polyline and
# every dimension subclass ActiveX creates.
_LIVE_NAMES = [
"AcDbBlockReference",
"AcDbPolyline",
"AcDbRotatedDimension",
"AcDbAlignedDimension",
"AcDbDiametricDimension",
"AcDbRadialDimension",
"AcDb2LineAngularDimension",
"AcDbOrdinateDimension",
"AcDbLine",
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("dxf_name", "expected"),
[
("INSERT", 1),
("LWPOLYLINE", 1),
("DIMENSION", 6),
("LINE", 1),
],
)
async def test_a_dxf_type_filter_finds_what_activex_names_differently(
monkeypatch, dxf_name, expected
):
"""block_find_references asks entity_list for INSERTs; ActiveX calls the
same entity AcDbBlockReference, so on the live seat the filter matched
nothing and every block reported zero references. The DXF name a tool
passes must find the entity on both engines."""
backend = _typed_modelspace(monkeypatch, _LIVE_NAMES)
listed = await backend.entity_list(type_filter=dxf_name, limit=100)
assert len(listed) == expected
assert await backend.entity_count(type_filter=dxf_name) == expected
@pytest.mark.asyncio
async def test_the_activex_type_names_still_filter_as_before(monkeypatch):
"""The reported type names do not change, so a caller already filtering by
the ActiveX spelling keeps getting exactly what it got."""
backend = _typed_modelspace(monkeypatch, _LIVE_NAMES)
assert len(await backend.entity_list(type_filter="BLOCKREFERENCE")) == 1
assert len(await backend.entity_list(type_filter="POLYLINE")) == 1
assert len(await backend.entity_list(type_filter="ROTATEDDIMENSION")) == 1
# ── hatch islands on the live engine: typed edges as temporary entities ─────
class _LoopEntity:
def __init__(self, log, kind):
self._log = log
self.kind = kind
self.Closed = False
self.StartAngle = None
self.EndAngle = None
def Delete(self):
self._log.append(("Delete", self.kind))
class _HatchSpace:
"""The hatch's owner block: records every temporary entity it creates."""
def __init__(self, log):
self._log = log
def AddLightWeightPolyline(self, flat):
values = list(getattr(flat, "value", flat))
self._log.append(("AddLightWeightPolyline", len(values) // 2))
return _LoopEntity(self._log, "pline")
def AddLine(self, a, b):
self._log.append(("AddLine",))
return _LoopEntity(self._log, "line")
def AddArc(self, center, radius, start, end):
self._log.append(("AddArc", radius, round(start, 9), round(end, 9)))
return _LoopEntity(self._log, "arc")
def AddEllipse(self, center, major, ratio):
self._log.append(("AddEllipse", ratio))
return _LoopEntity(self._log, "ellipse")
class _IslandHatch:
ObjectName = "AcDbHatch"
Handle = "2A"
OwnerID = 7
def __init__(self, log, refuse=False):
self._log = log
self._refuse = refuse
self.NumberOfLoops = 1
def AppendInnerLoop(self, objects):
items = list(getattr(objects, "value", objects))
if self._refuse:
raise RuntimeError("E_FAIL") # what AutoCAD 2026 answers an open loop with
self._log.append(("AppendInnerLoop", [e.kind for e in items]))
self.NumberOfLoops += 1
def Evaluate(self):
self._log.append(("Evaluate",))
@pytest.fixture
def island_seat(monkeypatch):
import backends.com_backend as cb
log: list = []
hatch = _IslandHatch(log)
space = _HatchSpace(log)
doc = SimpleNamespace(HandleToObject=lambda h: hatch, ObjectIdToObject=lambda oid: space)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
monkeypatch.setattr(cb, "_regen", lambda: None)
backend = ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, hatch, log
def _square(x0, y0, size):
corners = [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)]
return [
{"type": "line", "start": list(corners[i]), "end": list(corners[(i + 1) % 4])}
for i in range(4)
]
@pytest.mark.asyncio
@needs_pywin32
async def test_a_chain_of_lines_becomes_one_closed_polyline_loop(island_seat):
"""A flattened bore is 180 line edges; one temporary polyline carries them
in a single COM call and is deleted once the hatch has evaluated it."""
backend, _, log = island_seat
result = await backend.hatch_add_boundary("2A", _square(10, 10, 20))
assert result == {"ok": True, "handle": "2A", "path_count": 2, "edge_types": ["line"] * 4}
assert log == [
("AddLightWeightPolyline", 4),
("AppendInnerLoop", ["pline"]),
("Evaluate",),
("Delete", "pline"),
]
@pytest.mark.asyncio
@needs_pywin32
async def test_arcs_stay_true_arcs_in_radians_and_clockwise_is_swapped(island_seat):
"""Curved-edge fidelity is the reason this tool exists: an arc edge becomes
an AcDbArc, never a chord chain. ActiveX arcs run counter-clockwise, so a
clockwise edge is the counter-clockwise arc from its end to its start."""
backend, _, log = island_seat
edges = [
{"type": "arc", "center": [0, 0], "radius": 10, "start_angle": 0, "end_angle": 180},
{
"type": "arc",
"center": [0, 0],
"radius": 10,
"start_angle": 0,
"end_angle": 180,
"ccw": False,
},
]
result = await backend.hatch_add_boundary("2A", edges)
assert result["ok"] is True and result["edge_types"] == ["arc", "arc"]
assert log[0] == ("AddArc", 10.0, 0.0, round(math.pi, 9))
assert log[1] == ("AddArc", 10.0, round(math.pi, 9), 0.0)
assert log[2] == ("AppendInnerLoop", ["arc", "arc"])
assert [entry for entry in log if entry[0] == "Delete"] == [
("Delete", "arc"),
("Delete", "arc"),
]
@pytest.mark.asyncio
@needs_pywin32
async def test_a_loop_autocad_refuses_is_taken_back_out(island_seat):
"""AutoCAD answers an open loop with E_FAIL (measured); the temporary
entities are deleted and the call says nothing was added."""
backend, hatch, log = island_seat
hatch._refuse = True
open_chain = _square(0, 0, 5)[:3]
result = await backend.hatch_add_boundary("2A", open_chain)
assert result["ok"] is False and "closed loop" in result["error"]
assert ("Evaluate",) not in log
created = [e for e in log if e[0].startswith("Add")]
deleted = [e for e in log if e[0] == "Delete"]
assert len(created) == len(deleted) == 3, "every temporary line is deleted"
assert hatch.NumberOfLoops == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
("edges", "message"),
[
([], "edges must not be empty"),
(["line"], "edge 0 is not an object"),
([{"type": "spline"}], "unknown type 'spline'"),
([{"type": "arc", "center": [0, 0], "radius": 1, "start_angle": 0}], "missing 'end_angle'"),
([{"type": "line", "start": [0, float("nan")], "end": [1, 1]}], "non-finite"),
(
[{"type": "arc", "center": [0, 0], "radius": 0, "start_angle": 0, "end_angle": 90}],
"radius must be > 0",
),
],
)
async def test_a_malformed_edge_list_is_refused_before_anything_is_created(
island_seat, edges, message
):
backend, _, log = island_seat
result = await backend.hatch_add_boundary("2A", edges)
assert result["ok"] is False and message in result["error"]
assert log == [], "nothing reached AutoCAD"
def test_the_live_engine_now_advertises_typed_hatch_edge_paths():
feature = ComBackend().capabilities().features["hatch_edge_paths"]
assert feature.supported is True