Measured on AutoCAD 2026: under a UCS at (100,50) rotated 90 deg, a pick on a circle at WCS (105,70) came back from IAcadUtility.GetEntity as (20,-5,0), while Utility.GetPoint already answered in WCS. A model doing ucs_set -> user_select -> entity_create_* at `picked` would place geometry at a silently wrong position. The point goes through Utility.TranslateCoordinates(acUCS, acWorld) packed as a VT_ARRAY|VT_R8 VARIANT (the plain tuple GetEntity hands back is refused as "Invalid argument Point"). The fake Utility now models the UCS frame and the VARIANT requirement; a new test pins the measured figures and checks GetPoint stays untranslated. Docstrings state WCS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
538 lines
21 KiB
Python
538 lines
21 KiB
Python
"""The live-only environment trio: launch, preferences, operator prompts.
|
|
|
|
Headless there is no application to launch, no registry of preferences and
|
|
no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability``
|
|
defaults and refuses with a key ``system_capabilities`` declares. Live, the
|
|
COM paths are exercised against fake ActiveX objects asserting the call
|
|
shapes; ``scripts/smoke_settings_com.py`` runs them once for real.
|
|
|
|
A cancelled pick is an answer (``cancelled: true``), not an error; a
|
|
wall-clock timeout is ``timed_out: true``. Preference writes are refused by
|
|
name before any ActiveX call: read-only keys, unknown keys, values outside
|
|
the authored range.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
|
|
import config
|
|
import server
|
|
from backends.base import UnsupportedCapabilityError
|
|
from backends.capability import is_capability_default
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
from engineering.environment.preferences import (
|
|
PREFERENCE_KEYS,
|
|
READ_ONLY_KEYS,
|
|
SAVE_AS_TYPES,
|
|
decode_value,
|
|
describe_preferences,
|
|
split_key,
|
|
validate_preference,
|
|
)
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
# ── the whitelist, pure ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_whitelist_is_the_spec_list():
|
|
assert set(PREFERENCE_KEYS) == {
|
|
"OpenSave.SaveAsType",
|
|
"OpenSave.AutoSaveInterval",
|
|
"OpenSave.CreateBackup",
|
|
"OpenSave.IncrementalSavePercent",
|
|
"Display.CursorSize",
|
|
"Drafting.AutoSnapMarkerSize",
|
|
"Drafting.AutoSnapTooltip",
|
|
"Selection.PickBoxSize",
|
|
"Output.DefaultPlotStyleTable",
|
|
"Output.DefaultOutputDevice",
|
|
}
|
|
assert READ_ONLY_KEYS == (
|
|
"Files.SupportPath",
|
|
"Files.TemplateDwgPath",
|
|
"Files.PrinterStyleSheetPath",
|
|
"Files.PrinterConfigPath",
|
|
)
|
|
assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval")
|
|
assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12
|
|
|
|
|
|
def test_validate_preference_coerces_and_refuses_by_name():
|
|
assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10
|
|
assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10
|
|
assert validate_preference("OpenSave.CreateBackup", False) is False
|
|
assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64
|
|
assert validate_preference("OpenSave.SaveAsType", 48) == 48
|
|
assert (
|
|
validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb"
|
|
)
|
|
with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"):
|
|
validate_preference("Files.SupportPath", "C:/x")
|
|
with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"):
|
|
validate_preference("Display.Theme", 1)
|
|
with pytest.raises(ValueError, match="between 0 and 600, got 601"):
|
|
validate_preference("OpenSave.AutoSaveInterval", 601)
|
|
with pytest.raises(TypeError, match="takes an int"):
|
|
validate_preference("Selection.PickBoxSize", 2.5)
|
|
with pytest.raises(TypeError, match="takes a bool"):
|
|
validate_preference("OpenSave.CreateBackup", 1)
|
|
with pytest.raises(ValueError, match="must be one of"):
|
|
validate_preference("OpenSave.SaveAsType", "ac1999_dwg")
|
|
with pytest.raises(TypeError, match="takes a non-empty string"):
|
|
validate_preference("Output.DefaultOutputDevice", "")
|
|
|
|
|
|
def test_decode_value_maps_enum_values_back_to_names():
|
|
assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg"
|
|
assert decode_value("OpenSave.SaveAsType", 999) == 999
|
|
assert decode_value("OpenSave.CreateBackup", 1) is True
|
|
assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b"
|
|
|
|
|
|
def test_describe_preferences_covers_every_key():
|
|
rows = {row["key"]: row for row in describe_preferences()}
|
|
assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
|
|
assert rows["OpenSave.AutoSaveInterval"] == {
|
|
"key": "OpenSave.AutoSaveInterval",
|
|
"kind": "int",
|
|
"range": [0, 600],
|
|
"enum": None,
|
|
"writable": True,
|
|
}
|
|
assert rows["Files.SupportPath"]["writable"] is False
|
|
|
|
|
|
# ── headless: declared refusals ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"method, args, key",
|
|
[
|
|
("system_launch", (), "live_application"),
|
|
("preferences_get", (), "preferences"),
|
|
("preferences_set", ("Display.CursorSize", 5), "preferences"),
|
|
("user_pick_point", ("Pick",), "interactive_prompt"),
|
|
("user_select", ("Select",), "interactive_prompt"),
|
|
("system_prompt_message", ("hi",), "interactive_prompt"),
|
|
],
|
|
)
|
|
async def test_headless_refuses_with_the_declared_key(backend, method, args, key):
|
|
assert is_capability_default(EzdxfBackend, method)
|
|
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
|
await getattr(backend, method)(*args)
|
|
assert excinfo.value.capability == key
|
|
assert "ezdxf" in str(excinfo.value)
|
|
feature = backend.capabilities().features[key]
|
|
assert feature.supported is False and feature.reason
|
|
|
|
|
|
async def test_refusal_reaches_the_client_with_the_key(monkeypatch):
|
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
|
async with Client(server.mcp) as client:
|
|
await client.call_tool("drawing_new", {"bootstrap": False})
|
|
result = await client.call_tool(
|
|
"user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False
|
|
)
|
|
assert result.is_error is True
|
|
payload = result.structured_content
|
|
assert payload["ok"] is False and payload["capability"] == "interactive_prompt"
|
|
caps = (await client.call_tool("system_capabilities", {})).structured_content
|
|
assert caps["features"]["interactive_prompt"]["supported"] is False
|
|
|
|
|
|
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
|
|
|
|
|
def _com_error(description: str):
|
|
pywintypes = pytest.importorskip("pywintypes")
|
|
return pywintypes.com_error(
|
|
-2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None
|
|
)
|
|
|
|
|
|
class _FakeUtility:
|
|
"""IAcadUtility as measured on AutoCAD 2026.
|
|
|
|
``GetPoint`` answers in WCS; ``GetEntity``'s PickedPoint is in the
|
|
*current UCS* (``ucs_origin`` / ``ucs_xdir`` / ``ucs_ydir``, world by
|
|
default); ``TranslateCoordinates`` only accepts a VT_ARRAY|VT_R8 VARIANT —
|
|
the plain tuple GetEntity returns is refused as "Invalid argument Point".
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
self.point = (12.5, 7.25, 0.0)
|
|
self.picked_ucs = (3.0, 4.0, 0.0)
|
|
self.ucs_origin = (0.0, 0.0, 0.0)
|
|
self.ucs_xdir = (1.0, 0.0, 0.0)
|
|
self.ucs_ydir = (0.0, 1.0, 0.0)
|
|
self.raise_on_pick = None
|
|
self.entity = types.SimpleNamespace(Handle="1F3")
|
|
|
|
def GetPoint(self, base, prompt):
|
|
self.calls.append(("GetPoint", (base, prompt)))
|
|
if self.raise_on_pick is not None:
|
|
raise self.raise_on_pick
|
|
return self.point
|
|
|
|
def GetEntity(self, prompt):
|
|
self.calls.append(("GetEntity", (prompt,)))
|
|
if self.raise_on_pick is not None:
|
|
raise self.raise_on_pick
|
|
return self.entity, self.picked_ucs
|
|
|
|
def TranslateCoordinates(self, point, from_cs, to_cs, displacement):
|
|
import pythoncom
|
|
from win32com.client import VARIANT
|
|
|
|
if not isinstance(point, VARIANT) or point.varianttype != (
|
|
pythoncom.VT_ARRAY | pythoncom.VT_R8
|
|
):
|
|
raise _com_error("Invalid argument Point in TranslateCoordinates")
|
|
self.calls.append(
|
|
("TranslateCoordinates", (tuple(point.value), from_cs, to_cs, displacement))
|
|
)
|
|
assert (from_cs, to_cs, displacement) == (1, 0, False) # acUCS → acWorld, a point
|
|
u, v, w = point.value
|
|
ox, oy, oz = self.ucs_origin
|
|
xx, xy, xz = self.ucs_xdir
|
|
yx, yy, yz = self.ucs_ydir
|
|
zx, zy, zz = (
|
|
xy * yz - xz * yy,
|
|
xz * yx - xx * yz,
|
|
xx * yy - xy * yx,
|
|
)
|
|
return (
|
|
ox + u * xx + v * yx + w * zx,
|
|
oy + u * xy + v * yy + w * zy,
|
|
oz + u * xz + v * yz + w * zz,
|
|
)
|
|
|
|
def Prompt(self, text):
|
|
self.calls.append(("Prompt", (text,)))
|
|
|
|
|
|
class _FakeSelectionSet:
|
|
def __init__(self, name, items):
|
|
self.Name = name
|
|
self._items = items
|
|
self.calls: list[str] = []
|
|
|
|
@property
|
|
def Count(self):
|
|
return len(self._items)
|
|
|
|
def Item(self, index):
|
|
return self._items[index]
|
|
|
|
def SelectOnScreen(self):
|
|
self.calls.append("SelectOnScreen")
|
|
|
|
def Delete(self):
|
|
self.calls.append("Delete")
|
|
|
|
|
|
class _FakeSelectionSets:
|
|
def __init__(self, items):
|
|
self.items = items
|
|
self.sets: list[_FakeSelectionSet] = []
|
|
|
|
def Add(self, name):
|
|
ss = _FakeSelectionSet(name, self.items)
|
|
self.sets.append(ss)
|
|
return ss
|
|
|
|
|
|
@pytest.fixture
|
|
def com_backend(monkeypatch):
|
|
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
|
from backends import com_backend as module
|
|
|
|
utility = _FakeUtility()
|
|
selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")]
|
|
document = types.SimpleNamespace(
|
|
Name="Part.dwg",
|
|
FullName="C:/work/Part.dwg",
|
|
Utility=utility,
|
|
SelectionSets=_FakeSelectionSets(selection),
|
|
)
|
|
preferences = types.SimpleNamespace(
|
|
OpenSave=types.SimpleNamespace(
|
|
SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50
|
|
),
|
|
Display=types.SimpleNamespace(CursorSize=5),
|
|
Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True),
|
|
Selection=types.SimpleNamespace(PickBoxSize=3),
|
|
Output=types.SimpleNamespace(
|
|
DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3"
|
|
),
|
|
Files=types.SimpleNamespace(
|
|
SupportPath="C:/support",
|
|
TemplateDwgPath="C:/t",
|
|
PrinterStyleSheetPath="C:/p",
|
|
PrinterConfigPath="C:/pc3",
|
|
),
|
|
)
|
|
app = types.SimpleNamespace(
|
|
Preferences=preferences,
|
|
Version="25.0",
|
|
Documents=types.SimpleNamespace(Count=1),
|
|
ActiveDocument=document,
|
|
)
|
|
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
|
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
|
backend = module.ComBackend()
|
|
|
|
async def _run_inline(func, *args, **kwargs):
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(backend, "_run", _run_inline)
|
|
return backend, app, document
|
|
|
|
|
|
async def test_com_no_longer_inherits_the_defaults():
|
|
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
|
from backends.com_backend import ComBackend
|
|
|
|
for method in (
|
|
"system_launch",
|
|
"preferences_get",
|
|
"preferences_set",
|
|
"user_pick_point",
|
|
"user_select",
|
|
"system_prompt_message",
|
|
):
|
|
assert not is_capability_default(ComBackend, method), method
|
|
|
|
|
|
async def test_com_pick_point_returns_the_operator_point(com_backend):
|
|
backend, app, document = com_backend
|
|
import pythoncom
|
|
|
|
result = await backend.user_pick_point("Pick the base point")
|
|
assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"}
|
|
(call,) = document.Utility.calls
|
|
assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing
|
|
assert call[1][1] == "\nPick the base point"
|
|
|
|
|
|
async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend):
|
|
backend, app, document = com_backend
|
|
document.Utility.raise_on_pick = _com_error("User input is a keyword")
|
|
result = await backend.user_pick_point("Pick")
|
|
assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"}
|
|
|
|
|
|
async def test_com_pick_point_other_com_errors_still_raise(com_backend):
|
|
backend, app, document = com_backend
|
|
pywintypes = pytest.importorskip("pywintypes")
|
|
document.Utility.raise_on_pick = pywintypes.com_error(
|
|
-2147418111, "Call was rejected", None, None
|
|
)
|
|
with pytest.raises(pywintypes.com_error):
|
|
await backend.user_pick_point("Pick")
|
|
|
|
|
|
async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch):
|
|
backend, app, document = com_backend
|
|
monkeypatch.setattr(config.settings, "com_call_timeout", 7.0)
|
|
|
|
async def _timed_out(func, *args, **kwargs):
|
|
raise RuntimeError(
|
|
"AutoCAD did not respond within 7s. The application may be showing a modal dialog"
|
|
)
|
|
|
|
monkeypatch.setattr(backend, "_run", _timed_out)
|
|
result = await backend.user_pick_point("Pick")
|
|
assert result["timed_out"] is True and result["timeout_s"] == 7.0
|
|
assert "did not respond" in result["error"]
|
|
result = await backend.user_select("Pick", "multiple")
|
|
assert result["timed_out"] is True
|
|
|
|
|
|
async def test_com_prompt_validation_happens_before_activex(com_backend):
|
|
backend, app, document = com_backend
|
|
with pytest.raises(ValueError, match="prompt must not be empty"):
|
|
await backend.user_pick_point(" ")
|
|
with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"):
|
|
await backend.user_select("Pick", "many")
|
|
assert document.Utility.calls == [] and document.SelectionSets.sets == []
|
|
|
|
|
|
async def test_com_select_single_uses_getentity(com_backend):
|
|
backend, app, document = com_backend
|
|
result = await backend.user_select("Pick the flange")
|
|
assert result == {
|
|
"cancelled": False,
|
|
"mode": "single",
|
|
"handles": ["1F3"],
|
|
"picked": [3.0, 4.0],
|
|
"backend": "com",
|
|
}
|
|
assert document.Utility.calls == [
|
|
("GetEntity", ("\nPick the flange",)),
|
|
("TranslateCoordinates", ((3.0, 4.0, 0.0), 1, 0, False)),
|
|
]
|
|
document.Utility.raise_on_pick = _com_error("Function cancelled")
|
|
assert (await backend.user_select("Pick"))["cancelled"] is True
|
|
|
|
|
|
async def test_com_select_single_reports_the_pick_in_wcs_under_a_ucs(com_backend):
|
|
"""MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the current UCS.
|
|
|
|
UCS origin (100,50,0) rotated 90 deg (X dir (0,1,0)); the operator picked
|
|
a circle at WCS (105,70): GetEntity reported (20,-5,0) and
|
|
TranslateCoordinates(acUCS → acWorld) gave (105,70,0). GetPoint already
|
|
answers in WCS, so ``user_pick_point`` and ``user_select`` must agree.
|
|
"""
|
|
backend, app, document = com_backend
|
|
utility = document.Utility
|
|
utility.ucs_origin = (100.0, 50.0, 0.0)
|
|
utility.ucs_xdir = (0.0, 1.0, 0.0)
|
|
utility.ucs_ydir = (-1.0, 0.0, 0.0)
|
|
utility.picked_ucs = (20.0, -5.0, 0.0)
|
|
result = await backend.user_select("Pick the circle")
|
|
assert result["picked"] == [105.0, 70.0]
|
|
assert result["handles"] == ["1F3"] and result["cancelled"] is False
|
|
# A translated UCS without rotation (origin (100,50)): (15,20) → (115,70).
|
|
utility.ucs_xdir, utility.ucs_ydir = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)
|
|
utility.picked_ucs = (15.0, 20.0, 0.0)
|
|
assert (await backend.user_select("Pick"))["picked"] == [115.0, 70.0]
|
|
# GetPoint is not translated — it already answers in WCS.
|
|
utility.point = (110.0, 70.0, 0.0)
|
|
point = await backend.user_pick_point("Pick")
|
|
assert (point["x"], point["y"]) == (110.0, 70.0)
|
|
assert not any(name == "TranslateCoordinates" for name, _ in utility.calls[-1:])
|
|
|
|
|
|
async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend):
|
|
backend, app, document = com_backend
|
|
result = await backend.user_select("Select the pipes", mode="multiple")
|
|
assert result == {
|
|
"cancelled": False,
|
|
"mode": "multiple",
|
|
"handles": ["A1", "B2"],
|
|
"count": 2,
|
|
"backend": "com",
|
|
}
|
|
assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))]
|
|
(ss,) = document.SelectionSets.sets
|
|
assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"]
|
|
|
|
|
|
async def test_com_prompt_message_writes_to_the_command_line(com_backend):
|
|
backend, app, document = com_backend
|
|
result = await backend.system_prompt_message("Drawing saved by the assistant")
|
|
assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"}
|
|
assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))]
|
|
|
|
|
|
async def test_com_preferences_get_reads_the_whitelist(com_backend):
|
|
backend, app, document = com_backend
|
|
result = await backend.preferences_get()
|
|
assert result["read_only"] == list(READ_ONLY_KEYS)
|
|
assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg"
|
|
assert result["values"]["OpenSave.AutoSaveInterval"] == 10
|
|
assert result["values"]["Files.SupportPath"] == "C:/support"
|
|
assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
|
|
subset = await backend.preferences_get(["Display.CursorSize"])
|
|
assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"}
|
|
with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"):
|
|
await backend.preferences_get(["Display.Theme"])
|
|
|
|
|
|
async def test_com_preferences_set_reports_old_and_new(com_backend):
|
|
backend, app, document = com_backend
|
|
result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
|
|
assert result == {
|
|
"ok": True,
|
|
"key": "OpenSave.AutoSaveInterval",
|
|
"old": 10,
|
|
"new": 15,
|
|
"changed": True,
|
|
"backend": "com",
|
|
}
|
|
assert app.Preferences.OpenSave.AutoSaveInterval == 15
|
|
same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
|
|
assert same["changed"] is False
|
|
enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg")
|
|
assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg"
|
|
assert app.Preferences.OpenSave.SaveAsType == 60
|
|
|
|
|
|
async def test_com_preferences_set_refuses_before_any_write(com_backend):
|
|
backend, app, document = com_backend
|
|
with pytest.raises(ValueError, match="read-only"):
|
|
await backend.preferences_set("Files.SupportPath", "C:/evil")
|
|
with pytest.raises(ValueError, match="between 1 and 100"):
|
|
await backend.preferences_set("Display.CursorSize", 0)
|
|
with pytest.raises(ValueError, match="unknown preference"):
|
|
await backend.preferences_set("Display.Theme", 1)
|
|
assert app.Preferences.Files.SupportPath == "C:/support"
|
|
assert app.Preferences.Display.CursorSize == 5
|
|
|
|
|
|
async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch):
|
|
backend, app, document = com_backend
|
|
from backends import com_backend as module
|
|
|
|
monkeypatch.setattr(module, "_COM_STATE", {})
|
|
calls: list[str] = []
|
|
|
|
def _get_active(progid):
|
|
calls.append(f"GetActiveObject:{progid}")
|
|
raise RuntimeError("not running")
|
|
|
|
def _dispatch(progid):
|
|
calls.append(f"Dispatch:{progid}")
|
|
app.Visible = False
|
|
return app
|
|
|
|
monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active)
|
|
monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch)
|
|
result = await backend.system_launch(visible=True)
|
|
assert result == {
|
|
"launched": True,
|
|
"attached": False,
|
|
"version": "25.0",
|
|
"document": "Part.dwg",
|
|
"visible": True,
|
|
"progid": config.settings.cad_progid,
|
|
"backend": "com",
|
|
}
|
|
assert calls == [
|
|
f"GetActiveObject:{config.settings.cad_progid}",
|
|
f"Dispatch:{config.settings.cad_progid}",
|
|
]
|
|
assert app.Visible is True and module._COM_STATE["app"] is app
|
|
|
|
again = await backend.system_launch()
|
|
assert again["launched"] is False and again["attached"] is True
|
|
assert len(calls) == 2, "an app already cached is attached, not re-dispatched"
|
|
|
|
|
|
async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path):
|
|
backend, app, document = com_backend
|
|
from backends import com_backend as module
|
|
|
|
opened: list[str] = []
|
|
app.Documents = types.SimpleNamespace(
|
|
Count=1,
|
|
Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg"),
|
|
)
|
|
monkeypatch.setattr(module, "_COM_STATE", {"app": app})
|
|
target = tmp_path / "gear.dwg"
|
|
target.write_bytes(b"")
|
|
result = await backend.system_launch(open_path=str(target))
|
|
assert result["document"] == "gear.dwg" and opened == [str(target.resolve())]
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
with pytest.raises(ToolError):
|
|
await backend.system_launch(open_path="../../etc/passwd")
|