Files
autocad-mcp-pro/tests/test_com_sprint3.py
T
Umutcan Edizsalan 32a68be919 fix: correctness, cross-backend parity & enforced quality gate (audit Sprints 2-4)
Audit-driven fixes across the dual backend, the premium workflow, and the
engineering layer. 318 tests, ruff-clean.

Quality gate (the moat):
- drawing_finalize now runs the premium ISO-128 critique in addition to the
  8-step validator; leftover construction, non-ISO lineweights, untrimmed
  corners, duplicates and dim overlap block the gate (strict_critique fails on
  warnings too).
- dim_overlap critique no longer a no-op (dim reference point + bbox fallback);
  dim/construction layers resolve from the active layer set (iso13567 too).

Stability / correctness:
- view_screenshot & drawing_export_pdf render headless via Agg (no more
  worker-thread SIGSEGV).
- gear tooth profile no longer self-overlaps at high tooth counts; section view
  no longer duplicates bore lines; validator keyway heuristic fixed.
- ezdxf aligned/angular dim API, full-circle polar array duplicate already
  covered by Sprint 1; this bundle adds save-format-from-extension,
  entity_offset side selection, ARC length, properties parity (bounding_box /
  rotation / char_height), mtext rotation.

COM robustness (mocked-COM tested):
- CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via
  the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture;
  set_variable type coercion; HWND via Application.HWND; BlockInfo.description.

Security:
- HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`);
  StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests.

Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc
sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count,
word-boundary SPUR/HELICAL, undo-stack locking.

Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2),
which depends on it.
2026-06-19 08:36:58 +03:00

159 lines
5.3 KiB
Python

"""Mocked-COM regression tests for the Sprint-3 COM fixes.
These exercise the COM `_sync` bodies without a live AutoCAD by:
* bypassing the STA executor (replace ``_run`` with a direct caller), and
* patching the module-level ``_acad_app`` / ``_acad_doc`` / ``_entity_info``.
Covers: N6/R23 (run_lisp guard + USERS1 capture), set_variable coercion,
R16 (transaction flag cleared on error), R13 (offset honors side_x/side_y).
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import backends.com_backend as cb
from backends.com_backend import ComBackend
pytestmark = pytest.mark.asyncio
def _backend_no_executor():
b = ComBackend()
async def _run(func, *args, **kwargs): # bypass the ThreadPoolExecutor
return func(*args, **kwargs)
b._run = _run
return b
class _Doc:
"""Idle doc: GetVariable returns 0 (CMDACTIVE clear) and ModelSpace is empty."""
def __init__(self):
self.sent: list[str] = []
self._vars: dict = {}
self.ModelSpace: list = []
def GetVariable(self, name):
return self._vars.get(name, 0)
def SetVariable(self, name, val):
self._vars[name] = val
def SendCommand(self, cmd):
self.sent.append(cmd)
def EndUndoMark(self):
pass
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
app = MagicMock()
app.GetVariable.return_value = 4159 # OSMODE current value is an int
monkeypatch.setattr(cb, "_acad_app", lambda: app)
b = _backend_no_executor()
out = await b.system_set_variable("OSMODE", "0")
name, val = app.SetVariable.call_args[0]
assert name == "OSMODE"
assert val == 0 and isinstance(val, int) # coerced from "0" to int 0
assert out["value"] == 0
# ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
app = MagicMock()
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_acad_doc", lambda: _Doc())
b = _backend_no_executor()
with pytest.raises(RuntimeError):
await b.system_run_lisp("(+ 1 2)")
async def test_run_lisp_captures_value_from_users1(monkeypatch):
app = MagicMock()
app.GetVariable.side_effect = lambda n: {"CMDACTIVE": 0, "USERS1": "3"}.get(n, 0)
doc = _Doc()
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
out = await b.system_run_lisp("(+ 1 2)")
assert out["result"] == "3" # captured, not the bogus "nil"
assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band
# ── R16 — transaction flag cleared even on error ────────────────────────────
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
doc = _Doc()
doc.StartUndoMark = MagicMock()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
out = await b.transaction_begin()
assert out["ok"] is True
assert b._transaction_active is True
doc.StartUndoMark.assert_called_once()
async def test_transaction_commit_clears_flag_on_error():
b = _backend_no_executor()
async def boom(func, *a, **k):
raise RuntimeError("EndUndoMark failed")
b._run = boom
b._transaction_active = True
with pytest.raises(RuntimeError):
await b.transaction_commit()
assert b._transaction_active is False # not left stale
async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
doc = _Doc() # idle: CMDACTIVE=0 so _safe_send_command returns promptly
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
b._transaction_active = True
out = await b.transaction_rollback()
assert out["ok"] is True
assert b._transaction_active is False
# UNDO B went through _safe_send_command (which appends a trailing newline).
assert any("_UNDO B" in c for c in doc.sent)
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
pos_copy = MagicMock(name="pos")
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
neg_copy = MagicMock(name="neg")
neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10)
ent = MagicMock()
ent.Offset.side_effect = lambda d: [pos_copy] if d > 0 else [neg_copy]
doc = MagicMock()
doc.HandleToObject.return_value = ent
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
monkeypatch.setattr(cb, "_entity_info", lambda e: e) # return the entity itself
b = _backend_no_executor()
# Side point near the negative copy -> keep neg, delete pos.
kept = await b.entity_offset("AA", 5.0, side_x=0.0, side_y=-10.0)
assert kept is neg_copy
pos_copy.Delete.assert_called_once()
neg_copy.Delete.assert_not_called()