"""Unit tests for generation_ops.py - Blender operator tests""" from unittest.mock import Mock, patch, MagicMock, call import pytest from types import SimpleNamespace from src.operators.generation_ops import ( TEXT_TEXTURE_OT_generate, TEXT_TEXTURE_OT_preview, TEXT_TEXTURE_OT_generate_shader, TEXT_TEXTURE_OT_generate_plane, TEXT_TEXTURE_OT_reset_settings, _sanitize_text_for_name, _resolve_material_name, ) # 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 class DummyModifiers: """Simple stand-in for Blender's modifier collection.""" def __init__(self): self.created = [] def new(self, name, mod_type=None, **kwargs): mod_type = mod_type or kwargs.get('type') modifier = SimpleNamespace( name=name, type=mod_type, subdivision_type='CATMULL_CLARK', levels=0, render_levels=0, ) self.created.append(modifier) return modifier class DummyCollection: """Simple collection helper with clear tracking.""" def __init__(self): self.cleared = False def clear(self): self.cleared = True 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") class TestGenerationHelpers: """Unit tests for helper utilities in generation_ops.""" def test_sanitize_text_removes_invalid_characters(self): assert _sanitize_text_for_name("Hello @World!!!") == "Hello_World" def test_sanitize_text_truncates_and_defaults(self): assert _sanitize_text_for_name(" " * 5 + "--") == "Texture" assert len(_sanitize_text_for_name("A" * 50)) == 20 def test_resolve_material_uses_custom_name(self): props = create_mock_props() props.custom_material_name = " Custom Mat " assert _resolve_material_name(props, "Ignored") == "Custom Mat" def test_resolve_material_defaults_to_sanitized(self): props = create_mock_props() props.custom_material_name = "" assert _resolve_material_name(props, "Sample") == "TextTexture_Mat_Sample" class TestGeneratePlaneOperator: """Tests for the Create Plane operator.""" def test_generate_plane_success(self): op = TEXT_TEXTURE_OT_generate_plane() op.report = Mock() props = create_mock_props(text="Plane Test", width=800, height=400) context = create_mock_context(props) plane = SimpleNamespace( type='MESH', name='Plane', scale=SimpleNamespace(x=1.0, y=1.0, z=1.0), modifiers=DummyModifiers(), data=SimpleNamespace(materials=[]), ) context.active_object = plane mat = SimpleNamespace(name="TextTexture_Mat_Plane_Test") with patch('src.operators.generation_ops.bpy') as mock_bpy: mock_bpy.ops.text_texture.generate_shader.return_value = {'FINISHED'} mock_bpy.ops.mesh.primitive_plane_add.return_value = None mock_bpy.data.materials.get.return_value = mat result = op.execute(context) assert result == {'FINISHED'} assert pytest.approx(plane.scale.x, rel=1e-6) == 2.0 assert pytest.approx(plane.scale.y, rel=1e-6) == 1.0 assert plane.modifiers.created[0].subdivision_type == 'SIMPLE' assert plane.modifiers.created[0].levels == 1 assert plane.data.materials[0] is mat def test_generate_plane_handles_shader_failure(self): op = TEXT_TEXTURE_OT_generate_plane() op.report = Mock() props = create_mock_props() context = create_mock_context(props) with patch('src.operators.generation_ops.bpy') as mock_bpy: mock_bpy.ops.text_texture.generate_shader.return_value = {'CANCELLED'} result = op.execute(context) assert result == {'CANCELLED'} op.report.assert_called() class TestResetSettingsOperator: """Tests for the reset settings operator.""" class DummyProps: __annotations__ = { 'text': None, 'image_overlays': None, 'preset_search_query': None, } def __init__(self): self.text = "Custom" self.image_overlays = DummyCollection() self.preset_search_query = "search" self.unset_called = [] self.bl_rna = SimpleNamespace(properties=[ SimpleNamespace(identifier='text'), SimpleNamespace(identifier='image_overlays'), SimpleNamespace(identifier='preset_search_query'), ]) def property_unset(self, name): self.unset_called.append(name) def test_reset_settings_unsets_all_known_properties(self): op = TEXT_TEXTURE_OT_reset_settings() op.report = Mock() props = self.DummyProps() context = create_mock_context(props) result = op.execute(context) assert result == {'FINISHED'} assert set(props.unset_called) >= {'text', 'image_overlays', 'preset_search_query'} assert props.image_overlays.cleared is True op.report.assert_called()