remove multiline

This commit is contained in:
2025-10-13 00:10:24 +02:00
parent 97950f44c1
commit ad55572aa8
21 changed files with 112 additions and 943 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -59,7 +59,7 @@ The Text Texture Generator creates professional text textures directly in Blende
### 📖 Medium Description (100 words)
Turn any text into beautiful, high-resolution textures with the Text Texture Generator Blender addon. Generate up to 4096×4096 textures with professional effects including drop shadows, glows, strokes, and normal maps. Features 9-point positioning, batch processing, and smart presets. No external software needed - everything happens inside Blender. Perfect for indie game developers, 3D artists, motion graphics creators, and anyone who needs custom text textures fast. Supports unlimited text, multiline layouts, and complete shader generation. Compatible with Blender 4.0+ on Windows, macOS, and Linux.
Turn any text into beautiful, high-resolution textures with the Text Texture Generator Blender addon. Generate up to 4096×4096 textures with professional effects including drop shadows, glows, strokes, and normal maps. Features 9-point positioning, batch processing, and smart presets. No external software needed - everything happens inside Blender. Perfect for indie game developers, 3D artists, motion graphics creators, and anyone who needs custom text textures fast. Supports unlimited text and complete shader generation. Compatible with Blender 4.0+ on Windows, macOS, and Linux.
## Social Media Post Templates
@@ -282,7 +282,6 @@ Key Features:
• Professional effects: shadows, glows, strokes, normal maps
• Batch processing for multiple texts
• Improved three-tier preset system (Blend File, Persistent, Local) with enhanced UI display
• Multiline text support with auto-wrapping
• Complete shader generation with material assignment
• Cross-platform: Windows, macOS, Linux

View File

@@ -56,10 +56,6 @@ When I regenerate, notice it created a new material slot because the text conten
This preset can now be used in any other Blender file with one click. This is absolutely awesome if you're creating a product line or need consistent branding across multiple assets. No more recreating the same look over and over!"
## Multiline Text Support (4:10 - 4:25)
"The addon also handles multiline text beautifully. Watch as I add line breaks - the text automatically wraps at texture boundaries, maintaining proper spacing and alignment. Perfect for paragraphs or complex labels."
## Layout and Dimensions (4:25 - 4:45)
"Let's step into the layout options. Here's where you can change the dimensions of your texture - maybe you need a wide banner format or a square logo. Just adjust these values and regenerate."

View File

@@ -56,10 +56,6 @@ When I regenerate, notice it created a new material slot because the text conten
This preset can now be used in any other Blender file with one click. This is absolutely awesome if you're creating a product line or need consistent branding across multiple assets. No more recreating the same look over and over!"
## Multiline Text Support (4:10 - 4:25)
"The addon also handles multiline text beautifully. You can insert line breaks, and it automatically wraps at texture boundaries while maintaining proper spacing. Plus, you can adjust the text alignment—left, center, or right—so your multiline content is aligned exactly as you want."
## Layout and Dimensions (4:25 - 4:45)
"Let's step into the layout options. Here's where you can change the dimensions of your texture - maybe you need a wide banner format or a square logo. Just adjust these values and regenerate."

View File

