* 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
526 lines
18 KiB
Python
526 lines
18 KiB
Python
"""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()
|