Fix item visibility bugs and add effects web UI

- Fix recipe filter to allow owner=None (S-expression compiled recipes)
- Fix media uploads to use category (video/image/audio) not MIME type
- Fix IPFS imports to detect and store correct media type
- Add Effects navigation link between Recipes and Media
- Create effects list and detail templates with upload functionality
- Add cache/not_found.html template (was missing)
- Add type annotations to service classes
- Add tests for item visibility and effects web UI (30 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 12:01:54 +00:00
parent 19e2277155
commit 585c75e846
12 changed files with 1090 additions and 53 deletions

345
tests/test_effects_web.py Normal file
View File

@@ -0,0 +1,345 @@
"""
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