* ci: add status badges to README * chore: major overhaul - docs, commands & workflows * fix: general fixes and improvements * chore: various updates and fixes * chore: minor fixes
627 lines
17 KiB
Python
627 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 freecad::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"
|
|
step_path_str = str(step_path)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
obj = doc.getObject("ExportBox")
|
|
obj.Shape.exportStep({step_path_str!r})
|
|
|
|
import os
|
|
_result_ = {{"exported": os.path.exists({step_path_str!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"
|
|
fcstd_path_str = str(fcstd_path)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
doc.saveAs({fcstd_path_str!r})
|
|
|
|
import os
|
|
_result_ = {{
|
|
"saved": os.path.exists({fcstd_path_str!r}),
|
|
"path": {fcstd_path_str!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
|