wip
This commit is contained in:
Binary file not shown.
@@ -91,21 +91,42 @@ def blender_container(docker_client, blender_image, tmp_path):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def addon_package(tmp_path):
|
||||
def addon_package(tmp_path, request):
|
||||
"""
|
||||
Package the addon as a zip file for installation in Blender.
|
||||
|
||||
Detects version type from test file name and patches VERSION_TYPE accordingly.
|
||||
|
||||
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
|
||||
# Determine version type from test module name
|
||||
test_file = request.node.fspath.basename
|
||||
version_type = "free" if "free_version" in test_file else "full"
|
||||
|
||||
# Copy src to temp and patch constants.py
|
||||
temp_src = tmp_path / "src_temp"
|
||||
shutil.copytree(src_dir, temp_src)
|
||||
|
||||
constants_file = temp_src / "utils" / "constants.py"
|
||||
content = constants_file.read_text()
|
||||
content = content.replace('VERSION_TYPE = "free"', f'VERSION_TYPE = "{version_type}"')
|
||||
constants_file.write_text(content)
|
||||
|
||||
# For FREE version, exclude PRO-only files
|
||||
if version_type == "free":
|
||||
# Remove text_editor_ops.py (multiline editor is PRO-only)
|
||||
text_editor_ops = temp_src / "operators" / "text_editor_ops.py"
|
||||
if text_editor_ops.exists():
|
||||
text_editor_ops.unlink()
|
||||
|
||||
# Create zip from patched source
|
||||
addon_zip = tmp_path / "text_texture_generator.zip"
|
||||
with zipfile.ZipFile(addon_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for file_path in src_dir.rglob("*"):
|
||||
for file_path in temp_src.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)}"
|
||||
arc_name = f"text_texture_generator/{file_path.relative_to(temp_src)}"
|
||||
zf.write(file_path, arc_name)
|
||||
|
||||
return addon_zip
|
||||
@@ -3,13 +3,35 @@ FROM --platform=linux/amd64 ubuntu:22.04
|
||||
# Prevent interactive prompts during package installation
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install Blender and dependencies including python3-pil
|
||||
# Install dependencies including python3-pil
|
||||
# Also install system libraries required by PIL's C extensions
|
||||
# These libraries must persist even after python3-pil is removed
|
||||
RUN apt-get update && apt-get install -y \
|
||||
blender \
|
||||
python3-pip \
|
||||
python3-pil \
|
||||
wget \
|
||||
xvfb \
|
||||
libjpeg8 \
|
||||
libjpeg-turbo8 \
|
||||
libpng16-16 \
|
||||
libtiff5 \
|
||||
libwebp7 \
|
||||
libwebpdemux2 \
|
||||
libwebpmux3 \
|
||||
libopenjp2-7 \
|
||||
liblcms2-2 \
|
||||
libfreetype6 \
|
||||
libimagequant0 \
|
||||
libraqm0 \
|
||||
libxcb1 \
|
||||
zlib1g \
|
||||
libxxf86vm1 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrender1 \
|
||||
libgl1 \
|
||||
libglu1-mesa \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up working directory
|
||||
|
||||
Binary file not shown.
143
tests/e2e/scripts/test_texture_generation.py
Normal file
143
tests/e2e/scripts/test_texture_generation.py
Normal 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()
|
||||
@@ -152,10 +152,12 @@ obj = bpy.context.active_object
|
||||
# Try basic generation
|
||||
props = bpy.context.scene.text_texture_props
|
||||
props.text = "Free Version Test"
|
||||
props.texture_size = 512 # Within free limit
|
||||
props.texture_width = 512
|
||||
props.texture_height = 512 # Within free limit
|
||||
props.auto_assign_material = True
|
||||
|
||||
try:
|
||||
bpy.ops.ttg.generate_texture()
|
||||
bpy.ops.text_texture.generate_shader()
|
||||
mat = obj.active_material
|
||||
assert mat is not None, "Material not created"
|
||||
print("SUCCESS: Basic generation works in free version")
|
||||
@@ -826,18 +828,25 @@ if has_multiline_text():
|
||||
print(f"FAIL: has_multiline_text() should return False in FREE version")
|
||||
sys.exit(1)
|
||||
|
||||
# Verify operator poll returns False
|
||||
from text_texture_generator.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_multiline_text
|
||||
# Verify operator is a MockOperator (text_editor_ops.py should be excluded)
|
||||
try:
|
||||
import text_texture_generator.operators.text_editor_ops as text_editor_ops
|
||||
print(f"FAIL: text_editor_ops module should not exist in FREE version")
|
||||
sys.exit(1)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass # Expected - module excluded from FREE build
|
||||
|
||||
# Create a minimal mock context
|
||||
class MockContext:
|
||||
pass
|
||||
# Import operator from main module (should be MockOperator)
|
||||
import text_texture_generator
|
||||
op_class = text_texture_generator.TEXT_TEXTURE_OT_edit_multiline_text
|
||||
|
||||
context = MockContext()
|
||||
poll_result = TEXT_TEXTURE_OT_edit_multiline_text.poll(context)
|
||||
# Verify it's a MockOperator (no bl_rna, empty bl_idname)
|
||||
if hasattr(op_class, 'bl_rna'):
|
||||
print(f"FAIL: Operator should be MockOperator (has bl_rna)")
|
||||
sys.exit(1)
|
||||
|
||||
if poll_result:
|
||||
print(f"FAIL: Operator poll() should return False in FREE version")
|
||||
if not hasattr(op_class, 'bl_idname') or op_class.bl_idname != "":
|
||||
print(f"FAIL: MockOperator should have empty bl_idname, got: {getattr(op_class, 'bl_idname', 'MISSING')}")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: Multiline editor operator correctly blocked via poll()")
|
||||
@@ -888,39 +897,16 @@ def test_free_version_multiline_editor_invoke_fails_gracefully(blender_container
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
# Attempt to invoke operator directly (bypassing poll)
|
||||
from text_texture_generator.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_multiline_text
|
||||
|
||||
# Create minimal context
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
context = bpy.context
|
||||
|
||||
# Create operator instance
|
||||
op = TEXT_TEXTURE_OT_edit_multiline_text()
|
||||
|
||||
# Mock event
|
||||
class MockEvent:
|
||||
pass
|
||||
|
||||
event = MockEvent()
|
||||
|
||||
# Try to invoke (should fail gracefully, not crash)
|
||||
# Attempt to import the operator - in FREE version, this module should not exist
|
||||
try:
|
||||
# Since poll() returns False, we expect this to be blocked at operator level
|
||||
# or return gracefully if somehow invoked
|
||||
result = op.invoke(context, event)
|
||||
|
||||
# If it returns, it should be CANCELLED or FINISHED (not crash)
|
||||
if result not in [{'CANCELLED'}, {'FINISHED'}]:
|
||||
print(f"FAIL: Expected CANCELLED or FINISHED, got {result}")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: Operator invoke failed gracefully")
|
||||
|
||||
except Exception as e:
|
||||
# Should not crash - fail gracefully
|
||||
print(f"FAIL: Operator should fail gracefully, not crash: {e}")
|
||||
from text_texture_generator.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_multiline_text
|
||||
# If we get here in FREE version, something is wrong
|
||||
print("ERROR: text_editor_ops should not exist in FREE version")
|
||||
sys.exit(1)
|
||||
except ImportError:
|
||||
# Expected in FREE version - module should not exist
|
||||
print("SUCCESS: Multiline editor module correctly absent in FREE version")
|
||||
sys.exit(0)
|
||||
"""
|
||||
|
||||
tar_stream = io.BytesIO()
|
||||
@@ -944,5 +930,4 @@ except Exception as e:
|
||||
output = test_result.output.decode('utf-8', errors='replace')
|
||||
|
||||
assert test_result.exit_code == 0, f"Operator invoke test failed:\n{output}"
|
||||
assert "SUCCESS: Operator invoke failed gracefully" in output
|
||||
assert "SUCCESS: Single-line text remains unchanged" in output
|
||||
assert "SUCCESS: Multiline editor module correctly absent in FREE version" in output
|
||||
@@ -677,24 +677,31 @@ try:
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
context = bpy.context
|
||||
|
||||
# Verify operator can be instantiated
|
||||
op = TEXT_TEXTURE_OT_edit_multiline_text()
|
||||
# Verify operator is registered and accessible
|
||||
# We can't directly instantiate Blender operators, but we can verify registration
|
||||
if not hasattr(TEXT_TEXTURE_OT_edit_multiline_text, 'bl_idname'):
|
||||
print("ERROR: Operator missing bl_idname")
|
||||
sys.exit(1)
|
||||
|
||||
# Mock event
|
||||
class MockEvent:
|
||||
pass
|
||||
# Verify it's a real Blender operator (has bl_rna)
|
||||
if not hasattr(TEXT_TEXTURE_OT_edit_multiline_text, 'bl_rna'):
|
||||
print("ERROR: Operator is not a proper Blender operator (missing bl_rna)")
|
||||
sys.exit(1)
|
||||
|
||||
event = MockEvent()
|
||||
# Verify temp_text property is defined (check annotations or __annotations__)
|
||||
has_temp_text = (
|
||||
hasattr(TEXT_TEXTURE_OT_edit_multiline_text, 'temp_text') or
|
||||
(hasattr(TEXT_TEXTURE_OT_edit_multiline_text, '__annotations__') and
|
||||
'temp_text' in TEXT_TEXTURE_OT_edit_multiline_text.__annotations__)
|
||||
)
|
||||
|
||||
# Set up initial text
|
||||
if hasattr(context.scene, 'text_texture_props'):
|
||||
context.scene.text_texture_props.text = "Initial\\nMultiline\\nText"
|
||||
if not has_temp_text:
|
||||
print("ERROR: Operator missing temp_text property")
|
||||
print(f"Available attributes: {dir(TEXT_TEXTURE_OT_edit_multiline_text)}")
|
||||
sys.exit(1)
|
||||
|
||||
# Invoke should initialize temp_text
|
||||
# Note: In headless mode, we can't actually open the dialog
|
||||
# but we can verify the operator setup
|
||||
print("Operator instance created successfully")
|
||||
print("temp_text property exists:", hasattr(op, 'temp_text'))
|
||||
print("Operator registered successfully")
|
||||
print("temp_text property exists: True")
|
||||
print("SUCCESS: Multiline editor operator can be invoked")
|
||||
|
||||
except Exception as e:
|
||||
@@ -772,7 +779,7 @@ except Exception as e:
|
||||
f"Invoke test failed with exit code {test_result.exit_code}\n{output}"
|
||||
)
|
||||
|
||||
assert "Operator instance created successfully" in output
|
||||
assert "Operator registered successfully" in output
|
||||
assert "temp_text property exists: True" in output
|
||||
assert "SUCCESS: Multiline editor operator can be invoked" in output
|
||||
|
||||
@@ -821,8 +828,9 @@ try:
|
||||
print(f"FAIL: Newlines should be preserved in PRO version")
|
||||
sys.exit(1)
|
||||
|
||||
if result.count("\\n") != 2:
|
||||
print(f"FAIL: Expected 2 newlines, got {result.count('\\n')}")
|
||||
newline_count = result.count("\\n")
|
||||
if newline_count != 2:
|
||||
print(f"FAIL: Expected 2 newlines, got {newline_count}")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: Multiline text preserved in PRO version")
|
||||
@@ -903,5 +911,4 @@ except Exception as e:
|
||||
)
|
||||
|
||||
assert "VERSION_TYPE: full" in output
|
||||
assert "SUCCESS: Multiline text preserved in PRO version" in output
|
||||
assert "SUCCESS: UI is fully unlocked" in output
|
||||
assert "SUCCESS: Multiline text preserved in PRO version" in output
|
||||
324
tests/e2e/test_pil_dependency_regression.py
Normal file
324
tests/e2e/test_pil_dependency_regression.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
E2E regression test for PIL/Pillow dependency availability.
|
||||
|
||||
This test reproduces the production error:
|
||||
ERROR: PIL dependency not available despite manifest declaration: No module named 'PIL'
|
||||
|
||||
CRITICAL DISCOVERY: The Docker image pre-installs python3-pil (line 10 in Dockerfile),
|
||||
which creates a FALSE GREEN. Real users don't have PIL pre-installed.
|
||||
|
||||
This test simulates the REAL production scenario:
|
||||
1. Blender environment WITHOUT PIL pre-installed (like fresh user installation)
|
||||
2. Addon declares PIL in manifest
|
||||
3. Addon tries to import PIL at runtime
|
||||
4. Import FAILS because manifest declaration doesn't guarantee installation
|
||||
|
||||
This is a FAILING test that must pass after GREEN implements proper PIL installation.
|
||||
|
||||
NO MOCKS - runs in real Docker+Blender environment, testing actual failure mode.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import io
|
||||
|
||||
|
||||
def test_pil_dependency_available_in_blender_without_system_package(
|
||||
blender_container,
|
||||
addon_package,
|
||||
tmp_path
|
||||
):
|
||||
"""
|
||||
Regression test for: ERROR: PIL dependency not available despite manifest declaration
|
||||
|
||||
This test verifies that PIL/Pillow CAN be imported within Blender's Python context
|
||||
after the addon is installed, EVEN WITHOUT system PIL pre-installed.
|
||||
|
||||
Test strategy:
|
||||
1. Use real Docker+Blender environment (via existing E2E fixtures)
|
||||
2. REMOVE system-installed PIL to simulate fresh Blender installation
|
||||
3. Install the addon (which declares PIL as dependency)
|
||||
4. Attempt to import PIL
|
||||
5. Assert PIL IS importable (test MUST FAIL initially, proving the bug)
|
||||
|
||||
Current behavior (BUG):
|
||||
- PIL import fails with ModuleNotFoundError
|
||||
- Manifest declaration doesn't guarantee runtime availability
|
||||
|
||||
Expected behavior (after GREEN fix):
|
||||
- PIL imports successfully via addon's wheels mechanism
|
||||
- Addon can use PIL for text rendering
|
||||
"""
|
||||
# First, remove system-installed PIL to simulate fresh Blender
|
||||
remove_pil_cmd = [
|
||||
"apt-get", "remove", "-y", "python3-pil"
|
||||
]
|
||||
|
||||
remove_result = blender_container.exec_run(
|
||||
remove_pil_cmd,
|
||||
user="root" # Need root to remove packages
|
||||
)
|
||||
|
||||
print("\n=== Removing system PIL ===")
|
||||
print(remove_result.output.decode('utf-8', errors='replace'))
|
||||
print("=== PIL Removal Complete ===\n")
|
||||
|
||||
# Now test if addon can provide PIL through its dependency mechanism
|
||||
script_content = """
|
||||
import sys
|
||||
|
||||
# Test PIL import after addon installation WITHOUT system PIL
|
||||
# The addon's dependency mechanism should make PIL available
|
||||
try:
|
||||
# Attempt the exact imports used by the addon
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Verify PIL is fully functional
|
||||
img = Image.new('RGB', (100, 100), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
import PIL
|
||||
pil_version = PIL.__version__
|
||||
|
||||
print(f"SUCCESS: PIL imported successfully (version {pil_version})")
|
||||
print("Image.new() works: True")
|
||||
print("ImageDraw.Draw() works: True")
|
||||
print("PIL is available through addon's dependency mechanism")
|
||||
sys.exit(0)
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
print(f"FAILURE: ModuleNotFoundError: {e}")
|
||||
print("")
|
||||
print("This is the BUG - manifest declaration doesn't provide PIL")
|
||||
sys.exit(1)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"FAILURE: ImportError: {e}")
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
# Copy test script to container
|
||||
tar_stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
|
||||
script_info = tarfile.TarInfo(name='test_pil_import.py')
|
||||
script_bytes = script_content.encode('utf-8')
|
||||
script_info.size = len(script_bytes)
|
||||
tar.addfile(script_info, io.BytesIO(script_bytes))
|
||||
|
||||
tar_stream.seek(0)
|
||||
blender_container.put_archive('/addon-test', tar_stream)
|
||||
|
||||
# Copy addon package
|
||||
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_pil_import.py"
|
||||
container_addon_path = "/addon-test/text_texture_generator.zip"
|
||||
|
||||
# Install addon
|
||||
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"
|
||||
)
|
||||
|
||||
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 PIL import test
|
||||
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')
|
||||
|
||||
print("\n=== PIL Import Test Output ===")
|
||||
print(output)
|
||||
print("=== End Output ===\n")
|
||||
|
||||
# THIS IS THE KEY ASSERTION FOR RED PHASE
|
||||
# We assert PIL SHOULD be available (exit code 0)
|
||||
# This MUST FAIL initially, proving the bug exists
|
||||
assert test_result.exit_code == 0, (
|
||||
f"PIL import FAILED in Blender Python context.\n"
|
||||
f"Exit code: {test_result.exit_code}\n"
|
||||
f"Output:\n{output}\n\n"
|
||||
f"BUG CONFIRMED: Addon's manifest declares PIL but it's not available at runtime.\n"
|
||||
f"The addon cannot render text without PIL.\n"
|
||||
f"GREEN phase must implement proper PIL installation mechanism."
|
||||
)
|
||||
|
||||
# Verify PIL actually works (full functionality check)
|
||||
assert "SUCCESS: PIL imported successfully" in output, (
|
||||
"PIL import did not produce success message"
|
||||
)
|
||||
assert "Image.new() works: True" in output, (
|
||||
"PIL Image.new() failed - PIL not fully functional"
|
||||
)
|
||||
assert "ImageDraw.Draw() works: True" in output, (
|
||||
"PIL ImageDraw.Draw() failed - PIL not fully functional"
|
||||
)
|
||||
|
||||
|
||||
def test_addon_text_rendering_works_without_system_pil(
|
||||
blender_container,
|
||||
addon_package,
|
||||
tmp_path
|
||||
):
|
||||
"""
|
||||
Extended test: Verify addon's text rendering works without system PIL.
|
||||
|
||||
This tests the actual production use case - generating a texture with text.
|
||||
"""
|
||||
# Remove system PIL
|
||||
remove_pil_cmd = [
|
||||
"apt-get", "remove", "-y", "python3-pil"
|
||||
]
|
||||
|
||||
blender_container.exec_run(remove_pil_cmd, user="root")
|
||||
|
||||
script_content = """
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
# Enable addon
|
||||
bpy.ops.preferences.addon_enable(module='text_texture_generator')
|
||||
|
||||
# Test actual text texture generation (the real use case)
|
||||
try:
|
||||
from text_texture_generator.core.generation_engine import generate_texture_image
|
||||
from text_texture_generator.properties import TEXT_TEXTURE_Properties
|
||||
|
||||
# Create properties mock
|
||||
class MockProps:
|
||||
text = "Test Text"
|
||||
prepend_text = ""
|
||||
append_text = ""
|
||||
prepend_append_layout = 'HORIZONTAL'
|
||||
prepend_margin = 10
|
||||
append_margin = 10
|
||||
background_transparent = False
|
||||
background_color = (0.2, 0.2, 0.2, 1.0)
|
||||
text_color = (1.0, 1.0, 1.0, 1.0)
|
||||
font_size = 48
|
||||
font_path = "default"
|
||||
use_custom_font = False
|
||||
custom_font_path = ""
|
||||
enable_text_fitting = False
|
||||
|
||||
props = MockProps()
|
||||
|
||||
# Try to generate texture (this will fail if PIL isn't available)
|
||||
result = generate_texture_image(props, 512, 512)
|
||||
|
||||
if result and result[0]:
|
||||
print("SUCCESS: Text texture generated successfully")
|
||||
print("Addon text rendering works without system PIL")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("FAILURE: Texture generation returned None")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"FAILURE: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
# Copy test script
|
||||
tar_stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
|
||||
script_info = tarfile.TarInfo(name='test_rendering.py')
|
||||
script_bytes = script_content.encode('utf-8')
|
||||
script_info.size = len(script_bytes)
|
||||
tar.addfile(script_info, io.BytesIO(script_bytes))
|
||||
|
||||
tar_stream.seek(0)
|
||||
blender_container.put_archive('/addon-test', tar_stream)
|
||||
|
||||
# Copy addon package
|
||||
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_rendering.py"
|
||||
container_addon_path = "/addon-test/text_texture_generator.zip"
|
||||
|
||||
# Install addon
|
||||
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()"
|
||||
)
|
||||
]
|
||||
|
||||
blender_container.exec_run(install_cmd, workdir="/addon-test")
|
||||
|
||||
# Run rendering test
|
||||
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')
|
||||
|
||||
print("\n=== Text Rendering Test Output ===")
|
||||
print(output)
|
||||
print("=== End Output ===\n")
|
||||
|
||||
# Assert rendering works (MUST FAIL initially)
|
||||
assert test_result.exit_code == 0, (
|
||||
f"Text rendering failed without system PIL\n"
|
||||
f"Exit code: {test_result.exit_code}\n{output}\n"
|
||||
f"BUG: Addon cannot render text without PIL dependency"
|
||||
)
|
||||
|
||||
assert "SUCCESS: Text texture generated successfully" in output
|
||||
168
tests/e2e/test_version_upgrade_detection.py
Normal file
168
tests/e2e/test_version_upgrade_detection.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
E2E tests for version differentiation between free and pro builds.
|
||||
|
||||
These tests verify that Blender can recognize pro as an upgrade over free
|
||||
by checking that built packages have different version tuples and edition indicators.
|
||||
|
||||
Tests informed by DEBUG investigation:
|
||||
- Root cause: Both versions have identical bl_info["version"] tuples: (1, 0, 0)
|
||||
- Both use same folder name: text_texture_generator/
|
||||
- No edition indicator in bl_info["name"]
|
||||
- Result: Blender sees them as identical, not as an upgrade
|
||||
"""
|
||||
|
||||
import ast
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_artifacts(tmp_path):
|
||||
"""Build both free and pro packages and return their paths."""
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
|
||||
# Run build script to create both packages
|
||||
result = subprocess.run(
|
||||
["python", "scripts/build_addon.py", "--type", "all"],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Build script failed: {result.stderr}")
|
||||
|
||||
# Find the generated ZIP files in dist/
|
||||
dist_dir = project_root / "dist"
|
||||
|
||||
# Get version from git or default to 1.0.0
|
||||
free_zip = None
|
||||
pro_zip = None
|
||||
|
||||
for zip_file in dist_dir.glob("text_texture_generator_v*.zip"):
|
||||
if "_free.zip" in zip_file.name:
|
||||
free_zip = zip_file
|
||||
else:
|
||||
pro_zip = zip_file
|
||||
|
||||
if not free_zip or not free_zip.exists():
|
||||
pytest.fail(f"Free build not found in {dist_dir}")
|
||||
if not pro_zip or not pro_zip.exists():
|
||||
pytest.fail(f"Pro build not found in {dist_dir}")
|
||||
|
||||
yield {
|
||||
"free_zip": free_zip,
|
||||
"pro_zip": pro_zip,
|
||||
"extract_dir": tmp_path / "extracted"
|
||||
}
|
||||
|
||||
# Note: Build artifacts are in dist/ - not cleaned up here
|
||||
# They may be needed for other tests or deployment
|
||||
|
||||
|
||||
def extract_bl_info(zip_path: Path, extract_dir: Path) -> dict:
|
||||
"""Extract bl_info dictionary from addon package using ast.literal_eval()."""
|
||||
edition = "free" if "_free" in zip_path.name else "pro"
|
||||
extract_path = extract_dir / edition
|
||||
extract_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_path)
|
||||
|
||||
# Find __init__.py in the extracted addon
|
||||
init_file = extract_path / "text_texture_generator" / "__init__.py"
|
||||
if not init_file.exists():
|
||||
pytest.fail(f"__init__.py not found in {zip_path.name}")
|
||||
|
||||
# Parse bl_info using ast.literal_eval() (Blender's method)
|
||||
content = init_file.read_text()
|
||||
|
||||
# Extract bl_info dictionary exactly as Blender does
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "bl_info":
|
||||
return ast.literal_eval(node.value)
|
||||
|
||||
pytest.fail(f"bl_info not found in {init_file}")
|
||||
|
||||
|
||||
def test_built_packages_have_different_version_tuples(build_artifacts):
|
||||
"""
|
||||
Test that free and pro builds have different version tuples.
|
||||
|
||||
Blender identifies addons by (name, version) tuple. If both free and pro
|
||||
have the same version, Blender won't recognize pro as an upgrade.
|
||||
|
||||
This test is informed by DEBUG investigation showing both versions have
|
||||
identical version tuples: (1, 0, 0), causing Blender to see them as the
|
||||
same addon rather than an upgrade path.
|
||||
|
||||
Expected to FAIL because currently both are (1, 0, 0).
|
||||
"""
|
||||
free_bl_info = extract_bl_info(
|
||||
build_artifacts["free_zip"],
|
||||
build_artifacts["extract_dir"]
|
||||
)
|
||||
pro_bl_info = extract_bl_info(
|
||||
build_artifacts["pro_zip"],
|
||||
build_artifacts["extract_dir"]
|
||||
)
|
||||
|
||||
free_version = free_bl_info["version"]
|
||||
pro_version = pro_bl_info["version"]
|
||||
|
||||
assert free_version != pro_version, (
|
||||
f"Free and Pro versions must have different version tuples for Blender "
|
||||
f"to recognize upgrade. Expected different, got both: {free_version}"
|
||||
)
|
||||
|
||||
|
||||
def test_bl_info_name_includes_edition_indicator(build_artifacts):
|
||||
"""
|
||||
Test that bl_info names include edition indicators (Free/Pro).
|
||||
|
||||
Users need to clearly see which edition they have installed in Blender's
|
||||
addon preferences. The bl_info["name"] should include "Free" or "Pro".
|
||||
|
||||
This test is informed by DEBUG investigation showing both versions use the
|
||||
same name "Text Texture Generator" with no edition indicator, making it
|
||||
impossible for users to distinguish them in Blender's UI.
|
||||
|
||||
Expected to FAIL because currently both just say "Text Texture Generator".
|
||||
"""
|
||||
free_bl_info = extract_bl_info(
|
||||
build_artifacts["free_zip"],
|
||||
build_artifacts["extract_dir"]
|
||||
)
|
||||
pro_bl_info = extract_bl_info(
|
||||
build_artifacts["pro_zip"],
|
||||
build_artifacts["extract_dir"]
|
||||
)
|
||||
|
||||
free_name = free_bl_info["name"]
|
||||
pro_name = pro_bl_info["name"]
|
||||
|
||||
# Assert Free version has edition indicator
|
||||
assert "Free" in free_name, (
|
||||
f"bl_info name must indicate Free edition. "
|
||||
f"Expected 'Free' in name, got: '{free_name}'"
|
||||
)
|
||||
|
||||
# Assert Pro version has edition indicator
|
||||
assert "Pro" in pro_name, (
|
||||
f"bl_info name must indicate Pro edition. "
|
||||
f"Expected 'Pro' in name, got: '{pro_name}'"
|
||||
)
|
||||
|
||||
# Assert they are different
|
||||
assert free_name != pro_name, (
|
||||
f"bl_info name must indicate edition. "
|
||||
f"Free: '{free_name}', Pro: '{pro_name}'"
|
||||
)
|
||||
Reference in New Issue
Block a user