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