- Add Effects card to home page grid (now 3 columns)
- Add stats["effects"] count from cache.list_by_type('effect')
- Add tests for home page effects display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
368 lines
13 KiB
Python
368 lines
13 KiB
Python
"""
|
|
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 TestHomePageEffectsCount:
|
|
"""Test that effects count is shown on home page."""
|
|
|
|
def test_home_template_has_effects_card(self) -> None:
|
|
"""Home page should display effects count."""
|
|
path = Path('/home/giles/art/art-celery/app/templates/home.html')
|
|
content = path.read_text()
|
|
|
|
assert 'stats.effects' in content, \
|
|
"Home page should display stats.effects count"
|
|
assert 'href="/effects"' in content, \
|
|
"Home page should link to /effects"
|
|
|
|
def test_home_route_provides_effects_count(self) -> None:
|
|
"""Home route should provide effects count in stats."""
|
|
path = Path('/home/giles/art/art-celery/app/routers/home.py')
|
|
content = path.read_text()
|
|
|
|
assert 'stats["effects"]' in content, \
|
|
"Home route should populate stats['effects']"
|
|
|
|
|
|
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
|