text fitting pro feature
This commit is contained in:
Binary file not shown.
@@ -70,7 +70,7 @@ def create_template_variables(config: Dict, version: str, version_type: str = "f
|
|||||||
if version_type == "free":
|
if version_type == "free":
|
||||||
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities"]
|
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities"]
|
||||||
else: # full version
|
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
|
# Parse Blender version
|
||||||
blender_version = config['project']['blender_version_min']
|
blender_version = config['project']['blender_version_min']
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -19,7 +19,8 @@ bl_info = {
|
|||||||
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
|
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
|
||||||
"basic", "preview", "presets", "normal_maps", "batch_processing",
|
"basic", "preview", "presets", "normal_maps", "batch_processing",
|
||||||
"image_overlays", "basic_utilities", "advanced_utilities", "advanced_styling",
|
"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)
|
# Version-specific limits (applied at build time through file exclusions)
|
||||||
@@ -109,6 +110,10 @@ def has_shader_generation():
|
|||||||
"""Check if shader generation is available"""
|
"""Check if shader generation is available"""
|
||||||
return has_feature("shader_generation")
|
return has_feature("shader_generation")
|
||||||
|
|
||||||
|
def has_text_fitting():
|
||||||
|
"""Check if text fitting is available"""
|
||||||
|
return has_feature("text_fitting")
|
||||||
|
|
||||||
def get_max_resolution():
|
def get_max_resolution():
|
||||||
"""Get maximum texture resolution based on version"""
|
"""Get maximum texture resolution based on version"""
|
||||||
limits = get_version_limits()
|
limits = get_version_limits()
|
||||||
@@ -128,7 +133,8 @@ def should_show_feature_lock(feature_name):
|
|||||||
'stroke_effects': has_stroke_effects,
|
'stroke_effects': has_stroke_effects,
|
||||||
'blur_effects': has_blur_effects,
|
'blur_effects': has_blur_effects,
|
||||||
'shadow_glow_effects': has_shadow_glow_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():
|
if is_full_version():
|
||||||
|
|||||||
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
Binary file not shown.
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
Binary file not shown.
@@ -15,6 +15,128 @@ from typing import Dict, List, Optional
|
|||||||
import tempfile
|
import tempfile
|
||||||
import argparse
|
import argparse
|
||||||
import fnmatch
|
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:
|
def get_project_root() -> Path:
|
||||||
"""Get the project root directory."""
|
"""Get the project root directory."""
|
||||||
@@ -78,7 +200,7 @@ def should_exclude_file(file_path: Path, source_dir: Path, exclude_patterns: Lis
|
|||||||
|
|
||||||
return False
|
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."""
|
"""Copy source files to destination, excluding specified patterns and files."""
|
||||||
if exclude_files is None:
|
if exclude_files is None:
|
||||||
exclude_files = []
|
exclude_files = []
|
||||||
@@ -104,9 +226,23 @@ def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[s
|
|||||||
# Create parent directories
|
# Create parent directories
|
||||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Copy file
|
# Copy file with optional minification for Python files
|
||||||
shutil.copy2(item, dest_file)
|
if minify and item.suffix == '.py':
|
||||||
print(f" Copied: {rel_path}")
|
# 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
|
copied_count += 1
|
||||||
|
|
||||||
print(f"Files copied: {copied_count}, excluded: {excluded_count}")
|
print(f"Files copied: {copied_count}, excluded: {excluded_count}")
|
||||||
@@ -145,11 +281,19 @@ def get_version_from_git() -> Optional[str]:
|
|||||||
|
|
||||||
return None
|
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."""
|
"""Build addon for specified version type."""
|
||||||
config = load_config()
|
# Use current working directory as project root (supports testing with temp dirs)
|
||||||
exclusions_config = load_exclusions_config()
|
project_root = Path.cwd()
|
||||||
project_root = get_project_root()
|
|
||||||
|
# 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
|
# Determine version
|
||||||
if version is None:
|
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
|
# Copy source files to temp directory with version-specific exclusions
|
||||||
build_temp_dir = temp_dir / f"{config['project']['id']}_{version_type}"
|
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
|
# Create output filename
|
||||||
version_suffix = f"_{version_type}" if version_type == "free" else ""
|
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
|
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."""
|
"""Build both full and free versions."""
|
||||||
print("Building all versions...")
|
print("Building all versions...")
|
||||||
|
|
||||||
@@ -220,14 +364,14 @@ def build_all_versions(version: Optional[str] = None):
|
|||||||
print("\n" + "="*50)
|
print("\n" + "="*50)
|
||||||
print("BUILDING FULL VERSION")
|
print("BUILDING FULL VERSION")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
full_package = build_addon("full", version)
|
full_package = build_addon("full", version, minify=minify)
|
||||||
built_packages.append(("full", full_package))
|
built_packages.append(("full", full_package))
|
||||||
|
|
||||||
# Build free version
|
# Build free version
|
||||||
print("\n" + "="*50)
|
print("\n" + "="*50)
|
||||||
print("BUILDING FREE VERSION")
|
print("BUILDING FREE VERSION")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
free_package = build_addon("free", version)
|
free_package = build_addon("free", version, minify=minify)
|
||||||
built_packages.append(("free", free_package))
|
built_packages.append(("free", free_package))
|
||||||
|
|
||||||
print("\n" + "="*50)
|
print("\n" + "="*50)
|
||||||
@@ -246,14 +390,18 @@ def main():
|
|||||||
parser.add_argument("--type", choices=["free", "full", "all"], default="all",
|
parser.add_argument("--type", choices=["free", "full", "all"], default="all",
|
||||||
help="Version type to build")
|
help="Version type to build")
|
||||||
parser.add_argument("--output", type=Path, help="Output directory")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.type == "all":
|
if args.type == "all":
|
||||||
build_all_versions(args.version)
|
build_all_versions(args.version, args.minify)
|
||||||
else:
|
else:
|
||||||
build_addon(args.type, args.version, args.output)
|
build_addon(args.type, args.version, args.output, args.minify)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Build failed: {e}")
|
print(f"❌ Build failed: {e}")
|
||||||
|
|||||||
@@ -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"
|
|
||||||
Binary file not shown.
@@ -4,6 +4,7 @@ Core functionality for generating texture images with text overlays.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
from ..utils.constants import has_text_fitting
|
||||||
|
|
||||||
def generate_texture_image(props, width, height):
|
def generate_texture_image(props, width, height):
|
||||||
pass
|
pass
|
||||||
@@ -102,7 +103,7 @@ def generate_texture_image(props, width, height):
|
|||||||
|
|
||||||
# Determine font size (with text fitting if enabled)
|
# Determine font size (with text fitting if enabled)
|
||||||
font_size = props.font_size
|
font_size = props.font_size
|
||||||
if props.enable_text_fitting:
|
if props.enable_text_fitting and has_text_fitting():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try to load font for sizing
|
# Try to load font for sizing
|
||||||
|
|||||||
Binary file not shown.
@@ -12,7 +12,7 @@ from ..utils.constants import (
|
|||||||
get_feature_availability_text, get_version_info_details,
|
get_feature_availability_text, get_version_info_details,
|
||||||
has_image_overlays, has_basic_utilities, has_presets,
|
has_image_overlays, has_basic_utilities, has_presets,
|
||||||
has_normal_maps, has_batch_processing, has_advanced_utilities,
|
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")
|
info_box.label(text="• Invert changes raised/recessed effect")
|
||||||
|
|
||||||
def draw_text_fitting_section(layout, props):
|
def draw_text_fitting_section(layout, props):
|
||||||
pass
|
|
||||||
"""Draw text fitting controls"""
|
"""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
|
# Main toggle for text fitting
|
||||||
layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER')
|
layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER')
|
||||||
|
|
||||||
@@ -890,22 +893,11 @@ class TEXT_TEXTURE_PT_panel(Panel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pass
|
"""Show panel in Shader Editor only"""
|
||||||
# DEBUG: Log poll method calls
|
|
||||||
if hasattr(context.space_data, 'tree_type'):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# More permissive poll condition to ensure panel shows up
|
|
||||||
if context.space_data.type == 'NODE_EDITOR':
|
if context.space_data.type == 'NODE_EDITOR':
|
||||||
pass
|
|
||||||
if hasattr(context.space_data, 'tree_type'):
|
if hasattr(context.space_data, 'tree_type'):
|
||||||
pass
|
return context.space_data.tree_type == 'ShaderNodeTree'
|
||||||
result = context.space_data.tree_type == 'ShaderNodeTree'
|
|
||||||
return result
|
|
||||||
else:
|
else:
|
||||||
pass
|
|
||||||
# Allow panel even without tree_type (sometimes happens)
|
# Allow panel even without tree_type (sometimes happens)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -19,7 +19,8 @@ bl_info = {
|
|||||||
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
|
ENABLED_FEATURES = ["basic", "preview"] if VERSION_TYPE == "free" else [
|
||||||
"basic", "preview", "presets", "normal_maps", "batch_processing",
|
"basic", "preview", "presets", "normal_maps", "batch_processing",
|
||||||
"image_overlays", "basic_utilities", "advanced_utilities", "advanced_styling",
|
"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)
|
# Version-specific limits (applied at build time through file exclusions)
|
||||||
@@ -109,6 +110,10 @@ def has_shader_generation():
|
|||||||
"""Check if shader generation is available"""
|
"""Check if shader generation is available"""
|
||||||
return has_feature("shader_generation")
|
return has_feature("shader_generation")
|
||||||
|
|
||||||
|
def has_text_fitting():
|
||||||
|
"""Check if text fitting is available"""
|
||||||
|
return has_feature("text_fitting")
|
||||||
|
|
||||||
def get_max_resolution():
|
def get_max_resolution():
|
||||||
"""Get maximum texture resolution based on version"""
|
"""Get maximum texture resolution based on version"""
|
||||||
limits = get_version_limits()
|
limits = get_version_limits()
|
||||||
@@ -128,7 +133,8 @@ def should_show_feature_lock(feature_name):
|
|||||||
'stroke_effects': has_stroke_effects,
|
'stroke_effects': has_stroke_effects,
|
||||||
'blur_effects': has_blur_effects,
|
'blur_effects': has_blur_effects,
|
||||||
'shadow_glow_effects': has_shadow_glow_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():
|
if is_full_version():
|
||||||
|
|||||||
Binary file not shown.
@@ -15,7 +15,7 @@ def test_built_package_bl_info_is_parseable():
|
|||||||
|
|
||||||
# Step 1: Rebuild the package to ensure latest code
|
# Step 1: Rebuild the package to ensure latest code
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["python", "build/build_addon.py", "--type", "full"],
|
["python", "scripts/build_addon.py"],
|
||||||
cwd=project_root,
|
cwd=project_root,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True
|
text=True
|
||||||
@@ -69,4 +69,39 @@ def test_built_package_bl_info_is_parseable():
|
|||||||
print('\n'.join(content.split('\n')[:30]))
|
print('\n'.join(content.split('\n')[:30]))
|
||||||
raise AssertionError(f"bl_info failed ast.literal_eval() at line {node.lineno}: {e}")
|
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"
|
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"
|
||||||
@@ -347,7 +347,66 @@ print("SUCCESS: Image overlay properly blocked")
|
|||||||
output = test_result.output.decode('utf-8', errors='replace')
|
output = test_result.output.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
assert test_result.exit_code == 0, f"Overlay block test failed:\n{output}"
|
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):
|
def test_free_version_texture_size_limited(blender_container, addon_package):
|
||||||
|
|||||||
159
tests/integration/test_build_minification_integration.py
Normal file
159
tests/integration/test_build_minification_integration.py
Normal 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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,6 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import Mock, patch, MagicMock
|
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:
|
class TestGenerateTextureImageControlFlow:
|
||||||
@@ -49,8 +41,11 @@ class TestGenerateTextureImageControlFlow:
|
|||||||
mock_props.enable_text_fitting = False
|
mock_props.enable_text_fitting = False
|
||||||
mock_props.generate_normal_map = 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
|
# 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
|
# Properly structure the bpy mock
|
||||||
mock_images = Mock()
|
mock_images = Mock()
|
||||||
mock_images.new = Mock(return_value=mock_blender_img)
|
mock_images.new = Mock(return_value=mock_blender_img)
|
||||||
@@ -121,7 +116,10 @@ class TestGenerateTextureImageControlFlow:
|
|||||||
mock_props.enable_text_fitting = False
|
mock_props.enable_text_fitting = False
|
||||||
mock_props.generate_normal_map = 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
|
# Properly structure the bpy mock
|
||||||
mock_images = Mock()
|
mock_images = Mock()
|
||||||
mock_images.new = Mock(return_value=mock_blender_img)
|
mock_images.new = Mock(return_value=mock_blender_img)
|
||||||
|
|||||||
442
tests/unit/test_build_minification.py
Normal file
442
tests/unit/test_build_minification.py
Normal 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}"
|
||||||
@@ -114,6 +114,21 @@ class TestVersionDetection:
|
|||||||
assert limits["max_concurrent_operations"] == 1, \
|
assert limits["max_concurrent_operations"] == 1, \
|
||||||
f"Expected free version max_concurrent_operations=1, got {limits['max_concurrent_operations']}"
|
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:
|
class TestRuntimeImportDetection:
|
||||||
|
|||||||
Reference in New Issue
Block a user