text fitting pro feature

This commit is contained in:
2025-10-10 14:41:18 +02:00
parent 0edf324335
commit 596f640912
23 changed files with 909 additions and 86 deletions

View File

@@ -70,7 +70,7 @@ def create_template_variables(config: Dict, version: str, version_type: str = "f
if version_type == "free":
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities"]
else: # full version
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities", "advanced_styling", "normal_maps", "presets", "batch_processing", "advanced_utilities"]
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities", "advanced_styling", "normal_maps", "presets", "batch_processing", "advanced_utilities", "text_fitting"]
# Parse Blender version
blender_version = config['project']['blender_version_min']

View File

@@ -1,19 +0,0 @@
schema_version = "1.0.0"
id = "{{PROJECT_ID}}"
version = "{{VERSION}}"
name = "{{PROJECT_NAME}}"
tagline = "{{PROJECT_DESCRIPTION}}"
maintainer = "{{PROJECT_AUTHOR}}"
type = "add-on"
support = "{{PROJECT_SUPPORT}}"
tags = {{PROJECT_TAGS}}
blender_version_min = "{{BLENDER_VERSION_MIN}}"
license = {{PROJECT_LICENSE}}
# Dependencies
[dependencies]
{{DEPENDENCIES}}

View File

@@ -19,7 +19,8 @@ bl_info = {
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
"basic", "preview", "presets", "normal_maps", "batch_processing",
"image_overlays", "basic_utilities", "advanced_utilities", "advanced_styling",
"stroke_effects", "blur_effects", "shadow_glow_effects", "shader_generation"
"stroke_effects", "blur_effects", "shadow_glow_effects", "shader_generation",
"text_fitting"
]
# Version-specific limits (applied at build time through file exclusions)
@@ -109,6 +110,10 @@ def has_shader_generation():
"""Check if shader generation is available"""
return has_feature("shader_generation")
def has_text_fitting():
"""Check if text fitting is available"""
return has_feature("text_fitting")
def get_max_resolution():
"""Get maximum texture resolution based on version"""
limits = get_version_limits()
@@ -128,7 +133,8 @@ def should_show_feature_lock(feature_name):
'stroke_effects': has_stroke_effects,
'blur_effects': has_blur_effects,
'shadow_glow_effects': has_shadow_glow_effects,
'shader_generation': has_shader_generation
'shader_generation': has_shader_generation,
'text_fitting': has_text_fitting
}
if is_full_version():

Binary file not shown.

Binary file not shown.

View File

