diff --git a/build/__pycache__/sync_version.cpython-311.pyc b/build/__pycache__/sync_version.cpython-311.pyc index ce73a6d..059d279 100644 Binary files a/build/__pycache__/sync_version.cpython-311.pyc and b/build/__pycache__/sync_version.cpython-311.pyc differ diff --git a/build/sync_version.py b/build/sync_version.py index 56a448a..8d6fb18 100755 --- a/build/sync_version.py +++ b/build/sync_version.py @@ -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'] diff --git a/build/templates/blender_manifest.toml.template b/build/templates/blender_manifest.toml.template deleted file mode 100644 index 210d5b8..0000000 --- a/build/templates/blender_manifest.toml.template +++ /dev/null @@ -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}} \ No newline at end of file diff --git a/build/templates/constants.py.template b/build/templates/constants.py.template index 75d1d23..1fea53d 100644 --- a/build/templates/constants.py.template +++ b/build/templates/constants.py.template @@ -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(): diff --git a/dist/text_texture_generator_v1.0.0.zip b/dist/text_texture_generator_v1.0.0.zip index 8dc8c3c..92849ed 100644 Binary files a/dist/text_texture_generator_v1.0.0.zip and b/dist/text_texture_generator_v1.0.0.zip differ diff --git a/dist/text_texture_generator_v1.0.0_free.zip b/dist/text_texture_generator_v1.0.0_free.zip index 76b62e6..a4813e7 100644 Binary files a/dist/text_texture_generator_v1.0.0_free.zip and b/dist/text_texture_generator_v1.0.0_free.zip differ diff --git a/scripts/build_addon.py b/scripts/build_addon.py index 3021050..1fad4c7 100755 --- a/scripts/build_addon.py +++ b/scripts/build_addon.py @@ -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,9 +226,23 @@ 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 - shutil.copy2(item, dest_file) - print(f" Copied: {rel_path}") + # 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 print(f"Files copied: {copied_count}, excluded: {excluded_count}") @@ -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}") diff --git a/src/blender_manifest.toml b/src/blender_manifest.toml deleted file mode 100644 index 82dce71..0000000 --- a/src/blender_manifest.toml +++ /dev/null @@ -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 " -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" \ No newline at end of file diff --git a/src/core/__pycache__/generation_engine.cpython-311.pyc b/src/core/__pycache__/generation_engine.cpython-311.pyc index 3280c21..83fd243 100644 Binary files a/src/core/__pycache__/generation_engine.cpython-311.pyc and b/src/core/__pycache__/generation_engine.cpython-311.pyc differ diff --git a/src/core/generation_engine.py b/src/core/generation_engine.py index 7202f32..12732e3 100644 --- a/src/core/generation_engine.py +++ b/src/core/generation_engine.py @@ -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 diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index 09bf696..f028f46 100644 Binary files a/src/ui/__pycache__/panels.cpython-311.pyc and b/src/ui/__pycache__/panels.cpython-311.pyc differ diff --git a/src/ui/panels.py b/src/ui/panels.py index 9563c84..dab2b9d 100644 --- a/src/ui/panels.py +++ b/src/ui/panels.py @@ -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 diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index 58cf743..efb6015 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/src/utils/constants.py b/src/utils/constants.py index 41d72bc..210f893 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -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(): diff --git a/tests/e2e/__pycache__/test_bl_info_parsing.cpython-311-pytest-8.3.5.pyc b/tests/e2e/__pycache__/test_bl_info_parsing.cpython-311-pytest-8.3.5.pyc index fd95d76..c023ed8 100644 Binary files a/tests/e2e/__pycache__/test_bl_info_parsing.cpython-311-pytest-8.3.5.pyc and b/tests/e2e/__pycache__/test_bl_info_parsing.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/e2e/test_bl_info_parsing.py b/tests/e2e/test_bl_info_parsing.py index cbec28c..525393c 100644 --- a/tests/e2e/test_bl_info_parsing.py +++ b/tests/e2e/test_bl_info_parsing.py @@ -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 @@ -69,4 +69,39 @@ def test_built_package_bl_info_is_parseable(): print('\n'.join(content.split('\n')[:30])) 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" \ No newline at end of file + 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" \ No newline at end of file diff --git a/tests/e2e/test_free_version_e2e.py b/tests/e2e/test_free_version_e2e.py index 7329e16..62c8f79 100644 --- a/tests/e2e/test_free_version_e2e.py +++ b/tests/e2e/test_free_version_e2e.py @@ -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): diff --git a/tests/integration/test_build_minification_integration.py b/tests/integration/test_build_minification_integration.py new file mode 100644 index 0000000..1e6a1e8 --- /dev/null +++ b/tests/integration/test_build_minification_integration.py @@ -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 \ No newline at end of file diff --git a/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc b/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc index ddb0365..17d0588 100644 Binary files a/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc and b/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc index 6b1579e..02a8779 100644 Binary files a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc and b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/test_generation_engine.py b/tests/unit/core/test_generation_engine.py index 12505ed..19d487d 100644 --- a/tests/unit/core/test_generation_engine.py +++ b/tests/unit/core/test_generation_engine.py @@ -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) diff --git a/tests/unit/test_build_minification.py b/tests/unit/test_build_minification.py new file mode 100644 index 0000000..5e5a583 --- /dev/null +++ b/tests/unit/test_build_minification.py @@ -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, '', '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}" \ No newline at end of file diff --git a/tests/unit/test_version_differentiation.py b/tests/unit/test_version_differentiation.py index 20f2838..1dd39a3 100644 --- a/tests/unit/test_version_differentiation.py +++ b/tests/unit/test_version_differentiation.py @@ -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: