This commit is contained in:
2025-10-12 16:00:22 +02:00
parent 9917d724a3
commit 97950f44c1
7 changed files with 1227 additions and 146 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -1,154 +1,699 @@
"""Unit tests for texture generation engine control flow.""" """Unit tests for generation_engine.py - Core texture generation logic"""
import pytest 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: def setup_pil_mocks(mock_pil_image_module, mock_pil_imagedraw_module,
"""Tests for generate_texture_image() control flow and error handling.""" mock_pil_imagefont_module, width, height,
textbbox_return=(0, 0, 100, 50), font_type='default'):
"""Helper to setup PIL mocks with consistent behavior.
def test_generate_texture_image_returns_none_when_pack_fails(self): Args:
""" mock_pil_image_module: Mocked PIL.Image module
Test that verifies the control flow bug: when texture generation succeeds mock_pil_imagedraw_module: Mocked PIL.ImageDraw module
but pack() fails, the function incorrectly returns (None, None) instead mock_pil_imagefont_module: Mocked PIL.ImageFont module
of returning the successfully generated image. 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
This test MUST fail initially because of the missing return statement Returns:
after line 244 in generation_engine.py. tuple: (mock_pil_image, mock_draw, mock_font)
""" """
# Arrange: Create mock Blender image # Setup PIL Image mock
mock_blender_img = Mock() mock_pil_image = MagicMock()
mock_blender_img.name = "TestTexture_512x512" mock_pil_image.size = (width, height)
mock_blender_img.filepath_raw = "" mock_pil_image.getdata.return_value = [(255, 255, 255, 255)] * (width * height)
mock_blender_img.pack = Mock(side_effect=RuntimeError("Pack failed")) mock_pil_image.transpose.return_value = mock_pil_image
mock_blender_img.pixels = MagicMock() mock_pil_image_module.new.return_value = mock_pil_image
mock_pil_image_module.FLIP_TOP_BOTTOM = 1
# Mock props with valid generation settings # Setup ImageDraw mock
mock_props = Mock() mock_draw = MagicMock()
mock_props.text = "Test" mock_draw.textbbox.return_value = textbbox_return
mock_props.font_size = 64 mock_pil_imagedraw_module.Draw.return_value = mock_draw
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 # Setup ImageFont mock based on font_type
from src.core.generation_engine import generate_texture_image 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
# Patch bpy at module level with proper structure return mock_pil_image, mock_draw, mock_font
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
# 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: def setup_bpy_mocks(mock_bpy, width, height, raise_error=None):
mock_draw_instance = Mock() """Helper to setup bpy mocks with consistent behavior.
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: Args:
mock_font.load_default.return_value = Mock() mock_bpy: Mocked bpy module
width: Image width in pixels
height: Image height in pixels
raise_error: Optional exception to raise on image creation
# Act: Call generate_texture_image Returns:
result_img, result_normal = generate_texture_image(mock_props, 512, 512) MagicMock: The mocked Blender image object (or None if error)
"""
mock_bpy.data.images.__contains__.return_value = False
# Assert: Function should return the successfully generated image, if raise_error:
# not (None, None) even if pack() fails mock_bpy.data.images.new.side_effect = raise_error
# This assertion WILL FAIL due to the control flow bug return None
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): mock_blender_image = MagicMock()
""" mock_blender_image.pixels = [0.0] * (width * height * 4)
Test that when both generation and pack succeed, the function returns mock_bpy.data.images.new.return_value = mock_blender_image
the generated image (not None). return mock_blender_image
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: def create_mock_props(**kwargs):
# Properly structure the bpy mock """Helper to create a mock props object with sensible defaults."""
mock_images = Mock() props = MagicMock()
mock_images.new = Mock(return_value=mock_blender_img)
mock_images.__contains__ = Mock(return_value=False)
mock_bpy.data.images = mock_images
with patch('PIL.Image') as mock_pil: # Set default values
mock_pil_img = Mock() props.text = kwargs.get('text', '')
mock_pil_img.size = (512, 512) props.prepend_text = kwargs.get('prepend_text', '')
mock_pil_img.transpose = Mock(return_value=mock_pil_img) props.append_text = kwargs.get('append_text', '')
mock_pil_img.getdata = Mock(return_value=[(255, 255, 255, 255)] * (512 * 512)) props.prepend_margin = kwargs.get('prepend_margin', 50.0)
mock_pil.new.return_value = mock_pil_img props.append_margin = kwargs.get('append_margin', 50.0)
mock_pil.FLIP_TOP_BOTTOM = 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)
with patch('PIL.ImageDraw') as mock_draw: return props
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 class TestGenerateTextureImage:
result_img, result_normal = generate_texture_image(mock_props, 512, 512) """Test suite for generate_texture_image() function"""
# Assert: Should return the generated image def test_generate_simple_text(self):
assert result_img is not None, ( """Test basic text generation with default settings."""
"Expected function to return generated image on success, got None" with patch('PIL.Image') as mock_pil_image_module, \
) patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \
assert result_img == mock_blender_img patch('PIL.ImageFont') as mock_pil_imagefont_module, \
assert result_normal is None # No normal map requested patch('src.core.generation_engine.bpy') as mock_bpy:
# 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)
# 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
# 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

View File

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