Files
freecad-robust-mcp-fc111/tests/unit/test_tools_view.py
T
Sean P. KaneandGitHub 6a6cad9e65 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
2026-01-06 15:44:27 -08:00

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"]