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
Every one of these was reported ok/clean to the caller while being wrong.
Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
TRACE and HATCH, none of which carry that attribute, so every hatch
measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
`filtered_by` asserted the filter had run, and COM (which reads ActiveX
.Area) returned a different selection for the same call. It now answers
from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
`ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
`xround(value, dimrnd)` whenever the attribute is present at all --
`xround(33.333, 0.0)` is 33. So on any reopened file, including one this
server saved, every dimension was rounded to a whole unit before DIMDEC
formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
*no* rounding; the sentinel is now discarded at every load site, and a real
0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
they died in the header: dim_decimals=-1 returned ok and then killed the
next dimension with `ValueError: Format specifier missing precision` from
inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).
Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
have exactly ONE caller ... the policy is scoped to the CHANNEL". The
premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
treats a newline and ';' as segment separators -- so a `linetype=` argument
carrying either injected further AutoCAD commands, and the 36-verb denylist
never saw that channel. Guarded with the DXF symbol-name rule (the
injection characters fall out of it rather than being enumerated) plus
validate_path for the file argument, which was also the one path-taking
tool reaching the filesystem unchecked. The policy comment is corrected.
Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
rule 7 makes that call the gate an agent is told to trust. Only the
quarantine now escapes; an ordinary broken check still degrades.
`_check_dimension_legibility` also read `_doc` directly from the event loop
thread, missing the gate and performing exactly the unsynchronised access
the quarantine exists to prevent. `construction_clear` answered
"deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
1.0 mm critiqued clean. It now reads the effective dimension height and
separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
the ones that had drifted. The collected-test count (README said 1274, the
tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
passing plus 5 fixed is 26, not 24 -- and contradicted the report it
linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
already carried the dimension fixes and so never measured the v1.5.0 tag.
Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.
Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
absent. It now runs the same three lanes as ci.yml, Windows included.
1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
484 lines
17 KiB
Python
484 lines
17 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")
|