feat: Add workbench and major cleanup, refactor, and updates (#21)
* FreeCAD addon support for Workbench and Plugins * docs: refactor docs and clean up linters, etc. * Remove mdformat * test: improve test coverage * test:Lots of general fixes * chore: more general fixes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Unit tests for the FreeCAD Robust MCP workbench addon."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the FreeCAD Robust MCP workbench addon structure.
|
||||
|
||||
These tests verify that the addon has the correct file structure and
|
||||
that the Python files are valid (can be parsed).
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Get the addon directory path
|
||||
ADDON_DIR = Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCP"
|
||||
|
||||
|
||||
class TestAddonFileStructure:
|
||||
"""Tests for addon file structure."""
|
||||
|
||||
def test_addon_directory_exists(self):
|
||||
"""The addon directory should exist."""
|
||||
assert ADDON_DIR.exists(), f"Addon directory not found: {ADDON_DIR}"
|
||||
assert ADDON_DIR.is_dir(), f"Addon path is not a directory: {ADDON_DIR}"
|
||||
|
||||
def test_init_py_exists(self):
|
||||
"""Init.py should exist in the addon directory."""
|
||||
init_file = ADDON_DIR / "Init.py"
|
||||
assert init_file.exists(), f"Init.py not found: {init_file}"
|
||||
|
||||
def test_initgui_py_exists(self):
|
||||
"""InitGui.py should exist in the addon directory."""
|
||||
initgui_file = ADDON_DIR / "InitGui.py"
|
||||
assert initgui_file.exists(), f"InitGui.py not found: {initgui_file}"
|
||||
|
||||
def test_icon_exists(self):
|
||||
"""The workbench icon should exist."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
assert icon_file.exists(), f"Icon not found: {icon_file}"
|
||||
|
||||
def test_bridge_module_exists(self):
|
||||
"""The bridge module directory should exist."""
|
||||
bridge_dir = ADDON_DIR / "freecad_mcp_bridge"
|
||||
assert bridge_dir.exists(), f"Bridge module not found: {bridge_dir}"
|
||||
assert bridge_dir.is_dir(), f"Bridge path is not a directory: {bridge_dir}"
|
||||
|
||||
def test_bridge_init_exists(self):
|
||||
"""The bridge module __init__.py should exist."""
|
||||
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
|
||||
assert init_file.exists(), f"Bridge __init__.py not found: {init_file}"
|
||||
|
||||
def test_bridge_server_exists(self):
|
||||
"""The bridge server.py should exist."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
assert server_file.exists(), f"Bridge server.py not found: {server_file}"
|
||||
|
||||
def test_headless_server_exists(self):
|
||||
"""The headless_server.py should exist for headless mode support."""
|
||||
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
|
||||
assert headless_file.exists(), f"headless_server.py not found: {headless_file}"
|
||||
|
||||
|
||||
class TestAddonPythonSyntax:
|
||||
"""Tests to verify Python files have valid syntax."""
|
||||
|
||||
def test_init_py_valid_syntax(self):
|
||||
"""Init.py should have valid Python syntax."""
|
||||
init_file = ADDON_DIR / "Init.py"
|
||||
code = init_file.read_text()
|
||||
# This will raise SyntaxError if invalid
|
||||
ast.parse(code)
|
||||
|
||||
def test_initgui_py_valid_syntax(self):
|
||||
"""InitGui.py should have valid Python syntax."""
|
||||
initgui_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
# This will raise SyntaxError if invalid
|
||||
ast.parse(code)
|
||||
|
||||
def test_bridge_init_valid_syntax(self):
|
||||
"""Bridge __init__.py should have valid Python syntax."""
|
||||
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
|
||||
code = init_file.read_text()
|
||||
ast.parse(code)
|
||||
|
||||
def test_bridge_server_valid_syntax(self):
|
||||
"""Bridge server.py should have valid Python syntax."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
code = server_file.read_text()
|
||||
ast.parse(code)
|
||||
|
||||
def test_headless_server_valid_syntax(self):
|
||||
"""headless_server.py should have valid Python syntax."""
|
||||
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
|
||||
code = headless_file.read_text()
|
||||
ast.parse(code)
|
||||
|
||||
|
||||
class TestAddonMetadata:
|
||||
"""Tests for addon metadata and content."""
|
||||
|
||||
def test_init_py_has_freecad_import(self):
|
||||
"""Init.py should import FreeCAD."""
|
||||
init_file = ADDON_DIR / "Init.py"
|
||||
code = init_file.read_text()
|
||||
assert "import FreeCAD" in code
|
||||
|
||||
def test_initgui_py_has_workbench_class(self):
|
||||
"""InitGui.py should define the workbench class."""
|
||||
initgui_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
assert "FreecadRobustMCPWorkbench" in code
|
||||
assert "Gui.Workbench" in code or "Workbench" in code
|
||||
|
||||
def test_initgui_py_has_commands(self):
|
||||
"""InitGui.py should define start/stop commands."""
|
||||
initgui_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
assert "StartMCPBridgeCommand" in code
|
||||
assert "StopMCPBridgeCommand" in code
|
||||
|
||||
def test_initgui_py_registers_workbench(self):
|
||||
"""InitGui.py should register the workbench."""
|
||||
initgui_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
assert "Gui.addWorkbench" in code
|
||||
|
||||
def test_bridge_server_has_plugin_class(self):
|
||||
"""Bridge server.py should have FreecadMCPPlugin class."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
code = server_file.read_text()
|
||||
assert "class FreecadMCPPlugin" in code
|
||||
|
||||
def test_headless_server_imports_plugin(self):
|
||||
"""headless_server.py should import FreecadMCPPlugin."""
|
||||
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
|
||||
code = headless_file.read_text()
|
||||
assert "FreecadMCPPlugin" in code
|
||||
|
||||
def test_headless_server_has_run_forever(self):
|
||||
"""headless_server.py should call run_forever for blocking execution."""
|
||||
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
|
||||
code = headless_file.read_text()
|
||||
assert "run_forever" in code
|
||||
|
||||
def test_icon_is_valid_svg(self):
|
||||
"""The icon should be a valid SVG file."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
content = icon_file.read_text()
|
||||
assert content.startswith("<?xml") or content.startswith("<svg")
|
||||
assert "<svg" in content
|
||||
assert "</svg>" in content
|
||||
|
||||
|
||||
class TestAddonIconSize:
|
||||
"""Tests for addon icon size requirements."""
|
||||
|
||||
def test_icon_size_under_10kb(self):
|
||||
"""The icon file should be under 10KB (FreeCAD requirement)."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
size_bytes = icon_file.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
assert size_kb <= 10, f"Icon is {size_kb:.2f}KB, must be <= 10KB"
|
||||
|
||||
|
||||
class TestPackageXml:
|
||||
"""Tests for package.xml workbench entry."""
|
||||
|
||||
@pytest.fixture
|
||||
def package_xml(self):
|
||||
"""Load package.xml content."""
|
||||
package_file = ADDON_DIR.parent.parent / "package.xml"
|
||||
return package_file.read_text()
|
||||
|
||||
def test_workbench_entry_exists(self, package_xml):
|
||||
"""package.xml should have a workbench entry."""
|
||||
assert "<workbench>" in package_xml
|
||||
|
||||
def test_workbench_classname(self, package_xml):
|
||||
"""package.xml should reference the correct workbench classname."""
|
||||
assert "<classname>FreecadRobustMCPWorkbench</classname>" in package_xml
|
||||
|
||||
def test_workbench_subdirectory(self, package_xml):
|
||||
"""package.xml should reference the correct subdirectory."""
|
||||
assert "./addon/FreecadRobustMCP/" in package_xml
|
||||
|
||||
def test_workbench_icon(self, package_xml):
|
||||
"""package.xml should reference the workbench icon."""
|
||||
assert "<icon>FreecadRobustMCP.svg</icon>" in package_xml
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Tests for MCP resources module."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import (
|
||||
ConnectionStatus,
|
||||
DocumentInfo,
|
||||
MacroInfo,
|
||||
ObjectInfo,
|
||||
WorkbenchInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestFreecadResources:
|
||||
"""Tests for FreeCAD MCP resources."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self) -> MagicMock:
|
||||
"""Create a mock MCP server that captures resource registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_resources = {}
|
||||
|
||||
def resource_decorator(
|
||||
uri: str,
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
mcp._registered_resources[uri] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.resource = resource_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self) -> AsyncMock:
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_resources(
|
||||
self, mock_mcp: MagicMock, mock_bridge: AsyncMock
|
||||
) -> dict[str, Callable[..., Any]]:
|
||||
"""Register resources and return the registered functions."""
|
||||
from freecad_mcp.resources.freecad import register_resources
|
||||
|
||||
async def get_bridge() -> AsyncMock:
|
||||
return mock_bridge
|
||||
|
||||
register_resources(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_resources
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_version(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://version should return version info."""
|
||||
mock_bridge.get_freecad_version = AsyncMock(
|
||||
return_value={
|
||||
"version": "1.0.0",
|
||||
"build_date": "2024-01-15",
|
||||
"python_version": "3.11.6",
|
||||
"gui_available": True,
|
||||
}
|
||||
)
|
||||
|
||||
resource_version = register_resources["freecad://version"]
|
||||
result = await resource_version()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["version"] == "1.0.0"
|
||||
assert data["gui_available"] is True
|
||||
mock_bridge.get_freecad_version.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_status_connected(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://status should return connected status."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=True,
|
||||
mode="xmlrpc",
|
||||
freecad_version="1.0.0",
|
||||
gui_available=True,
|
||||
last_ping_ms=5.5,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
resource_status = register_resources["freecad://status"]
|
||||
result = await resource_status()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["connected"] is True
|
||||
assert data["mode"] == "xmlrpc"
|
||||
assert data["last_ping_ms"] == 5.5
|
||||
assert data["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_status_disconnected(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://status should return error when disconnected."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=False,
|
||||
mode="xmlrpc",
|
||||
error="Connection refused",
|
||||
)
|
||||
)
|
||||
|
||||
resource_status = register_resources["freecad://status"]
|
||||
result = await resource_status()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["connected"] is False
|
||||
assert data["error"] == "Connection refused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_documents_empty(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://documents should return empty list when no documents."""
|
||||
mock_bridge.get_documents = AsyncMock(return_value=[])
|
||||
|
||||
resource_documents = register_resources["freecad://documents"]
|
||||
result = await resource_documents()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_documents_with_docs(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://documents should return document list."""
|
||||
mock_docs = [
|
||||
DocumentInfo(
|
||||
name="Doc1",
|
||||
label="Document 1",
|
||||
path="/tmp/doc1.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=False,
|
||||
active_object="Box",
|
||||
),
|
||||
DocumentInfo(
|
||||
name="Doc2",
|
||||
label="Document 2",
|
||||
path=None,
|
||||
objects=["Sphere"],
|
||||
is_modified=True,
|
||||
active_object=None,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
|
||||
|
||||
resource_documents = register_resources["freecad://documents"]
|
||||
result = await resource_documents()
|
||||
data = json.loads(result)
|
||||
|
||||
assert len(data) == 2
|
||||
assert data[0]["name"] == "Doc1"
|
||||
assert data[0]["object_count"] == 2
|
||||
assert data[1]["name"] == "Doc2"
|
||||
assert data[1]["is_modified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_document_found(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://documents/{name} should return document info."""
|
||||
mock_docs = [
|
||||
DocumentInfo(
|
||||
name="TestDoc",
|
||||
label="Test Document",
|
||||
path="/tmp/test.FCStd",
|
||||
objects=["Part1", "Part2"],
|
||||
is_modified=False,
|
||||
active_object="Part1",
|
||||
),
|
||||
]
|
||||
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
|
||||
|
||||
resource_document = register_resources["freecad://documents/{name}"]
|
||||
result = await resource_document(name="TestDoc")
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["name"] == "TestDoc"
|
||||
assert data["objects"] == ["Part1", "Part2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_document_not_found(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://documents/{name} should return error when not found."""
|
||||
mock_bridge.get_documents = AsyncMock(return_value=[])
|
||||
|
||||
resource_document = register_resources["freecad://documents/{name}"]
|
||||
result = await resource_document(name="NonExistent")
|
||||
data = json.loads(result)
|
||||
|
||||
assert "error" in data
|
||||
assert "not found" in data["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_document_objects(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://documents/{name}/objects should return object list."""
|
||||
mock_objects = [
|
||||
ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
),
|
||||
ObjectInfo(
|
||||
name="Cylinder",
|
||||
label="My Cylinder",
|
||||
type_id="Part::Cylinder",
|
||||
visibility=False,
|
||||
children=[],
|
||||
parents=[],
|
||||
),
|
||||
]
|
||||
mock_bridge.get_objects = AsyncMock(return_value=mock_objects)
|
||||
|
||||
resource_objects = register_resources["freecad://documents/{name}/objects"]
|
||||
result = await resource_objects(name="TestDoc")
|
||||
data = json.loads(result)
|
||||
|
||||
assert len(data) == 2
|
||||
assert data[0]["name"] == "Box"
|
||||
assert data[0]["type_id"] == "Part::Box"
|
||||
assert data[1]["visibility"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_object_details(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://objects/{doc_name}/{obj_name} should return object details."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
|
||||
shape_info={
|
||||
"shape_type": "Solid",
|
||||
"volume": 6000.0,
|
||||
"area": 2200.0,
|
||||
"is_valid": True,
|
||||
},
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.get_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
resource_object = register_resources["freecad://objects/{doc_name}/{obj_name}"]
|
||||
result = await resource_object(doc_name="TestDoc", obj_name="Box")
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["name"] == "Box"
|
||||
assert data["type_id"] == "Part::Box"
|
||||
assert data["properties"]["Length"] == 10.0
|
||||
assert data["shape_info"]["volume"] == 6000.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_active_document(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://active-document should return active document."""
|
||||
mock_doc = DocumentInfo(
|
||||
name="ActiveDoc",
|
||||
label="Active Document",
|
||||
path="/tmp/active.FCStd",
|
||||
objects=["Part1"],
|
||||
is_modified=True,
|
||||
active_object="Part1",
|
||||
)
|
||||
mock_bridge.get_active_document = AsyncMock(return_value=mock_doc)
|
||||
|
||||
resource_active = register_resources["freecad://active-document"]
|
||||
result = await resource_active()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["name"] == "ActiveDoc"
|
||||
assert data["is_modified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_active_document_none(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://active-document should return null when no active document."""
|
||||
mock_bridge.get_active_document = AsyncMock(return_value=None)
|
||||
|
||||
resource_active = register_resources["freecad://active-document"]
|
||||
result = await resource_active()
|
||||
data = json.loads(result)
|
||||
|
||||
# Implementation returns json.dumps(None) which deserializes to Python None
|
||||
assert data is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_workbenches(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://workbenches should return workbench list."""
|
||||
mock_workbenches = [
|
||||
WorkbenchInfo(
|
||||
name="PartDesignWorkbench",
|
||||
label="Part Design",
|
||||
icon="",
|
||||
is_active=True,
|
||||
),
|
||||
WorkbenchInfo(
|
||||
name="SketcherWorkbench",
|
||||
label="Sketcher",
|
||||
icon="",
|
||||
is_active=False,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
|
||||
|
||||
resource_workbenches = register_resources["freecad://workbenches"]
|
||||
result = await resource_workbenches()
|
||||
data = json.loads(result)
|
||||
|
||||
assert len(data) == 2
|
||||
assert data[0]["name"] == "PartDesignWorkbench"
|
||||
assert data[0]["is_active"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_active_workbench(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://workbenches/active should return active workbench."""
|
||||
mock_workbenches = [
|
||||
WorkbenchInfo(
|
||||
name="PartDesignWorkbench",
|
||||
label="Part Design",
|
||||
icon="",
|
||||
is_active=True,
|
||||
),
|
||||
WorkbenchInfo(
|
||||
name="SketcherWorkbench",
|
||||
label="Sketcher",
|
||||
icon="",
|
||||
is_active=False,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
|
||||
|
||||
resource_active_wb = register_resources["freecad://workbenches/active"]
|
||||
result = await resource_active_wb()
|
||||
data = json.loads(result)
|
||||
|
||||
assert data["name"] == "PartDesignWorkbench"
|
||||
assert data["label"] == "Part Design"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_macros(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://macros should return macro list."""
|
||||
mock_macros = [
|
||||
MacroInfo(
|
||||
name="MultiExport",
|
||||
path="/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro",
|
||||
description="Export to multiple formats",
|
||||
is_system=False,
|
||||
),
|
||||
MacroInfo(
|
||||
name="SystemMacro",
|
||||
path="/usr/share/freecad/Macro/SystemMacro.FCMacro",
|
||||
description="System macro",
|
||||
is_system=True,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_macros = AsyncMock(return_value=mock_macros)
|
||||
|
||||
resource_macros = register_resources["freecad://macros"]
|
||||
result = await resource_macros()
|
||||
data = json.loads(result)
|
||||
|
||||
assert len(data) == 2
|
||||
assert data[0]["name"] == "MultiExport"
|
||||
assert data[0]["is_system"] is False
|
||||
assert data[1]["is_system"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_console(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://console should return console output."""
|
||||
mock_bridge.get_console_output = AsyncMock(
|
||||
return_value=[
|
||||
"FreeCAD started",
|
||||
"Document created",
|
||||
"Box created",
|
||||
]
|
||||
)
|
||||
|
||||
resource_console = register_resources["freecad://console"]
|
||||
result = await resource_console()
|
||||
data = json.loads(result)
|
||||
|
||||
assert "lines" in data
|
||||
assert len(data["lines"]) == 3
|
||||
assert data["count"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_capabilities(
|
||||
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
||||
) -> None:
|
||||
"""freecad://capabilities should return server capabilities."""
|
||||
resource_capabilities = register_resources["freecad://capabilities"]
|
||||
result = await resource_capabilities()
|
||||
data = json.loads(result)
|
||||
|
||||
# Should have tools section
|
||||
assert "tools" in data
|
||||
assert "execution" in data["tools"]
|
||||
assert "documents" in data["tools"]
|
||||
|
||||
# Should have resources section - list of dicts with uri/description
|
||||
assert "resources" in data
|
||||
assert any("capabilities" in r.get("uri", "") for r in data["resources"])
|
||||
|
||||
# Should have prompts section
|
||||
assert "prompts" in data
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for the main server module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.config import FreecadMode
|
||||
|
||||
|
||||
class TestGetInstanceId:
|
||||
"""Tests for get_instance_id function."""
|
||||
|
||||
def test_returns_string(self):
|
||||
"""Instance ID should be a string."""
|
||||
from freecad_mcp.server import get_instance_id
|
||||
|
||||
instance_id = get_instance_id()
|
||||
assert isinstance(instance_id, str)
|
||||
|
||||
def test_returns_uuid_format(self):
|
||||
"""Instance ID should be a valid UUID format."""
|
||||
from freecad_mcp.server import get_instance_id
|
||||
|
||||
instance_id = get_instance_id()
|
||||
# UUID format: 8-4-4-4-12 hex characters
|
||||
parts = instance_id.split("-")
|
||||
assert len(parts) == 5
|
||||
assert len(parts[0]) == 8
|
||||
assert len(parts[1]) == 4
|
||||
assert len(parts[2]) == 4
|
||||
assert len(parts[3]) == 4
|
||||
assert len(parts[4]) == 12
|
||||
|
||||
def test_consistent_across_calls(self):
|
||||
"""Instance ID should be consistent within a process."""
|
||||
from freecad_mcp.server import get_instance_id
|
||||
|
||||
id1 = get_instance_id()
|
||||
id2 = get_instance_id()
|
||||
assert id1 == id2
|
||||
|
||||
|
||||
class TestGetBridge:
|
||||
"""Tests for get_bridge function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_not_initialized(self):
|
||||
"""Should raise RuntimeError when bridge is not initialized."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
# Save original bridge
|
||||
original_bridge = server_module._bridge
|
||||
|
||||
try:
|
||||
# Set bridge to None
|
||||
server_module._bridge = None
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
await server_module.get_bridge()
|
||||
finally:
|
||||
# Restore original bridge
|
||||
server_module._bridge = original_bridge
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_bridge_when_initialized(self):
|
||||
"""Should return bridge when it's initialized."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
# Save original bridge
|
||||
original_bridge = server_module._bridge
|
||||
|
||||
try:
|
||||
# Set up mock bridge
|
||||
mock_bridge = MagicMock()
|
||||
server_module._bridge = mock_bridge
|
||||
|
||||
bridge = await server_module.get_bridge()
|
||||
assert bridge is mock_bridge
|
||||
finally:
|
||||
# Restore original bridge
|
||||
server_module._bridge = original_bridge
|
||||
|
||||
|
||||
class TestLifespan:
|
||||
"""Tests for the lifespan context manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedded_mode_initialization(self):
|
||||
"""Should initialize embedded bridge in embedded mode."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = FreecadMode.EMBEDDED
|
||||
mock_config.freecad_path = None
|
||||
|
||||
mock_embedded_bridge = AsyncMock()
|
||||
mock_embedded_bridge.get_freecad_version = AsyncMock(
|
||||
return_value={"version": "1.0.0", "gui_available": False}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch(
|
||||
"freecad_mcp.bridge.embedded.EmbeddedBridge",
|
||||
return_value=mock_embedded_bridge,
|
||||
) as mock_embedded_class,
|
||||
):
|
||||
mock_server = MagicMock()
|
||||
|
||||
async with server_module.lifespan(mock_server):
|
||||
# Bridge should be initialized
|
||||
mock_embedded_class.assert_called_once_with(freecad_path=None)
|
||||
mock_embedded_bridge.connect.assert_called_once()
|
||||
|
||||
# After exiting, disconnect should be called
|
||||
mock_embedded_bridge.disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xmlrpc_mode_initialization(self):
|
||||
"""Should initialize XML-RPC bridge in xmlrpc mode."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = FreecadMode.XMLRPC
|
||||
mock_config.socket_host = "localhost"
|
||||
mock_config.xmlrpc_port = 9875
|
||||
|
||||
mock_xmlrpc_bridge = AsyncMock()
|
||||
mock_xmlrpc_bridge.get_freecad_version = AsyncMock(
|
||||
return_value={"version": "1.0.0", "gui_available": True}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch(
|
||||
"freecad_mcp.bridge.xmlrpc.XmlRpcBridge",
|
||||
return_value=mock_xmlrpc_bridge,
|
||||
) as mock_xmlrpc_class,
|
||||
):
|
||||
mock_server = MagicMock()
|
||||
|
||||
async with server_module.lifespan(mock_server):
|
||||
mock_xmlrpc_class.assert_called_once_with(host="localhost", port=9875)
|
||||
mock_xmlrpc_bridge.connect.assert_called_once()
|
||||
|
||||
mock_xmlrpc_bridge.disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_mode_initialization(self):
|
||||
"""Should initialize socket bridge in socket mode."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = FreecadMode.SOCKET
|
||||
mock_config.socket_host = "localhost"
|
||||
mock_config.socket_port = 9876
|
||||
|
||||
mock_socket_bridge = AsyncMock()
|
||||
mock_socket_bridge.get_freecad_version = AsyncMock(
|
||||
return_value={"version": "1.0.0", "gui_available": True}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch(
|
||||
"freecad_mcp.bridge.socket.SocketBridge",
|
||||
return_value=mock_socket_bridge,
|
||||
) as mock_socket_class,
|
||||
):
|
||||
mock_server = MagicMock()
|
||||
|
||||
async with server_module.lifespan(mock_server):
|
||||
mock_socket_class.assert_called_once_with(host="localhost", port=9876)
|
||||
mock_socket_bridge.connect.assert_called_once()
|
||||
|
||||
mock_socket_bridge.disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_fetch_failure_logs_warning(self):
|
||||
"""Should log warning if version fetch fails."""
|
||||
import freecad_mcp.server as server_module
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = FreecadMode.EMBEDDED
|
||||
mock_config.freecad_path = None
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_freecad_version = AsyncMock(
|
||||
side_effect=Exception("Connection failed")
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch(
|
||||
"freecad_mcp.bridge.embedded.EmbeddedBridge",
|
||||
return_value=mock_bridge,
|
||||
),
|
||||
patch.object(server_module.logger, "warning") as mock_warning,
|
||||
):
|
||||
mock_server = MagicMock()
|
||||
|
||||
async with server_module.lifespan(mock_server):
|
||||
# Warning should be logged
|
||||
mock_warning.assert_called_once()
|
||||
assert "Could not get FreeCAD version" in str(mock_warning.call_args)
|
||||
|
||||
|
||||
class TestRegisterAllComponents:
|
||||
"""Tests for register_all_components function."""
|
||||
|
||||
def test_registers_tools(self):
|
||||
"""Should register all tool categories."""
|
||||
from freecad_mcp.server import mcp
|
||||
|
||||
# The function is called at module load, but we can verify
|
||||
# that the mcp instance exists and has tools registered
|
||||
assert mcp is not None
|
||||
assert mcp.name == "freecad-mcp"
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main function."""
|
||||
|
||||
def test_main_prints_instance_id(self):
|
||||
"""Main should print instance ID on startup."""
|
||||
import freecad_mcp.server as server_module
|
||||
from freecad_mcp.config import TransportType
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.log_level = "INFO"
|
||||
mock_config.mode = FreecadMode.EMBEDDED
|
||||
mock_config.transport = TransportType.STDIO
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch.object(server_module.mcp, "run") as mock_run,
|
||||
patch("builtins.print") as mock_print,
|
||||
):
|
||||
# Mock run to exit immediately
|
||||
mock_run.return_value = None
|
||||
|
||||
server_module.main()
|
||||
|
||||
# Check that instance ID was printed
|
||||
print_calls = [str(call) for call in mock_print.call_args_list]
|
||||
assert any("FREECAD_MCP_INSTANCE_ID=" in call for call in print_calls)
|
||||
|
||||
def test_main_http_transport(self):
|
||||
"""Main should start HTTP transport when configured."""
|
||||
import freecad_mcp.server as server_module
|
||||
from freecad_mcp.config import TransportType
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.log_level = "INFO"
|
||||
mock_config.mode = FreecadMode.EMBEDDED
|
||||
mock_config.transport = TransportType.HTTP
|
||||
mock_config.http_port = 8080
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch.object(server_module.mcp, "run") as mock_run,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
server_module.main()
|
||||
|
||||
# Should call run with HTTP transport settings
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs.get("transport") == "streamable-http"
|
||||
assert call_kwargs.get("port") == 8080
|
||||
|
||||
def test_main_stdio_transport(self):
|
||||
"""Main should start stdio transport by default."""
|
||||
import freecad_mcp.server as server_module
|
||||
from freecad_mcp.config import TransportType
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.log_level = "INFO"
|
||||
mock_config.mode = FreecadMode.EMBEDDED
|
||||
mock_config.transport = TransportType.STDIO
|
||||
|
||||
with (
|
||||
patch.object(server_module, "get_config", return_value=mock_config),
|
||||
patch.object(server_module.mcp, "run") as mock_run,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
server_module.main()
|
||||
|
||||
# Should call run without transport arguments (stdio is default)
|
||||
mock_run.assert_called_once_with()
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Tests for document tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import DocumentInfo, ExecutionResult
|
||||
|
||||
|
||||
class TestDocumentTools:
|
||||
"""Tests for document management tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register document tools and return the registered functions."""
|
||||
from freecad_mcp.tools.documents import register_document_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_document_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_empty(self, register_tools, mock_bridge):
|
||||
"""list_documents should return empty list when no documents."""
|
||||
mock_bridge.get_documents = AsyncMock(return_value=[])
|
||||
|
||||
list_documents = register_tools["list_documents"]
|
||||
result = await list_documents()
|
||||
|
||||
assert result == []
|
||||
mock_bridge.get_documents.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_with_docs(self, register_tools, mock_bridge):
|
||||
"""list_documents should return document info."""
|
||||
mock_docs = [
|
||||
DocumentInfo(
|
||||
name="Doc1",
|
||||
label="Document 1",
|
||||
path="/tmp/doc1.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=False,
|
||||
active_object="Box",
|
||||
),
|
||||
DocumentInfo(
|
||||
name="Doc2",
|
||||
label="Document 2",
|
||||
path=None,
|
||||
objects=["Sphere"],
|
||||
is_modified=True,
|
||||
active_object=None,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
|
||||
|
||||
list_documents = register_tools["list_documents"]
|
||||
result = await list_documents()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "Doc1"
|
||||
assert result[0]["object_count"] == 2
|
||||
assert result[0]["is_modified"] is False
|
||||
assert result[1]["name"] == "Doc2"
|
||||
assert result[1]["object_count"] == 1
|
||||
assert result[1]["is_modified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_document_none(self, register_tools, mock_bridge):
|
||||
"""get_active_document should return None when no active document."""
|
||||
mock_bridge.get_active_document = AsyncMock(return_value=None)
|
||||
|
||||
get_active_document = register_tools["get_active_document"]
|
||||
result = await get_active_document()
|
||||
|
||||
assert result is None
|
||||
mock_bridge.get_active_document.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_document_returns_info(self, register_tools, mock_bridge):
|
||||
"""get_active_document should return document info when available."""
|
||||
mock_doc = DocumentInfo(
|
||||
name="ActiveDoc",
|
||||
label="Active Document",
|
||||
path="/tmp/active.FCStd",
|
||||
objects=["Part1", "Part2"],
|
||||
is_modified=True,
|
||||
active_object="Part1",
|
||||
)
|
||||
mock_bridge.get_active_document = AsyncMock(return_value=mock_doc)
|
||||
|
||||
get_active_document = register_tools["get_active_document"]
|
||||
result = await get_active_document()
|
||||
|
||||
assert result["name"] == "ActiveDoc"
|
||||
assert result["label"] == "Active Document"
|
||||
assert result["objects"] == ["Part1", "Part2"]
|
||||
assert result["is_modified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_default_name(self, register_tools, mock_bridge):
|
||||
"""create_document should create with default name."""
|
||||
mock_doc = DocumentInfo(
|
||||
name="Unnamed",
|
||||
label="Unnamed",
|
||||
path=None,
|
||||
objects=[],
|
||||
is_modified=False,
|
||||
)
|
||||
mock_bridge.create_document = AsyncMock(return_value=mock_doc)
|
||||
|
||||
create_document = register_tools["create_document"]
|
||||
result = await create_document()
|
||||
|
||||
assert result["name"] == "Unnamed"
|
||||
mock_bridge.create_document.assert_called_once_with("Unnamed", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_with_name_and_label(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""create_document should use provided name and label."""
|
||||
mock_doc = DocumentInfo(
|
||||
name="MyPart",
|
||||
label="My Part Design",
|
||||
path=None,
|
||||
objects=[],
|
||||
is_modified=False,
|
||||
)
|
||||
mock_bridge.create_document = AsyncMock(return_value=mock_doc)
|
||||
|
||||
create_document = register_tools["create_document"]
|
||||
result = await create_document(name="MyPart", label="My Part Design")
|
||||
|
||||
assert result["name"] == "MyPart"
|
||||
assert result["label"] == "My Part Design"
|
||||
mock_bridge.create_document.assert_called_once_with("MyPart", "My Part Design")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_document(self, register_tools, mock_bridge):
|
||||
"""open_document should open and return document info."""
|
||||
mock_doc = DocumentInfo(
|
||||
name="OpenedDoc",
|
||||
label="Opened Document",
|
||||
path="/tmp/test.FCStd",
|
||||
objects=["Box", "Fillet"],
|
||||
is_modified=False,
|
||||
)
|
||||
mock_bridge.open_document = AsyncMock(return_value=mock_doc)
|
||||
|
||||
open_document = register_tools["open_document"]
|
||||
result = await open_document(path="/tmp/test.FCStd")
|
||||
|
||||
assert result["name"] == "OpenedDoc"
|
||||
assert result["path"] == "/tmp/test.FCStd"
|
||||
assert result["objects"] == ["Box", "Fillet"]
|
||||
mock_bridge.open_document.assert_called_once_with("/tmp/test.FCStd")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_document_default(self, register_tools, mock_bridge):
|
||||
"""save_document should save active document."""
|
||||
mock_bridge.save_document = AsyncMock(return_value="/tmp/saved.FCStd")
|
||||
|
||||
save_document = register_tools["save_document"]
|
||||
result = await save_document()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/tmp/saved.FCStd"
|
||||
mock_bridge.save_document.assert_called_once_with(None, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_document_with_path(self, register_tools, mock_bridge):
|
||||
"""save_document should save to specified path."""
|
||||
mock_bridge.save_document = AsyncMock(return_value="/new/path.FCStd")
|
||||
|
||||
save_document = register_tools["save_document"]
|
||||
result = await save_document(doc_name="MyDoc", path="/new/path.FCStd")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/new/path.FCStd"
|
||||
mock_bridge.save_document.assert_called_once_with("MyDoc", "/new/path.FCStd")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_document_without_save(self, register_tools, mock_bridge):
|
||||
"""close_document should close without saving by default."""
|
||||
mock_bridge.close_document = AsyncMock()
|
||||
|
||||
close_document = register_tools["close_document"]
|
||||
result = await close_document(doc_name="TestDoc")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["saved"] is False
|
||||
mock_bridge.close_document.assert_called_once_with("TestDoc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_document_with_save(self, register_tools, mock_bridge):
|
||||
"""close_document should save before closing when requested."""
|
||||
mock_bridge.save_document = AsyncMock(return_value="/tmp/doc.FCStd")
|
||||
mock_bridge.close_document = AsyncMock()
|
||||
|
||||
close_document = register_tools["close_document"]
|
||||
result = await close_document(doc_name="TestDoc", save_changes=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["saved"] is True
|
||||
mock_bridge.save_document.assert_called_once_with("TestDoc")
|
||||
mock_bridge.close_document.assert_called_once_with("TestDoc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_document_save_failure(self, register_tools, mock_bridge):
|
||||
"""close_document should still close even if save fails."""
|
||||
mock_bridge.save_document = AsyncMock(side_effect=Exception("Save failed"))
|
||||
mock_bridge.close_document = AsyncMock()
|
||||
|
||||
close_document = register_tools["close_document"]
|
||||
result = await close_document(doc_name="TestDoc", save_changes=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["saved"] is False
|
||||
mock_bridge.close_document.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recompute_document_success(self, register_tools, mock_bridge):
|
||||
"""recompute_document should return success on recompute."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
recompute_document = register_tools["recompute_document"]
|
||||
result = await recompute_document(doc_name="TestDoc")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result.get("error") is None
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recompute_document_failure(self, register_tools, mock_bridge):
|
||||
"""recompute_document should return error on failure."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
error_type="ValueError",
|
||||
error_traceback="No document found",
|
||||
)
|
||||
)
|
||||
|
||||
recompute_document = register_tools["recompute_document"]
|
||||
result = await recompute_document(doc_name="NonExistent")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "No document found"
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for execution tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ConnectionStatus, ExecutionResult
|
||||
|
||||
|
||||
class TestExecutionTools:
|
||||
"""Tests for Python execution tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register execution tools and return the registered functions."""
|
||||
from freecad_mcp.tools.execution import register_execution_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_execution_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_success(self, register_tools, mock_bridge):
|
||||
"""execute_python should return success result."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"value": 42, "type": "int"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.5,
|
||||
)
|
||||
)
|
||||
|
||||
execute_python = register_tools["execute_python"]
|
||||
result = await execute_python(code="_result_ = {'value': 42, 'type': 'int'}")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["result"] == {"value": 42, "type": "int"}
|
||||
assert result["execution_time_ms"] == 10.5
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_with_timeout(self, register_tools, mock_bridge):
|
||||
"""execute_python should pass timeout to bridge."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
execute_python = register_tools["execute_python"]
|
||||
await execute_python(code="_result_ = True", timeout_ms=60000)
|
||||
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
call_args = mock_bridge.execute_python.call_args
|
||||
assert call_args.kwargs.get("timeout_ms") == 60000 or call_args.args[1] == 60000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_failure(self, register_tools, mock_bridge):
|
||||
"""execute_python should return error on failure."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="NameError: name 'foo' is not defined",
|
||||
execution_time_ms=2.0,
|
||||
error_type="NameError",
|
||||
error_traceback="Traceback...\nNameError: name 'foo' is not defined",
|
||||
)
|
||||
)
|
||||
|
||||
execute_python = register_tools["execute_python"]
|
||||
result = await execute_python(code="foo")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "NameError"
|
||||
assert "foo" in result["error_traceback"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_with_stdout(self, register_tools, mock_bridge):
|
||||
"""execute_python should capture stdout."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=None,
|
||||
stdout="Hello, World!\n",
|
||||
stderr="",
|
||||
execution_time_ms=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
execute_python = register_tools["execute_python"]
|
||||
result = await execute_python(code="print('Hello, World!')")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stdout"] == "Hello, World!\n"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_freecad_version(self, register_tools, mock_bridge):
|
||||
"""get_freecad_version should return version info."""
|
||||
mock_bridge.get_freecad_version = AsyncMock(
|
||||
return_value={
|
||||
"version": "1.0.0",
|
||||
"version_tuple": [1, 0, 0],
|
||||
"build_date": "2024-01-15",
|
||||
"python_version": "3.11.6",
|
||||
"gui_available": True,
|
||||
}
|
||||
)
|
||||
|
||||
get_freecad_version = register_tools["get_freecad_version"]
|
||||
result = await get_freecad_version()
|
||||
|
||||
assert result["version"] == "1.0.0"
|
||||
assert result["gui_available"] is True
|
||||
mock_bridge.get_freecad_version.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_connection_status_connected(self, register_tools, mock_bridge):
|
||||
"""get_connection_status should return connected status."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=True,
|
||||
mode="xmlrpc",
|
||||
freecad_version="1.0.0",
|
||||
gui_available=True,
|
||||
last_ping_ms=5.5,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
get_connection_status = register_tools["get_connection_status"]
|
||||
result = await get_connection_status()
|
||||
|
||||
assert result["connected"] is True
|
||||
assert result["mode"] == "xmlrpc"
|
||||
assert result["last_ping_ms"] == 5.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_connection_status_disconnected(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""get_connection_status should return disconnected status with error."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=False,
|
||||
mode="xmlrpc",
|
||||
error="Connection refused",
|
||||
)
|
||||
)
|
||||
|
||||
get_connection_status = register_tools["get_connection_status"]
|
||||
result = await get_connection_status()
|
||||
|
||||
assert result["connected"] is False
|
||||
assert result["error"] == "Connection refused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_console_output(self, register_tools, mock_bridge):
|
||||
"""get_console_output should return console lines."""
|
||||
mock_bridge.get_console_output = AsyncMock(
|
||||
return_value=[
|
||||
"FreeCAD started",
|
||||
"Document created: TestDoc",
|
||||
"Box created",
|
||||
]
|
||||
)
|
||||
|
||||
get_console_output = register_tools["get_console_output"]
|
||||
result = await get_console_output()
|
||||
|
||||
# Returns a list directly, not a dict
|
||||
assert result == [
|
||||
"FreeCAD started",
|
||||
"Document created: TestDoc",
|
||||
"Box created",
|
||||
]
|
||||
mock_bridge.get_console_output.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_console_output_with_lines_param(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""get_console_output should pass lines parameter."""
|
||||
mock_bridge.get_console_output = AsyncMock(return_value=["Line 1"])
|
||||
|
||||
get_console_output = register_tools["get_console_output"]
|
||||
await get_console_output(lines=50)
|
||||
|
||||
mock_bridge.get_console_output.assert_called_once_with(50)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mcp_server_environment(self, register_tools, mock_bridge):
|
||||
"""get_mcp_server_environment should return environment info."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=True,
|
||||
mode="xmlrpc",
|
||||
freecad_version="1.0.0",
|
||||
gui_available=True,
|
||||
last_ping_ms=5.0,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
get_env = register_tools["get_mcp_server_environment"]
|
||||
result = await get_env()
|
||||
|
||||
# Should have standard fields
|
||||
assert "instance_id" in result
|
||||
assert "hostname" in result
|
||||
assert "os_name" in result
|
||||
assert "python_version" in result
|
||||
assert "in_docker" in result
|
||||
|
||||
# Should have freecad status
|
||||
assert "freecad" in result
|
||||
assert result["freecad"]["connected"] is True
|
||||
assert result["freecad"]["mode"] == "xmlrpc"
|
||||
assert result["freecad"]["is_headless"] is False
|
||||
|
||||
# Should have env vars
|
||||
assert "env_vars" in result
|
||||
mock_bridge.get_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mcp_server_environment_headless(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""get_mcp_server_environment should detect headless mode."""
|
||||
mock_bridge.get_status = AsyncMock(
|
||||
return_value=ConnectionStatus(
|
||||
connected=True,
|
||||
mode="embedded",
|
||||
freecad_version="1.0.0",
|
||||
gui_available=False,
|
||||
last_ping_ms=0.0,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
get_env = register_tools["get_mcp_server_environment"]
|
||||
result = await get_env()
|
||||
|
||||
assert result["freecad"]["gui_available"] is False
|
||||
assert result["freecad"]["is_headless"] is True
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Tests for export/import tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ExecutionResult
|
||||
|
||||
|
||||
class TestExportTools:
|
||||
"""Tests for export/import tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register export tools and return the registered functions."""
|
||||
from freecad_mcp.tools.export import register_export_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_export_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_step(self, register_tools, mock_bridge):
|
||||
"""export_step should export to STEP format via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.step",
|
||||
"object_count": 2,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=50.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_step = register_tools["export_step"]
|
||||
result = await export_step(
|
||||
file_path="/tmp/output.step", object_names=["Box", "Cylinder"]
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/tmp/output.step"
|
||||
assert result["object_count"] == 2
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_step_all_visible(self, register_tools, mock_bridge):
|
||||
"""export_step should export all visible objects when no names given."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.step",
|
||||
"object_count": 5,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=75.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_step = register_tools["export_step"]
|
||||
result = await export_step(file_path="/tmp/output.step")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["object_count"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_stl(self, register_tools, mock_bridge):
|
||||
"""export_stl should export to STL format via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.stl",
|
||||
"object_count": 1,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=30.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_stl = register_tools["export_stl"]
|
||||
result = await export_stl(file_path="/tmp/output.stl", object_names=["Box"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/tmp/output.stl"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_stl_with_tolerance(self, register_tools, mock_bridge):
|
||||
"""export_stl should accept mesh tolerance parameter."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/fine.stl",
|
||||
"object_count": 1,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=45.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_stl = register_tools["export_stl"]
|
||||
result = await export_stl(file_path="/tmp/fine.stl", mesh_tolerance=0.01)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_3mf(self, register_tools, mock_bridge):
|
||||
"""export_3mf should export to 3MF format via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.3mf",
|
||||
"object_count": 1,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=40.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_3mf = register_tools["export_3mf"]
|
||||
result = await export_3mf(file_path="/tmp/output.3mf")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_obj(self, register_tools, mock_bridge):
|
||||
"""export_obj should export to OBJ format via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.obj",
|
||||
"object_count": 1,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=35.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_obj = register_tools["export_obj"]
|
||||
result = await export_obj(file_path="/tmp/output.obj")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_iges(self, register_tools, mock_bridge):
|
||||
"""export_iges should export to IGES format via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/tmp/output.iges",
|
||||
"object_count": 1,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=55.0,
|
||||
)
|
||||
)
|
||||
|
||||
export_iges = register_tools["export_iges"]
|
||||
result = await export_iges(file_path="/tmp/output.iges")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_step(self, register_tools, mock_bridge):
|
||||
"""import_step should import STEP files via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"document": "Imported",
|
||||
"objects": ["Part", "Assembly"],
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=100.0,
|
||||
)
|
||||
)
|
||||
|
||||
import_step = register_tools["import_step"]
|
||||
result = await import_step(file_path="/tmp/input.step")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["document"] == "Imported"
|
||||
assert len(result["objects"]) == 2
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_stl(self, register_tools, mock_bridge):
|
||||
"""import_stl should import STL files via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"document": "Mesh",
|
||||
"object": "Mesh001",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=80.0,
|
||||
)
|
||||
)
|
||||
|
||||
import_stl = register_tools["import_stl"]
|
||||
result = await import_stl(file_path="/tmp/input.stl")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["object"] == "Mesh001"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_step_failure(self, register_tools, mock_bridge):
|
||||
"""export_step should raise ValueError on failure."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="FileNotFoundError: Directory does not exist",
|
||||
execution_time_ms=5.0,
|
||||
error_type="FileNotFoundError",
|
||||
error_traceback="Traceback: FileNotFoundError: Directory does not exist",
|
||||
)
|
||||
)
|
||||
|
||||
export_step = register_tools["export_step"]
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await export_step(file_path="/nonexistent/output.step")
|
||||
|
||||
assert "FileNotFoundError" in str(exc_info.value) or "Traceback" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_step_into_document(self, register_tools, mock_bridge):
|
||||
"""import_step should import into specified document."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"document": "MyDoc",
|
||||
"objects": ["ImportedPart"],
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=90.0,
|
||||
)
|
||||
)
|
||||
|
||||
import_step = register_tools["import_step"]
|
||||
result = await import_step(file_path="/tmp/part.step", doc_name="MyDoc")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["document"] == "MyDoc"
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Tests for macro tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ExecutionResult, MacroInfo
|
||||
|
||||
|
||||
class TestMacroTools:
|
||||
"""Tests for macro management tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register macro tools and return the registered functions."""
|
||||
from freecad_mcp.tools.macros import register_macro_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_macro_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_macros_empty(self, register_tools, mock_bridge):
|
||||
"""list_macros should return empty list when no macros."""
|
||||
mock_bridge.get_macros = AsyncMock(return_value=[])
|
||||
|
||||
list_macros = register_tools["list_macros"]
|
||||
result = await list_macros()
|
||||
|
||||
assert result == []
|
||||
mock_bridge.get_macros.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_macros_with_macros(self, register_tools, mock_bridge):
|
||||
"""list_macros should return macro info."""
|
||||
mock_macros = [
|
||||
MacroInfo(
|
||||
name="MultiExport",
|
||||
path="/home/user/.FreeCAD/Macro/MultiExport.FCMacro",
|
||||
description="Export to multiple formats",
|
||||
is_system=False,
|
||||
),
|
||||
MacroInfo(
|
||||
name="SystemMacro",
|
||||
path="/usr/share/freecad/Macro/SystemMacro.FCMacro",
|
||||
description="System macro",
|
||||
is_system=True,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_macros = AsyncMock(return_value=mock_macros)
|
||||
|
||||
list_macros = register_tools["list_macros"]
|
||||
result = await list_macros()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "MultiExport"
|
||||
assert result[0]["is_system"] is False
|
||||
assert result[1]["name"] == "SystemMacro"
|
||||
assert result[1]["is_system"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_macro_success(self, register_tools, mock_bridge):
|
||||
"""run_macro should execute a macro and return results."""
|
||||
# run_macro calls bridge.run_macro which returns ExecutionResult
|
||||
mock_bridge.run_macro = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"exported_count": 3},
|
||||
stdout="Exported 3 objects\n",
|
||||
stderr="",
|
||||
execution_time_ms=150.0,
|
||||
)
|
||||
)
|
||||
|
||||
run_macro = register_tools["run_macro"]
|
||||
result = await run_macro(macro_name="MultiExport")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stdout"] == "Exported 3 objects\n"
|
||||
mock_bridge.run_macro.assert_called_once_with("MultiExport", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_macro_with_args(self, register_tools, mock_bridge):
|
||||
"""run_macro should pass arguments to macro."""
|
||||
mock_bridge.run_macro = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=50.0,
|
||||
)
|
||||
)
|
||||
|
||||
run_macro = register_tools["run_macro"]
|
||||
args = {"output_dir": "/tmp", "format": "step"}
|
||||
result = await run_macro(macro_name="CustomMacro", args=args)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.run_macro.assert_called_once_with("CustomMacro", args)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_macro_failure(self, register_tools, mock_bridge):
|
||||
"""run_macro should return error info on failure."""
|
||||
mock_bridge.run_macro = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="NameError: name 'undefined_var' is not defined",
|
||||
execution_time_ms=10.0,
|
||||
error_type="NameError",
|
||||
error_traceback="Traceback...",
|
||||
)
|
||||
)
|
||||
|
||||
run_macro = register_tools["run_macro"]
|
||||
result = await run_macro(macro_name="BrokenMacro")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "NameError"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro(self, register_tools, mock_bridge):
|
||||
"""create_macro should create a new macro file via bridge.create_macro."""
|
||||
# create_macro calls bridge.create_macro which returns MacroInfo
|
||||
mock_macro = MacroInfo(
|
||||
name="MyMacro",
|
||||
path="/home/user/.FreeCAD/Macro/MyMacro.FCMacro",
|
||||
description="My custom macro",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_macro = register_tools["create_macro"]
|
||||
result = await create_macro(
|
||||
name="MyMacro",
|
||||
code="FreeCAD.Console.PrintMessage('Hello')",
|
||||
description="My custom macro",
|
||||
)
|
||||
|
||||
assert result["name"] == "MyMacro"
|
||||
assert result["path"] == "/home/user/.FreeCAD/Macro/MyMacro.FCMacro"
|
||||
assert result["description"] == "My custom macro"
|
||||
mock_bridge.create_macro.assert_called_once_with(
|
||||
"MyMacro", "FreeCAD.Console.PrintMessage('Hello')", "My custom macro"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_macro(self, register_tools, mock_bridge):
|
||||
"""read_macro should return macro source code via execute_python."""
|
||||
# read_macro uses execute_python to read file contents
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "MyMacro",
|
||||
"code": "import FreeCAD\nFreeCAD.Console.PrintMessage('Hello')",
|
||||
"path": "/home/user/.FreeCAD/Macro/MyMacro.FCMacro",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
read_macro = register_tools["read_macro"]
|
||||
result = await read_macro(macro_name="MyMacro")
|
||||
|
||||
assert result["name"] == "MyMacro"
|
||||
assert "FreeCAD" in result["code"]
|
||||
assert result["path"] == "/home/user/.FreeCAD/Macro/MyMacro.FCMacro"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_macro_not_found(self, register_tools, mock_bridge):
|
||||
"""read_macro should raise error when macro not found."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="FileNotFoundError: Macro not found: NonExistent",
|
||||
execution_time_ms=5.0,
|
||||
error_type="FileNotFoundError",
|
||||
error_traceback="Traceback...\nFileNotFoundError: Macro not found: NonExistent",
|
||||
)
|
||||
)
|
||||
|
||||
read_macro = register_tools["read_macro"]
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await read_macro(macro_name="NonExistent")
|
||||
|
||||
assert "NonExistent" in str(exc_info.value) or "Traceback" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_macro(self, register_tools, mock_bridge):
|
||||
"""delete_macro should delete a user macro via execute_python."""
|
||||
# delete_macro uses execute_python to delete file
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": True,
|
||||
"path": "/home/user/.FreeCAD/Macro/OldMacro.FCMacro",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=8.0,
|
||||
)
|
||||
)
|
||||
|
||||
delete_macro = register_tools["delete_macro"]
|
||||
result = await delete_macro(macro_name="OldMacro")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/home/user/.FreeCAD/Macro/OldMacro.FCMacro"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_macro_not_found(self, register_tools, mock_bridge):
|
||||
"""delete_macro should raise error when macro not found."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="FileNotFoundError: User macro not found: NonExistent",
|
||||
execution_time_ms=5.0,
|
||||
error_type="FileNotFoundError",
|
||||
error_traceback="Traceback...\nFileNotFoundError: User macro not found: NonExistent",
|
||||
)
|
||||
)
|
||||
|
||||
delete_macro = register_tools["delete_macro"]
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await delete_macro(macro_name="NonExistent")
|
||||
|
||||
assert "NonExistent" in str(exc_info.value) or "Traceback" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_basic(self, register_tools, mock_bridge):
|
||||
"""create_macro_from_template should create from basic template."""
|
||||
# Uses bridge.create_macro with template code
|
||||
mock_macro = MacroInfo(
|
||||
name="NewMacro",
|
||||
path="/home/user/.FreeCAD/Macro/NewMacro.FCMacro",
|
||||
description="",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
result = await create_from_template(name="NewMacro", template="basic")
|
||||
|
||||
assert result["name"] == "NewMacro"
|
||||
assert result["template"] == "basic"
|
||||
mock_bridge.create_macro.assert_called_once()
|
||||
# Verify basic template code was used
|
||||
call_args = mock_bridge.create_macro.call_args
|
||||
code_arg = call_args[0][1] # Second positional arg is code
|
||||
assert "FreeCAD.ActiveDocument" in code_arg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_part(self, register_tools, mock_bridge):
|
||||
"""create_macro_from_template should create from part template."""
|
||||
mock_macro = MacroInfo(
|
||||
name="PartMacro",
|
||||
path="/home/user/.FreeCAD/Macro/PartMacro.FCMacro",
|
||||
description="Part operations",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
result = await create_from_template(
|
||||
name="PartMacro", template="part", description="Part operations"
|
||||
)
|
||||
|
||||
assert result["name"] == "PartMacro"
|
||||
assert result["template"] == "part"
|
||||
# Verify part template code was used
|
||||
call_args = mock_bridge.create_macro.call_args
|
||||
code_arg = call_args[0][1]
|
||||
assert "import Part" in code_arg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_sketch(self, register_tools, mock_bridge):
|
||||
"""create_macro_from_template should create from sketch template."""
|
||||
mock_macro = MacroInfo(
|
||||
name="SketchMacro",
|
||||
path="/home/user/.FreeCAD/Macro/SketchMacro.FCMacro",
|
||||
description="",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
result = await create_from_template(name="SketchMacro", template="sketch")
|
||||
|
||||
assert result["name"] == "SketchMacro"
|
||||
assert result["template"] == "sketch"
|
||||
# Verify sketch template code was used
|
||||
call_args = mock_bridge.create_macro.call_args
|
||||
code_arg = call_args[0][1]
|
||||
assert "Sketcher" in code_arg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_gui(self, register_tools, mock_bridge):
|
||||
"""create_macro_from_template should create from gui template."""
|
||||
mock_macro = MacroInfo(
|
||||
name="GuiMacro",
|
||||
path="/home/user/.FreeCAD/Macro/GuiMacro.FCMacro",
|
||||
description="",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
result = await create_from_template(name="GuiMacro", template="gui")
|
||||
|
||||
assert result["name"] == "GuiMacro"
|
||||
assert result["template"] == "gui"
|
||||
# Verify gui template code was used
|
||||
call_args = mock_bridge.create_macro.call_args
|
||||
code_arg = call_args[0][1]
|
||||
assert "QtWidgets" in code_arg or "PySide" in code_arg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_selection(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""create_macro_from_template should create from selection template."""
|
||||
mock_macro = MacroInfo(
|
||||
name="SelectionMacro",
|
||||
path="/home/user/.FreeCAD/Macro/SelectionMacro.FCMacro",
|
||||
description="",
|
||||
is_system=False,
|
||||
)
|
||||
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
|
||||
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
result = await create_from_template(name="SelectionMacro", template="selection")
|
||||
|
||||
assert result["name"] == "SelectionMacro"
|
||||
assert result["template"] == "selection"
|
||||
# Verify selection template code was used
|
||||
call_args = mock_bridge.create_macro.call_args
|
||||
code_arg = call_args[0][1]
|
||||
assert "Selection" in code_arg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_macro_from_template_invalid(
|
||||
self, register_tools, mock_bridge
|
||||
):
|
||||
"""create_macro_from_template should raise error for invalid template."""
|
||||
create_from_template = register_tools["create_macro_from_template"]
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await create_from_template(name="BadMacro", template="invalid_template")
|
||||
|
||||
assert "Unknown template" in str(exc_info.value)
|
||||
assert "invalid_template" in str(exc_info.value)
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Tests for object tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ExecutionResult, ObjectInfo
|
||||
|
||||
|
||||
class TestObjectTools:
|
||||
"""Tests for object management tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register object tools and return the registered functions."""
|
||||
from freecad_mcp.tools.objects import register_object_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_object_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_objects_empty(self, register_tools, mock_bridge):
|
||||
"""list_objects should return empty list when no objects."""
|
||||
mock_bridge.get_objects = AsyncMock(return_value=[])
|
||||
|
||||
list_objects = register_tools["list_objects"]
|
||||
result = await list_objects()
|
||||
|
||||
assert result == []
|
||||
mock_bridge.get_objects.assert_called_once_with(None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_objects_with_objects(self, register_tools, mock_bridge):
|
||||
"""list_objects should return object info."""
|
||||
mock_objects = [
|
||||
ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
),
|
||||
ObjectInfo(
|
||||
name="Cylinder",
|
||||
label="My Cylinder",
|
||||
type_id="Part::Cylinder",
|
||||
visibility=False,
|
||||
children=[],
|
||||
parents=[],
|
||||
),
|
||||
]
|
||||
mock_bridge.get_objects = AsyncMock(return_value=mock_objects)
|
||||
|
||||
list_objects = register_tools["list_objects"]
|
||||
result = await list_objects(doc_name="TestDoc")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "Box"
|
||||
assert result[0]["type_id"] == "Part::Box"
|
||||
assert result[0]["visibility"] is True
|
||||
assert result[1]["name"] == "Cylinder"
|
||||
assert result[1]["visibility"] is False
|
||||
mock_bridge.get_objects.assert_called_once_with("TestDoc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_object(self, register_tools, mock_bridge):
|
||||
"""inspect_object should return detailed object info."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
|
||||
shape_info={
|
||||
"shape_type": "Solid",
|
||||
"volume": 6000.0,
|
||||
"area": 2200.0,
|
||||
"is_valid": True,
|
||||
},
|
||||
visibility=True,
|
||||
children=["Fillet001"],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.get_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
inspect_object = register_tools["inspect_object"]
|
||||
result = await inspect_object(object_name="Box")
|
||||
|
||||
assert result["name"] == "Box"
|
||||
assert result["type_id"] == "Part::Box"
|
||||
assert result["properties"]["Length"] == 10.0
|
||||
assert result["shape_info"]["volume"] == 6000.0
|
||||
assert result["children"] == ["Fillet001"]
|
||||
mock_bridge.get_object.assert_called_once_with("Box", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_object_without_properties(self, register_tools, mock_bridge):
|
||||
"""inspect_object should exclude properties when not requested."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0},
|
||||
shape_info=None,
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.get_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
inspect_object = register_tools["inspect_object"]
|
||||
result = await inspect_object(
|
||||
object_name="Box", include_properties=False, include_shape=False
|
||||
)
|
||||
|
||||
assert result["name"] == "Box"
|
||||
assert "properties" not in result
|
||||
assert "shape_info" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_object(self, register_tools, mock_bridge):
|
||||
"""create_object should create and return object info."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="Box",
|
||||
type_id="Part::Box",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_object = register_tools["create_object"]
|
||||
result = await create_object(type_id="Part::Box", name="Box")
|
||||
|
||||
assert result["name"] == "Box"
|
||||
assert result["type_id"] == "Part::Box"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_object(self, register_tools, mock_bridge):
|
||||
"""edit_object should update object properties."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 20.0, "Width": 10.0},
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.edit_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
edit_object = register_tools["edit_object"]
|
||||
result = await edit_object(object_name="Box", properties={"Length": 20.0})
|
||||
|
||||
assert result["name"] == "Box"
|
||||
mock_bridge.edit_object.assert_called_once_with("Box", {"Length": 20.0}, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_object(self, register_tools, mock_bridge):
|
||||
"""delete_object should delete and return success."""
|
||||
mock_bridge.delete_object = AsyncMock(return_value=True)
|
||||
|
||||
delete_object = register_tools["delete_object"]
|
||||
result = await delete_object(object_name="Box")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.delete_object.assert_called_once_with("Box", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_box(self, register_tools, mock_bridge):
|
||||
"""create_box should create a box primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Box",
|
||||
label="Box",
|
||||
type_id="Part::Box",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_box = register_tools["create_box"]
|
||||
result = await create_box(length=20.0, width=10.0, height=5.0)
|
||||
|
||||
assert result["name"] == "Box"
|
||||
assert result["volume"] == 20.0 * 10.0 * 5.0
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cylinder(self, register_tools, mock_bridge):
|
||||
"""create_cylinder should create a cylinder primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Cylinder",
|
||||
label="Cylinder",
|
||||
type_id="Part::Cylinder",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_cylinder = register_tools["create_cylinder"]
|
||||
result = await create_cylinder(radius=5.0, height=20.0)
|
||||
|
||||
assert result["name"] == "Cylinder"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sphere(self, register_tools, mock_bridge):
|
||||
"""create_sphere should create a sphere primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Sphere",
|
||||
label="Sphere",
|
||||
type_id="Part::Sphere",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_sphere = register_tools["create_sphere"]
|
||||
result = await create_sphere(radius=10.0)
|
||||
|
||||
assert result["name"] == "Sphere"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cone(self, register_tools, mock_bridge):
|
||||
"""create_cone should create a cone primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Cone",
|
||||
label="Cone",
|
||||
type_id="Part::Cone",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_cone = register_tools["create_cone"]
|
||||
result = await create_cone(radius1=10.0, radius2=0.0, height=20.0)
|
||||
|
||||
assert result["name"] == "Cone"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_torus(self, register_tools, mock_bridge):
|
||||
"""create_torus should create a torus primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Torus",
|
||||
label="Torus",
|
||||
type_id="Part::Torus",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_torus = register_tools["create_torus"]
|
||||
result = await create_torus(radius1=20.0, radius2=5.0)
|
||||
|
||||
assert result["name"] == "Torus"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_wedge(self, register_tools, mock_bridge):
|
||||
"""create_wedge should create a wedge primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Wedge",
|
||||
label="Wedge",
|
||||
type_id="Part::Wedge",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_wedge = register_tools["create_wedge"]
|
||||
result = await create_wedge()
|
||||
|
||||
assert result["name"] == "Wedge"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_helix(self, register_tools, mock_bridge):
|
||||
"""create_helix should create a helix primitive via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Helix",
|
||||
label="Helix",
|
||||
type_id="Part::Helix",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_helix = register_tools["create_helix"]
|
||||
result = await create_helix(pitch=5.0, height=20.0)
|
||||
|
||||
assert result["name"] == "Helix"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
# Tests for execute_python based tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boolean_operation_fuse(self, register_tools, mock_bridge):
|
||||
"""boolean_operation should perform union operation via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Fusion",
|
||||
"label": "Fusion",
|
||||
"type_id": "Part::MultiFuse",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
boolean_operation = register_tools["boolean_operation"]
|
||||
result = await boolean_operation(
|
||||
operation="fuse", object1_name="Box", object2_name="Cylinder"
|
||||
)
|
||||
|
||||
assert result["name"] == "Fusion"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_placement(self, register_tools, mock_bridge):
|
||||
"""set_placement should set position and rotation via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"position": [10.0, 20.0, 30.0], "rotation": [0.0, 0.0, 45.0]},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_placement = register_tools["set_placement"]
|
||||
result = await set_placement(object_name="Box", position=[10.0, 20.0, 30.0])
|
||||
|
||||
assert result["position"] == [10.0, 20.0, 30.0]
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scale_object(self, register_tools, mock_bridge):
|
||||
"""scale_object should scale an object via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "ScaledBox",
|
||||
"label": "ScaledBox",
|
||||
"type_id": "Part::Feature",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
scale_object = register_tools["scale_object"]
|
||||
result = await scale_object(object_name="Box", scale=2.0)
|
||||
|
||||
assert result["name"] == "ScaledBox"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_object(self, register_tools, mock_bridge):
|
||||
"""rotate_object should rotate an object via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"position": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 45.0]},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
rotate_object = register_tools["rotate_object"]
|
||||
result = await rotate_object(
|
||||
object_name="Box", axis=[0.0, 0.0, 1.0], angle=45.0
|
||||
)
|
||||
|
||||
assert result["rotation"] == [0.0, 0.0, 45.0]
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_object(self, register_tools, mock_bridge):
|
||||
"""copy_object should create a copy via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"name": "Box001", "label": "Box001", "type_id": "Part::Box"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
copy_object = register_tools["copy_object"]
|
||||
result = await copy_object(object_name="Box")
|
||||
|
||||
assert result["name"] == "Box001"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirror_object(self, register_tools, mock_bridge):
|
||||
"""mirror_object should mirror across a plane via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "MirroredBox",
|
||||
"label": "MirroredBox",
|
||||
"type_id": "Part::Feature",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
mirror_object = register_tools["mirror_object"]
|
||||
result = await mirror_object(object_name="Box", plane="XY")
|
||||
|
||||
assert result["name"] == "MirroredBox"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_selection(self, register_tools, mock_bridge):
|
||||
"""get_selection should return selected objects via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=[
|
||||
{
|
||||
"name": "Box",
|
||||
"label": "Box",
|
||||
"type_id": "Part::Box",
|
||||
"sub_elements": ["Face1"],
|
||||
}
|
||||
],
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
get_selection = register_tools["get_selection"]
|
||||
result = await get_selection()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Box"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_selection(self, register_tools, mock_bridge):
|
||||
"""set_selection should select objects via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "selected_count": 2},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_selection = register_tools["set_selection"]
|
||||
result = await set_selection(object_names=["Box", "Cylinder"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["selected_count"] == 2
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_selection(self, register_tools, mock_bridge):
|
||||
"""clear_selection should clear selections via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
clear_selection = register_tools["clear_selection"]
|
||||
result = await clear_selection()
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Tests for PartDesign tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ExecutionResult, ObjectInfo
|
||||
|
||||
|
||||
class TestPartDesignTools:
|
||||
"""Tests for PartDesign tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register PartDesign tools and return the registered functions."""
|
||||
from freecad_mcp.tools.partdesign import register_partdesign_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_partdesign_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_partdesign_body(self, register_tools, mock_bridge):
|
||||
"""create_partdesign_body should create a body container via create_object."""
|
||||
mock_object = ObjectInfo(
|
||||
name="Body",
|
||||
label="Body",
|
||||
type_id="PartDesign::Body",
|
||||
visibility=True,
|
||||
children=[],
|
||||
parents=[],
|
||||
)
|
||||
mock_bridge.create_object = AsyncMock(return_value=mock_object)
|
||||
|
||||
create_body = register_tools["create_partdesign_body"]
|
||||
result = await create_body(name="Body")
|
||||
|
||||
assert result["name"] == "Body"
|
||||
assert result["type_id"] == "PartDesign::Body"
|
||||
mock_bridge.create_object.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sketch(self, register_tools, mock_bridge):
|
||||
"""create_sketch should create a sketch via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Sketch",
|
||||
"label": "Sketch",
|
||||
"type_id": "Sketcher::SketchObject",
|
||||
"support": "XY_Plane",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
create_sketch = register_tools["create_sketch"]
|
||||
result = await create_sketch(body_name="Body", plane="XY_Plane")
|
||||
|
||||
assert result["name"] == "Sketch"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sketch_rectangle(self, register_tools, mock_bridge):
|
||||
"""add_sketch_rectangle should add a rectangle via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"constraint_count": 8, "geometry_count": 4},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
add_rectangle = register_tools["add_sketch_rectangle"]
|
||||
result = await add_rectangle(
|
||||
sketch_name="Sketch", x=-10, y=-10, width=20, height=20
|
||||
)
|
||||
|
||||
assert result["constraint_count"] == 8
|
||||
assert result["geometry_count"] == 4
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sketch_circle(self, register_tools, mock_bridge):
|
||||
"""add_sketch_circle should add a circle via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"geometry_index": 0, "geometry_count": 1},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
add_circle = register_tools["add_sketch_circle"]
|
||||
result = await add_circle(
|
||||
sketch_name="Sketch", center_x=0, center_y=0, radius=10
|
||||
)
|
||||
|
||||
assert result["geometry_index"] == 0
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sketch_line(self, register_tools, mock_bridge):
|
||||
"""add_sketch_line should add a line via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"geometry_index": 0, "geometry_count": 1},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
add_line = register_tools["add_sketch_line"]
|
||||
result = await add_line(sketch_name="Sketch", x1=0, y1=0, x2=10, y2=10)
|
||||
|
||||
assert result["geometry_index"] == 0
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sketch_arc(self, register_tools, mock_bridge):
|
||||
"""add_sketch_arc should add an arc via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"geometry_index": 0, "geometry_count": 1},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
add_arc = register_tools["add_sketch_arc"]
|
||||
result = await add_arc(
|
||||
sketch_name="Sketch",
|
||||
center_x=0,
|
||||
center_y=0,
|
||||
radius=10,
|
||||
start_angle=0,
|
||||
end_angle=90,
|
||||
)
|
||||
|
||||
assert result["geometry_index"] == 0
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sketch_point(self, register_tools, mock_bridge):
|
||||
"""add_sketch_point should add a point via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"geometry_index": 0, "geometry_count": 1},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
add_point = register_tools["add_sketch_point"]
|
||||
result = await add_point(sketch_name="Sketch", x=5, y=5)
|
||||
|
||||
assert result["geometry_index"] == 0
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pad_sketch(self, register_tools, mock_bridge):
|
||||
"""pad_sketch should extrude a sketch via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"name": "Pad", "label": "Pad", "type_id": "PartDesign::Pad"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
pad_sketch = register_tools["pad_sketch"]
|
||||
result = await pad_sketch(sketch_name="Sketch", length=10)
|
||||
|
||||
assert result["name"] == "Pad"
|
||||
assert result["type_id"] == "PartDesign::Pad"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pocket_sketch(self, register_tools, mock_bridge):
|
||||
"""pocket_sketch should cut into solid via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Pocket",
|
||||
"label": "Pocket",
|
||||
"type_id": "PartDesign::Pocket",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
pocket_sketch = register_tools["pocket_sketch"]
|
||||
result = await pocket_sketch(sketch_name="Sketch", length=5)
|
||||
|
||||
assert result["name"] == "Pocket"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revolution_sketch(self, register_tools, mock_bridge):
|
||||
"""revolution_sketch should revolve a sketch via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Revolution",
|
||||
"label": "Revolution",
|
||||
"type_id": "PartDesign::Revolution",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
revolution = register_tools["revolution_sketch"]
|
||||
result = await revolution(sketch_name="Sketch", angle=360)
|
||||
|
||||
assert result["name"] == "Revolution"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_groove_sketch(self, register_tools, mock_bridge):
|
||||
"""groove_sketch should cut by revolving via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Groove",
|
||||
"label": "Groove",
|
||||
"type_id": "PartDesign::Groove",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
groove = register_tools["groove_sketch"]
|
||||
result = await groove(sketch_name="Sketch", angle=180)
|
||||
|
||||
assert result["name"] == "Groove"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fillet_edges(self, register_tools, mock_bridge):
|
||||
"""fillet_edges should add rounded edges via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Fillet",
|
||||
"label": "Fillet",
|
||||
"type_id": "PartDesign::Fillet",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
fillet = register_tools["fillet_edges"]
|
||||
result = await fillet(object_name="Pad", radius=2.0)
|
||||
|
||||
assert result["name"] == "Fillet"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chamfer_edges(self, register_tools, mock_bridge):
|
||||
"""chamfer_edges should add beveled edges via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Chamfer",
|
||||
"label": "Chamfer",
|
||||
"type_id": "PartDesign::Chamfer",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
chamfer = register_tools["chamfer_edges"]
|
||||
result = await chamfer(object_name="Pad", size=1.0)
|
||||
|
||||
assert result["name"] == "Chamfer"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_hole(self, register_tools, mock_bridge):
|
||||
"""create_hole should create parametric holes via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"name": "Hole", "label": "Hole", "type_id": "PartDesign::Hole"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
create_hole = register_tools["create_hole"]
|
||||
result = await create_hole(sketch_name="HoleSketch", diameter=6.0, depth=10.0)
|
||||
|
||||
assert result["name"] == "Hole"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linear_pattern(self, register_tools, mock_bridge):
|
||||
"""linear_pattern should create linear pattern via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "LinearPattern",
|
||||
"label": "LinearPattern",
|
||||
"type_id": "PartDesign::LinearPattern",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
pattern = register_tools["linear_pattern"]
|
||||
result = await pattern(
|
||||
feature_name="Pad", direction="X", length=50, occurrences=5
|
||||
)
|
||||
|
||||
assert result["name"] == "LinearPattern"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polar_pattern(self, register_tools, mock_bridge):
|
||||
"""polar_pattern should create circular pattern via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "PolarPattern",
|
||||
"label": "PolarPattern",
|
||||
"type_id": "PartDesign::PolarPattern",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
pattern = register_tools["polar_pattern"]
|
||||
result = await pattern(feature_name="Pad", axis="Z", angle=360, occurrences=6)
|
||||
|
||||
assert result["name"] == "PolarPattern"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirrored_feature(self, register_tools, mock_bridge):
|
||||
"""mirrored_feature should mirror a feature via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Mirrored",
|
||||
"label": "Mirrored",
|
||||
"type_id": "PartDesign::Mirrored",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
mirrored = register_tools["mirrored_feature"]
|
||||
result = await mirrored(feature_name="Pad", plane="XY")
|
||||
|
||||
assert result["name"] == "Mirrored"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loft_sketches(self, register_tools, mock_bridge):
|
||||
"""loft_sketches should create a loft via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Loft",
|
||||
"label": "Loft",
|
||||
"type_id": "PartDesign::AdditiveLoft",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=25.0,
|
||||
)
|
||||
)
|
||||
|
||||
loft = register_tools["loft_sketches"]
|
||||
result = await loft(sketch_names=["Sketch", "Sketch001"])
|
||||
|
||||
assert result["name"] == "Loft"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_sketch(self, register_tools, mock_bridge):
|
||||
"""sweep_sketch should sweep a profile via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Sweep",
|
||||
"label": "Sweep",
|
||||
"type_id": "PartDesign::AdditivePipe",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=25.0,
|
||||
)
|
||||
)
|
||||
|
||||
sweep = register_tools["sweep_sketch"]
|
||||
result = await sweep(profile_sketch="Profile", spine_sketch="Spine")
|
||||
|
||||
assert result["name"] == "Sweep"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Tests for view and GUI tools module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import ExecutionResult, ScreenshotResult, WorkbenchInfo
|
||||
|
||||
|
||||
class TestViewTools:
|
||||
"""Tests for view and GUI tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self):
|
||||
"""Create a mock MCP server that captures tool registrations."""
|
||||
mcp = MagicMock()
|
||||
mcp._registered_tools = {}
|
||||
|
||||
def tool_decorator():
|
||||
def wrapper(func):
|
||||
mcp._registered_tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
mcp.tool = tool_decorator
|
||||
return mcp
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge(self):
|
||||
"""Create a mock FreeCAD bridge."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def register_tools(self, mock_mcp, mock_bridge):
|
||||
"""Register view tools and return the registered functions."""
|
||||
from freecad_mcp.tools.view import register_view_tools
|
||||
|
||||
async def get_bridge():
|
||||
return mock_bridge
|
||||
|
||||
register_view_tools(mock_mcp, get_bridge)
|
||||
return mock_mcp._registered_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_screenshot_success(self, register_tools, mock_bridge):
|
||||
"""get_screenshot should return base64 image data."""
|
||||
# get_screenshot calls bridge.get_screenshot which returns ScreenshotResult
|
||||
mock_bridge.get_screenshot = AsyncMock(
|
||||
return_value=ScreenshotResult(
|
||||
success=True,
|
||||
data="iVBORw0KGgo...", # Base64 PNG data
|
||||
format="png",
|
||||
width=800,
|
||||
height=600,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
get_screenshot = register_tools["get_screenshot"]
|
||||
result = await get_screenshot(view_angle="Isometric")
|
||||
|
||||
assert result["success"] is True
|
||||
assert "data" in result
|
||||
assert result["format"] == "png"
|
||||
mock_bridge.get_screenshot.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_screenshot_custom_size(self, register_tools, mock_bridge):
|
||||
"""get_screenshot should accept width and height parameters."""
|
||||
mock_bridge.get_screenshot = AsyncMock(
|
||||
return_value=ScreenshotResult(
|
||||
success=True,
|
||||
data="...",
|
||||
format="png",
|
||||
width=1920,
|
||||
height=1080,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
|
||||
get_screenshot = register_tools["get_screenshot"]
|
||||
result = await get_screenshot(width=1920, height=1080)
|
||||
|
||||
assert result["width"] == 1920
|
||||
assert result["height"] == 1080
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_screenshot_headless_error(self, register_tools, mock_bridge):
|
||||
"""get_screenshot should return error in headless mode."""
|
||||
mock_bridge.get_screenshot = AsyncMock(
|
||||
return_value=ScreenshotResult(
|
||||
success=False,
|
||||
data=None,
|
||||
format="png",
|
||||
width=0,
|
||||
height=0,
|
||||
error="GUI not available - screenshot cannot be captured in headless mode",
|
||||
)
|
||||
)
|
||||
|
||||
get_screenshot = register_tools["get_screenshot"]
|
||||
result = await get_screenshot()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "headless" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_screenshot_invalid_view_angle(self, register_tools, mock_bridge):
|
||||
"""get_screenshot should return error for invalid view angle."""
|
||||
get_screenshot = register_tools["get_screenshot"]
|
||||
result = await get_screenshot(view_angle="InvalidAngle")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid view_angle" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_view_angle(self, register_tools, mock_bridge):
|
||||
"""set_view_angle should set the camera view via bridge.set_view."""
|
||||
mock_bridge.set_view = AsyncMock(return_value=None)
|
||||
|
||||
set_view_angle = register_tools["set_view_angle"]
|
||||
result = await set_view_angle(view_angle="Front")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.set_view.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_view_angle_invalid(self, register_tools, mock_bridge):
|
||||
"""set_view_angle should return error for invalid view angle."""
|
||||
set_view_angle = register_tools["set_view_angle"]
|
||||
result = await set_view_angle(view_angle="InvalidAngle")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid view_angle" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fit_all(self, register_tools, mock_bridge):
|
||||
"""fit_all should zoom to fit all objects via bridge.set_view."""
|
||||
mock_bridge.set_view = AsyncMock(return_value=None)
|
||||
|
||||
fit_all = register_tools["fit_all"]
|
||||
result = await fit_all()
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.set_view.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_visibility(self, register_tools, mock_bridge):
|
||||
"""set_object_visibility should show/hide objects via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "visible": False},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_visibility = register_tools["set_object_visibility"]
|
||||
result = await set_visibility(object_name="Box", visible=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["visible"] is False
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_visibility_headless(self, register_tools, mock_bridge):
|
||||
"""set_object_visibility should return error in headless mode."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": False,
|
||||
"error": "GUI not available - visibility cannot be set in headless mode",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_visibility = register_tools["set_object_visibility"]
|
||||
result = await set_visibility(object_name="Box", visible=True)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "headless" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_display_mode(self, register_tools, mock_bridge):
|
||||
"""set_display_mode should change display mode via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "mode": "Wireframe"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_mode = register_tools["set_display_mode"]
|
||||
result = await set_mode(object_name="Box", mode="Wireframe")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "Wireframe"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_color(self, register_tools, mock_bridge):
|
||||
"""set_object_color should change object color via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "color": [1.0, 0.0, 0.0]},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_color = register_tools["set_object_color"]
|
||||
result = await set_color(object_name="Box", color=[1.0, 0.0, 0.0])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["color"] == [1.0, 0.0, 0.0] # Red
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_object_color_invalid_color(self, register_tools, mock_bridge):
|
||||
"""set_object_color should validate color array length."""
|
||||
set_color = register_tools["set_object_color"]
|
||||
result = await set_color(object_name="Box", color=[1.0, 0.0]) # Missing blue
|
||||
|
||||
assert result["success"] is False
|
||||
assert "must be [r, g, b]" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workbenches(self, register_tools, mock_bridge):
|
||||
"""list_workbenches should return available workbenches."""
|
||||
mock_workbenches = [
|
||||
WorkbenchInfo(
|
||||
name="PartDesignWorkbench",
|
||||
label="Part Design",
|
||||
icon="",
|
||||
is_active=True,
|
||||
),
|
||||
WorkbenchInfo(
|
||||
name="SketcherWorkbench",
|
||||
label="Sketcher",
|
||||
icon="",
|
||||
is_active=False,
|
||||
),
|
||||
]
|
||||
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
|
||||
|
||||
list_workbenches = register_tools["list_workbenches"]
|
||||
result = await list_workbenches()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "PartDesignWorkbench"
|
||||
assert result[0]["is_active"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_workbench(self, register_tools, mock_bridge):
|
||||
"""activate_workbench should switch to a workbench."""
|
||||
mock_bridge.activate_workbench = AsyncMock(return_value=None)
|
||||
|
||||
activate = register_tools["activate_workbench"]
|
||||
result = await activate(workbench_name="SketcherWorkbench")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.activate_workbench.assert_called_once_with("SketcherWorkbench")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zoom_in(self, register_tools, mock_bridge):
|
||||
"""zoom_in should increase zoom level via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
zoom_in = register_tools["zoom_in"]
|
||||
result = await zoom_in(factor=2.0)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zoom_out(self, register_tools, mock_bridge):
|
||||
"""zoom_out should decrease zoom level via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
zoom_out = register_tools["zoom_out"]
|
||||
result = await zoom_out(factor=2.0)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_camera_position(self, register_tools, mock_bridge):
|
||||
"""set_camera_position should set camera location via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=15.0,
|
||||
)
|
||||
)
|
||||
|
||||
set_camera = register_tools["set_camera_position"]
|
||||
result = await set_camera(
|
||||
position=[100.0, 100.0, 100.0], look_at=[0.0, 0.0, 0.0]
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_undo(self, register_tools, mock_bridge):
|
||||
"""undo should undo the last operation via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "can_undo": True},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
undo = register_tools["undo"]
|
||||
result = await undo()
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redo(self, register_tools, mock_bridge):
|
||||
"""redo should redo an undone operation via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "can_redo": False},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
redo = register_tools["redo"]
|
||||
result = await redo()
|
||||
|
||||
assert result["success"] is True
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_undo_redo_status(self, register_tools, mock_bridge):
|
||||
"""get_undo_redo_status should return available operations via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"undo_count": 5,
|
||||
"redo_count": 2,
|
||||
"undo_names": ["Create Box", "Edit Box", "Create Fillet"],
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
get_status = register_tools["get_undo_redo_status"]
|
||||
result = await get_status()
|
||||
|
||||
assert result["undo_count"] == 5
|
||||
assert result["redo_count"] == 2
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_parts_library(self, register_tools, mock_bridge):
|
||||
"""list_parts_library should return available parts via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=[
|
||||
{
|
||||
"name": "bolt_m6.FCStd",
|
||||
"path": "/lib/bolt_m6.FCStd",
|
||||
"category": "Fasteners",
|
||||
},
|
||||
{
|
||||
"name": "nut_m6.FCStd",
|
||||
"path": "/lib/nut_m6.FCStd",
|
||||
"category": "Fasteners",
|
||||
},
|
||||
],
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=50.0,
|
||||
)
|
||||
)
|
||||
|
||||
list_parts = register_tools["list_parts_library"]
|
||||
result = await list_parts()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "bolt_m6.FCStd"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_parts_library_empty(self, register_tools, mock_bridge):
|
||||
"""list_parts_library should return empty list when no parts found."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result=[],
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=30.0,
|
||||
)
|
||||
)
|
||||
|
||||
list_parts = register_tools["list_parts_library"]
|
||||
result = await list_parts()
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_part_from_library(self, register_tools, mock_bridge):
|
||||
"""insert_part_from_library should insert a part via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"name": "Bolt",
|
||||
"label": "Bolt",
|
||||
"type_id": "Part::Feature",
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=100.0,
|
||||
)
|
||||
)
|
||||
|
||||
insert_part = register_tools["insert_part_from_library"]
|
||||
result = await insert_part(
|
||||
part_path="/lib/bolt_m6.FCStd", position=[10.0, 20.0, 0.0]
|
||||
)
|
||||
|
||||
assert result["name"] == "Bolt"
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_console_log(self, register_tools, mock_bridge):
|
||||
"""get_console_log should return console messages."""
|
||||
mock_bridge.get_console_output = AsyncMock(
|
||||
return_value=[
|
||||
"Info: Started",
|
||||
"Info: Complete",
|
||||
"Warning: Deprecated feature",
|
||||
]
|
||||
)
|
||||
|
||||
get_log = register_tools["get_console_log"]
|
||||
result = await get_log(lines=50)
|
||||
|
||||
assert len(result["messages"]) == 3
|
||||
assert len(result["warnings"]) == 1
|
||||
assert len(result["errors"]) == 0
|
||||
mock_bridge.get_console_output.assert_called_once_with(50)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_console_log_with_errors(self, register_tools, mock_bridge):
|
||||
"""get_console_log should categorize error messages."""
|
||||
mock_bridge.get_console_output = AsyncMock(
|
||||
return_value=[
|
||||
"Info: Started",
|
||||
"Error: Failed to load module",
|
||||
"Warning: Deprecated API",
|
||||
]
|
||||
)
|
||||
|
||||
get_log = register_tools["get_console_log"]
|
||||
result = await get_log()
|
||||
|
||||
assert len(result["messages"]) == 3
|
||||
assert len(result["errors"]) == 1
|
||||
assert "Failed to load module" in result["errors"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recompute(self, register_tools, mock_bridge):
|
||||
"""recompute should force document recomputation via execute_python."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={"success": True, "touch_count": 3},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
recompute = register_tools["recompute"]
|
||||
result = await recompute()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["touch_count"] == 3
|
||||
mock_bridge.execute_python.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recompute_no_document(self, register_tools, mock_bridge):
|
||||
"""recompute should handle no document gracefully."""
|
||||
mock_bridge.execute_python = AsyncMock(
|
||||
return_value=ExecutionResult(
|
||||
success=True,
|
||||
result={
|
||||
"success": False,
|
||||
"error": "No document found",
|
||||
"touch_count": 0,
|
||||
},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=5.0,
|
||||
)
|
||||
)
|
||||
|
||||
recompute = register_tools["recompute"]
|
||||
result = await recompute()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No document" in result["error"]
|
||||
Reference in New Issue
Block a user