@@ -33,7 +33,6 @@ from .text_fitting import (
# Text processing and rendering functions
from .text_processor import (
process_multiline_text,
wrap_text_to_width,
render_text_with_stroke,
calculate_text_metrics,
@@ -63,7 +62,6 @@ __all__ = [
'resolve_text_conflicts',
# Text processing
'process_multiline_text',
'wrap_text_to_width',
'render_text_with_stroke',
'calculate_text_metrics',
@@ -72,4 +70,4 @@ __all__ = [
'rectangles_overlap',
'find_non_overlapping_position',
'validate_text_rendering'
]
]

View File

@@ -1,66 +1,3 @@
def process_multiline_text(text, font, max_width, max_height):
pass
"""Process multiline text with automatic wrapping and fitting"""
try:
pass
from PIL import ImageFont, ImageDraw, Image
# Split text into lines
lines = text.split('\n')
processed_lines = []
# Create temporary image for measurements
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
total_height = 0
for line in lines:
pass
if not line.strip():
pass
# Empty line
bbox = draw.textbbox((0, 0), "A", font=font)
line_height = bbox[3] - bbox[1]
processed_lines.append("")
total_height += line_height
continue
# Wrap line if necessary
wrapped_lines = wrap_text_to_width(line, font, max_width)
for wrapped_line in wrapped_lines:
pass
bbox = draw.textbbox((0, 0), wrapped_line, font=font)
line_height = bbox[3] - bbox[1]
# Check if adding this line exceeds max height
if total_height + line_height > max_height and processed_lines:
pass
break
processed_lines.append(wrapped_line)
total_height += line_height
# Break if height limit reached
if total_height >= max_height:
pass
break
return {
'lines': processed_lines,
'total_height': total_height,
'line_count': len(processed_lines)
}
except Exception as e:
pass
import traceback
traceback.print_exc()
return {'lines': [text], 'total_height': 0, 'line_count': 1}
def wrap_text_to_width(text, font, max_width):
pass
"""Wrap text to fit within specified width"""
@@ -379,19 +316,8 @@ def validate_text_rendering(img, text_positions):
traceback.print_exc()
return []
from ..utils import constants
def process_text_content(text: str) -> str:
"""Process text content based on version capabilities.
FREE version: Strip newlines and normalize whitespace.
PRO version: Preserve newlines for multiline rendering.
"""
if constants.VERSION_TYPE == "free":
# Replace newlines with spaces and normalize consecutive whitespace
text = text.replace('\n', ' ')
text = ' '.join(text.split())
return text
else: # PRO version
return text
"""Normalize text content for single-line rendering."""
text = text.replace('\n', ' ')
text = ' '.join(text.split())
return text

View File

@@ -114,13 +114,6 @@ class TEXT_TEXTURE_OT_save_preset(Operator):
'normal_map_strength': props.normal_map_strength,
'normal_map_blur_radius': props.normal_map_blur_radius,
'normal_map_invert': props.normal_map_invert,
'enable_multiline': props.enable_multiline,
'line_height': props.line_height,
'text_alignment': props.text_alignment,
'auto_wrap': props.auto_wrap,
'wrap_width': props.wrap_width,
'max_lines': props.max_lines,
'line_spacing_pixels': props.line_spacing_pixels,
'enable_stroke': props.enable_stroke,
'stroke_width': props.stroke_width,
'stroke_position': props.stroke_position,
@@ -334,15 +327,6 @@ class TEXT_TEXTURE_OT_load_preset(Operator):
props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius)
props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert)
# Load multiline settings
props.enable_multiline = preset_data.get('enable_multiline', props.enable_multiline)
props.line_height = preset_data.get('line_height', props.line_height)
props.text_alignment = preset_data.get('text_alignment', props.text_alignment)
props.auto_wrap = preset_data.get('auto_wrap', props.auto_wrap)
props.wrap_width = preset_data.get('wrap_width', props.wrap_width)
props.max_lines = preset_data.get('max_lines', props.max_lines)
props.line_spacing_pixels = preset_data.get('line_spacing_pixels', props.line_spacing_pixels)
# Load stroke settings
props.enable_stroke = preset_data.get('enable_stroke', props.enable_stroke)
props.stroke_width = preset_data.get('stroke_width', props.stroke_width)
@@ -830,4 +814,4 @@ __all__ = [
'TEXT_TEXTURE_OT_refresh_presets',
'TEXT_TEXTURE_OT_export_presets',
'TEXT_TEXTURE_OT_import_presets',
]
]

View File

@@ -208,12 +208,6 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
)
# Collapsible section expand/collapse states
expand_multiline: BoolProperty(
name="Multiline Text",
description="Show/hide Multiline Text section",
default=False
)
expand_image_overlays: BoolProperty(
name="Image Overlays",
description="Show/hide Image Overlays section",
@@ -832,74 +826,6 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
update=update_live_preview
)
# ============================================================================
# MULTILINE TEXT PROPERTIES
# ============================================================================
enable_multiline: BoolProperty(
name="Enable Multiline",
description="Enable multiline text rendering",
default=False,
update=update_live_preview
)
line_height: FloatProperty(
name="Line Height",
description="Line height multiplier (1.0 = normal, 1.5 = 150% spacing)",
default=1.2,
min=0.1,
max=5.0,
step=0.1,
precision=2,
update=update_live_preview
)
text_alignment: EnumProperty(
name="Text Alignment",
description="Text alignment for multiline text",
items=[
('LEFT', "Left", "Left align text"),
('CENTER', "Center", "Center align text"),
('RIGHT', "Right", "Right align text")
],
default='CENTER',
update=update_live_preview
)
auto_wrap: BoolProperty(
name="Auto Wrap",
description="Automatically wrap text to fit texture width",
default=True,
update=update_live_preview
)
wrap_width: IntProperty(
name="Wrap Width",
description="Maximum width in pixels before text wraps (0 = use texture width)",
default=0,
min=0,
max=10000,
update=update_live_preview
)
max_lines: IntProperty(
name="Max Lines",
description="Maximum number of lines (0 = unlimited)",
default=0,
min=0,
max=100,
update=update_live_preview
)
line_spacing_pixels: IntProperty(
name="Line Spacing (px)",
description="Additional spacing between lines in pixels",
default=0,
min=-50,
max=200,
update=update_live_preview
)
# ============================================================================
# STROKE SYSTEM PROPERTIES
# ============================================================================
@@ -1077,4 +1003,4 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
step=0.1,
precision=1,
update=update_live_preview
)
)

View File

