Implements ascii_fx_zone effect that allows applying arbitrary sexp effects to each character cell via cell_effect lambdas. Each cell is rendered as a small image that effects can operate on. Key changes: - New ascii_fx_zone effect with cell_effect parameter for per-cell transforms - Zone context (row, col, lum, sat, hue, etc.) available in cell_effect lambdas - Effects are now loaded explicitly from recipe declarations, not auto-loaded - Added effects_registry to plan for explicit effect dependency tracking - Updated effect definition syntax across all sexp effects - New run_staged.py for executing staged recipes - Example recipes demonstrating alternating rotation and blur/rgb_split patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
237 lines
5.9 KiB
Python
237 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test the S-expression effect interpreter.
|
|
"""
|
|
|
|
import numpy as np
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Add parent to path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from sexp_effects import (
|
|
get_interpreter,
|
|
load_effects_dir,
|
|
run_effect,
|
|
list_effects,
|
|
parse,
|
|
)
|
|
|
|
|
|
def test_parser():
|
|
"""Test S-expression parser."""
|
|
print("Testing parser...")
|
|
|
|
# Simple expressions
|
|
assert parse("42") == 42
|
|
assert parse("3.14") == 3.14
|
|
assert parse('"hello"') == "hello"
|
|
assert parse("true") == True
|
|
|
|
# Lists
|
|
assert parse("(+ 1 2)")[0].name == "+"
|
|
assert parse("(+ 1 2)")[1] == 1
|
|
|
|
# Nested
|
|
expr = parse("(define x (+ 1 2))")
|
|
assert expr[0].name == "define"
|
|
|
|
print(" Parser OK")
|
|
|
|
|
|
def test_interpreter_basics():
|
|
"""Test basic interpreter operations."""
|
|
print("Testing interpreter basics...")
|
|
|
|
interp = get_interpreter()
|
|
|
|
# Math
|
|
assert interp.eval(parse("(+ 1 2)")) == 3
|
|
assert interp.eval(parse("(* 3 4)")) == 12
|
|
assert interp.eval(parse("(- 10 3)")) == 7
|
|
|
|
# Comparison
|
|
assert interp.eval(parse("(< 1 2)")) == True
|
|
assert interp.eval(parse("(> 1 2)")) == False
|
|
|
|
# Let binding
|
|
assert interp.eval(parse("(let ((x 5)) x)")) == 5
|
|
assert interp.eval(parse("(let ((x 5) (y 3)) (+ x y))")) == 8
|
|
|
|
# Lambda
|
|
result = interp.eval(parse("((lambda (x) (* x 2)) 5)"))
|
|
assert result == 10
|
|
|
|
# If
|
|
assert interp.eval(parse("(if true 1 2)")) == 1
|
|
assert interp.eval(parse("(if false 1 2)")) == 2
|
|
|
|
print(" Interpreter basics OK")
|
|
|
|
|
|
def test_primitives():
|
|
"""Test image primitives."""
|
|
print("Testing primitives...")
|
|
|
|
interp = get_interpreter()
|
|
|
|
# Create test image
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
img[50, 50] = [255, 128, 64]
|
|
|
|
interp.global_env.set('test_img', img)
|
|
|
|
# Width/height
|
|
assert interp.eval(parse("(width test_img)")) == 100
|
|
assert interp.eval(parse("(height test_img)")) == 100
|
|
|
|
# Pixel
|
|
pixel = interp.eval(parse("(pixel test_img 50 50)"))
|
|
assert pixel == [255, 128, 64]
|
|
|
|
# RGB
|
|
color = interp.eval(parse("(rgb 100 150 200)"))
|
|
assert color == [100, 150, 200]
|
|
|
|
# Luminance
|
|
lum = interp.eval(parse("(luminance (rgb 100 100 100))"))
|
|
assert abs(lum - 100) < 1
|
|
|
|
print(" Primitives OK")
|
|
|
|
|
|
def test_effect_loading():
|
|
"""Test loading effects from .sexp files."""
|
|
print("Testing effect loading...")
|
|
|
|
# Load all effects
|
|
effects_dir = Path(__file__).parent / "effects"
|
|
load_effects_dir(str(effects_dir))
|
|
|
|
effects = list_effects()
|
|
print(f" Loaded {len(effects)} effects: {', '.join(sorted(effects))}")
|
|
|
|
assert len(effects) > 0
|
|
print(" Effect loading OK")
|
|
|
|
|
|
def test_effect_execution():
|
|
"""Test running effects on images."""
|
|
print("Testing effect execution...")
|
|
|
|
# Create test image
|
|
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
|
|
|
|
# Load effects
|
|
effects_dir = Path(__file__).parent / "effects"
|
|
load_effects_dir(str(effects_dir))
|
|
|
|
# Test each effect
|
|
effects = list_effects()
|
|
passed = 0
|
|
failed = []
|
|
|
|
for name in sorted(effects):
|
|
try:
|
|
result, state = run_effect(name, img.copy(), {'_time': 0.5}, {})
|
|
assert isinstance(result, np.ndarray)
|
|
assert result.shape == img.shape
|
|
passed += 1
|
|
print(f" {name}: OK")
|
|
except Exception as e:
|
|
failed.append((name, str(e)))
|
|
print(f" {name}: FAILED - {e}")
|
|
|
|
print(f" Passed: {passed}/{len(effects)}")
|
|
if failed:
|
|
print(f" Failed: {[f[0] for f in failed]}")
|
|
|
|
return passed, failed
|
|
|
|
|
|
def test_ascii_fx_zone():
|
|
"""Test ascii_fx_zone effect with zone expressions."""
|
|
print("Testing ascii_fx_zone...")
|
|
|
|
interp = get_interpreter()
|
|
|
|
# Load the effect
|
|
effects_dir = Path(__file__).parent / "effects"
|
|
load_effects_dir(str(effects_dir))
|
|
|
|
# Create gradient test frame
|
|
frame = np.zeros((120, 160, 3), dtype=np.uint8)
|
|
for x in range(160):
|
|
frame[:, x] = int(x / 160 * 255)
|
|
frame = np.stack([frame[:,:,0]]*3, axis=2)
|
|
|
|
# Test 1: Basic without expressions
|
|
result, _ = run_effect('ascii_fx_zone', frame, {'cols': 20}, {})
|
|
assert result.shape == frame.shape
|
|
print(" Basic run: OK")
|
|
|
|
# Test 2: With zone-lum expression
|
|
expr = parse('(* zone-lum 180)')
|
|
result, _ = run_effect('ascii_fx_zone', frame, {
|
|
'cols': 20,
|
|
'char_hue': expr
|
|
}, {})
|
|
assert result.shape == frame.shape
|
|
print(" Zone-lum expression: OK")
|
|
|
|
# Test 3: With multiple expressions
|
|
scale_expr = parse('(+ 0.5 (* zone-lum 0.5))')
|
|
rot_expr = parse('(* zone-row-norm 30)')
|
|
result, _ = run_effect('ascii_fx_zone', frame, {
|
|
'cols': 20,
|
|
'char_scale': scale_expr,
|
|
'char_rotation': rot_expr
|
|
}, {})
|
|
assert result.shape == frame.shape
|
|
print(" Multiple expressions: OK")
|
|
|
|
# Test 4: With numeric literals
|
|
result, _ = run_effect('ascii_fx_zone', frame, {
|
|
'cols': 20,
|
|
'char_hue': 90,
|
|
'char_scale': 1.2
|
|
}, {})
|
|
assert result.shape == frame.shape
|
|
print(" Numeric literals: OK")
|
|
|
|
# Test 5: Zone position expressions
|
|
col_expr = parse('(* zone-col-norm 360)')
|
|
result, _ = run_effect('ascii_fx_zone', frame, {
|
|
'cols': 20,
|
|
'char_hue': col_expr
|
|
}, {})
|
|
assert result.shape == frame.shape
|
|
print(" Zone position expression: OK")
|
|
|
|
print(" ascii_fx_zone OK")
|
|
|
|
|
|
def main():
|
|
print("=" * 60)
|
|
print("S-Expression Effect Interpreter Tests")
|
|
print("=" * 60)
|
|
|
|
test_parser()
|
|
test_interpreter_basics()
|
|
test_primitives()
|
|
test_effect_loading()
|
|
test_ascii_fx_zone()
|
|
passed, failed = test_effect_execution()
|
|
|
|
print("=" * 60)
|
|
if not failed:
|
|
print("All tests passed!")
|
|
else:
|
|
print(f"Tests completed with {len(failed)} failures")
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|