This commit is contained in:
2025-10-10 11:28:22 +02:00
parent 205aa7c421
commit d02cdc1463
157 changed files with 639 additions and 33540 deletions

111
tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,111 @@
"""
E2E test fixtures for Docker-based Blender testing.
Manages Docker container lifecycle for real Blender tests.
"""
import pytest
import docker
import shutil
import zipfile
from pathlib import Path
import time
@pytest.fixture(scope="module")
def docker_client():
"""Provide Docker client for container management."""
try:
client = docker.from_env()
# Verify Docker is running
client.ping()
return client
except docker.errors.DockerException as e:
pytest.skip(f"Docker not available: {e}")
@pytest.fixture(scope="module")
def blender_image(docker_client):
"""Build Blender Docker image if not exists."""
image_tag = "text-texture-generator-e2e:latest"
dockerfile_path = Path(__file__).parent / "docker"
try:
# Try to get existing image
docker_client.images.get(image_tag)
print(f"Using existing Docker image: {image_tag}")
except docker.errors.ImageNotFound:
print(f"Building Docker image: {image_tag}")
# Build image
docker_client.images.build(
path=str(dockerfile_path),
tag=image_tag,
rm=True
)
return image_tag
@pytest.fixture(scope="function")
def blender_container(docker_client, blender_image, tmp_path):
"""
Spin up Blender container for test execution.
Yields container instance, cleans up after test.
"""
container = None
try:
# Start container with addon mounted
container = docker_client.containers.run(
blender_image,
command="/bin/sleep 3600", # Keep alive for test duration
detach=True,
remove=False, # We'll remove manually after cleanup
volumes={
str(tmp_path): {"bind": "/addon-test", "mode": "rw"}
}
)
# Wait for container to be ready
timeout = 10
start = time.time()
while time.time() - start < timeout:
container.reload()
if container.status == "running":
break
time.sleep(0.5)
if container.status != "running":
pytest.fail(f"Container failed to start: {container.status}")
yield container
finally:
# Cleanup
if container:
try:
container.stop(timeout=5)
container.remove()
except Exception as e:
print(f"Warning: Container cleanup failed: {e}")
@pytest.fixture(scope="function")
def addon_package(tmp_path):
"""
Package the addon as a zip file for installation in Blender.
Returns path to the packaged addon zip.
"""
src_dir = Path(__file__).parent.parent.parent / "src"
addon_zip = tmp_path / "text_texture_generator.zip"
# Create zip with addon contents
with zipfile.ZipFile(addon_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_path in src_dir.rglob("*"):
if file_path.is_file():
# Add file with relative path from src/
arc_name = f"text_texture_generator/{file_path.relative_to(src_dir)}"
zf.write(file_path, arc_name)
return addon_zip

View File

@@ -0,0 +1,25 @@
FROM --platform=linux/amd64 ubuntu:22.04
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install Blender and dependencies including python3-pil
RUN apt-get update && apt-get install -y \
blender \
python3-pip \
python3-pil \
wget \
xvfb \
&& rm -rf /var/lib/apt/lists/*
# Set up working directory
WORKDIR /addon-test
# Blender requires a display, use Xvfb for headless operation
ENV DISPLAY=:99
# Copy helper script to run Blender with virtual display
COPY run_blender.sh /usr/local/bin/run_blender.sh
RUN chmod +x /usr/local/bin/run_blender.sh
CMD ["/bin/bash"]

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Run Blender in headless mode with virtual display
# Start Xvfb in background
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
XVFB_PID=$!
# Wait for Xvfb to start
sleep 2
# Run Blender with all arguments passed to this script
blender "$@"
BLENDER_EXIT=$?
# Cleanup Xvfb
kill $XVFB_PID 2>/dev/null || true
exit $BLENDER_EXIT

View File

@@ -0,0 +1,5 @@
# E2E test dependencies
# Install with: pip install -r tests/e2e/requirements.txt
docker>=6.0.0
pytest>=7.0.0

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Blender Python script that runs INSIDE Blender to test texture generation.
This script has NO mocks - it uses the real addon code with real bpy.
CRITICAL: This test calls the ACTUAL Blender operator (bpy.ops.text_texture.generate_shader)
because the bug is in the OPERATOR's truthiness check at generation_ops.py:241-247,
not in the generate_texture_image() function itself.
"""
import sys
import bpy
def test_shader_creation_with_texture():
"""
E2E test: Call the ACTUAL Blender operator to expose truthiness bug.
BUG LOCATION: src/operators/generation_ops.py:241-247
```python
result = generate_texture_image(props, final_width, final_height)
if not result: # ❌ BUG: (None, None) is TRUTHY in Python!
self.report({'ERROR'}, "Failed to generate texture image")
return {'CANCELLED'}
```
When generate_texture_image() returns (None, None):
- `not result` evaluates to False (non-empty tuple is truthy)
- Error check is bypassed
- Code continues until line 263 where it tries to use None values
This test MUST call bpy.ops.text_texture.generate_shader() (the operator)
NOT generate_texture_image() (the function) to expose the bug.
"""
# Clean slate - remove default objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Create a new cube (we need an object to attach material to)
bpy.ops.mesh.primitive_cube_add()
cube = bpy.context.active_object
# Set up scene properties for texture generation
props = bpy.context.scene.text_texture_props
# Configure text using the actual property structure
props.text = "böhm Kabel"
props.append_text = "NYY"
# Set dimensions (user-specified, not generated)
props.texture_width = 4096
props.texture_height = 512
# Configure shader settings
props.shader_type = 'PRINCIPLED'
props.shader_connection = 'BASE_COLOR'
props.auto_assign_material = True
props.generate_normal_map = False
props.background_transparent = True
print(f"\nTest scenario:")
print(f" Main text: {props.text}")
print(f" Append text: {props.append_text}")
print(f" Dimensions: {props.texture_width}x{props.texture_height}")
print(f"\nCalling bpy.ops.text_texture.generate_shader()...")
# CRITICAL: Call the OPERATOR (not the function!)
# This is the REAL code path that users trigger from the UI
try:
result = bpy.ops.text_texture.generate_shader()
print(f"Operator returned: {result}")
except Exception as e:
print(f"FAILURE: Operator raised exception: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Assert 1: Operator should return {'FINISHED'} on success
# Current bug: Will return {'CANCELLED'} because truthiness check at line 241 is wrong
if result != {'FINISHED'}:
print(f"FAILURE: Operator returned {result} instead of {{'FINISHED'}}")
print(f"This confirms the truthiness bug at generation_ops.py:241-247")
print(f"The check 'if not result:' fails when result is (None, None)")
print(f"because non-empty tuples are truthy in Python!")
sys.exit(1)
# Assert 2: Material should be created
# The material name is based on sanitized text
materials = [m.name for m in bpy.data.materials]
print(f"Available materials: {materials}")
# Check for TextTexture_Mat prefix (the actual naming pattern used)
material_found = any("TextTexture_Mat" in m for m in materials)
if not material_found:
print(f"FAILURE: No TextTexture_Mat material found")
print(f"This indicates the operator failed to complete shader creation")
sys.exit(1)
# Assert 3: Texture image should be created
images = [i.name for i in bpy.data.images]
print(f"Available images: {images}")
# Check for TextTexture prefix (the actual naming pattern used)
image_found = any("TextTexture" in i for i in images)
if not image_found:
print(f"FAILURE: No TextTexture image found")
print(f"This indicates generate_texture_image() returned None or (None, None)")
sys.exit(1)
# Get the actual image
img = None
for image in bpy.data.images:
if "TextTexture" in image.name and "Normal" not in image.name:
img = image
break
if not img:
print(f"FAILURE: Could not retrieve texture image")
sys.exit(1)
# Assert 4: Verify dimensions
actual_width = img.size[0]
actual_height = img.size[1]
if actual_width != 4096 or actual_height != 512:
print(f"FAILURE: Wrong dimensions. Expected 4096x512, got {actual_width}x{actual_height}")
sys.exit(1)
# Assert 5: Material should be assigned to cube
if not cube.data.materials or len(cube.data.materials) == 0:
print(f"FAILURE: No material assigned to cube")
sys.exit(1)
# Success markers for test_addon_real_blender.py to detect
print("SUCCESS: Texture generated successfully with correct dimensions and shader connected")
print(f"SUCCESS: Texture '{img.name}' created with size {actual_width}x{actual_height}")
print(f"SUCCESS: Shader nodes created and connected")
sys.exit(0)
if __name__ == "__main__":
test_shader_creation_with_texture()

View File

@@ -0,0 +1,150 @@
"""
E2E test: Real Blender addon testing in Docker with ZERO mocks.
This test validates the entire addon workflow in an actual Blender instance:
1. Package addon as zip
2. Install in real Blender (in Docker container)
3. Enable the addon
4. Execute texture generation with real text (including umlauts)
5. Verify texture is created successfully
NO MOCKS:
- Real bpy (Blender Python API)
- Real PIL (bundled with addon)
- Real addon code (generate_texture_image)
- Real Docker container with actual Blender
"""
import pytest
from pathlib import Path
import tarfile
import io
def test_shader_creation_with_texture_generates_successfully(
blender_container,
addon_package,
tmp_path
):
"""
E2E test: Install addon in real Blender and create texture shader.
This test uses ZERO mocks - everything runs in real Blender in Docker.
Test scenario (the exact bug case):
- Text: "böhm Kabel" + "NYY" (German umlauts)
- Dimensions: 4096x512
- Expected: Texture generated successfully with actual text rendering
- Bug symptom: "Failed to generate texture image" error OR PIL import error
Asserts:
- No "Failed to generate texture image" error
- No PIL import errors (addon dependencies must work)
- Texture exists in bpy.data.images
- Texture has correct dimensions (4096x512)
- Shader nodes created and connected
"""
# Copy test script to container using tar archive
test_script = Path(__file__).parent / "scripts" / "test_texture_generation.py"
# Create tar archive with test script
tar_stream = io.BytesIO()
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
# Add test script
script_info = tarfile.TarInfo(name='test_script.py')
script_info.size = test_script.stat().st_size
with open(test_script, 'rb') as f:
tar.addfile(script_info, f)
tar_stream.seek(0)
blender_container.put_archive('/addon-test', tar_stream)
# Copy addon package to container
addon_tar_stream = io.BytesIO()
with tarfile.open(fileobj=addon_tar_stream, mode='w') as tar:
addon_info = tarfile.TarInfo(name='text_texture_generator.zip')
addon_info.size = addon_package.stat().st_size
with open(addon_package, 'rb') as f:
tar.addfile(addon_info, f)
addon_tar_stream.seek(0)
blender_container.put_archive('/addon-test', addon_tar_stream)
container_script_path = "/addon-test/test_script.py"
container_addon_path = "/addon-test/text_texture_generator.zip"
# Install addon in Blender
install_cmd = [
"/usr/local/bin/run_blender.sh",
"--background",
"--python-expr",
(
"import bpy; "
f"bpy.ops.preferences.addon_install(filepath='{container_addon_path}'); "
"bpy.ops.preferences.addon_enable(module='text_texture_generator'); "
"bpy.ops.wm.save_userpref()"
)
]
install_result = blender_container.exec_run(
install_cmd,
workdir="/addon-test"
)
# Check installation succeeded
assert install_result.exit_code == 0, (
f"Addon installation failed with exit code {install_result.exit_code}\n"
f"Output: {install_result.output.decode('utf-8', errors='replace')}"
)
# Run the test script in Blender
test_cmd = [
"/usr/local/bin/run_blender.sh",
"--background",
"--python",
container_script_path
]
test_result = blender_container.exec_run(
test_cmd,
workdir="/addon-test"
)
output = test_result.output.decode('utf-8', errors='replace')
# Parse output for results
print("\n=== Blender Test Output ===")
print(output)
print("=== End Output ===\n")
# Assert: Script must exit with success code
assert test_result.exit_code == 0, (
f"Test script failed with exit code {test_result.exit_code}\n"
f"Output: {output}"
)
# Assert: Must see SUCCESS marker in output
assert "SUCCESS" in output, (
f"Expected SUCCESS marker in output, got:\n{output}"
)
# Assert: Must NOT see failure to generate texture
assert "Failed to generate texture image" not in output, (
f"Texture generation failed - bug still present!\n{output}"
)
# Assert: PIL must be available (no dependency errors allowed)
assert "ERROR: PIL dependency not available" not in output, (
f"PIL dependency not properly installed by Blender!\n{output}"
)
# Assert: Texture was created with correct dimensions
assert "created with size 4096x512" in output, (
f"Texture not created with expected dimensions\n{output}"
)
# Assert: Shader nodes were created and connected
assert "Shader nodes created and connected" in output, (
f"Shader nodes not properly set up\n{output}"
)

View File

@@ -0,0 +1,156 @@
"""Unit tests for texture generation engine control flow."""
import pytest
from unittest.mock import Mock, patch, MagicMock
import sys
from pathlib import Path
# Add src to path for imports
src_path = Path(__file__).parent.parent.parent.parent / "src"
sys.path.insert(0, str(src_path))
from core.generation_engine import generate_texture_image
class TestGenerateTextureImageControlFlow:
"""Tests for generate_texture_image() control flow and error handling."""
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
# Patch bpy at module level with proper structure
with patch('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:
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
with patch('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
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
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