AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):
hasattr(app, 'GetVariable') -> False
hasattr(app, 'SetVariable') -> False
app.GetVariable('INSUNITS') -> AttributeError: ... IAcadApplication
instance ... has no attribute 'GetVariable'
doc.GetVariable('INSUNITS') -> 4
doc.GetVariable('MEASUREMENT') -> 1
doc.GetVariable('FILEDIA') -> 1; doc.SetVariable('FILEDIA', 0) -> None,
reads back 0, restored to 1
doc.GetVariable('AUDITCTL') -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''
Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.
Linetype loading, measured the same way: `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 whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.
`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).
Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.
Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1071 lines
39 KiB
Python
1071 lines
39 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?
|
|
|
|
|
|
@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_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)
|