Files
freecad-robust-mcp-fc111/tests/integration/test_multi_export.py
T
Sean P. KaneandGitHub f14ab8177d Fix: rename workbench and prepare for release (#27)
* chore: rename workbench to FreecadRobustMCPBridge

* fix: stdio cleanup and bug fix for JSON RPC

* fix: update FreeCAD MCP bridge and tests

* test: Fix GUI Integration tests and other issues

* fix: unit tests in CI and a few other things

* docs: generate GitHub pages site and link to it.

* fix: General code improvements and DRY refactoring

* chore: General cleanup

* chore: small fixes

* docs: cleanup

* docs: fixes

* docs: small corrections

* docs: fix

* test: release check

* bump: workbench v0.6.0

* chore: bump macros to 0.6.0

* docs: Release improvements

* refactor: improve DRYness of code
2026-01-12 09:30:05 -08:00

397 lines
11 KiB
Python

"""Integration tests for the MultiExport macro.
These tests verify the MultiExporter class functionality including:
- Exporting to STL format
- Exporting to STEP format
- Exporting to multiple formats simultaneously
- Handling mesh tolerance settings
Note: These tests require a running FreeCAD Robust MCP Bridge.
Start it with: just freecad::run-gui or just freecad::run-headless
To run these tests:
pytest tests/integration/test_multi_export.py -v
"""
from __future__ import annotations
import os
import tempfile
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
import xmlrpc.client
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
"""Execute Python code via the MCP bridge and return the result."""
result = proxy.execute(code) # type: ignore[union-attr]
assert isinstance(result, dict), f"Unexpected result type: {type(result)}"
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
return result
# The MultiExporter class code embedded for testing
MULTI_EXPORTER_CODE = '''
import os
import FreeCAD as App
import Part
import Mesh
class MultiExporter:
"""Handles exporting objects to multiple formats."""
def __init__(self, objects, params):
"""Initialize the exporter."""
self.objects = objects
self.params = params
self.exported_files = []
self.errors = []
def export_all(self):
"""Export objects to all selected formats."""
formats = self.params["formats"]
if not formats:
return [], ["No formats selected"]
for fmt in formats:
try:
filepath = self._export_format(fmt)
self.exported_files.append(filepath)
except Exception as e:
self.errors.append(f"Failed to export {fmt.upper()}: {str(e)}")
return self.exported_files, self.errors
def _export_format(self, extension):
"""Export to a specific format."""
directory = self.params["directory"]
base_name = self.params["base_filename"]
filepath = os.path.join(directory, f"{base_name}.{extension}")
shapes = []
for obj in self.objects:
if hasattr(obj, "Shape"):
shapes.append(obj.Shape)
if not shapes:
raise ValueError("No exportable shapes found")
if len(shapes) == 1:
combined_shape = shapes[0]
else:
combined_shape = Part.makeCompound(shapes)
if extension == "stl":
self._export_mesh(combined_shape, filepath)
elif extension == "step":
combined_shape.exportStep(filepath)
elif extension == "brep":
combined_shape.exportBrep(filepath)
else:
raise ValueError(f"Unsupported format: {extension}")
return filepath
def _export_mesh(self, shape, filepath):
"""Export shape as mesh format."""
tolerance = self.params.get("mesh_tolerance", 0.1)
mesh = Mesh.Mesh()
vertices, facets = shape.tessellate(tolerance)
mesh_data = []
for facet in facets:
triangle = [vertices[facet[0]], vertices[facet[1]], vertices[facet[2]]]
mesh_data.append(triangle)
mesh.addFacets(mesh_data)
mesh.write(filepath)
'''
@pytest.fixture
def temp_export_dir():
"""Create a temporary directory for export tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
class TestMultiExporter:
"""Tests for the MultiExporter functionality."""
def test_export_stl(self, xmlrpc_proxy, temp_export_dir):
"""Test exporting a simple box to STL format."""
code = f"""
{MULTI_EXPORTER_CODE}
# Create a new document and simple box
doc = App.newDocument("TestExportSTL")
box = doc.addObject("Part::Box", "TestBox")
box.Length = 20
box.Width = 20
box.Height = 10
doc.recompute()
# Set up export parameters
params = {{
"directory": "{temp_export_dir}",
"base_filename": "test_box",
"formats": ["stl"],
"mesh_tolerance": 0.1,
}}
# Create exporter and export
exporter = MultiExporter([box], params)
exported_files, errors = exporter.export_all()
# Clean up
App.closeDocument("TestExportSTL")
_result_ = {{
"exported_files": exported_files,
"errors": errors,
"file_exists": os.path.exists(exported_files[0]) if exported_files else False,
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
assert len(data["exported_files"]) == 1
assert len(data["errors"]) == 0
assert data["file_exists"] is True
assert data["exported_files"][0].endswith(".stl")
def test_export_step(self, xmlrpc_proxy, temp_export_dir):
"""Test exporting a simple cylinder to STEP format."""
code = f"""
{MULTI_EXPORTER_CODE}
# Create a new document and simple cylinder
doc = App.newDocument("TestExportSTEP")
cylinder = doc.addObject("Part::Cylinder", "TestCylinder")
cylinder.Radius = 10
cylinder.Height = 30
doc.recompute()
# Set up export parameters
params = {{
"directory": "{temp_export_dir}",
"base_filename": "test_cylinder",
"formats": ["step"],
"mesh_tolerance": 0.1,
}}
# Create exporter and export
exporter = MultiExporter([cylinder], params)
exported_files, errors = exporter.export_all()
# Clean up
App.closeDocument("TestExportSTEP")
_result_ = {{
"exported_files": exported_files,
"errors": errors,
"file_exists": os.path.exists(exported_files[0]) if exported_files else False,
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
assert len(data["exported_files"]) == 1
assert len(data["errors"]) == 0
assert data["file_exists"] is True
assert data["exported_files"][0].endswith(".step")
def test_export_multiple_formats(self, xmlrpc_proxy, temp_export_dir):
"""Test exporting to multiple formats simultaneously."""
code = f"""
{MULTI_EXPORTER_CODE}
# Create a new document and simple sphere
doc = App.newDocument("TestExportMulti")
sphere = doc.addObject("Part::Sphere", "TestSphere")
sphere.Radius = 15
doc.recompute()
# Set up export parameters for multiple formats
params = {{
"directory": "{temp_export_dir}",
"base_filename": "test_sphere",
"formats": ["stl", "step", "brep"],
"mesh_tolerance": 0.1,
}}
# Create exporter and export
exporter = MultiExporter([sphere], params)
exported_files, errors = exporter.export_all()
# Check which files exist
files_exist = [os.path.exists(f) for f in exported_files]
# Clean up
App.closeDocument("TestExportMulti")
_result_ = {{
"exported_files": exported_files,
"errors": errors,
"files_exist": files_exist,
"count": len(exported_files),
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
assert data["count"] == 3
assert len(data["errors"]) == 0
assert all(data["files_exist"])
# Verify each format is present
extensions = [os.path.splitext(f)[1] for f in data["exported_files"]]
assert ".stl" in extensions
assert ".step" in extensions
assert ".brep" in extensions
def test_export_multiple_objects(self, xmlrpc_proxy, temp_export_dir):
"""Test exporting multiple objects as a compound."""
code = f"""
{MULTI_EXPORTER_CODE}
# Create a new document with multiple objects
doc = App.newDocument("TestExportMultiObj")
box = doc.addObject("Part::Box", "Box1")
box.Length = 10
box.Width = 10
box.Height = 10
cylinder = doc.addObject("Part::Cylinder", "Cyl1")
cylinder.Radius = 5
cylinder.Height = 20
cylinder.Placement.Base = App.Vector(20, 0, 0)
doc.recompute()
# Set up export parameters
params = {{
"directory": "{temp_export_dir}",
"base_filename": "test_compound",
"formats": ["stl"],
"mesh_tolerance": 0.1,
}}
# Create exporter with multiple objects
exporter = MultiExporter([box, cylinder], params)
exported_files, errors = exporter.export_all()
# Check file size (compound should be larger than single object)
file_size = os.path.getsize(exported_files[0]) if exported_files else 0
# Clean up
App.closeDocument("TestExportMultiObj")
_result_ = {{
"exported_files": exported_files,
"errors": errors,
"file_exists": os.path.exists(exported_files[0]) if exported_files else False,
"file_size": file_size,
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
assert len(data["exported_files"]) == 1
assert len(data["errors"]) == 0
assert data["file_exists"] is True
# Compound file should have some reasonable size
assert data["file_size"] > 100
def test_export_with_custom_tolerance(self, xmlrpc_proxy, temp_export_dir):
"""Test that mesh tolerance affects output file size.
Note: FreeCAD's tessellate function has internal limits, so we need
to use a very fine tolerance (0.01) to actually see more triangles
compared to the default tessellation.
"""
code = f"""
{MULTI_EXPORTER_CODE}
# Create a sphere (curved surface shows tolerance effect best)
doc = App.newDocument("TestTolerance")
sphere = doc.addObject("Part::Sphere", "TestSphere")
sphere.Radius = 20
doc.recompute()
# Export with coarse tolerance (uses default tessellation)
params_coarse = {{
"directory": "{temp_export_dir}",
"base_filename": "sphere_coarse",
"formats": ["stl"],
"mesh_tolerance": 1.0,
}}
exporter_coarse = MultiExporter([sphere], params_coarse)
coarse_files, _ = exporter_coarse.export_all()
coarse_size = os.path.getsize(coarse_files[0]) if coarse_files else 0
# Export with very fine tolerance (0.01 required to see difference)
params_fine = {{
"directory": "{temp_export_dir}",
"base_filename": "sphere_fine",
"formats": ["stl"],
"mesh_tolerance": 0.01,
}}
exporter_fine = MultiExporter([sphere], params_fine)
fine_files, _ = exporter_fine.export_all()
fine_size = os.path.getsize(fine_files[0]) if fine_files else 0
# Clean up
App.closeDocument("TestTolerance")
_result_ = {{
"coarse_size": coarse_size,
"fine_size": fine_size,
"fine_is_larger": fine_size > coarse_size,
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
# Fine tolerance (0.01) should produce larger file (more triangles)
assert data["fine_is_larger"] is True
assert data["fine_size"] > data["coarse_size"]
def test_export_empty_formats_list(self, xmlrpc_proxy, temp_export_dir):
"""Test that empty formats list returns appropriate error."""
code = f"""
{MULTI_EXPORTER_CODE}
doc = App.newDocument("TestEmptyFormats")
box = doc.addObject("Part::Box", "TestBox")
doc.recompute()
params = {{
"directory": "{temp_export_dir}",
"base_filename": "test",
"formats": [],
"mesh_tolerance": 0.1,
}}
exporter = MultiExporter([box], params)
exported_files, errors = exporter.export_all()
App.closeDocument("TestEmptyFormats")
_result_ = {{
"exported_files": exported_files,
"errors": errors,
}}
"""
result = execute_code(xmlrpc_proxy, code)
data = result.get("result", {})
assert len(data["exported_files"]) == 0
assert len(data["errors"]) == 1
assert "No formats selected" in data["errors"][0]