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:
Sean P. Kane
2026-01-06 15:44:27 -08:00
committed by GitHub
parent 4344ff7536
commit 6a6cad9e65
64 changed files with 8317 additions and 2245 deletions
+1
View File
@@ -0,0 +1 @@
"""Unit tests for the FreeCAD Robust MCP workbench addon."""
+187
View File
@@ -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
+439
View File
@@ -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
+290
View File
@@ -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()
+283
View File
@@ -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"
+275
View File
@@ -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
+304
View File
@@ -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"
+390
View File
@@ -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)
+525
View File
@@ -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()
+465
View File
@@ -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()
+549
View File
@@ -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"]