""" Tests for Effects web UI. Tests effect metadata parsing, listing, and templates. """ import pytest import re from pathlib import Path from unittest.mock import MagicMock def parse_effect_metadata_standalone(source: str) -> dict: """ Standalone copy of parse_effect_metadata for testing. This avoids import issues with the router module. """ metadata = { "name": "", "version": "1.0.0", "author": "", "temporal": False, "description": "", "params": [], "dependencies": [], "requires_python": ">=3.10", } # Parse PEP 723 dependencies pep723_match = re.search(r"# /// script\n(.*?)# ///", source, re.DOTALL) if pep723_match: block = pep723_match.group(1) deps_match = re.search(r'# dependencies = \[(.*?)\]', block, re.DOTALL) if deps_match: metadata["dependencies"] = re.findall(r'"([^"]+)"', deps_match.group(1)) python_match = re.search(r'# requires-python = "([^"]+)"', block) if python_match: metadata["requires_python"] = python_match.group(1) # Parse docstring @-tags docstring_match = re.search(r'"""(.*?)"""', source, re.DOTALL) if not docstring_match: docstring_match = re.search(r"'''(.*?)'''", source, re.DOTALL) if docstring_match: docstring = docstring_match.group(1) lines = docstring.split("\n") current_param = None desc_lines = [] in_description = False for line in lines: stripped = line.strip() if stripped.startswith("@effect "): metadata["name"] = stripped[8:].strip() in_description = False elif stripped.startswith("@version "): metadata["version"] = stripped[9:].strip() elif stripped.startswith("@author "): metadata["author"] = stripped[8:].strip() elif stripped.startswith("@temporal "): val = stripped[10:].strip().lower() metadata["temporal"] = val in ("true", "yes", "1") elif stripped.startswith("@description"): in_description = True desc_lines = [] elif stripped.startswith("@param "): if in_description: metadata["description"] = " ".join(desc_lines) in_description = False if current_param: metadata["params"].append(current_param) parts = stripped[7:].split() if len(parts) >= 2: current_param = { "name": parts[0], "type": parts[1], "description": "", } else: current_param = None elif stripped.startswith("@range ") and current_param: range_parts = stripped[7:].split() if len(range_parts) >= 2: try: current_param["range"] = [float(range_parts[0]), float(range_parts[1])] except ValueError: pass elif stripped.startswith("@default ") and current_param: current_param["default"] = stripped[9:].strip() elif stripped.startswith("@example"): if in_description: metadata["description"] = " ".join(desc_lines) in_description = False if current_param: metadata["params"].append(current_param) current_param = None elif in_description and stripped: desc_lines.append(stripped) elif current_param and stripped and not stripped.startswith("@"): current_param["description"] = stripped if in_description: metadata["description"] = " ".join(desc_lines) if current_param: metadata["params"].append(current_param) return metadata class TestEffectMetadataParsing: """Tests for parse_effect_metadata function.""" def test_parses_pep723_dependencies(self) -> None: """Should extract dependencies from PEP 723 script block.""" source = ''' # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "opencv-python"] # /// """ @effect test_effect """ def process_frame(frame, params, state): return frame, state ''' meta = parse_effect_metadata_standalone(source) assert meta["dependencies"] == ["numpy", "opencv-python"] assert meta["requires_python"] == ">=3.10" def test_parses_effect_name(self) -> None: """Should extract effect name from @effect tag.""" source = ''' """ @effect brightness @version 2.0.0 @author @artist@example.com """ def process_frame(frame, params, state): return frame, state ''' meta = parse_effect_metadata_standalone(source) assert meta["name"] == "brightness" assert meta["version"] == "2.0.0" assert meta["author"] == "@artist@example.com" def test_parses_parameters(self) -> None: """Should extract parameter definitions.""" source = ''' """ @effect brightness @param level float @range -1.0 1.0 @default 0.0 Brightness adjustment level """ def process_frame(frame, params, state): return frame, state ''' meta = parse_effect_metadata_standalone(source) assert len(meta["params"]) == 1 param = meta["params"][0] assert param["name"] == "level" assert param["type"] == "float" assert param["range"] == [-1.0, 1.0] assert param["default"] == "0.0" def test_parses_temporal_flag(self) -> None: """Should parse temporal flag correctly.""" source_temporal = ''' """ @effect motion_blur @temporal true """ def process_frame(frame, params, state): return frame, state ''' source_not_temporal = ''' """ @effect brightness @temporal false """ def process_frame(frame, params, state): return frame, state ''' assert parse_effect_metadata_standalone(source_temporal)["temporal"] is True assert parse_effect_metadata_standalone(source_not_temporal)["temporal"] is False def test_handles_missing_metadata(self) -> None: """Should return sensible defaults for minimal source.""" source = ''' def process_frame(frame, params, state): return frame, state ''' meta = parse_effect_metadata_standalone(source) assert meta["name"] == "" assert meta["version"] == "1.0.0" assert meta["dependencies"] == [] assert meta["params"] == [] def test_parses_description(self) -> None: """Should extract description text.""" source = ''' """ @effect test @description This is a multi-line description of the effect. @param x float """ def process_frame(frame, params, state): return frame, state ''' meta = parse_effect_metadata_standalone(source) assert "multi-line" in meta["description"] class TestNavigationIncludesEffects: """Test that Effects link is in navigation.""" def test_base_template_has_effects_link(self) -> None: """Base template should have Effects navigation link.""" base_path = Path('/home/giles/art/art-celery/app/templates/base.html') content = base_path.read_text() assert 'href="/effects"' in content assert "Effects" in content assert "active_tab == 'effects'" in content def test_effects_link_between_recipes_and_media(self) -> None: """Effects link should be positioned between Recipes and Media.""" base_path = Path('/home/giles/art/art-celery/app/templates/base.html') content = base_path.read_text() recipes_pos = content.find('href="/recipes"') effects_pos = content.find('href="/effects"') media_pos = content.find('href="/media"') assert recipes_pos < effects_pos < media_pos, \ "Effects link should be between Recipes and Media" class TestEffectsTemplatesExist: """Tests for effects template files.""" def test_list_template_exists(self) -> None: """List template should exist.""" path = Path('/home/giles/art/art-celery/app/templates/effects/list.html') assert path.exists(), "effects/list.html template should exist" def test_detail_template_exists(self) -> None: """Detail template should exist.""" path = Path('/home/giles/art/art-celery/app/templates/effects/detail.html') assert path.exists(), "effects/detail.html template should exist" def test_list_template_extends_base(self) -> None: """List template should extend base.html.""" path = Path('/home/giles/art/art-celery/app/templates/effects/list.html') content = path.read_text() assert '{% extends "base.html" %}' in content def test_detail_template_extends_base(self) -> None: """Detail template should extend base.html.""" path = Path('/home/giles/art/art-celery/app/templates/effects/detail.html') content = path.read_text() assert '{% extends "base.html" %}' in content def test_list_template_has_upload_button(self) -> None: """List template should have upload functionality.""" path = Path('/home/giles/art/art-celery/app/templates/effects/list.html') content = path.read_text() assert 'Upload Effect' in content or 'upload' in content.lower() def test_detail_template_shows_parameters(self) -> None: """Detail template should display parameters.""" path = Path('/home/giles/art/art-celery/app/templates/effects/detail.html') content = path.read_text() assert 'params' in content.lower() or 'parameter' in content.lower() def test_detail_template_shows_source_code(self) -> None: """Detail template should show source code.""" path = Path('/home/giles/art/art-celery/app/templates/effects/detail.html') content = path.read_text() assert 'source' in content.lower() assert 'language-python' in content def test_detail_template_shows_dependencies(self) -> None: """Detail template should display dependencies.""" path = Path('/home/giles/art/art-celery/app/templates/effects/detail.html') content = path.read_text() assert 'dependencies' in content.lower() class TestEffectsRouterExists: """Tests for effects router configuration.""" def test_effects_router_file_exists(self) -> None: """Effects router should exist.""" path = Path('/home/giles/art/art-celery/app/routers/effects.py') assert path.exists(), "effects.py router should exist" def test_effects_router_has_list_endpoint(self) -> None: """Effects router should have list endpoint.""" path = Path('/home/giles/art/art-celery/app/routers/effects.py') content = path.read_text() assert '@router.get("")' in content or "@router.get('')" in content def test_effects_router_has_detail_endpoint(self) -> None: """Effects router should have detail endpoint.""" path = Path('/home/giles/art/art-celery/app/routers/effects.py') content = path.read_text() assert '@router.get("/{cid}")' in content def test_effects_router_has_upload_endpoint(self) -> None: """Effects router should have upload endpoint.""" path = Path('/home/giles/art/art-celery/app/routers/effects.py') content = path.read_text() assert '@router.post("/upload")' in content def test_effects_router_renders_templates(self) -> None: """Effects router should render HTML templates.""" path = Path('/home/giles/art/art-celery/app/routers/effects.py') content = path.read_text() assert 'effects/list.html' in content assert 'effects/detail.html' in content