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