Refactor (#37)

* refactor: remove macros

* refactor: additional cleanup

* chore: small cleanup

* refactor: update .coderabbit.yaml configuration

* docs: update CLAUDE.md with new documentation

* docs: table alignment
This commit is contained in:
Sean P. Kane
2026-01-17 21:24:37 -08:00
committed by GitHub
parent 62f6f4dbcf
commit 47e80f1bc7
46 changed files with 356 additions and 7425 deletions
File diff suppressed because it is too large Load Diff
-396
View File
@@ -1,396 +0,0 @@
"""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]
+22 -6
View File
@@ -23,13 +23,9 @@ class TestInstallSyntax:
"install::uninstall-mcp-server",
"install::mcp-bridge-workbench",
"install::uninstall-mcp-bridge-workbench",
"install::macro-cut",
"install::uninstall-macro-cut",
"install::macro-export",
"install::uninstall-macro-export",
"install::macro-all",
"install::uninstall-macro-all",
"install::status",
"install::uninstall",
"install::cleanup",
]
@pytest.mark.just_syntax
@@ -77,3 +73,23 @@ class TestInstallRuntime:
assert uninstall_result.success, (
f"MCP server uninstall failed: {uninstall_result.stderr}"
)
@pytest.mark.just_runtime
def test_uninstall_runs(self, just: JustRunner) -> None:
"""Uninstall command should run without error.
This command uninstalls all components. It may complete with warnings
if nothing is installed, but should not error.
"""
result = just.run("install::uninstall", timeout=120)
# Command should succeed even if nothing was installed
assert result.success, f"Uninstall failed: {result.stderr}"
@pytest.mark.just_runtime
def test_cleanup_runs(self, just: JustRunner) -> None:
"""Cleanup command should run without error.
This command cleans up caches and temporary files.
"""
result = just.run("install::cleanup", timeout=60)
assert result.success, f"Cleanup failed: {result.stderr}"
+9 -5
View File
@@ -63,11 +63,17 @@ class TestModulesExist:
# Map of modules to their expected commands (subset for validation)
EXPECTED_COMMANDS: ClassVar[dict[str, list[str]]] = {
"quality": ["check", "format", "lint", "typecheck", "security"],
"testing": ["unit", "cov", "fast", "integration"],
"testing": ["unit", "cov", "quick", "integration"],
"dev": ["install-deps", "install-pre-commit", "clean"],
"docker": ["build", "run", "clean"],
"documentation": ["build", "serve", "open"],
"install": ["mcp-server", "mcp-bridge-workbench", "status"],
"install": [
"mcp-server",
"mcp-bridge-workbench",
"status",
"uninstall",
"cleanup",
],
"mcp": ["run", "check"],
"freecad": ["run-gui", "run-headless"],
"release": ["status", "list-tags", "latest-versions"],
@@ -110,7 +116,7 @@ class TestSyntaxValidation:
# Testing commands
"testing::unit",
"testing::cov",
"testing::fast",
"testing::quick",
"testing::verbose",
# Dev commands
"dev::install-deps",
@@ -131,8 +137,6 @@ class TestSyntaxValidation:
# Install commands
"install::mcp-server",
"install::mcp-bridge-workbench",
"install::macro-cut",
"install::macro-export",
"install::status",
# MCP commands
"mcp::check",
+7 -59
View File
@@ -44,13 +44,9 @@ class TestReleaseSyntax:
RELEASE_COMMANDS: ClassVar[list[tuple[str, list[str]]]] = [
# Version bump commands
("bump-workbench", ["0.0.1-test"]),
("bump-macro-magnets", ["0.0.1-test"]),
("bump-macro-export", ["0.0.1-test"]),
# Tag commands (require version argument)
("tag-mcp-server", ["0.0.1-test"]),
("tag-workbench", ["0.0.1-test"]),
("tag-macro-magnets", ["0.0.1-test"]),
("tag-macro-export", ["0.0.1-test"]),
# Info commands
("list-tags", []),
("latest-versions", []),
@@ -63,9 +59,9 @@ class TestReleaseSyntax:
# Tag management
("delete-tag", ["test-tag-v0.0.1"]),
# Wiki commands
("wiki-update", ["magnets"]),
("wiki-show", ["magnets"]),
("wiki-diff", ["magnets"]),
("wiki-update", ["workbench"]),
("wiki-show", ["workbench"]),
("wiki-diff", ["workbench"]),
]
@pytest.mark.just_syntax
@@ -106,7 +102,7 @@ class TestReleaseReadOnly:
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component",
["mcp-server", "workbench", "macro-magnets", "macro-export"],
["mcp-server", "workbench"],
)
def test_changes_since_works(self, just: JustRunner, component: str) -> None:
"""changes-since should work for each component."""
@@ -121,7 +117,7 @@ class TestReleaseReadOnly:
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component",
["mcp-server", "workbench", "macro-magnets", "macro-export"],
["mcp-server", "workbench"],
)
def test_draft_notes_works(self, just: JustRunner, component: str) -> None:
"""draft-notes should work for each component."""
@@ -135,8 +131,6 @@ class TestReleaseReadOnly:
[
("mcp-server", "1.0.0"),
("workbench", "1.0.0"),
("macro-magnets", "1.0.0"),
("macro-export", "1.0.0"),
],
)
def test_dry_run_tag_shows_info(
@@ -148,10 +142,9 @@ class TestReleaseReadOnly:
assert "Would create tag" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize("macro", ["magnets", "export"])
def test_wiki_show_works(self, just: JustRunner, macro: str) -> None:
def test_wiki_show_works(self, just: JustRunner) -> None:
"""wiki-show should display wiki source content."""
result = just.run("release::wiki-show", macro, timeout=10)
result = just.run("release::wiki-show", "workbench", timeout=10)
assert result.success, f"wiki-show failed: {result.stderr}"
assert "Wiki Source" in result.stdout
@@ -175,15 +168,6 @@ class TestReleaseBumpCommands:
/ "addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py",
PROJECT_ROOT / "addon/FreecadRobustMCPBridge/wiki-source.txt",
PROJECT_ROOT / "package.xml",
# Cut Object for Magnets macro files
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro",
PROJECT_ROOT
/ "macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md",
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/wiki-source.txt",
# Multi Export macro files
PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro",
PROJECT_ROOT / "macros/Multi_Export/README-MultiExport.md",
PROJECT_ROOT / "macros/Multi_Export/wiki-source.txt",
]
backups: dict[Path, str] = {}
@@ -214,38 +198,6 @@ class TestReleaseBumpCommands:
content = init_file.read_text()
assert "99.99.99-test" in content
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_bump_macro_magnets_modifies_files(
self, just: JustRunner, backup_and_restore_files: None
) -> None:
"""bump-macro-magnets should modify version files."""
result = just.run("release::bump-macro-magnets", "99.99.99-test", timeout=30)
assert result.success, f"bump-macro-magnets failed: {result.stderr}"
assert "Version bump complete" in result.stdout
# Verify version was updated
macro_file = (
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro"
)
content = macro_file.read_text()
assert "99.99.99-test" in content
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_bump_macro_export_modifies_files(
self, just: JustRunner, backup_and_restore_files: None
) -> None:
"""bump-macro-export should modify version files."""
result = just.run("release::bump-macro-export", "99.99.99-test", timeout=30)
assert result.success, f"bump-macro-export failed: {result.stderr}"
assert "Version bump complete" in result.stdout
# Verify version was updated
macro_file = PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro"
content = macro_file.read_text()
assert "99.99.99-test" in content
class TestReleaseTagCommands:
"""Tests for tag creation commands.
@@ -347,10 +299,6 @@ class TestReleaseValidation:
"mcp-server",
"server",
"workbench",
"macro-magnets",
"magnets",
"macro-export",
"export",
],
)
def test_changes_since_component_aliases(
+6 -5
View File
@@ -22,11 +22,12 @@ class TestTestingSyntax:
TESTING_COMMANDS: ClassVar[list[str]] = [
"testing::unit",
"testing::cov",
"testing::fast",
"testing::quick",
"testing::integration",
"testing::verbose",
"testing::all",
"testing::watch",
"testing::check-deps",
"testing::integration-freecad-auto",
"testing::just-syntax",
"testing::just-runtime",
@@ -73,11 +74,11 @@ class TestTestingRuntime:
assert_command_executed(result, "testing::unit")
@pytest.mark.just_runtime
def test_fast_command_recognizes_markers(self, just: JustRunner) -> None:
"""Fast test command should recognize the 'not slow' marker."""
def test_quick_command_recognizes_markers(self, just: JustRunner) -> None:
"""Quick test command should recognize the 'not slow' marker."""
result = just.run(
"testing::fast",
"testing::quick",
timeout=60,
env={"PYTEST_ADDOPTS": "--collect-only -q"},
)
assert_command_executed(result, "testing::fast")
assert_command_executed(result, "testing::quick")
+4 -4
View File
@@ -373,9 +373,9 @@ class TestFreecadResources:
"""freecad://macros should return macro list."""
mock_macros = [
MacroInfo(
name="MultiExport",
path="/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro",
description="Export to multiple formats",
name="ExportSTL",
path="/home/user/.local/share/FreeCAD/Macro/ExportSTL.FCMacro",
description="Export objects to STL",
is_system=False,
),
MacroInfo(
@@ -392,7 +392,7 @@ class TestFreecadResources:
data = json.loads(result)
assert len(data) == 2
assert data[0]["name"] == "MultiExport"
assert data[0]["name"] == "ExportSTL"
assert data[0]["is_system"] is False
assert data[1]["is_system"] is True
+6 -6
View File
@@ -58,9 +58,9 @@ class TestMacroTools:
"""list_macros should return macro info."""
mock_macros = [
MacroInfo(
name="MultiExport",
path="/home/user/.FreeCAD/Macro/MultiExport.FCMacro",
description="Export to multiple formats",
name="ExportSTL",
path="/home/user/.FreeCAD/Macro/ExportSTL.FCMacro",
description="Export objects to STL",
is_system=False,
),
MacroInfo(
@@ -76,7 +76,7 @@ class TestMacroTools:
result = await list_macros()
assert len(result) == 2
assert result[0]["name"] == "MultiExport"
assert result[0]["name"] == "ExportSTL"
assert result[0]["is_system"] is False
assert result[1]["name"] == "SystemMacro"
assert result[1]["is_system"] is True
@@ -96,11 +96,11 @@ class TestMacroTools:
)
run_macro = register_tools["run_macro"]
result = await run_macro(macro_name="MultiExport")
result = await run_macro(macro_name="ExportSTL")
assert result["success"] is True
assert result["stdout"] == "Exported 3 objects\n"
mock_bridge.run_macro.assert_called_once_with("MultiExport", None)
mock_bridge.run_macro.assert_called_once_with("ExportSTL", None)
@pytest.mark.asyncio
async def test_run_macro_with_args(self, register_tools, mock_bridge):