@@ -201,9 +201,6 @@ def draw_text_input_section(layout, props):
"""Draw the main text input field"""
layout.prop(props, "text", text="")
# Note: Multiline text editor feature is not currently available
# The operator "text_texture.edit_text" does not exist in the codebase
def draw_font_dropdown_section(layout, props):
pass
"""Draw basic font dropdown for top level"""
@@ -706,47 +703,6 @@ def draw_text_fitting_section(layout, props):
info_box.label(text="• Respects min/max font size limits")
def draw_multiline_section(layout, props):
pass
"""Draw multiline text controls"""
# Main toggle for multiline text
layout.prop(props, "enable_multiline", text="Enable Multiline", icon='TEXT')
# Show controls only if multiline is enabled
if props.enable_multiline:
pass
layout.separator()
# Line height and alignment settings
col = layout.column(align=True)
col.prop(props, "line_height", text="Line Height", slider=True)
col.prop(props, "text_alignment", text="Alignment")
layout.separator()
# Auto wrap settings
layout.prop(props, "auto_wrap", text="Auto Wrap")
if props.auto_wrap:
pass
layout.prop(props, "wrap_width", text="Wrap Width (0 = texture width)")
layout.separator()
# Line limits and spacing
col = layout.column(align=True)
col.prop(props, "max_lines", text="Max Lines (0 = unlimited)")
col.prop(props, "line_spacing_pixels", text="Line Spacing (px)")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Multiline text supports:", icon='INFO')
info_box.label(text="• Use \\n for manual line breaks")
info_box.label(text="• Auto-wrap fits text to width")
info_box.label(text="• Alignment works per line")
info_box.label(text="• Line height controls spacing")
def draw_stroke_section(layout, props):
pass
"""Draw stroke and outline controls"""
@@ -789,7 +745,6 @@ def draw_stroke_section(layout, props):
info_box.label(text="• Center: Stroke on text edge")
info_box.label(text="• Width 0 = no stroke")
info_box.label(text="• Works with multiline text")
def draw_shadow_glow_section(layout, props):
pass
@@ -950,12 +905,6 @@ class TEXT_TEXTURE_PT_panel(Panel):
# === FREE FEATURES ===
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
pass
draw_multiline_section(multiline_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
@@ -1174,12 +1123,6 @@ class TEXT_TEXTURE_PT_panel_3d(Panel):
# === FREE FEATURES ===
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
pass
draw_multiline_section(multiline_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
@@ -1398,12 +1341,6 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
# === FREE FEATURES ===
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
pass
draw_multiline_section(multiline_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
@@ -1546,4 +1483,4 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
# Only show content if available and expanded
if presets_expanded:
draw_presets_section(presets_expanded, props)
draw_presets_section(presets_expanded, props)

View File

@@ -31,12 +31,13 @@ Comprehensive test suite for the Text Texture Generator Blender addon, focusing
- Text that fits within constraints
- Text requiring wrapping
- Single word too long edge case
- **TestProcessMultilineText** (4 tests)
- **TestProcessTextContent** (5 tests)
- Single line processing
- Multiline text handling
- Height limit constraints
- Empty text edge cases
- Newline normalization
- Multiple newline collapsing
- Mixed whitespace handling
- Empty string handling
- No-newline passthrough
- **TestCalculateTextMetrics** (2 tests)
@@ -138,7 +139,7 @@ Comprehensive test suite for the Text Texture Generator Blender addon, focusing
### Core Algorithms
✅ Text wrapping and multiline processing
✅ Text wrapping and newline normalization
✅ Largest rectangle in histogram algorithm
✅ Text positioning and alignment
✅ Optimal font size calculation

View File

@@ -144,7 +144,7 @@ def sample_text_long():
@pytest.fixture
def sample_text_multiline():
"""Multiline sample text for testing"""
"""Sample text containing newlines for testing"""
return "Line 1\nLine 2\nLine 3\nLine 4"
@@ -259,4 +259,4 @@ def mock_props():
props.normal_map_strength = 1.0
props.normal_map_blur_radius = 0
props.normal_map_invert = False
return props
return props

View File

@@ -114,13 +114,6 @@ def addon_package(tmp_path, request):
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:
@@ -129,4 +122,4 @@ def addon_package(tmp_path, request):
arc_name = f"text_texture_generator/{file_path.relative_to(temp_src)}"
zf.write(file_path, arc_name)
return addon_zip
return addon_zip

View File

@@ -800,56 +800,33 @@ print("SUCCESS: Single-line text remains unchanged")
assert test_result.exit_code == 0, f"Single-line test failed:\n{output}"
def test_free_version_multiline_editor_operator_poll_blocks_access(blender_container, addon_package):
def test_free_version_no_multiline_artifacts(blender_container, addon_package):
"""
SPECIFICATION: FREE version must block multiline editor operator via poll().
SPECIFICATION: Multiline feature must be absent from the addon.
Expected behavior (VERSION_TYPE="free"):
- TEXT_TEXTURE_OT_edit_multiline_text.poll() returns False
- Operator button should be disabled/grayed out in UI
- has_multiline_text() returns False
Current state (VERSION_TYPE="full"):
- Test SKIPS (multiline editor is available)
This verification runs inside Blender to ensure that stale helpers or
operators are not exposed through the package API.
"""
version_type, enabled_features = get_version_type(blender_container, addon_package)
if version_type == "full":
pytest.skip("Test requires VERSION_TYPE='free' - currently 'full' (specification test)")
script_content = """
import bpy
import sys
# Verify multiline text feature is disabled
from text_texture_generator.utils.constants import has_multiline_text
if has_multiline_text():
print(f"FAIL: has_multiline_text() should return False in FREE version")
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
# Verify operator is a MockOperator (text_editor_ops.py should be excluded)
# has_multiline_text helper must be gone
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
from text_texture_generator.utils.constants import has_multiline_text
fail("has_multiline_text() helper is still importable")
except ImportError:
print("SUCCESS: has_multiline_text helper removed")
# Import operator from main module (should be MockOperator)
import text_texture_generator
op_class = text_texture_generator.TEXT_TEXTURE_OT_edit_multiline_text
# 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 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()")
# Legacy multiline operator must also be absent
try:
from text_texture_generator import TEXT_TEXTURE_OT_edit_multiline_text
fail("TEXT_TEXTURE_OT_edit_multiline_text is still exposed")
except ImportError:
print("SUCCESS: Multiline editor operator removed")
"""
tar_stream = io.BytesIO()
@@ -873,61 +850,6 @@ print("SUCCESS: Multiline editor operator correctly blocked via poll()")
output = test_result.output.decode('utf-8', errors='replace')
assert test_result.exit_code == 0, f"Operator poll test failed:\n{output}"
assert "SUCCESS: Multiline editor operator correctly blocked" in output
assert "SUCCESS: has_multiline_text helper removed" in output
assert "SUCCESS: Multiline editor operator removed" in output
def test_free_version_multiline_editor_invoke_fails_gracefully(blender_container, addon_package):
"""
SPECIFICATION: FREE version must fail gracefully if operator is invoked.
Expected behavior (VERSION_TYPE="free"):
- Even if poll() is bypassed, invoke should fail gracefully
- No crashes or exceptions
- Returns appropriate error status
Current state (VERSION_TYPE="full"):
- Test SKIPS (operator works normally)
"""
version_type, enabled_features = get_version_type(blender_container, addon_package)
if version_type == "full":
pytest.skip("Test requires VERSION_TYPE='free' - currently 'full' (specification test)")
script_content = """
import bpy
import sys
# Attempt to import the operator - in FREE version, this module should not exist
try:
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()
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
script_info = tarfile.TarInfo(name='test_editor_invoke.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)
test_cmd = [
"/usr/local/bin/run_blender.sh",
"--background",
"--python",
"/addon-test/test_editor_invoke.py"
]
test_result = blender_container.exec_run(test_cmd, workdir="/addon-test")
output = test_result.output.decode('utf-8', errors='replace')
assert test_result.exit_code == 0, f"Operator invoke test failed:\n{output}"
assert "SUCCESS: Multiline editor module correctly absent in FREE version" in output

View File

@@ -516,286 +516,13 @@ except Exception as e:
assert "Can set 4096px: True" in output
def test_pro_version_multiline_editor_operator_poll_allows_access(
def test_pro_version_text_normalization_matches_free_version(
blender_container,
addon_package,
tmp_path
):
"""
Verify multiline editor operator poll() returns True in PRO version.
Expected behavior (VERSION_TYPE="full"):
- TEXT_TEXTURE_OT_edit_multiline_text.poll() returns True
- Operator button should be enabled in UI
- has_multiline_text() returns True
"""
script_content = """
import bpy
import sys
# Enable addon
try:
bpy.ops.preferences.addon_enable(module='text_texture_generator')
except Exception as e:
print(f"ERROR enabling addon: {e}")
sys.exit(1)
# Verify multiline text feature is enabled
try:
from text_texture_generator.utils.constants import has_multiline_text, VERSION_TYPE
print(f"VERSION_TYPE: {VERSION_TYPE}")
if not has_multiline_text():
print(f"FAIL: has_multiline_text() should return True in PRO version")
sys.exit(1)
# Verify operator poll returns True
from text_texture_generator.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_multiline_text
# Create a minimal mock context
class MockContext:
pass
context = MockContext()
poll_result = TEXT_TEXTURE_OT_edit_multiline_text.poll(context)
if not poll_result:
print(f"FAIL: Operator poll() should return True in PRO version")
sys.exit(1)
print("SUCCESS: Multiline editor operator poll() returns True")
except Exception as e:
print(f"ERROR: {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_editor_poll.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_editor_poll.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
# Run poll verification 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=== Multiline Editor Poll Output ===")
print(output)
print("=== End Output ===\n")
assert test_result.exit_code == 0, (
f"Poll verification failed with exit code {test_result.exit_code}\n{output}"
)
assert "VERSION_TYPE: full" in output
assert "SUCCESS: Multiline editor operator poll() returns True" in output
def test_pro_version_multiline_editor_can_be_invoked(
blender_container,
addon_package,
tmp_path
):
"""
Verify multiline editor operator can be successfully invoked in PRO version.
Expected behavior (VERSION_TYPE="full"):
- Operator can be invoked without errors
- Dialog is opened (invoke_props_dialog called)
- temp_text is initialized from current text property
"""
script_content = """
import bpy
import sys
# Enable addon
try:
bpy.ops.preferences.addon_enable(module='text_texture_generator')
except Exception as e:
print(f"ERROR enabling addon: {e}")
sys.exit(1)
# Test operator invocation
try:
from text_texture_generator.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_multiline_text
# Create scene and properties
bpy.ops.mesh.primitive_cube_add()
context = bpy.context
# 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)
# 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)
# 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__)
)
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)
print("Operator registered successfully")
print("temp_text property exists: True")
print("SUCCESS: Multiline editor operator can be invoked")
except Exception as e:
print(f"ERROR: {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_editor_invoke.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_editor_invoke.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
# Run invoke 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=== Multiline Editor Invoke Output ===")
print(output)
print("=== End Output ===\n")
assert test_result.exit_code == 0, (
f"Invoke test failed with exit code {test_result.exit_code}\n{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
def test_pro_version_multiline_text_preserved_during_processing(
blender_container,
addon_package,
tmp_path
):
"""
Verify multiline text with newlines is preserved in PRO version.
Expected behavior (VERSION_TYPE="full"):
- Text processor preserves \\n characters in PRO version
- "Line 1\\nLine 2\\nLine 3" remains unchanged
- No newline stripping in PRO mode
Verify newline handling matches the single-line specification in PRO builds.
"""
script_content = """
import bpy
@@ -822,18 +549,15 @@ try:
print(f"Input: {repr(input_text)}")
print(f"Output: {repr(result)}")
# In PRO version, newlines should be preserved
if VERSION_TYPE == "full" or VERSION_TYPE == "pro":
if "\\n" not in result:
print(f"FAIL: Newlines should be preserved in PRO version")
sys.exit(1)
newline_count = result.count("\\n")
if newline_count != 2:
print(f"FAIL: Expected 2 newlines, got {newline_count}")
sys.exit(1)
if "\\n" in result:
print("FAIL: Result should not contain newline characters")
sys.exit(1)
print("SUCCESS: Multiline text preserved in PRO version")
if result != "Line 1 Line 2 Line 3":
print(f"FAIL: Expected normalized output, got {repr(result)}")
sys.exit(1)
print("SUCCESS: Text content normalized in PRO version")
except Exception as e:
print(f"ERROR: {e}")
@@ -845,7 +569,7 @@ except Exception as e:
# Copy test script
tar_stream = io.BytesIO()
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
script_info = tarfile.TarInfo(name='test_multiline_preserve.py')
script_info = tarfile.TarInfo(name='test_text_normalization.py')
script_bytes = script_content.encode('utf-8')
script_info.size = len(script_bytes)
tar.addfile(script_info, io.BytesIO(script_bytes))
@@ -864,7 +588,7 @@ except Exception as e:
addon_tar_stream.seek(0)
blender_container.put_archive('/addon-test', addon_tar_stream)
container_script_path = "/addon-test/test_multiline_preserve.py"
container_script_path = "/addon-test/test_text_normalization.py"
container_addon_path = "/addon-test/text_texture_generator.zip"
# Install addon
@@ -887,7 +611,7 @@ except Exception as e:
assert install_result.exit_code == 0
# Run multiline preservation test
# Run text normalization test
test_cmd = [
"/usr/local/bin/run_blender.sh",
"--background",
@@ -902,13 +626,13 @@ except Exception as e:
output = test_result.output.decode('utf-8', errors='replace')
print("\n=== Multiline Preservation Output ===")
print("\n=== Text Normalization Output ===")
print(output)
print("=== End Output ===\n")
assert test_result.exit_code == 0, (
f"Multiline preservation test failed with exit code {test_result.exit_code}\n{output}"
f"Text normalization test failed with exit code {test_result.exit_code}\n{output}"
)
assert "VERSION_TYPE: full" in output
assert "SUCCESS: Multiline text preserved in PRO version" in output
assert "SUCCESS: Text content normalized in PRO version" in output

View File

@@ -4,7 +4,7 @@ Tests how the core modules work together.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import Mock, patch
import sys
import os
@@ -18,17 +18,13 @@ class TestTextGenerationPipeline:
"""Integration tests for the complete text generation workflow"""
@pytest.mark.integration
@patch('src.core.text_processor.process_multiline_text')
@patch('src.core.text_processor.wrap_text_to_width')
@patch('src.core.text_fitting.find_optimal_font_size')
@patch('src.core.text_fitting.calculate_text_position')
def test_basic_text_generation_workflow(self, mock_calc_position, mock_find_size, mock_process_text):
def test_basic_text_generation_workflow(self, mock_calc_position, mock_find_size, mock_wrap_text):
"""Test the basic workflow from text input to positioned output"""
# Setup mocks
mock_process_text.return_value = {
'lines': ['Hello', 'World'],
'total_height': 40,
'line_count': 2
}
mock_wrap_text.return_value = ['Hello', 'World']
mock_find_size.return_value = 24
mock_calc_position.return_value = {
'x': 50, 'y': 50,
@@ -40,9 +36,10 @@ class TestTextGenerationPipeline:
font = Mock()
canvas_width, canvas_height = 300, 200
# Step 1: Process multiline text
from src.core.text_processor import process_multiline_text
processed = process_multiline_text(text, font, canvas_width, canvas_height)
# Step 1: Normalize and wrap text
from src.core.text_processor import process_text_content, wrap_text_to_width
normalized = process_text_content(text)
wrapped_lines = wrap_text_to_width(normalized, font, canvas_width)
# Step 2: Find optimal font size
from src.core.text_fitting import find_optimal_font_size
@@ -54,12 +51,12 @@ class TestTextGenerationPipeline:
position = calculate_text_position(text, font, available_area, 'center')
# Verify workflow completed
assert processed['line_count'] == 2
assert wrapped_lines == ['Hello', 'World']
assert optimal_size == 24
assert position['x'] == 50 and position['y'] == 50
# Verify functions were called in sequence
mock_process_text.assert_called_once()
mock_wrap_text.assert_called_once_with(normalized, font, canvas_width)
mock_find_size.assert_called_once()
mock_calc_position.assert_called_once()
@@ -190,15 +187,13 @@ class TestErrorHandlingIntegration:
@pytest.mark.integration
def test_error_handling_in_text_processing_chain(self):
"""Test error handling when text processing components fail"""
with patch('src.core.text_processor.wrap_text_to_width') as mock_wrap:
mock_wrap.side_effect = Exception("Text wrapping failed")
"""Text wrapping should fail gracefully when PIL raises errors."""
with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class:
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
mock_draw.textbbox.side_effect = Exception("Text measurement failed")
# Test that multiline processing handles wrap failures
from src.core.text_processor import process_multiline_text
result = process_multiline_text("Test text", Mock(), 200, 100)
from src.core.text_processor import wrap_text_to_width
result = wrap_text_to_width("Test text", Mock(), 200)
# Should return fallback result instead of crashing
assert 'lines' in result
assert 'total_height' in result
assert 'line_count' in result
assert result == ["Test text"]

View File

@@ -1,180 +1,47 @@
"""
Unit tests for version-aware text processing behavior.
Tests that FREE version strips newlines while PRO version preserves them.
Unit tests for text content normalization.
"""
import pytest
from unittest.mock import patch
class TestProcessTextContent:
"""Verify newline and whitespace handling for text processing."""
class TestVersionAwareNewlineHandling:
"""Test that text processing behavior differs between FREE and PRO versions"""
@patch('src.core.text_processor.VERSION_TYPE', 'free')
def test_process_text_strips_newlines_in_free_version(self):
"""FREE version should strip newlines and replace with spaces"""
def test_collapses_single_newlines(self):
from src.core.text_processor import process_text_content
text = "Line 1\nLine 2\nLine 3"
text = "Line 1\nLine 2"
result = process_text_content(text)
assert result == "Line 1 Line 2 Line 3", \
f"FREE version must strip newlines, got: {repr(result)}"
@patch('src.core.text_processor.VERSION_TYPE', 'full')
def test_process_text_preserves_newlines_in_pro_version(self):
"""PRO version should preserve newlines for multiline rendering"""
from src.core.text_processor import process_text_content
text = "Line 1\nLine 2\nLine 3"
result = process_text_content(text)
assert result == "Line 1\nLine 2\nLine 3", \
f"PRO version must preserve newlines, got: {repr(result)}"
assert result == "Line 1 Line 2"
@patch('src.core.text_processor.VERSION_TYPE', 'free')
def test_process_text_strips_multiple_consecutive_newlines_in_free(self):
"""FREE version should collapse multiple newlines to single space"""
def test_collapses_multiple_newlines(self):
from src.core.text_processor import process_text_content
text = "Line 1\n\n\nLine 2"
result = process_text_content(text)
assert result == "Line 1 Line 2", \
f"FREE version must collapse multiple newlines, got: {repr(result)}"
@patch('src.core.text_processor.VERSION_TYPE', 'free')
def test_process_text_handles_mixed_whitespace_in_free(self):
"""FREE version should normalize mixed whitespace with newlines"""
assert result == "Line 1 Line 2"
def test_normalizes_mixed_whitespace(self):
from src.core.text_processor import process_text_content
text = "Line 1\n \nLine 2"
text = "Line 1\n \t\nLine 2"
result = process_text_content(text)
# Should have no newlines and normalized spacing
assert "\n" not in result, \
f"FREE version must remove all newlines, got: {repr(result)}"
assert "Line 1" in result and "Line 2" in result, \
f"Must preserve actual text content, got: {repr(result)}"
@patch('src.core.text_processor.VERSION_TYPE', 'full')
def test_process_text_preserves_multiple_newlines_in_pro(self):
"""PRO version should preserve multiple consecutive newlines"""
from src.core.text_processor import process_text_content
text = "Line 1\n\n\nLine 2"
result = process_text_content(text)
assert result == "Line 1\n\n\nLine 2", \
f"PRO version must preserve all newlines, got: {repr(result)}"
assert "\n" not in result
assert result == "Line 1 Line 2"
@patch('src.core.text_processor.VERSION_TYPE', 'free')
def test_process_empty_text_in_free(self):
"""FREE version should handle empty text gracefully"""
def test_handles_empty_string(self):
from src.core.text_processor import process_text_content
result = process_text_content("")
assert result == "", \
f"Empty text should remain empty, got: {repr(result)}"
@patch('src.core.text_processor.VERSION_TYPE', 'free')
def test_process_text_without_newlines_in_free(self):
"""FREE version should pass through text without newlines unchanged"""
assert result == ""
def test_preserves_text_without_newlines(self):
from src.core.text_processor import process_text_content
text = "Single line text"
result = process_text_content(text)
assert result == "Single line text", \
f"Text without newlines should be unchanged, got: {repr(result)}"
"""
Unit tests for version-aware text processing behavior.
Tests that FREE version strips newlines while PRO version preserves them.
"""
import pytest
from unittest.mock import patch
class TestVersionAwareNewlineHandling:
"""Test that text processing behavior differs between FREE and PRO versions"""
@patch('src.utils.constants.VERSION_TYPE', 'free')
def test_process_text_strips_newlines_in_free_version(self):
"""FREE version should strip newlines and replace with spaces"""
from src.core.text_processor import process_text_content
text = "Line 1\nLine 2\nLine 3"
result = process_text_content(text)
assert result == "Line 1 Line 2 Line 3", \
f"FREE version must strip newlines, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'full')
def test_process_text_preserves_newlines_in_pro_version(self):
"""PRO version should preserve newlines for multiline rendering"""
from src.core.text_processor import process_text_content
text = "Line 1\nLine 2\nLine 3"
result = process_text_content(text)
assert result == "Line 1\nLine 2\nLine 3", \
f"PRO version must preserve newlines, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'free')
def test_process_text_strips_multiple_consecutive_newlines_in_free(self):
"""FREE version should collapse multiple newlines to single space"""
from src.core.text_processor import process_text_content
text = "Line 1\n\n\nLine 2"
result = process_text_content(text)
assert result == "Line 1 Line 2", \
f"FREE version must collapse multiple newlines, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'free')
def test_process_text_handles_mixed_whitespace_in_free(self):
"""FREE version should normalize mixed whitespace with newlines"""
from src.core.text_processor import process_text_content
text = "Line 1\n \nLine 2"
result = process_text_content(text)
# Should have no newlines and normalized spacing
assert "\n" not in result, \
f"FREE version must remove all newlines, got: {repr(result)}"
assert "Line 1" in result and "Line 2" in result, \
f"Must preserve actual text content, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'full')
def test_process_text_preserves_multiple_newlines_in_pro(self):
"""PRO version should preserve multiple consecutive newlines"""
from src.core.text_processor import process_text_content
text = "Line 1\n\n\nLine 2"
result = process_text_content(text)
assert result == "Line 1\n\n\nLine 2", \
f"PRO version must preserve all newlines, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'free')
def test_process_empty_text_in_free(self):
"""FREE version should handle empty text gracefully"""
from src.core.text_processor import process_text_content
result = process_text_content("")
assert result == "", \
f"Empty text should remain empty, got: {repr(result)}"
@patch('src.utils.constants.VERSION_TYPE', 'free')
def test_process_text_without_newlines_in_free(self):
"""FREE version should pass through text without newlines unchanged"""
from src.core.text_processor import process_text_content
text = "Single line text"
result = process_text_content(text)
assert result == "Single line text", \
f"Text without newlines should be unchanged, got: {repr(result)}"
assert result == text

View File

@@ -28,22 +28,22 @@ class TestModalTextEditorOperator:
assert hasattr(TEXT_TEXTURE_OT_edit_text, 'draw')
def test_poll_denies_access_in_free_version(self):
"""poll() must return False when has_text_editor() is False (FREE version)."""
"""poll() must return False when running in the free version."""
from src.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_text
mock_context = Mock()
with patch('src.operators.text_editor_ops.has_text_editor', return_value=False):
with patch('src.operators.text_editor_ops.is_full_version', return_value=False):
result = TEXT_TEXTURE_OT_edit_text.poll(mock_context)
assert result is False, "FREE version should not have access to text editor"
def test_poll_allows_access_in_pro_version(self):
"""poll() must return True when has_text_editor() is True (PRO version)."""
"""poll() must return True when running in the pro version."""
from src.operators.text_editor_ops import TEXT_TEXTURE_OT_edit_text
mock_context = Mock()
with patch('src.operators.text_editor_ops.has_text_editor', return_value=True):
with patch('src.operators.text_editor_ops.is_full_version', return_value=True):
result = TEXT_TEXTURE_OT_edit_text.poll(mock_context)
assert result is True, "PRO version should have access to text editor"
@@ -125,4 +125,4 @@ class TestModalTextEditorOperator:
from src.operators import text_editor_ops
assert hasattr(text_editor_ops, 'unregister')
assert callable(text_editor_ops.unregister)
assert callable(text_editor_ops.unregister)

View File

@@ -9,28 +9,16 @@ src_path = Path(__file__).parent.parent.parent / "src"
sys.path.insert(0, str(src_path))
def test_has_multiline_text_function_complete():
"""Test that has_multiline_text() is a complete function with body."""
from utils.constants import has_multiline_text
# Should be callable
assert callable(has_multiline_text)
# Should return a boolean (not crash with incomplete body)
result = has_multiline_text()
assert isinstance(result, bool)
def test_has_multiline_text_not_importable():
"""Ensure has_multiline_text was fully removed."""
with pytest.raises(ImportError, match="cannot import name 'has_multiline_text'"):
from utils.constants import has_multiline_text
def test_has_text_editor_imports_from_constants():
"""Test that has_text_editor can be imported from utils.constants."""
from utils.constants import has_text_editor
# Verify it's callable (should be a function)
assert callable(has_text_editor)
# Should return a boolean without crashing
result = has_text_editor()
assert isinstance(result, bool)
def test_has_text_editor_not_importable():
"""Ensure has_text_editor helper was removed with the multiline feature."""
with pytest.raises(ImportError, match="cannot import name 'has_text_editor'"):
from utils.constants import has_text_editor
def test_new_operator_name_imports_from_operators():
@@ -45,4 +33,4 @@ def test_new_operator_name_imports_from_operators():
def test_old_operator_name_not_importable():
"""Test that TEXT_TEXTURE_OT_edit_multiline_text (OLD name) is NOT importable."""
with pytest.raises(ImportError, match="cannot import name 'TEXT_TEXTURE_OT_edit_multiline_text'"):
from src.operators import TEXT_TEXTURE_OT_edit_multiline_text
from src.operators import TEXT_TEXTURE_OT_edit_multiline_text

View File

@@ -4,7 +4,7 @@ Tests the core text processing logic in isolation.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import Mock, patch
import sys
import os
@@ -13,7 +13,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src'))
from fixtures.sample_data import *
from src.core.text_processor import (
process_multiline_text,
wrap_text_to_width,
render_text_with_stroke,
calculate_text_metrics,
@@ -97,68 +96,6 @@ class TestWrapTextToWidth:
assert "SuperLongWordThatCannotFit" in result
class TestProcessMultilineText:
"""Test the process_multiline_text function"""
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_single_line_text(self, mock_wrap, sample_font):
"""Test processing single line text"""
mock_wrap.return_value = [SHORT_TEXT]
with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class:
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
mock_draw.textbbox.return_value = (0, 0, 100, 20)
result = process_multiline_text(SHORT_TEXT, sample_font, 200, 100)
assert result['line_count'] == 1
assert result['lines'] == [SHORT_TEXT]
assert result['total_height'] == 20
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_multiline_text_basic(self, mock_wrap, sample_font):
"""Test processing actual multiline text"""
mock_wrap.side_effect = [['Line 1'], ['Line 2'], ['Line 3'], ['Line 4']]
with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class:
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
mock_draw.textbbox.return_value = (0, 0, 80, 20)
result = process_multiline_text(MULTILINE_TEXT, sample_font, 200, 200)
assert result['line_count'] == 4
assert len(result['lines']) == 4
assert result['total_height'] == 80 # 4 lines * 20 height
@pytest.mark.unit
def test_process_empty_text(self, sample_font):
"""Test processing empty text"""
result = process_multiline_text("", sample_font, 200, 100)
assert result['line_count'] == 1
assert result['lines'] == [""]
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_height_limit_exceeded(self, mock_wrap, sample_font):
"""Test text processing when height limit is exceeded"""
mock_wrap.side_effect = [['Line 1'], ['Line 2'], ['Line 3'], ['Line 4']]
with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class:
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
mock_draw.textbbox.return_value = (0, 0, 80, 30) # Each line is 30px high
# With max_height=50, only 1 line should fit
result = process_multiline_text(MULTILINE_TEXT, sample_font, 200, 50)
assert result['line_count'] <= 2 # Should stop when height limit reached
assert result['total_height'] <= 60 # Should not exceed much
class TestCalculateTextMetrics:
"""Test the calculate_text_metrics function"""
@@ -416,4 +353,4 @@ class TestOptimizeTextLayout:
"""Test optimization with empty text blocks list"""
result = optimize_text_layout([], 500, 400)
assert result == []
assert result == []

View File

@@ -129,26 +129,6 @@ class TestVersionDetection:
patch('src.utils.constants.ENABLED_FEATURES', ['basic', 'preview', 'text_fitting']):
assert has_text_fitting() is True
def test_has_multiline_text_returns_false_in_free_version(self):
"""Multiline text is not available in free version."""
from src.utils.constants import has_multiline_text
from unittest.mock import patch
# Test free version - need to patch ENABLED_FEATURES since it's computed at import
with patch('src.utils.constants.VERSION_TYPE', 'free'), \
patch('src.utils.constants.ENABLED_FEATURES', ['basic', 'preview']):
assert has_multiline_text() is False
def test_has_multiline_text_returns_true_in_pro_version(self):
"""Multiline text is available in PRO version."""
from src.utils.constants import has_multiline_text
from unittest.mock import patch
# Test full version - need to patch ENABLED_FEATURES since it's computed at import
with patch('src.utils.constants.VERSION_TYPE', 'full'), \
patch('src.utils.constants.ENABLED_FEATURES', ['basic', 'preview', 'multiline_text']):
assert has_multiline_text() is True
class TestRuntimeImportDetection:
@@ -317,4 +297,4 @@ class TestVersionDifferentiationScenarios:
# Generous technical limits
limits = get_version_limits()
assert limits["max_texture_size"] == 4096, "Should have 4096px texture limit"
assert limits["max_concurrent_operations"] == 4, "Should have 4 concurrent operations limit"
assert limits["max_concurrent_operations"] == 4, "Should have 4 concurrent operations limit"