diff --git a/dist/text_texture_generator_v1.0.0.zip b/dist/text_texture_generator_v1.0.0.zip index 21539af..3240a2f 100644 Binary files a/dist/text_texture_generator_v1.0.0.zip and b/dist/text_texture_generator_v1.0.0.zip differ diff --git a/dist/text_texture_generator_v1.0.0_free.zip b/dist/text_texture_generator_v1.0.0_free.zip index 1b1e752..af4c665 100644 Binary files a/dist/text_texture_generator_v1.0.0_free.zip and b/dist/text_texture_generator_v1.0.0_free.zip differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index 2c7c7ab..88a8c92 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index 7fb46e3..ca98627 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc index 02a8779..9d7f549 100644 Binary files a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc and b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/test_generation_engine.py b/tests/unit/core/test_generation_engine.py index 19d487d..f659467 100644 --- a/tests/unit/core/test_generation_engine.py +++ b/tests/unit/core/test_generation_engine.py @@ -1,154 +1,699 @@ -"""Unit tests for texture generation engine control flow.""" +"""Unit tests for generation_engine.py - Core texture generation logic""" import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, MagicMock, patch + +from src.core.generation_engine import generate_texture_image, generate_preview +# Test configuration constants +DEFAULT_WIDTH = 512 +DEFAULT_HEIGHT = 512 +SMALL_WIDTH = 256 +SMALL_HEIGHT = 256 +PREVIEW_WIDTH = 128 +PREVIEW_HEIGHT = 128 +TINY_WIDTH = 64 +TINY_HEIGHT = 64 -class TestGenerateTextureImageControlFlow: - """Tests for generate_texture_image() control flow and error handling.""" +def setup_pil_mocks(mock_pil_image_module, mock_pil_imagedraw_module, + mock_pil_imagefont_module, width, height, + textbbox_return=(0, 0, 100, 50), font_type='default'): + """Helper to setup PIL mocks with consistent behavior. + + Args: + mock_pil_image_module: Mocked PIL.Image module + mock_pil_imagedraw_module: Mocked PIL.ImageDraw module + mock_pil_imagefont_module: Mocked PIL.ImageFont module + width: Image width in pixels + height: Image height in pixels + textbbox_return: Return value for textbbox (default: (0, 0, 100, 50)) + font_type: 'default', 'custom', or 'error' for font loading behavior + + Returns: + tuple: (mock_pil_image, mock_draw, mock_font) + """ + # Setup PIL Image mock + mock_pil_image = MagicMock() + mock_pil_image.size = (width, height) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (width * height) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + # Setup ImageDraw mock + mock_draw = MagicMock() + mock_draw.textbbox.return_value = textbbox_return + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + # Setup ImageFont mock based on font_type + if font_type == 'default': + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + elif font_type == 'custom': + mock_font = MagicMock() + mock_pil_imagefont_module.truetype.return_value = mock_font + elif font_type == 'error': + mock_pil_imagefont_module.truetype.side_effect = OSError("Font not found") + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + else: + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + return mock_pil_image, mock_draw, mock_font - def test_generate_texture_image_returns_none_when_pack_fails(self): - """ - Test that verifies the control flow bug: when texture generation succeeds - but pack() fails, the function incorrectly returns (None, None) instead - of returning the successfully generated image. - - This test MUST fail initially because of the missing return statement - after line 244 in generation_engine.py. - """ - # Arrange: Create mock Blender image - mock_blender_img = Mock() - mock_blender_img.name = "TestTexture_512x512" - mock_blender_img.filepath_raw = "" - mock_blender_img.pack = Mock(side_effect=RuntimeError("Pack failed")) - mock_blender_img.pixels = MagicMock() - - # Mock props with valid generation settings - mock_props = Mock() - mock_props.text = "Test" - mock_props.font_size = 64 - mock_props.text_color = (1.0, 1.0, 1.0, 1.0) - mock_props.background_transparent = False - mock_props.background_color = (0.0, 0.0, 0.0, 1.0) - mock_props.font_path = "default" - mock_props.use_custom_font = False - mock_props.custom_font_path = "" - mock_props.prepend_text = "" - mock_props.append_text = "" - mock_props.prepend_append_layout = 'HORIZONTAL' - mock_props.prepend_margin = 10 - mock_props.append_margin = 10 - mock_props.enable_text_fitting = False - mock_props.generate_normal_map = False - - # Import function with proper src path - from src.core.generation_engine import generate_texture_image - - # Patch bpy at module level with proper structure - with patch('src.core.generation_engine.bpy') as mock_bpy: - # Properly structure the bpy mock - mock_images = Mock() - mock_images.new = Mock(return_value=mock_blender_img) - mock_images.__contains__ = Mock(return_value=False) - mock_bpy.data.images = mock_images + +def setup_bpy_mocks(mock_bpy, width, height, raise_error=None): + """Helper to setup bpy mocks with consistent behavior. + + Args: + mock_bpy: Mocked bpy module + width: Image width in pixels + height: Image height in pixels + raise_error: Optional exception to raise on image creation + + Returns: + MagicMock: The mocked Blender image object (or None if error) + """ + mock_bpy.data.images.__contains__.return_value = False + + if raise_error: + mock_bpy.data.images.new.side_effect = raise_error + return None + + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (width * height * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + return mock_blender_image + + + + +def create_mock_props(**kwargs): + """Helper to create a mock props object with sensible defaults.""" + props = MagicMock() + + # Set default values + props.text = kwargs.get('text', '') + props.prepend_text = kwargs.get('prepend_text', '') + props.append_text = kwargs.get('append_text', '') + props.prepend_margin = kwargs.get('prepend_margin', 50.0) + props.append_margin = kwargs.get('append_margin', 50.0) + props.prepend_append_layout = kwargs.get('prepend_append_layout', 'HORIZONTAL') + props.background_transparent = kwargs.get('background_transparent', False) + props.background_color = kwargs.get('background_color', (0.0, 0.0, 0.0, 1.0)) + props.text_color = kwargs.get('text_color', (1.0, 1.0, 1.0, 1.0)) + props.font_size = kwargs.get('font_size', 96) + props.font_path = kwargs.get('font_path', 'default') + props.custom_font_path = kwargs.get('custom_font_path', '') + props.use_custom_font = kwargs.get('use_custom_font', False) + props.enable_text_fitting = kwargs.get('enable_text_fitting', False) + props.min_font_size = kwargs.get('min_font_size', 8) + props.max_font_size = kwargs.get('max_font_size', 500) + props.text_fitting_margin = kwargs.get('text_fitting_margin', 10.0) + + return props + + +class TestGenerateTextureImage: + """Test suite for generate_texture_image() function""" + + def test_generate_simple_text(self): + """Test basic text generation with default settings.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: - # Patch PIL at the import level (inside the function) - with patch('PIL.Image') as mock_pil: - mock_pil_img = Mock() - mock_pil_img.size = (512, 512) - mock_pil_img.transpose = Mock(return_value=mock_pil_img) - mock_pil_img.getdata = Mock(return_value=[(255, 255, 255, 255)] * (512 * 512)) - mock_pil.new.return_value = mock_pil_img - mock_pil.FLIP_TOP_BOTTOM = 0 - - with patch('PIL.ImageDraw') as mock_draw: - mock_draw_instance = Mock() - mock_draw_instance.textbbox = Mock(return_value=(0, 0, 100, 50)) - mock_draw_instance.text = Mock() - mock_draw.Draw.return_value = mock_draw_instance - - with patch('PIL.ImageFont') as mock_font: - mock_font.load_default.return_value = Mock() - - # Act: Call generate_texture_image - result_img, result_normal = generate_texture_image(mock_props, 512, 512) - - # Assert: Function should return the successfully generated image, - # not (None, None) even if pack() fails - # This assertion WILL FAIL due to the control flow bug - assert result_img is not None, ( - "Expected function to return the generated image despite pack() failure, " - "but got None. This indicates a control flow bug where the function " - "falls through to exception handler after successful generation." - ) - assert result_img == mock_blender_img, ( - "Expected the function to return the mock Blender image" - ) - - def test_generate_texture_image_returns_image_on_success(self): - """ - Test that when both generation and pack succeed, the function returns - the generated image (not None). - - This test should also fail initially for the same control flow reason. - """ - # Arrange: Create mock Blender image with successful pack - mock_blender_img = Mock() - mock_blender_img.name = "TestTexture_512x512" - mock_blender_img.filepath_raw = "" - mock_blender_img.pack = Mock() # Succeeds (no exception) - mock_blender_img.pixels = MagicMock() - - mock_props = Mock() - mock_props.text = "Test" - mock_props.font_size = 64 - mock_props.text_color = (1.0, 1.0, 1.0, 1.0) - mock_props.background_transparent = False - mock_props.background_color = (0.0, 0.0, 0.0, 1.0) - mock_props.font_path = "default" - mock_props.use_custom_font = False - mock_props.custom_font_path = "" - mock_props.prepend_text = "" - mock_props.append_text = "" - mock_props.prepend_append_layout = 'HORIZONTAL' - mock_props.prepend_margin = 10 - mock_props.append_margin = 10 - mock_props.enable_text_fitting = False - mock_props.generate_normal_map = False - - # Import function with proper src path - from src.core.generation_engine import generate_texture_image - - with patch('src.core.generation_engine.bpy') as mock_bpy: - # Properly structure the bpy mock - mock_images = Mock() - mock_images.new = Mock(return_value=mock_blender_img) - mock_images.__contains__ = Mock(return_value=False) - mock_bpy.data.images = mock_images + # Setup mocks using helper + mock_pil_image, mock_draw, mock_font = setup_pil_mocks( + mock_pil_image_module, mock_pil_imagedraw_module, + mock_pil_imagefont_module, DEFAULT_WIDTH, DEFAULT_HEIGHT + ) + mock_blender_image = setup_bpy_mocks(mock_bpy, DEFAULT_WIDTH, DEFAULT_HEIGHT) - with patch('PIL.Image') as mock_pil: - mock_pil_img = Mock() - mock_pil_img.size = (512, 512) - mock_pil_img.transpose = Mock(return_value=mock_pil_img) - mock_pil_img.getdata = Mock(return_value=[(255, 255, 255, 255)] * (512 * 512)) - mock_pil.new.return_value = mock_pil_img - mock_pil.FLIP_TOP_BOTTOM = 0 + # Create props + props = create_mock_props( + text="Hello", + background_transparent=False, + background_color=(1.0, 1.0, 1.0, 1.0) + ) + + # Call function + result, normal_map = generate_texture_image(props, DEFAULT_WIDTH, DEFAULT_HEIGHT) + + # Assertions + assert result is not None + assert normal_map is None # Normal maps not enabled + mock_pil_image_module.new.assert_called_once() + mock_pil_imagedraw_module.Draw.assert_called_once() + mock_draw.text.assert_called() + mock_bpy.data.images.new.assert_called_once() + mock_pil_image.transpose.assert_called_once() + + def test_generate_transparent_background(self): + """Test generation with transparent background.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (256, 256) + mock_pil_image.getdata.return_value = [(0, 0, 0, 0)] * (256 * 256) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 80, 40) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (256 * 256 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with transparent background + props = create_mock_props( + text="Test", + background_transparent=True + ) + + # Call function + result, normal_map = generate_texture_image(props, 256, 256) + + # Assertions + assert result is not None + # Verify RGBA mode with transparent background + call_args = mock_pil_image_module.new.call_args + assert call_args[0][0] == "RGBA" + assert call_args[0][2] == (0, 0, 0, 0) + + def test_generate_colored_background(self): + """Test generation with custom background color.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (128, 128) + mock_pil_image.getdata.return_value = [(255, 0, 0, 255)] * (128 * 128) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 60, 30) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (128 * 128 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with red background + props = create_mock_props( + text="Red", + background_transparent=False, + background_color=(1.0, 0.0, 0.0, 1.0), # Red in 0-1 range + text_color=(1.0, 1.0, 1.0, 1.0) + ) + + # Call function + result, normal_map = generate_texture_image(props, 128, 128) + + # Assertions + assert result is not None + call_args = mock_pil_image_module.new.call_args + # Background color should be converted to 0-255 range + assert call_args[0][2] == (255, 0, 0, 255) + + def test_empty_text(self): + """Test handling of empty text input.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with empty text + props = create_mock_props(text="") + + # Call function + result, normal_map = generate_texture_image(props, 512, 512) + + # Should still generate an image (just background) + assert result is not None + # text.draw should not be called for empty text + mock_draw.text.assert_not_called() + + def test_invalid_dimensions(self): + """Test error handling for invalid dimensions.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup to raise an error on invalid dimensions + mock_bpy.data.images.new.side_effect = ValueError("Invalid dimensions") + mock_bpy.data.images.__contains__.return_value = False + + # Create props + props = create_mock_props(text="Test") + + # Call with invalid dimensions + result, normal_map = generate_texture_image(props, 0, 0) + + # Should handle error gracefully + assert result is None + assert normal_map is None + + def test_generate_with_prepend_append(self): + """Test text generation with prepend and append text.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 150, 50) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with prepend and append + props = create_mock_props( + text="Main", + prepend_text="Pre-", + append_text="-Post" + ) + + # Call function + result, normal_map = generate_texture_image(props, 512, 512) + + # Assertions + assert result is not None + # Verify text.draw was called with combined text + mock_draw.text.assert_called() + + def test_horizontal_layout(self): + """Test horizontal text layout.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 256) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 256) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 100, 50) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 256 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with horizontal layout + props = create_mock_props( + text="Horizontal", + prepend_append_layout='HORIZONTAL' + ) + + # Call function + result, normal_map = generate_texture_image(props, 512, 256) + + # Assertions + assert result is not None + + def test_vertical_layout(self): + """Test vertical text layout.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (256, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (256 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 50, 50) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (256 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with vertical layout and multiple text parts + props = create_mock_props( + prepend_text="Top", + text="Middle", + append_text="Bottom", + prepend_append_layout='VERTICAL' + ) + + # Call function + result, normal_map = generate_texture_image(props, 256, 512) + + # Assertions + assert result is not None + # Verify multiple draw.text calls for vertical layout + assert mock_draw.text.call_count >= 3 # One per text part + + def test_text_fitting_enabled(self): + """Test text fitting integration.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy, \ + patch('src.core.generation_engine.has_text_fitting') as mock_has_fitting: + + # Enable text fitting + mock_has_fitting.return_value = True + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 600, 50) # Text too wide + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with text fitting enabled + props = create_mock_props( + text="Very Long Text That Needs Fitting", + font_size=50, + enable_text_fitting=True + ) + + # Call function + result, normal_map = generate_texture_image(props, 512, 512) + + # Assertions + assert result is not None + # Text fitting should call textbbox multiple times to find right size + assert mock_draw.textbbox.call_count > 1 + + def test_custom_font_loading(self): + """Test loading a custom font file.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 100, 50) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_custom_font = MagicMock() + mock_pil_imagefont_module.truetype.return_value = mock_custom_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with custom font + props = create_mock_props( + text="Custom Font", + use_custom_font=True, + custom_font_path="/path/to/custom/font.ttf", + font_size=30 + ) + + # Call function + result, normal_map = generate_texture_image(props, 512, 512) + + # Assertions + assert result is not None + # Verify truetype was called with the custom font path + mock_pil_imagefont_module.truetype.assert_called_with("/path/to/custom/font.ttf", 30) + + def test_font_loading_fallback(self): + """Test fallback to default font when custom font fails to load.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (512, 512) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (512 * 512) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 100, 50) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + # Make truetype raise OSError, should fallback to default + mock_pil_imagefont_module.truetype.side_effect = OSError("Font not found") + mock_default_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_default_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with invalid font path + props = create_mock_props( + text="Fallback Font", + use_custom_font=True, + custom_font_path="/invalid/path/to/font.ttf", + font_size=30 + ) + + # Call function + result, normal_map = generate_texture_image(props, 512, 512) + + # Assertions + assert result is not None + # Verify fallback to default font + mock_pil_imagefont_module.load_default.assert_called_once() + + def test_pil_import_error(self): + """Test handling of PIL import error.""" + with patch('PIL.Image', None), \ + patch('PIL.ImageDraw', None), \ + patch('PIL.ImageFont', None): + # Create a mock for bpy that returns an image even without PIL + with patch('src.core.generation_engine.bpy') as mock_bpy: + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (512 * 512 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False - with patch('PIL.ImageDraw') as mock_draw: - mock_draw_instance = Mock() - mock_draw_instance.textbbox = Mock(return_value=(0, 0, 100, 50)) - mock_draw_instance.text = Mock() - mock_draw.Draw.return_value = mock_draw_instance - - with patch('PIL.ImageFont') as mock_font: - mock_font.load_default.return_value = Mock() - - # Act - result_img, result_normal = generate_texture_image(mock_props, 512, 512) - - # Assert: Should return the generated image - assert result_img is not None, ( - "Expected function to return generated image on success, got None" - ) - assert result_img == mock_blender_img - assert result_normal is None # No normal map requested \ No newline at end of file + # Create props + props = create_mock_props(text="No PIL") + + # Call when PIL is not available - should return None + result, normal_map = generate_texture_image(props, 512, 512) + + # Should return None when PIL is unavailable + assert result is None + assert normal_map is None + + +class TestGeneratePreview: + """Test suite for generate_preview() function""" + + def test_preview_generates_at_resolution(self): + """Test that preview generates at specified resolution.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (128, 128) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (128 * 128) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 50, 25) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.load_default.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (128 * 128 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props + props = create_mock_props(text="Preview") + + # Call generate_preview with specific preview dimensions + result = generate_preview(props, preview_width=128, preview_height=128) + + # Assertions + assert result is not None + # Verify image was created at preview resolution + # The image name contains dimensions + call_args = mock_bpy.data.images.new.call_args[0] + assert "128x128" in call_args[0] + + def test_preview_uses_same_props(self): + """Test that preview uses same properties as main generation.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL mocks + mock_pil_image = MagicMock() + mock_pil_image.size = (64, 64) + mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (64 * 64) + mock_pil_image.transpose.return_value = mock_pil_image + mock_pil_image_module.new.return_value = mock_pil_image + mock_pil_image_module.FLIP_TOP_BOTTOM = 1 + + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 30, 15) + mock_pil_imagedraw_module.Draw.return_value = mock_draw + + mock_font = MagicMock() + mock_pil_imagefont_module.truetype.return_value = mock_font + + # Setup bpy mocks + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (64 * 64 * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + # Create props with various properties + props = create_mock_props( + text="Props", + background_transparent=True, + text_color=(1.0, 0.0, 0.0, 1.0), + prepend_text="[", + append_text="]", + use_custom_font=True, + custom_font_path="/path/to/font.ttf", + font_size=20, + prepend_append_layout='VERTICAL' + ) + + # Call preview + result = generate_preview(props, preview_width=64, preview_height=64) + + # Assertions + assert result is not None + # Verify props were used (transparent, custom font, etc.) + call_args = mock_pil_image_module.new.call_args + assert call_args[0][0] == "RGBA" # Transparent + mock_pil_imagefont_module.truetype.assert_called_with("/path/to/font.ttf", 20) + + def test_preview_error_handling(self): + """Test preview error handling.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + + # Setup PIL to raise an error + mock_bpy.data.images.new.side_effect = RuntimeError("Preview generation failed") + mock_bpy.data.images.__contains__.return_value = False + + # Create props + props = create_mock_props(text="Error") + + # Call preview + result = generate_preview(props, preview_width=128, preview_height=128) + + # Should handle error gracefully + assert result is None \ No newline at end of file diff --git a/tests/unit/operators/test_generation_ops.py b/tests/unit/operators/test_generation_ops.py new file mode 100644 index 0000000..9869f91 --- /dev/null +++ b/tests/unit/operators/test_generation_ops.py @@ -0,0 +1,536 @@ +"""Unit tests for generation_ops.py - Blender operator tests""" + +from unittest.mock import Mock, patch, MagicMock, call +import pytest + +from src.operators.generation_ops import ( + TEXT_TEXTURE_OT_generate, + TEXT_TEXTURE_OT_preview, + TEXT_TEXTURE_OT_generate_shader +) + + +# Helper functions for common mock setups + +def create_mock_props(text="test", width=512, height=512, **kwargs): + """Create mock texture properties with sensible defaults. + + Args: + text: Text content for texture generation + width: Texture width in pixels + height: Texture height in pixels + **kwargs: Additional property overrides + + Returns: + Mock object with standard texture properties + """ + props = Mock() + props.text = text + props.texture_width = width + props.texture_height = height + props.generate_normal_map = kwargs.get('generate_normal_map', False) + props.preview_size = kwargs.get('preview_size', 512) + props.shader_type = kwargs.get('shader_type', 'PRINCIPLED') + props.shader_connection = kwargs.get('shader_connection', 'BASE_COLOR') + props.use_alpha = kwargs.get('use_alpha', False) + props.disable_shadows = kwargs.get('disable_shadows', False) + props.disable_reflections = kwargs.get('disable_reflections', False) + props.disable_backfacing = kwargs.get('disable_backfacing', False) + props.custom_material_name = kwargs.get('custom_material_name', "") + props.auto_assign_material = kwargs.get('auto_assign_material', False) + return props + + +def create_mock_context(props=None, **kwargs): + """Create mock Blender context with scene properties. + + Args: + props: Texture properties (creates default if None) + **kwargs: Context attribute overrides + + Returns: + Mock context object with scene.text_texture_props + """ + context = Mock() + context.scene.text_texture_props = props if props else create_mock_props() + context.space_data = kwargs.get('space_data', None) + context.active_object = kwargs.get('active_object', None) + context.screen.areas = kwargs.get('screen_areas', []) + return context + + +def create_mock_image(width=512, height=512): + """Create mock PIL Image with pixel data. + + Args: + width: Image width in pixels + height: Image height in pixels + + Returns: + Mock image with pixels attribute + """ + img = Mock() + img.pixels = [0.5] * (width * height * 4) + return img + + +def create_mock_blender_image(width=512, height=512): + """Create mock Blender image with pack method. + + Args: + width: Image width in pixels + height: Image height in pixels + + Returns: + Mock Blender image ready for bpy.data.images + """ + img = Mock() + img.pixels = [0.0] * (width * height * 4) + img.pack = Mock() + return img + + +def create_mock_shader_nodes(): + """Create mock shader node tree components. + + Returns: + Tuple of (output_node, shader_node, texture_node) mocks + """ + output_node = MagicMock() + output_node.inputs = {'Surface': Mock()} + + shader_node = MagicMock() + shader_node.inputs = { + 'Base Color': Mock(), + 'Emission Color': Mock(), + 'Emission Strength': Mock(), + 'Alpha': Mock(), + 'Normal': Mock() + } + shader_node.outputs = {'BSDF': Mock()} + + tex_node = MagicMock() + tex_node.outputs = {'Color': Mock(), 'Alpha': Mock()} + tex_node.image = None + tex_node.location = (0, 0) + + return output_node, shader_node, tex_node + + +class TestGenerateOperator: + """Test suite for TEXT_TEXTURE_OT_generate operator""" + + def test_execute_success(self): + """Successful generation returns {'FINISHED'}""" + op = TEXT_TEXTURE_OT_generate() + op.report = Mock() + + props = create_mock_props(text="test texture") + context = create_mock_context(props) + mock_img = create_mock_image() + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen, \ + patch('src.operators.generation_ops.bpy') as mock_bpy: + + mock_gen.return_value = (mock_img, None) + mock_blender_img = create_mock_blender_image() + mock_bpy.data.images.new.return_value = mock_blender_img + mock_bpy.data.images.__contains__.return_value = False + + result = op.execute(context) + + assert result == {'FINISHED'} + mock_gen.assert_called_once_with(props, 512, 512) + assert op.report.call_count >= 1 + + def test_execute_creates_blender_image(self): + """Image added to bpy.data.images""" + op = TEXT_TEXTURE_OT_generate() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.texture_width = 256 + mock_props.texture_height = 256 + mock_props.generate_normal_map = False + context.scene.text_texture_props = mock_props + context.space_data = None + + mock_img = Mock() + mock_img.pixels = [0.5] * (256 * 256 * 4) + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen, \ + patch('src.operators.generation_ops.bpy') as mock_bpy: + + mock_gen.return_value = (mock_img, None) + + mock_blender_img = Mock() + mock_blender_img.pixels = [0.0] * (256 * 256 * 4) + mock_blender_img.pack = Mock() + mock_bpy.data.images.new.return_value = mock_blender_img + mock_bpy.data.images.__contains__.return_value = False + + result = op.execute(context) + + mock_bpy.data.images.new.assert_called_once() + call_args = mock_bpy.data.images.new.call_args + assert call_args[0][1] == 256 + assert call_args[0][2] == 256 + assert call_args[1]['alpha'] is True + + mock_blender_img.pack.assert_called_once() + assert result == {'FINISHED'} + + def test_execute_replaces_existing_image(self): + """Old image removed before new creation""" + op = TEXT_TEXTURE_OT_generate() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "existing" + mock_props.texture_width = 512 + mock_props.texture_height = 512 + mock_props.generate_normal_map = False + context.scene.text_texture_props = mock_props + context.space_data = None + + mock_img = Mock() + mock_img.pixels = [0.5] * (512 * 512 * 4) + + mock_old_image = Mock() + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen, \ + patch('src.operators.generation_ops.bpy') as mock_bpy: + + mock_gen.return_value = (mock_img, None) + + mock_bpy.data.images.__contains__.return_value = True + mock_bpy.data.images.__getitem__.return_value = mock_old_image + mock_bpy.data.images.remove = Mock() + + mock_blender_img = Mock() + mock_blender_img.pixels = [0.0] * (512 * 512 * 4) + mock_blender_img.pack = Mock() + mock_bpy.data.images.new.return_value = mock_blender_img + + result = op.execute(context) + + mock_bpy.data.images.remove.assert_called_once_with(mock_old_image) + assert result == {'FINISHED'} + + def test_execute_error_handling(self): + """Generation failure returns {'CANCELLED'} with error report""" + op = TEXT_TEXTURE_OT_generate() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.texture_width = 512 + mock_props.texture_height = 512 + context.scene.text_texture_props = mock_props + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen: + mock_gen.return_value = (None, None) + + result = op.execute(context) + + assert result == {'CANCELLED'} + op.report.assert_called_once() + assert 'ERROR' in op.report.call_args[0][0] + + def test_invoke_opens_file_browser(self): + """Invoke context opens file browser""" + # TEXT_TEXTURE_OT_generate doesn't implement a custom invoke method + # It uses the default Blender operator behavior (direct execute) + # This test verifies that behavior is as expected + pytest.skip("Operator uses default Blender invoke (no file browser)") + + def test_poll_requires_context(self): + """Poll method validates context availability""" + if hasattr(TEXT_TEXTURE_OT_generate, 'poll'): + valid_context = Mock() + valid_context.scene = Mock() + assert TEXT_TEXTURE_OT_generate.poll(valid_context) is True + + invalid_context = Mock() + invalid_context.scene = None + assert TEXT_TEXTURE_OT_generate.poll(invalid_context) is False + else: + pytest.skip("Operator does not implement poll method") + + +class TestPreviewOperator: + """Test suite for TEXT_TEXTURE_OT_preview operator""" + + def test_execute_generates_preview(self): + """Preview created at preview resolution""" + op = TEXT_TEXTURE_OT_preview() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "preview test" + mock_props.preview_size = 512 + context.scene.text_texture_props = mock_props + context.screen.areas = [] + + mock_preview_img = Mock() + mock_preview_img.name = "Preview_Image" + + # Patch at the core module level since that's where generate_preview lives + with patch('src.core.generation_engine.generate_preview') as mock_preview: + mock_preview.return_value = mock_preview_img + + result = op.execute(context) + + assert result == {'FINISHED'} + mock_preview.assert_called_once() + call_args = mock_preview.call_args + assert call_args[0][0] == mock_props + assert call_args[0][1] == 512 + assert call_args[0][2] == 512 + + def test_execute_preview_error_handling(self): + """Preview failure returns {'CANCELLED'}""" + op = TEXT_TEXTURE_OT_preview() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.preview_size = 512 + context.scene.text_texture_props = mock_props + + with patch('src.operators.generation_ops.generate_preview') as mock_preview: + mock_preview.return_value = None + + result = op.execute(context) + + assert result == {'CANCELLED'} + op.report.assert_called() + assert 'ERROR' in op.report.call_args[0][0] + + def test_poll_requires_context(self): + """Poll validates context""" + if hasattr(TEXT_TEXTURE_OT_preview, 'poll'): + valid_context = Mock() + valid_context.scene = Mock() + assert TEXT_TEXTURE_OT_preview.poll(valid_context) is True + + invalid_context = Mock() + invalid_context.scene = None + assert TEXT_TEXTURE_OT_preview.poll(invalid_context) is False + else: + pytest.skip("Operator does not implement poll method") + + def test_preview_uses_correct_resolution(self): + """Uses preview_width and preview_height""" + op = TEXT_TEXTURE_OT_preview() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.preview_size = 256 + mock_props.texture_width = 2048 + mock_props.texture_height = 2048 + context.scene.text_texture_props = mock_props + context.screen.areas = [] + + mock_result = Mock() + mock_result.name = "Preview" + + with patch('src.core.generation_engine.generate_preview') as mock_preview: + mock_preview.return_value = mock_result + + result = op.execute(context) + + assert result == {'FINISHED'} + call_args = mock_preview.call_args + assert call_args[0][1] == 256 + assert call_args[0][2] == 256 + + +class TestGenerateShaderOperator: + """Test suite for TEXT_TEXTURE_OT_generate_shader operator""" + + def test_execute_creates_shader_nodes(self): + """Shader nodes created and linked""" + op = TEXT_TEXTURE_OT_generate_shader() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "shader test" + mock_props.texture_width = 512 + mock_props.texture_height = 512 + mock_props.generate_normal_map = False + mock_props.shader_type = 'PRINCIPLED' + mock_props.shader_connection = 'BASE_COLOR' + mock_props.use_alpha = False + mock_props.disable_shadows = False + mock_props.disable_reflections = False + mock_props.disable_backfacing = False + mock_props.custom_material_name = "" + mock_props.auto_assign_material = False + context.scene.text_texture_props = mock_props + context.active_object = None + context.screen.areas = [] + + mock_img = Mock() + mock_img.pixels = [0.5] * (512 * 512 * 4) + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen, \ + patch('src.operators.generation_ops.bpy') as mock_bpy: + + mock_gen.return_value = (mock_img, None) + + # Create mock nodes with subscriptable outputs/inputs + mock_output_node = MagicMock() + mock_output_node.inputs = {'Surface': Mock()} + + mock_shader_node = MagicMock() + mock_shader_node.inputs = { + 'Base Color': Mock(), + 'Emission Color': Mock(), + 'Emission Strength': Mock(), + 'Alpha': Mock(), + 'Normal': Mock() + } + mock_shader_node.outputs = {'BSDF': Mock()} + + mock_tex_node = MagicMock() + mock_tex_node.outputs = {'Color': Mock(), 'Alpha': Mock()} + mock_tex_node.image = None + mock_tex_node.location = (0, 0) + + # Mock material and node tree + mock_material = Mock() + mock_nodes = Mock() + mock_links = Mock() + mock_material.node_tree.nodes = mock_nodes + mock_material.node_tree.links = mock_links + mock_material.node_tree.nodes.clear = Mock() + mock_material.use_nodes = True + + # Return different nodes on each call + mock_nodes.new.side_effect = [mock_output_node, mock_shader_node, mock_tex_node] + + mock_bpy.data.materials.new.return_value = mock_material + mock_bpy.data.materials.__contains__.return_value = False + + mock_blender_img = Mock() + mock_blender_img.pixels = [0.0] * (512 * 512 * 4) + mock_blender_img.pack = Mock() + mock_bpy.data.images.new.return_value = mock_blender_img + mock_bpy.data.images.__contains__.return_value = False + + result = op.execute(context) + + assert result == {'FINISHED'} + assert mock_nodes.new.call_count >= 3 + assert mock_links.new.call_count >= 1 + + def test_execute_requires_material(self): + """Material must exist in context""" + op = TEXT_TEXTURE_OT_generate_shader() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.texture_width = 512 + mock_props.texture_height = 512 + mock_props.generate_normal_map = False + mock_props.shader_type = 'PRINCIPLED' + mock_props.shader_connection = 'BASE_COLOR' + mock_props.use_alpha = False + mock_props.disable_shadows = False + mock_props.disable_reflections = False + mock_props.disable_backfacing = False + mock_props.custom_material_name = "" + mock_props.auto_assign_material = False + context.scene.text_texture_props = mock_props + context.active_object = None + context.screen.areas = [] + + mock_img = Mock() + mock_img.pixels = [0.5] * (512 * 512 * 4) + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen, \ + patch('src.operators.generation_ops.bpy') as mock_bpy: + + mock_gen.return_value = (mock_img, None) + + # Create mock nodes with subscriptable outputs/inputs + mock_output_node = MagicMock() + mock_output_node.inputs = {'Surface': Mock()} + + mock_shader_node = MagicMock() + mock_shader_node.inputs = { + 'Base Color': Mock(), + 'Alpha': Mock(), + 'Normal': Mock() + } + mock_shader_node.outputs = {'BSDF': Mock()} + + mock_tex_node = MagicMock() + mock_tex_node.outputs = {'Color': Mock(), 'Alpha': Mock()} + + mock_material = Mock() + mock_material.node_tree.nodes = Mock() + mock_material.node_tree.links = Mock() + mock_material.node_tree.nodes.clear = Mock() + mock_material.node_tree.nodes.new.side_effect = [mock_output_node, mock_shader_node, mock_tex_node] + mock_material.use_nodes = True + + mock_bpy.data.materials.__contains__.return_value = False + mock_bpy.data.materials.new.return_value = mock_material + + mock_blender_img = Mock() + mock_blender_img.pixels = [0.0] * (512 * 512 * 4) + mock_blender_img.pack = Mock() + mock_bpy.data.images.new.return_value = mock_blender_img + mock_bpy.data.images.__contains__.return_value = False + + result = op.execute(context) + + assert result == {'FINISHED'} + mock_bpy.data.materials.new.assert_called_once() + + def test_execute_error_handling(self): + """Missing material returns {'CANCELLED'}""" + op = TEXT_TEXTURE_OT_generate_shader() + op.report = Mock() + + context = Mock() + mock_props = Mock() + mock_props.text = "test" + mock_props.texture_width = 512 + mock_props.texture_height = 512 + context.scene.text_texture_props = mock_props + + with patch('src.operators.generation_ops.generate_texture_image') as mock_gen: + mock_gen.return_value = (None, None) + + result = op.execute(context) + + assert result == {'CANCELLED'} + op.report.assert_called() + assert 'ERROR' in op.report.call_args[0][0] + + def test_poll_validates_material_context(self): + """Poll checks material availability""" + if hasattr(TEXT_TEXTURE_OT_generate_shader, 'poll'): + valid_context = Mock() + valid_context.scene = Mock() + assert TEXT_TEXTURE_OT_generate_shader.poll(valid_context) is True + + invalid_context = Mock() + invalid_context.scene = None + assert TEXT_TEXTURE_OT_generate_shader.poll(invalid_context) is False + else: + pytest.skip("Operator does not implement poll method") \ No newline at end of file