Files
freecad-robust-mcp-fc111/tests/integration/test_headless_mode.py
T

625 lines
17 KiB
Python

"""Integration tests for FreeCAD MCP headless mode.
These tests verify that the MCP bridge works correctly when FreeCAD is running
in headless mode (without GUI). They test object creation, manipulation, and
export functionality.
Note: These tests require a running FreeCAD headless server.
Start it with: just run-headless
To run these tests:
pytest tests/integration/test_headless_mode.py -v
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
# Note: xmlrpc_proxy fixture is defined in conftest.py
@pytest.fixture(scope="module")
def temp_dir() -> Generator[str, None, None]:
"""Create a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
"""Execute Python code via the MCP bridge and return the result."""
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
return result
class TestHeadlessConnection:
"""Tests for basic headless mode connectivity."""
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test that the server responds to ping."""
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
assert result["pong"] is True
assert "timestamp" in result
def test_headless_mode_detected(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
_result_ = {"gui_up": FreeCAD.GuiUp}
""",
)
# In headless mode, GuiUp should be False (0)
assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False
class TestDocumentManagement:
"""Tests for document creation and management in headless mode."""
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a new document."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.newDocument("TestDoc")
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
""",
)
assert result["result"]["name"] == "TestDoc"
assert result["result"]["object_count"] == 0
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test listing open documents."""
# First create a document
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
if not FreeCAD.listDocuments():
FreeCAD.newDocument("ListTestDoc")
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
docs = list(FreeCAD.listDocuments().keys())
_result_ = {"documents": docs, "count": len(docs)}
""",
)
assert result["result"]["count"] >= 1
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test closing a document."""
# Create a document to close
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.newDocument("ToClose")
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
FreeCAD.closeDocument("ToClose")
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
""",
)
assert result["result"]["closed"] is True
class TestPrimitiveCreation:
"""Tests for creating primitive shapes in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document for each test."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
# Close any existing PrimitiveTestDoc
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("PrimitiveTestDoc")
doc = FreeCAD.newDocument("PrimitiveTestDoc")
_result_ = True
""",
)
def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Box primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
box = Part.makeBox(10, 20, 30)
obj = doc.addObject("Part::Feature", "TestBox")
obj.Shape = box
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestBox"
# Volume should be 10 * 20 * 30 = 6000
assert abs(result["result"]["volume"] - 6000.0) < 0.01
assert result["result"]["valid"] is True
def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Cylinder primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
import math
doc = FreeCAD.ActiveDocument
cylinder = Part.makeCylinder(5, 20)
obj = doc.addObject("Part::Feature", "TestCylinder")
obj.Shape = cylinder
doc.recompute()
expected_volume = math.pi * 5**2 * 20
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"expected_volume": expected_volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestCylinder"
# Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8
assert (
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1
)
assert result["result"]["valid"] is True
def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Sphere primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
import math
doc = FreeCAD.ActiveDocument
sphere = Part.makeSphere(10)
obj = doc.addObject("Part::Feature", "TestSphere")
obj.Shape = sphere
doc.recompute()
expected_volume = (4/3) * math.pi * 10**3
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"expected_volume": expected_volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestSphere"
# Volume should be (4/3) * π * r³ ≈ 4188.79
assert (
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0
)
assert result["result"]["valid"] is True
def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Cone primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
obj = doc.addObject("Part::Feature", "TestCone")
obj.Shape = cone
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestCone"
# Volume of a cone = (1/3) * π * r² * h ≈ 2094.4
assert result["result"]["volume"] > 2000
assert result["result"]["valid"] is True
def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Torus primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
obj = doc.addObject("Part::Feature", "TestTorus")
obj.Shape = torus
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestTorus"
assert result["result"]["volume"] > 0
assert result["result"]["valid"] is True
class TestBooleanOperations:
"""Tests for boolean operations in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document for each test."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
if "BooleanTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("BooleanTestDoc")
doc = FreeCAD.newDocument("BooleanTestDoc")
_result_ = True
""",
)
def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test boolean fuse (union) operation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
# Create two overlapping boxes
box1 = Part.makeBox(10, 10, 10)
box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0))
obj1 = doc.addObject("Part::Feature", "Box1")
obj1.Shape = box1
obj2 = doc.addObject("Part::Feature", "Box2")
obj2.Shape = box2
# Fuse them
fused = box1.fuse(box2)
obj_fused = doc.addObject("Part::Feature", "Fused")
obj_fused.Shape = fused
doc.recompute()
# Fused volume should be less than 2000 (two boxes) due to overlap
_result_ = {
"fused_volume": obj_fused.Shape.Volume,
"valid": obj_fused.Shape.isValid()
}
""",
)
# Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500
assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01)
assert result["result"]["valid"] is True
def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test boolean cut (subtract) operation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
# Create a box and a cylinder to cut from it
box = Part.makeBox(20, 20, 20)
cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
# Cut cylinder from box
cut = box.cut(cylinder)
obj_cut = doc.addObject("Part::Feature", "Cut")
obj_cut.Shape = cut
doc.recompute()
box_volume = 20 * 20 * 20
import math
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
_result_ = {
"cut_volume": obj_cut.Shape.Volume,
"expected_volume": box_volume - cylinder_volume_in_box,
"valid": obj_cut.Shape.isValid()
}
""",
)
# Volume should be box - cylinder intersection
assert (
abs(result["result"]["cut_volume"] - result["result"]["expected_volume"])
< 10
)
assert result["result"]["valid"] is True
class TestObjectManipulation:
"""Tests for object manipulation in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document with a test box."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
if "ManipTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ManipTestDoc")
doc = FreeCAD.newDocument("ManipTestDoc")
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ManipBox")
obj.Shape = box
doc.recompute()
_result_ = True
""",
)
def test_set_placement_position(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test setting object position."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ManipBox")
# Move the object
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
_result_ = {
"x": obj.Placement.Base.x,
"y": obj.Placement.Base.y,
"z": obj.Placement.Base.z
}
""",
)
assert result["result"]["x"] == 100.0
assert result["result"]["y"] == 200.0
assert result["result"]["z"] == 300.0
def test_set_placement_rotation(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test setting object rotation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ManipBox")
# Rotate the object 45 degrees around Z axis
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
doc.recompute()
euler = obj.Placement.Rotation.toEuler()
_result_ = {
"rotation_z": euler[0], # Yaw
"valid": obj.Shape.isValid()
}
""",
)
# Check rotation is approximately 45 degrees
assert abs(result["result"]["rotation_z"] - 45.0) < 0.1
assert result["result"]["valid"] is True
class TestExportOperations:
"""Tests for export functionality in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Create a document with objects for export tests."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
if "ExportTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ExportTestDoc")
doc = FreeCAD.newDocument("ExportTestDoc")
# Create a simple box
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ExportBox")
obj.Shape = box
doc.recompute()
_result_ = True
""",
)
self.temp_dir = temp_dir
def test_export_step(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Test exporting to STEP format."""
step_path = Path(temp_dir) / "test_export.step"
result = execute_code(
xmlrpc_proxy,
f"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ExportBox")
obj.Shape.exportStep({step_path!r})
import os
_result_ = {{"exported": os.path.exists({step_path!r})}}
""",
)
assert result["result"]["exported"] is True
assert step_path.exists()
def test_save_fcstd(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Test saving as FreeCAD native format."""
fcstd_path = Path(temp_dir) / "test_save.FCStd"
result = execute_code(
xmlrpc_proxy,
f"""
import FreeCAD
doc = FreeCAD.ActiveDocument
doc.saveAs({fcstd_path!r})
import os
_result_ = {{
"saved": os.path.exists({fcstd_path!r}),
"path": {fcstd_path!r}
}}
""",
)
assert result["result"]["saved"] is True
assert fcstd_path.exists()
class TestGUIFeaturesInHeadless:
"""Tests to verify GUI features fail gracefully in headless mode."""
def test_gui_features_not_available(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that GUI-only features return appropriate errors."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
gui_available = FreeCAD.GuiUp
if not gui_available:
_result_ = {
"success": False,
"error": "GUI not available in headless mode",
"gui_up": False
}
else:
_result_ = {
"success": True,
"gui_up": True
}
""",
)
# In headless mode, GUI should not be available
assert result["result"]["gui_up"] is False
class TestComplexWorkflow:
"""Tests for complex multi-step workflows in headless mode."""
def test_parametric_modeling_workflow(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test a complete parametric modeling workflow."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
# Create document
if "WorkflowTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("WorkflowTestDoc")
doc = FreeCAD.newDocument("WorkflowTestDoc")
# Step 1: Create base box
base_box = Part.makeBox(50, 50, 10)
base_obj = doc.addObject("Part::Feature", "Base")
base_obj.Shape = base_box
# Step 2: Create cylinder to cut
cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5))
# Step 3: Cut hole in base
result_shape = base_box.cut(cylinder)
final_obj = doc.addObject("Part::Feature", "BaseWithHole")
final_obj.Shape = result_shape
# Step 4: Add fillet (rounded edges) - using Part API
# Select edges and apply fillet
try:
filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4])
fillet_obj = doc.addObject("Part::Feature", "Filleted")
fillet_obj.Shape = filleted
fillet_success = True
except Exception:
fillet_success = False
doc.recompute()
_result_ = {
"objects": [obj.Name for obj in doc.Objects],
"final_volume": final_obj.Shape.Volume,
"final_valid": final_obj.Shape.isValid(),
"fillet_success": fillet_success
}
""",
)
assert "Base" in result["result"]["objects"]
assert "BaseWithHole" in result["result"]["objects"]
assert result["result"]["final_valid"] is True
# Volume should be base - cylinder hole
assert result["result"]["final_volume"] > 0