- 21 parametrized normalize_lineweight table tests (base.py, platform-independent) - _safe_send_command: CMDACTIVE immediate/polling/timeout/variable-restore/ESC/handle-detection - Live integration smoke test skipped on non-Windows via @pytest.mark.skipif - Runs in CI (Linux) via mocked doc objects; no AutoCAD process required 213 tests total (29 new).
229 lines
7.7 KiB
Python
229 lines
7.7 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 typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backends.base import normalize_lineweight
|
|
from backends.com_backend import ComBackend
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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),
|
|
# 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"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|