Skip to main content
Glama

OpenSCAD MCP Server

by quellant
test_openscad_mcp.pyโ€ข26.4 kB
""" Comprehensive test suite for OpenSCAD MCP Server improvements. Tests cover: - Parameter validation (parse_list_param, parse_dict_param, parse_image_size_param) - Directory auto-creation - Response size optimization (estimate_response_size, save_image_to_file, compress_base64_image, manage_response_size) - Integration tests for backward compatibility and new features """ import pytest import json import os import sys import tempfile import base64 from pathlib import Path from unittest.mock import Mock, patch, AsyncMock, MagicMock, call from typing import Any, Dict, List # Add parent directory to path to import the server module sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) from openscad_mcp.server import ( parse_list_param, parse_dict_param, parse_image_size_param, estimate_response_size, save_image_to_file, compress_base64_image, manage_response_size, parse_camera_param ) # ============================================================================ # Test Parameter Parsers # ============================================================================ class TestParameterParsers: """Test flexible parameter parsing functions.""" # ------------------------------------------------------------------------ # parse_list_param tests # ------------------------------------------------------------------------ def test_parse_list_param_with_list(self): """Test parse_list_param with native list input.""" result = parse_list_param(["front", "top", "side"], []) assert result == ["front", "top", "side"] assert isinstance(result, list) def test_parse_list_param_with_json_string(self): """Test parse_list_param with JSON array string.""" result = parse_list_param('["front", "top", "side"]', []) assert result == ["front", "top", "side"] # Test with numbers result = parse_list_param('[1, 2, 3]', []) assert result == [1, 2, 3] def test_parse_list_param_with_csv_string(self): """Test parse_list_param with CSV format string.""" result = parse_list_param("front,top,side", []) assert result == ["front", "top", "side"] # Test with spaces result = parse_list_param("front, top , side", []) assert result == ["front", "top", "side"] # Test with trailing comma result = parse_list_param("front,top,", []) assert result == ["front", "top"] def test_parse_list_param_with_single_value(self): """Test parse_list_param with single value string.""" result = parse_list_param("front", []) assert result == ["front"] def test_parse_list_param_with_none(self): """Test parse_list_param with None returns default.""" default = ["default1", "default2"] result = parse_list_param(None, default) assert result == default assert result is default # Should return the same object def test_parse_list_param_invalid(self): """Test parse_list_param with invalid input raises error.""" with pytest.raises(ValueError, match="Cannot parse list from type"): parse_list_param(12345, []) with pytest.raises(ValueError, match="Cannot parse list from type"): parse_list_param({"key": "value"}, []) # ------------------------------------------------------------------------ # parse_dict_param tests # ------------------------------------------------------------------------ def test_parse_dict_param_with_dict(self): """Test parse_dict_param with native dict input.""" input_dict = {"x": 10, "y": 20, "name": "test"} result = parse_dict_param(input_dict, {}) assert result == input_dict assert isinstance(result, dict) def test_parse_dict_param_with_json_string(self): """Test parse_dict_param with JSON object string.""" result = parse_dict_param('{"x": 10, "y": 20.5, "active": true}', {}) assert result == {"x": 10, "y": 20.5, "active": True} # Test with nested objects result = parse_dict_param('{"point": {"x": 1, "y": 2}}', {}) assert result == {"point": {"x": 1, "y": 2}} def test_parse_dict_param_with_keyvalue_string(self): """Test parse_dict_param with key=value format.""" result = parse_dict_param("x=10,y=20,name=test", {}) assert result == {"x": 10, "y": 20, "name": "test"} # Test with spaces result = parse_dict_param("x = 10, y = 20.5 , active = true", {}) assert result == {"x": 10, "y": 20.5, "active": True} # Test with boolean values result = parse_dict_param("enabled=true,disabled=false", {}) assert result == {"enabled": True, "disabled": False} def test_parse_dict_param_type_conversion(self): """Test parse_dict_param auto-converts types in key=value format.""" result = parse_dict_param("int=42,float=3.14,bool=true,string=hello", {}) assert result["int"] == 42 assert isinstance(result["int"], int) assert result["float"] == 3.14 assert isinstance(result["float"], float) assert result["bool"] is True assert isinstance(result["bool"], bool) assert result["string"] == "hello" assert isinstance(result["string"], str) def test_parse_dict_param_with_none(self): """Test parse_dict_param with None returns default.""" default = {"default": "value"} result = parse_dict_param(None, default) assert result == default assert result is default def test_parse_dict_param_invalid(self): """Test parse_dict_param with invalid input raises error.""" with pytest.raises(ValueError, match="Cannot parse dict from type"): parse_dict_param(12345, {}) with pytest.raises(ValueError, match="Cannot parse dict from type"): parse_dict_param(["list", "item"], {}) def test_parse_dict_param_empty_string(self): """Test parse_dict_param with empty object string.""" result = parse_dict_param("{}", {}) assert result == {} # ------------------------------------------------------------------------ # parse_image_size_param tests # ------------------------------------------------------------------------ def test_parse_image_size_param_with_list(self): """Test parse_image_size_param with list format.""" result = parse_image_size_param([800, 600], []) assert result == [800, 600] # Test with float values (should convert to int) result = parse_image_size_param([800.5, 600.9], []) assert result == [800, 600] def test_parse_image_size_param_with_string_x(self): """Test parse_image_size_param with 'WxH' format.""" result = parse_image_size_param("800x600", []) assert result == [800, 600] # Test with spaces result = parse_image_size_param(" 1920 x 1080 ", []) assert result == [1920, 1080] def test_parse_image_size_param_with_string_comma(self): """Test parse_image_size_param with 'W,H' format.""" result = parse_image_size_param("800,600", []) assert result == [800, 600] # Test with spaces result = parse_image_size_param(" 1920 , 1080 ", []) assert result == [1920, 1080] def test_parse_image_size_param_with_tuple(self): """Test parse_image_size_param with tuple format.""" result = parse_image_size_param((800, 600), []) assert result == [800, 600] def test_parse_image_size_param_with_json(self): """Test parse_image_size_param with JSON array string.""" result = parse_image_size_param("[800, 600]", []) assert result == [800, 600] def test_parse_image_size_param_with_none(self): """Test parse_image_size_param with None returns default.""" default = [1024, 768] result = parse_image_size_param(None, default) assert result == default def test_parse_image_size_param_invalid(self): """Test parse_image_size_param with invalid input raises error.""" # Wrong number of values with pytest.raises(ValueError, match="must have 2 values"): parse_image_size_param([800], []) with pytest.raises(ValueError, match="must have 2 values"): parse_image_size_param([800, 600, 400], []) # Invalid format with pytest.raises(ValueError, match="Cannot parse image size"): parse_image_size_param("invalid", []) # Invalid type with pytest.raises(ValueError, match="Cannot parse image size"): parse_image_size_param({"width": 800}, []) # ------------------------------------------------------------------------ # parse_camera_param tests # ------------------------------------------------------------------------ def test_parse_camera_param_with_list(self): """Test parse_camera_param with list format.""" result = parse_camera_param([10, 20, 30], []) assert result == [10.0, 20.0, 30.0] assert all(isinstance(v, float) for v in result) def test_parse_camera_param_with_dict(self): """Test parse_camera_param with dict format.""" result = parse_camera_param({"x": 10, "y": 20, "z": 30}, []) assert result == [10.0, 20.0, 30.0] def test_parse_camera_param_with_json_list(self): """Test parse_camera_param with JSON list string.""" result = parse_camera_param("[10, 20, 30]", []) assert result == [10.0, 20.0, 30.0] def test_parse_camera_param_with_json_dict(self): """Test parse_camera_param with JSON dict string.""" result = parse_camera_param('{"x": 10, "y": 20, "z": 30}', []) assert result == [10.0, 20.0, 30.0] def test_parse_camera_param_with_none(self): """Test parse_camera_param with None returns default.""" default = [1.0, 2.0, 3.0] result = parse_camera_param(None, default) assert result == default # ============================================================================ # Test Directory Management # ============================================================================ class TestDirectoryManagement: """Test directory auto-creation and management.""" def test_temp_directory_creation(self): """Test that temp directory is created automatically.""" with tempfile.TemporaryDirectory() as tmpdir: test_dir = Path(tmpdir) / "test_temp" / "nested" assert not test_dir.exists() # Should create directory when needed test_dir.mkdir(parents=True, exist_ok=True) assert test_dir.exists() assert test_dir.is_dir() def test_directory_creation_with_parents(self): """Test creating nested directories with parents=True.""" with tempfile.TemporaryDirectory() as tmpdir: nested_dir = Path(tmpdir) / "level1" / "level2" / "level3" assert not nested_dir.exists() nested_dir.mkdir(parents=True, exist_ok=True) assert nested_dir.exists() assert (Path(tmpdir) / "level1").exists() assert (Path(tmpdir) / "level1" / "level2").exists() def test_directory_exists_handling(self): """Test that exist_ok=True prevents errors when directory exists.""" with tempfile.TemporaryDirectory() as tmpdir: test_dir = Path(tmpdir) / "test" test_dir.mkdir() assert test_dir.exists() # Should not raise error with exist_ok=True test_dir.mkdir(exist_ok=True) assert test_dir.exists() def test_fallback_directory_strategy(self): """Test fallback directory strategy when primary fails.""" fallback_dirs = [ "/tmp/openscad-mcp", Path.home() / ".openscad-mcp" / "tmp", Path.cwd() / "tmp" ] # At least one fallback should be writable writable = False for dir_path in fallback_dirs: try: dir_path.mkdir(parents=True, exist_ok=True) test_file = dir_path / "test.txt" test_file.write_text("test") test_file.unlink() writable = True break except: continue assert writable, "No fallback directory is writable" # ============================================================================ # Test Response Size Management # ============================================================================ class TestResponseSizeManagement: """Test response size estimation and optimization.""" def test_estimate_response_size_accuracy(self): """Test response size estimation for various data structures.""" # Simple string data = "Hello World" size = estimate_response_size(data) assert size > 0 assert size == len(json.dumps(data)) // 4 # Base64 data (typical image) base64_data = "A" * 10000 size = estimate_response_size(base64_data) assert size == len(json.dumps(base64_data)) // 4 # Complex structure complex_data = { "images": { "front": "A" * 1000, "top": "B" * 1000, "side": "C" * 1000 }, "metadata": { "count": 3, "format": "png" } } size = estimate_response_size(complex_data) assert size > 750 # Should be roughly 3000+ chars / 4 def test_save_image_to_file(self): """Test saving base64 image to file.""" # Create a small test image (1x1 red pixel PNG) test_image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) filename = "test_image.png" # Save image file_path = save_image_to_file(test_image_base64, filename, output_dir) assert os.path.exists(file_path) assert file_path == str(output_dir / filename) # Verify file content with open(file_path, 'rb') as f: saved_data = f.read() assert saved_data == base64.b64decode(test_image_base64) def test_save_image_to_file_creates_directory(self): """Test that save_image_to_file creates directory if it doesn't exist.""" test_image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) / "new_dir" / "nested" assert not output_dir.exists() file_path = save_image_to_file(test_image_base64, "test.png", output_dir) assert output_dir.exists() assert os.path.exists(file_path) @patch('openscad_mcp.server.Image') def test_compress_base64_image(self, mock_image_class): """Test base64 image compression.""" # Mock PIL Image mock_image = Mock() mock_image_class.open.return_value = mock_image # Simulate compression by returning smaller data original_data = "A" * 10000 compressed_data = "A" * 7000 # 30% smaller mock_image.save.side_effect = lambda buffer, **kwargs: buffer.write(base64.b64decode(base64.b64encode(compressed_data.encode()))) test_image_base64 = base64.b64encode(original_data.encode()).decode() result = compress_base64_image(test_image_base64) assert mock_image_class.open.called assert mock_image.save.called save_kwargs = mock_image.save.call_args[1] assert save_kwargs['format'] == 'PNG' assert save_kwargs['optimize'] is True def test_manage_response_size_auto_mode(self): """Test intelligent response size management in auto mode.""" # Small response - should keep as base64 small_images = { "front": "A" * 100, "top": "B" * 100 } result = manage_response_size(small_images, output_format="auto") assert isinstance(result, dict) # Should return simple dict for backward compatibility assert all(isinstance(v, str) for v in result.values()) @patch('openscad_mcp.server.save_image_to_file') def test_manage_response_size_force_file_path(self, mock_save): """Test forcing file path output format.""" mock_save.return_value = "/tmp/test.png" images = {"render": "A" * 1000} with tempfile.TemporaryDirectory() as tmpdir: result = manage_response_size( images, output_format="file_path", output_dir=Path(tmpdir) ) assert isinstance(result, dict) assert "render" in result assert result["render"]["type"] == "file_path" assert result["render"]["path"] == "/tmp/test.png" assert result["render"]["mime_type"] == "image/png" @patch('openscad_mcp.server.compress_base64_image') def test_manage_response_size_force_compressed(self, mock_compress): """Test forcing compressed output format.""" original = "A" * 1000 compressed = "A" * 700 mock_compress.return_value = compressed images = {"render": original} result = manage_response_size(images, output_format="compressed") assert isinstance(result, dict) assert "render" in result assert result["render"]["type"] == "base64_compressed" assert result["render"]["data"] == compressed assert result["render"]["compression_ratio"] == 0.7 def test_manage_response_size_large_response_handling(self): """Test handling of multiple large images exceeding token limit.""" # Create large images that exceed default limit large_images = { f"view_{i}": "A" * 20000 for i in range(5) } with tempfile.TemporaryDirectory() as tmpdir: # Should automatically switch to file_path mode result = manage_response_size( large_images, output_format="auto", max_size=1000, # Very small limit to force file mode output_dir=Path(tmpdir) ) assert isinstance(result, dict) # Should have switched to file path mode assert all( v.get("type") == "file_path" for v in result.values() if isinstance(v, dict) ) def test_manage_response_size_with_list_input(self): """Test manage_response_size with list input format.""" images_list = [ {"data": "A" * 100}, {"data": "B" * 100} ] result = manage_response_size(images_list, output_format="base64") assert isinstance(result, list) assert len(result) == 2 assert all(item["type"] == "base64" for item in result) # ============================================================================ # Test Integration # ============================================================================ class TestIntegration: """Integration tests for complete workflows.""" @pytest.mark.asyncio async def test_render_single_with_flexible_params(self): """Test render_single with all flexible parameter formats.""" from openscad_mcp.server import render_single with patch('openscad_mcp.server.render_scad_to_png') as mock_render: mock_render.return_value = "base64imagedata" # Test with various parameter formats result = await render_single( scad_content="cube(10);", camera_position='{"x": 10, "y": 20, "z": 30}', # JSON dict string camera_target=[0, 0, 0], # List camera_up=None, # Use default image_size="1024x768", # String format variables="x=10,y=20", # Key=value format auto_center=True ) assert result["success"] is True assert "data" in result or "path" in result # Verify parameters were parsed correctly call_args = mock_render.call_args[0] assert call_args[2] == [10.0, 20.0, 30.0] # camera_position assert call_args[3] == [0, 0, 0] # camera_target assert call_args[5] == [1024, 768] # image_size @pytest.mark.asyncio async def test_render_single_with_view_keywords(self): """Test render_single with view keyword parameter.""" from openscad_mcp.server import render_single with patch('openscad_mcp.server.render_scad_to_png') as mock_render: mock_render.return_value = "base64imagedata" # Test multiple views for view_name in ["front", "top", "isometric"]: result = await render_single( scad_content="sphere(10);", view=view_name, image_size=[800, 600], variables={"radius": 10}, output_format="base64" ) assert result["success"] is True assert "data" in result assert result["mime_type"] == "image/png" @pytest.mark.asyncio async def test_render_with_output_format_auto(self): """Test automatic output format selection based on size.""" from openscad_mcp.server import render_single # Small response with patch('openscad_mcp.server.render_scad_to_png') as mock_render: mock_render.return_value = "A" * 100 # Small image result = await render_single( scad_content="cube(5);", output_format="auto" ) assert result["success"] is True assert "data" in result # Should use base64 for small images @pytest.mark.asyncio async def test_render_with_large_response(self): """Test handling of large responses with auto mode.""" from openscad_mcp.server import render_single with patch('openscad_mcp.server.render_scad_to_png') as mock_render: # Return very large image data mock_render.return_value = "A" * 50000 with tempfile.TemporaryDirectory() as tmpdir: with patch('openscad_mcp.server.Path') as mock_path: mock_path.return_value = Path(tmpdir) result = await render_single( scad_content="complex_model();", view="isometric", output_format="auto" ) assert result["success"] is True # Should have handled the large response assert "operation_id" in result def test_backward_compatibility(self): """Test that existing code using old API still works.""" # Old style with lists result = parse_list_param(["view1", "view2"], []) assert result == ["view1", "view2"] # Old style with dicts result = parse_dict_param({"key": "value"}, {}) assert result == {"key": "value"} # Old style with image size result = parse_image_size_param([800, 600], []) assert result == [800, 600] # ============================================================================ # Test Error Handling # ============================================================================ class TestErrorHandling: """Test error handling and edge cases.""" def test_parse_list_param_malformed_json(self): """Test parse_list_param with malformed JSON.""" # Malformed JSON should fall back to CSV parsing result = parse_list_param("[front, top", []) assert result == ["[front", "top"] # Treated as CSV def test_parse_dict_param_malformed_json(self): """Test parse_dict_param with malformed JSON.""" # Malformed JSON should fall back to key=value parsing with pytest.raises(ValueError): parse_dict_param("{key: value", {}) def test_save_image_invalid_base64(self): """Test save_image_to_file with invalid base64.""" with tempfile.TemporaryDirectory() as tmpdir: with pytest.raises(ValueError, match="Failed to save image"): save_image_to_file("invalid_base64", "test.png", Path(tmpdir)) def test_compress_image_invalid_data(self): """Test compress_base64_image with invalid data.""" with pytest.raises(ValueError, match="Failed to compress image"): compress_base64_image("invalid_image_data") @pytest.mark.asyncio async def test_render_missing_input(self): """Test render functions with missing required input.""" from openscad_mcp.server import render_single with pytest.raises(ValueError, match="Exactly one of scad_content or scad_file"): await render_single() # Missing both with pytest.raises(ValueError, match="Exactly one of scad_content or scad_file"): await render_single(scad_content="cube();", scad_file="file.scad") # Both provided

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/quellant/openscad-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server