@@ -15,6 +15,128 @@ from typing import Dict, List, Optional
import tempfile
import argparse
import fnmatch
import ast
import tokenize
import io
def minify_python_code(source_code: str) -> str:
"""
Minify Python source code by removing comments and docstrings.
This function:
1. Removes all # comments (inline and standalone)
2. Preserves bl_info dictionary (Blender requirement)
3. Removes other function/class docstrings
4. Optimizes whitespace (max 1 blank line between definitions)
5. Maintains valid Python syntax
Args:
source_code: Python source code to minify
Returns:
Minified Python code as a string
"""
# Parse the code to find docstring line ranges to remove
try:
tree = ast.parse(source_code)
except SyntaxError:
return source_code
# Collect line numbers of docstrings to remove
docstring_lines_to_remove = set()
def process_body(body, parent_is_module=False):
"""Process a body of statements to find docstrings."""
if not body:
return
first_stmt = body[0]
if isinstance(first_stmt, ast.Expr):
value = first_stmt.value
if isinstance(value, (ast.Str, ast.Constant)):
# Check if this is a docstring (string literal as first statement)
if isinstance(value, ast.Constant) and not isinstance(value.value, str):
return
# Always remove docstrings (module, class, and function)
# The bl_info DICT itself will be preserved by not being marked
# Mark these lines for removal
for lineno in range(first_stmt.lineno, first_stmt.end_lineno + 1):
docstring_lines_to_remove.add(lineno)
# Process module-level docstrings
process_body(tree.body, parent_is_module=True)
# Process function and class docstrings
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
process_body(node.body, parent_is_module=False)
# First pass: Remove comments and docstring lines using tokenize
# This safely handles # symbols inside strings
result_lines = []
try:
tokens = tokenize.generate_tokens(io.StringIO(source_code).readline)
last_lineno = -1
last_col = 0
for tok in tokens:
token_type = tok[0]
token_string = tok[1]
start_line, start_col = tok[2]
end_line, end_col = tok[3]
# Skip comments
if token_type == tokenize.COMMENT:
continue
# Skip tokens that are part of docstrings we want to remove
if start_line in docstring_lines_to_remove:
continue
# Handle newlines and track lines
if start_line > last_lineno:
last_col = 0
# Add spacing if needed
if start_col > last_col:
result_lines.append(" " * (start_col - last_col))
# Add the token
result_lines.append(token_string)
# Update position tracking
last_col = end_col
last_lineno = end_line
except tokenize.TokenError:
# If tokenization fails, return original
return source_code
# Join tokens back into source
result = "".join(result_lines)
# Second pass: Optimize whitespace (max 1 blank line between definitions)
lines = result.split('\n')
optimized_lines = []
blank_count = 0
for line in lines:
if line.strip() == '':
blank_count += 1
# Allow max 1 consecutive blank line
if blank_count <= 1:
optimized_lines.append(line)
else:
blank_count = 0
optimized_lines.append(line)
# Remove trailing blank lines
while optimized_lines and optimized_lines[-1].strip() == '':
optimized_lines.pop()
return '\n'.join(optimized_lines)
def get_project_root() -> Path:
"""Get the project root directory."""
@@ -78,7 +200,7 @@ def should_exclude_file(file_path: Path, source_dir: Path, exclude_patterns: Lis
return False
def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[str], exclude_files: List[str] = None):
def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[str], exclude_files: List[str] = None, minify: bool = True):
"""Copy source files to destination, excluding specified patterns and files."""
if exclude_files is None:
exclude_files = []
@@ -104,7 +226,21 @@ def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[s
# Create parent directories
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Copy file
# Copy file with optional minification for Python files
if minify and item.suffix == '.py':
# Read, minify, and write Python files
try:
source_code = item.read_text(encoding='utf-8')
minified_code = minify_python_code(source_code)
dest_file.write_text(minified_code, encoding='utf-8')
print(f" Copied (minified): {rel_path}")
except Exception as e:
# If minification fails, fall back to regular copy
print(f" Warning: Could not minify {rel_path}, copying as-is: {e}")
shutil.copy2(item, dest_file)
print(f" Copied: {rel_path}")
else:
# Copy non-Python files or when minification is disabled
shutil.copy2(item, dest_file)
print(f" Copied: {rel_path}")
copied_count += 1
@@ -145,11 +281,19 @@ def get_version_from_git() -> Optional[str]:
return None
def build_addon(version_type: str, version: Optional[str] = None, output_dir: Optional[Path] = None):
def build_addon(version_type: str, version: Optional[str] = None, output_dir: Optional[Path] = None, minify: bool = True):
"""Build addon for specified version type."""
config = load_config()
exclusions_config = load_exclusions_config()
project_root = get_project_root()
# Use current working directory as project root (supports testing with temp dirs)
project_root = Path.cwd()
# Load configs from project root
config_path = project_root / "build" / "config.json"
with open(config_path, 'r') as f:
config = json.load(f)
exclusions_path = project_root / "build" / "file_exclusions.json"
with open(exclusions_path, 'r') as f:
exclusions_config = json.load(f)
# Determine version
if version is None:
@@ -191,7 +335,7 @@ def build_addon(version_type: str, version: Optional[str] = None, output_dir: Op
# Copy source files to temp directory with version-specific exclusions
build_temp_dir = temp_dir / f"{config['project']['id']}_{version_type}"
copy_source_files(source_dir, build_temp_dir, exclude_patterns, exclude_files)
copy_source_files(source_dir, build_temp_dir, exclude_patterns, exclude_files, minify)
# Create output filename
version_suffix = f"_{version_type}" if version_type == "free" else ""
@@ -210,7 +354,7 @@ def build_addon(version_type: str, version: Optional[str] = None, output_dir: Op
return output_file
def build_all_versions(version: Optional[str] = None):
def build_all_versions(version: Optional[str] = None, minify: bool = True):
"""Build both full and free versions."""
print("Building all versions...")
@@ -220,14 +364,14 @@ def build_all_versions(version: Optional[str] = None):
print("\n" + "="*50)
print("BUILDING FULL VERSION")
print("="*50)
full_package = build_addon("full", version)
full_package = build_addon("full", version, minify=minify)
built_packages.append(("full", full_package))
# Build free version
print("\n" + "="*50)
print("BUILDING FREE VERSION")
print("="*50)
free_package = build_addon("free", version)
free_package = build_addon("free", version, minify=minify)
built_packages.append(("free", free_package))
print("\n" + "="*50)
@@ -246,14 +390,18 @@ def main():
parser.add_argument("--type", choices=["free", "full", "all"], default="all",
help="Version type to build")
parser.add_argument("--output", type=Path, help="Output directory")
parser.add_argument("--minify", dest="minify", action="store_true", default=True,
help="Minify Python code (default: enabled)")
parser.add_argument("--no-minify", dest="minify", action="store_false",
help="Disable Python code minification")
args = parser.parse_args()
try:
if args.type == "all":
build_all_versions(args.version)
build_all_versions(args.version, args.minify)
else:
build_addon(args.type, args.version, args.output)
build_addon(args.type, args.version, args.output, args.minify)
except Exception as e:
print(f"❌ Build failed: {e}")

View File

@@ -1,19 +0,0 @@
schema_version = "1.0.0"
id = "text_texture_generator"
version = "1.0.0"
name = "Text Texture Generator"
tagline = "Generate image textures from text with accurate dimensions and instant live preview"
maintainer = "Marc Mintel <marc@mintel.me>"
type = "add-on"
support = "COMMUNITY"
tags = ["Material", "Shader", "Texture", "Text", "Image"]
blender_version_min = "4.0.0"
license = ["GPL-3.0-or-later"]
# Dependencies
[dependencies]
pillow = ">=10.0.0"

View File

@@ -4,6 +4,7 @@ Core functionality for generating texture images with text overlays.
"""
import bpy
from ..utils.constants import has_text_fitting
def generate_texture_image(props, width, height):
pass
@@ -102,7 +103,7 @@ def generate_texture_image(props, width, height):
# Determine font size (with text fitting if enabled)
font_size = props.font_size
if props.enable_text_fitting:
if props.enable_text_fitting and has_text_fitting():
pass
# Try to load font for sizing

View File

@@ -12,7 +12,7 @@ from ..utils.constants import (
get_feature_availability_text, get_version_info_details,
has_image_overlays, has_basic_utilities, has_presets,
has_normal_maps, has_batch_processing, has_advanced_utilities,
has_advanced_styling
has_advanced_styling, has_text_fitting
)
@@ -655,8 +655,11 @@ def draw_normal_maps_section(layout, props):
info_box.label(text="• Invert changes raised/recessed effect")
def draw_text_fitting_section(layout, props):
pass
"""Draw text fitting controls"""
# Check if text fitting is available
if not has_text_fitting():
draw_feature_lock_indicator(layout, 'text_fitting')
return
# Main toggle for text fitting
layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER')
@@ -890,22 +893,11 @@ class TEXT_TEXTURE_PT_panel(Panel):
@classmethod
def poll(cls, context):
pass
# DEBUG: Log poll method calls
if hasattr(context.space_data, 'tree_type'):
pass
else:
pass
# More permissive poll condition to ensure panel shows up
"""Show panel in Shader Editor only"""
if context.space_data.type == 'NODE_EDITOR':
pass
if hasattr(context.space_data, 'tree_type'):
pass
result = context.space_data.tree_type == 'ShaderNodeTree'
return result
return context.space_data.tree_type == 'ShaderNodeTree'
else:
pass
# Allow panel even without tree_type (sometimes happens)
return True

View File

@@ -19,7 +19,8 @@ bl_info = {
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
"basic", "preview", "presets", "normal_maps", "batch_processing",
"image_overlays", "basic_utilities", "advanced_utilities", "advanced_styling",
"stroke_effects", "blur_effects", "shadow_glow_effects", "shader_generation"
"stroke_effects", "blur_effects", "shadow_glow_effects", "shader_generation",
"text_fitting"
]
# Version-specific limits (applied at build time through file exclusions)
@@ -109,6 +110,10 @@ def has_shader_generation():
"""Check if shader generation is available"""
return has_feature("shader_generation")
def has_text_fitting():
"""Check if text fitting is available"""
return has_feature("text_fitting")
def get_max_resolution():
"""Get maximum texture resolution based on version"""
limits = get_version_limits()
@@ -128,7 +133,8 @@ def should_show_feature_lock(feature_name):
'stroke_effects': has_stroke_effects,
'blur_effects': has_blur_effects,
'shadow_glow_effects': has_shadow_glow_effects,
'shader_generation': has_shader_generation
'shader_generation': has_shader_generation,
'text_fitting': has_text_fitting
}
if is_full_version():

View File

@@ -15,7 +15,7 @@ def test_built_package_bl_info_is_parseable():
# Step 1: Rebuild the package to ensure latest code
result = subprocess.run(
["python", "build/build_addon.py", "--type", "full"],
["python", "scripts/build_addon.py"],
cwd=project_root,
capture_output=True,
text=True
@@ -70,3 +70,38 @@ def test_built_package_bl_info_is_parseable():
raise AssertionError(f"bl_info failed ast.literal_eval() at line {node.lineno}: {e}")
assert bl_info_found, "bl_info not found in __init__.py"
def test_manifest_not_in_source():
"""Verify blender_manifest.toml is NOT in source directory (legacy mode)."""
manifest_path = Path(__file__).parent.parent.parent / "src" / "blender_manifest.toml"
assert not manifest_path.exists(), \
"blender_manifest.toml should not exist in src/ for legacy addon mode"
def test_manifest_not_in_built_package():
"""Verify blender_manifest.toml is NOT in built packages (both FREE and FULL)."""
project_root = Path(__file__).parent.parent.parent
# Check both FREE and FULL versions
zip_files = [
project_root / "dist" / "text_texture_generator_v1.0.0.zip", # FULL
project_root / "dist" / "text_texture_generator_v1.0.0_free.zip", # FREE
]
for zip_path in zip_files:
if not zip_path.exists():
continue # Skip if not built yet
with zipfile.ZipFile(zip_path, 'r') as z:
files = z.namelist()
manifest_files = [f for f in files if 'blender_manifest.toml' in f]
assert len(manifest_files) == 0, \
f"blender_manifest.toml should not be in {zip_path.name}. Found: {manifest_files}"
def test_manifest_template_not_exists():
"""Verify blender_manifest.toml.template doesn't exist (prevents regeneration)."""
template_path = Path(__file__).parent.parent.parent / "build" / "templates" / "blender_manifest.toml.template"
assert not template_path.exists(), \
"blender_manifest.toml.template should not exist to prevent automatic regeneration during builds"

View File

@@ -347,7 +347,66 @@ print("SUCCESS: Image overlay properly blocked")
output = test_result.output.decode('utf-8', errors='replace')
assert test_result.exit_code == 0, f"Overlay block test failed:\n{output}"
assert "SUCCESS: Image overlay properly blocked" in output
def test_free_version_blocks_text_fitting(blender_container, addon_package):
"""
SPECIFICATION: Free version MUST block text fitting functionality.
Expected behavior (VERSION_TYPE="free"):
- "text_fitting" NOT in ENABLED_FEATURES
- has_text_fitting() returns False
- Text fitting controls unavailable
- UI shows "🔒 PRO" on text fitting controls
Current state (VERSION_TYPE="full"):
- Test SKIPS (text_fitting is available)
"""
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 text_fitting NOT enabled
from text_texture_generator.utils.constants import ENABLED_FEATURES, has_text_fitting
if "text_fitting" in ENABLED_FEATURES:
print(f"FAIL: text_fitting should NOT be in ENABLED_FEATURES for free version")
sys.exit(1)
# Verify has_text_fitting() returns False
if has_text_fitting():
print(f"FAIL: has_text_fitting() should return False for free version")
sys.exit(1)
print("SUCCESS: Text fitting properly blocked")
"""
tar_stream = io.BytesIO()
with tarfile.open(fileobj=tar_stream, mode='w') as tar:
script_info = tarfile.TarInfo(name='test_text_fitting.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_text_fitting.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"Text fitting block test failed:\n{output}"
assert "SUCCESS: Text fitting properly blocked" in output
def test_free_version_texture_size_limited(blender_container, addon_package):

View File

@@ -0,0 +1,159 @@
"""
Integration tests for Python code minification during addon build process.
Tests verify that the build system correctly applies minification when requested.
"""
import pytest
import tempfile
import zipfile
from pathlib import Path
import shutil
import sys
import os
import json
# Add scripts to path to import build module
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
from build_addon import build_addon, minify_python_code
class TestBuildMinificationIntegration:
"""Integration tests for minification in the build process"""
@pytest.fixture
def temp_build_env(self):
"""Create a temporary build environment with sample Python files"""
# Create temp directories
temp_dir = tempfile.mkdtemp()
project_root = Path(temp_dir)
src_dir = project_root / "src"
build_dir = project_root / "build"
output_dir = project_root / "dist"
src_dir.mkdir(parents=True)
build_dir.mkdir(parents=True)
output_dir.mkdir(parents=True)
# Create sample Python file with comments and docstrings
sample_code = '''"""Module docstring to be removed"""
# This is a comment
def hello_world():
"""Function docstring to be removed"""
# Inline comment
return "Hello, World!"
class MyClass:
"""Class docstring to be removed"""
def method(self):
# Another comment
return 42
'''
(src_dir / "__init__.py").write_text(sample_code)
(src_dir / "blender_manifest.toml").write_text('[manifest]\nname = "Test"\n')
# Create minimal config files
config = {
"project": {"id": "test_addon"},
"build": {
"source_dir": "src",
"dist_dir": "dist",
"temp_dir": "build/temp",
"exclude_patterns": ["__pycache__", "*.pyc"]
}
}
exclusions = {
"version_configs": {
"full": {"exclude_files": [], "exclude_patterns": []},
"free": {"exclude_files": [], "exclude_patterns": []}
}
}
(build_dir / "config.json").write_text(json.dumps(config))
(build_dir / "file_exclusions.json").write_text(json.dumps(exclusions))
# Create dummy sync_version.py
sync_script = '''#!/usr/bin/env python3
import sys
if "--version" in sys.argv:
print("Synchronizing version: 1.0.0")
sys.exit(0)
'''
(build_dir / "sync_version.py").write_text(sync_script)
yield {
'project_root': project_root,
'src_dir': src_dir,
'output_dir': output_dir,
'sample_code': sample_code
}
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
def test_build_with_minify_flag_produces_minified_python_files(self, temp_build_env, monkeypatch):
"""Test that build with minify=True produces minified .py files in output"""
# This test will fail because minify parameter doesn't exist in build_addon()
project_root = temp_build_env['project_root']
output_dir = temp_build_env['output_dir']
# Change to project root for build
monkeypatch.chdir(project_root)
# Build with minification enabled
# Expected: build_addon() should accept minify=True parameter
output_zip = build_addon(
version_type="full",
version="1.0.0",
output_dir=output_dir,
minify=True # This parameter doesn't exist yet - will cause TypeError
)
# Extract and verify minified content
with zipfile.ZipFile(output_zip, 'r') as zf:
# Find the __init__.py file in the zip
init_files = [name for name in zf.namelist() if name.endswith('__init__.py')]
assert len(init_files) > 0, "No __init__.py found in package"
minified_content = zf.read(init_files[0]).decode('utf-8')
# Verify minification occurred
assert '# This is a comment' not in minified_content, "Comments should be removed"
assert '"""Module docstring to be removed"""' not in minified_content, "Module docstrings should be removed"
assert '"""Function docstring to be removed"""' not in minified_content, "Function docstrings should be removed"
assert 'def hello_world():' in minified_content, "Function definition should be preserved"
assert 'return "Hello, World!"' in minified_content, "Function body should be preserved"
def test_build_without_minify_flag_preserves_original_code(self, temp_build_env, monkeypatch):
"""Test that build with minify=False preserves original Python code"""
# This test will fail because minify parameter doesn't exist
project_root = temp_build_env['project_root']
output_dir = temp_build_env['output_dir']
original_code = temp_build_env['sample_code']
monkeypatch.chdir(project_root)
# Build with minification disabled
output_zip = build_addon(
version_type="full",
version="1.0.0",
output_dir=output_dir,
minify=False # This parameter doesn't exist yet - will cause TypeError
)
# Extract and verify original content preserved
with zipfile.ZipFile(output_zip, 'r') as zf:
init_files = [name for name in zf.namelist() if name.endswith('__init__.py')]
assert len(init_files) > 0
preserved_content = zf.read(init_files[0]).decode('utf-8')
# Verify original code is intact
assert '# This is a comment' in preserved_content, "Comments should be preserved"
assert '"""Module docstring to be removed"""' in preserved_content, "Docstrings should be preserved"
assert 'def hello_world():' in preserved_content
assert 'return "Hello, World!"' in preserved_content

View File

@@ -2,14 +2,6 @@
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:
@@ -49,8 +41,11 @@ class TestGenerateTextureImageControlFlow:
mock_props.enable_text_fitting = False
mock_props.generate_normal_map = False
# Import function with proper src path
from src.core.generation_engine import generate_texture_image
# Patch bpy at module level with proper structure
with patch('core.generation_engine.bpy') as mock_bpy:
with patch('src.core.generation_engine.bpy') as mock_bpy:
# Properly structure the bpy mock
mock_images = Mock()
mock_images.new = Mock(return_value=mock_blender_img)
@@ -121,7 +116,10 @@ class TestGenerateTextureImageControlFlow:
mock_props.enable_text_fitting = False
mock_props.generate_normal_map = False
with patch('core.generation_engine.bpy') as mock_bpy:
# Import function with proper src path
from src.core.generation_engine import generate_texture_image
with patch('src.core.generation_engine.bpy') as mock_bpy:
# Properly structure the bpy mock
mock_images = Mock()
mock_images.new = Mock(return_value=mock_blender_img)

View File

@@ -0,0 +1,442 @@
"""
Unit tests for build system code minification features.
These tests verify that the build process can:
1. Strip comments while preserving functionality
2. Remove docstrings except bl_info and functionally required ones
3. Optimize whitespace
4. Maintain valid Python syntax and runtime behavior
"""
import ast
import textwrap
from pathlib import Path
import sys
# Add scripts directory to path to import build_addon
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
import pytest
def test_strips_inline_comments():
"""Test that inline comments are removed from code."""
source = textwrap.dedent("""
def calculate_width(text, font_size):
# Calculate the pixel width of text
base_width = len(text) * font_size # Simple approximation
return base_width * 0.6 # Adjustment factor
""").strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Verify comments are removed
assert "# Calculate" not in result
assert "# Simple approximation" not in result
assert "# Adjustment factor" not in result
# Verify functionality is preserved
assert "def calculate_width(text, font_size):" in result
assert "base_width = len(text) * font_size" in result
assert "return base_width * 0.6" in result
# Verify result is valid Python
ast.parse(result)
def test_strips_block_comments():
"""Test that block comments at various positions are removed."""
source = textwrap.dedent("""
# Module-level comment about the purpose
# Another line of module comment
def process_text(text):
# Beginning of function comment
result = text.upper()
# Middle comment
result = result.strip()
# End comment
return result
""").strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# All comments should be gone
assert "# Module-level" not in result
assert "# Another line" not in result
assert "# Beginning of function" not in result
assert "# Middle comment" not in result
assert "# End comment" not in result
# But code should remain
assert "def process_text(text):" in result
assert "result = text.upper()" in result
assert "result = result.strip()" in result
assert "return result" in result
def test_preserves_bl_info_dict():
"""Test that bl_info dictionary is preserved (required by Blender)."""
source = textwrap.dedent('''
"""
Addon docstring that should be removed.
"""
bl_info = {
"name": "Text Texture Generator",
"author": "Developer",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"description": "Generate text textures",
}
def some_function():
"""Regular docstring to remove."""
return "value"
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# bl_info must be preserved exactly
assert "bl_info = {" in result
assert '"name": "Text Texture Generator"' in result
assert '"author": "Developer"' in result
assert '"version": (1, 0, 0)' in result
# Module docstring should be removed
assert "Addon docstring that should be removed" not in result
# Function docstring should be removed
assert "Regular docstring to remove" not in result
# Code should remain
assert "def some_function():" in result
assert 'return "value"' in result
def test_removes_function_docstrings():
"""Test that function and method docstrings are removed."""
source = textwrap.dedent('''
class TextGenerator:
"""Class docstring to remove."""
def generate(self, text):
"""
Generate a texture from text.
Args:
text: The input text string
Returns:
Generated texture object
"""
return self._process(text)
def _process(self, text):
"""Internal processing method."""
return text.upper()
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# All docstrings should be removed
assert "Class docstring to remove" not in result
assert "Generate a texture from text" not in result
assert "Args:" not in result
assert "Returns:" not in result
assert "Internal processing method" not in result
# Code structure should remain
assert "class TextGenerator:" in result
assert "def generate(self, text):" in result
assert "return self._process(text)" in result
assert "def _process(self, text):" in result
assert "return text.upper()" in result
def test_preserves_string_literals():
"""Test that string literals used as data are not removed."""
source = textwrap.dedent('''
def get_error_messages():
"""Function docstring to remove."""
# Comment to remove
errors = {
"invalid_input": "Input text cannot be empty",
"font_missing": "Selected font not found",
}
return errors
def validate(text):
# Check if text is valid
if not text:
raise ValueError("Text cannot be empty") # Inline comment
return True
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Docstrings and comments should be removed
assert "Function docstring to remove" not in result
assert "# Comment to remove" not in result
assert "# Check if text is valid" not in result
assert "# Inline comment" not in result
# String literals in code must be preserved
assert '"invalid_input": "Input text cannot be empty"' in result
assert '"font_missing": "Selected font not found"' in result
assert 'raise ValueError("Text cannot be empty")' in result
# Code structure preserved
assert "def get_error_messages():" in result
assert "def validate(text):" in result
def test_optimizes_whitespace():
"""Test that excessive blank lines and inconsistent indentation are cleaned up."""
source = textwrap.dedent("""
def function_one():
result = 1
return result
def function_two():
# Comment
value = 2
return value
class MyClass:
def method(self):
pass
""").strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Should not have excessive blank lines (max 1 between definitions)
assert "\n\n\n" not in result
# Functions should be separated by at most one blank line
lines = result.split("\n")
blank_line_count = 0
max_consecutive_blanks = 0
for line in lines:
if line.strip() == "":
blank_line_count += 1
max_consecutive_blanks = max(max_consecutive_blanks, blank_line_count)
else:
blank_line_count = 0
assert max_consecutive_blanks <= 1, f"Found {max_consecutive_blanks} consecutive blank lines"
# Code should still be present
assert "def function_one():" in result
assert "def function_two():" in result
assert "class MyClass:" in result
def test_maintains_valid_python_syntax():
"""Test that minified code is syntactically valid Python."""
source = textwrap.dedent('''
"""Module docstring."""
# Import comment
import bpy # Blender Python API
from typing import Optional # Type hints
class TextureGenerator:
"""Generator class."""
def __init__(self, resolution=1024):
"""Initialize generator."""
# Store resolution
self.resolution = resolution # Texture size
def generate(self, text: str) -> Optional[object]:
"""
Generate texture from text.
Args:
text: Input text string
Returns:
Texture object or None
"""
if not text: # Validate input
return None
# Create texture
texture = bpy.data.images.new(
"TextTexture",
self.resolution,
self.resolution
)
return texture # Return result
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Must be valid Python
try:
ast.parse(result)
except SyntaxError as e:
pytest.fail(f"Minified code has syntax error: {e}")
# Should not have docstrings or comments
assert '"""Module docstring."""' not in result
assert '"""Generator class."""' not in result
assert '"""Initialize generator."""' not in result
assert "# Import comment" not in result
assert "# Blender Python API" not in result
assert "# Store resolution" not in result
# Code should be present
assert "import bpy" in result
assert "from typing import Optional" in result
assert "class TextureGenerator:" in result
assert "def __init__(self, resolution=1024):" in result
assert "self.resolution = resolution" in result
def test_preserves_strings_with_hash_symbols():
"""Test that strings containing hash symbols are not treated as comments."""
source = textwrap.dedent('''
def create_id(text):
"""Generate hash ID.""" # Function comment
# Create the ID with a hash prefix
return f"#{hash(text)}" # Hash prefix
def parse_color(color_string):
# Check if it's a hex color
if color_string.startswith("#"): # Hex colors start with #
return color_string
return None
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Comments should be removed
assert "Function comment" not in result
assert "# Create the ID" not in result
assert "# Hash prefix" not in result
assert "# Check if it's a hex color" not in result
assert "# Hex colors start with #" not in result
# String literals with # should be preserved
assert 'f"#{hash(text)}"' in result or 'f"#{hash(text)}"' in result
assert 'startswith("#")' in result or 'startswith("#")' in result
# Code structure preserved
assert "def create_id(text):" in result
assert "def parse_color(color_string):" in result
def test_preserves_functionality_complex_example():
"""Test minification preserves functionality in complex real-world code."""
source = textwrap.dedent('''
"""
Text processing module for the addon.
This module handles text wrapping and fitting.
"""
import re # Regular expressions
from typing import List, Tuple # Type hints
class TextProcessor:
"""Process text for texture generation."""
def __init__(self, max_width: int = 100):
"""
Initialize processor.
Args:
max_width: Maximum line width in pixels
"""
# Store configuration
self.max_width = max_width # Width limit
self._cache = {} # Results cache
def wrap_text(self, text: str) -> List[str]:
"""
Wrap text to fit within max width.
Args:
text: Input text to wrap
Returns:
List of wrapped lines
"""
# Check cache first
if text in self._cache: # Cache hit
return self._cache[text]
# Split into words
words = re.split(r'\\s+', text) # Word boundaries
lines = []
current_line = ""
# Process each word
for word in words:
# Check if adding word exceeds width
test_line = f"{current_line} {word}".strip()
if len(test_line) <= self.max_width: # Fits
current_line = test_line
else: # Doesn't fit
if current_line: # Save current line
lines.append(current_line)
current_line = word # Start new line
# Add final line
if current_line: # Not empty
lines.append(current_line)
# Cache result
self._cache[text] = lines # Store for reuse
return lines
''').strip()
from build_addon import minify_python_code
result = minify_python_code(source)
# Must be valid Python
try:
compiled = compile(result, '<minified>', 'exec')
except SyntaxError as e:
pytest.fail(f"Minified code won't compile: {e}")
# Verify it's executable (can define the class)
namespace = {}
exec(compiled, namespace)
assert 'TextProcessor' in namespace
# Verify functionality works
processor = namespace['TextProcessor'](max_width=20)
result_lines = processor.wrap_text("hello world test")
assert isinstance(result_lines, list)
assert len(result_lines) > 0
# No docstrings or comments should remain
minified_lines = result.split('\n')
for line in minified_lines:
stripped = line.strip()
if stripped:
assert not stripped.startswith('#'), f"Found comment line: {line}"
assert '"""' not in line, f"Found docstring in: {line}"

View File

@@ -114,6 +114,21 @@ class TestVersionDetection:
assert limits["max_concurrent_operations"] == 1, \
f"Expected free version max_concurrent_operations=1, got {limits['max_concurrent_operations']}"
def test_has_text_fitting_returns_correct_value_per_version(self):
"""Test that has_text_fitting() returns correct value based on VERSION_TYPE."""
from src.utils.constants import has_text_fitting
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_text_fitting() is False
# 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', 'text_fitting']):
assert has_text_fitting() is True
class TestRuntimeImportDetection: