Files
autocad-mcp-pro/tests/test_com_backend.py
T
Umut CANandClaude Opus 5 d17a26103f fix(task-14): arcs past a semicircle and foreign ports behind tag text
Two verified blocking findings on pid_graph.

`_point_segment_distance` put the centre of an arc larger than a
semicircle (|bulge| > 1) on the wrong side of the chord: `copysign`
discarded the sign of `radius - sagitta`, which is exactly what carries
the centre across the chord past 180 degrees. A bulge-2 arc was tested
as its own reflection, so a line ending on its true apex dangled and one
ending on empty paper made a junction. The offset now keeps that sign
and only takes the bulge's direction from the bulge.

Foreign-block port inference tested line ends against the INSERT's
`bounding_box`, which on both engines takes the ATTRIB text with it. A
TAG lettered above a valve pushed the box past the body, so a line
ending on the body's real edge on that side was *inside* the box and
reported dangling. Both engines now report `geometry_bbox` for an
INSERT - the drawn geometry only, ATTRIB/ATTDEF excluded, in WCS, omitted
when the block draws nothing (ezdxf via `virtual_entities`, COM by
carrying each definition member's box through the INSERT's origin,
scale and rotation). The graph prefers it over `bounding_box`, counts
an end as touching when it is within tolerance of the box or inside it,
and when several foreign boxes hold the end picks the nearest insertion.

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

607 lines
21 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 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
@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?
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
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_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