* 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
550 lines
19 KiB
Python
550 lines
19 KiB
Python
"""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"]
|