feat(effects): add plugin architecture with performance monitoring
This commit is contained in:
427
tests/test_effects.py
Normal file
427
tests/test_effects.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Tests for engine.effects module.
|
||||
"""
|
||||
|
||||
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||
|
||||
|
||||
class MockEffect:
|
||||
name = "mock"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def __init__(self):
|
||||
self.processed = False
|
||||
self.last_ctx = None
|
||||
|
||||
def process(self, buf, ctx):
|
||||
self.processed = True
|
||||
self.last_ctx = ctx
|
||||
return buf + ["processed"]
|
||||
|
||||
def configure(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class TestEffectConfig:
|
||||
def test_defaults(self):
|
||||
cfg = EffectConfig()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intensity == 1.0
|
||||
assert cfg.params == {}
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.intensity == 0.5
|
||||
assert cfg.params == {"key": "value"}
|
||||
|
||||
|
||||
class TestEffectContext:
|
||||
def test_defaults(self):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
assert ctx.terminal_width == 80
|
||||
assert ctx.terminal_height == 24
|
||||
assert ctx.ticker_height == 20
|
||||
assert ctx.items == []
|
||||
|
||||
def test_with_items(self):
|
||||
items = [("Title", "Source", "12:00")]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=items,
|
||||
)
|
||||
assert ctx.items == items
|
||||
|
||||
|
||||
class TestEffectRegistry:
|
||||
def test_init_empty(self):
|
||||
registry = EffectRegistry()
|
||||
assert len(registry.list_all()) == 0
|
||||
|
||||
def test_register(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
assert "mock" in registry.list_all()
|
||||
|
||||
def test_get(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
retrieved = registry.get("mock")
|
||||
assert retrieved is effect
|
||||
|
||||
def test_get_nonexistent(self):
|
||||
registry = EffectRegistry()
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_enable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
registry.enable("mock")
|
||||
assert effect.config.enabled is True
|
||||
|
||||
def test_disable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
registry.disable("mock")
|
||||
assert effect.config.enabled is False
|
||||
|
||||
def test_list_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
|
||||
class EnabledEffect:
|
||||
name = "enabled_effect"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
class DisabledEffect:
|
||||
name = "disabled_effect"
|
||||
config = EffectConfig(enabled=False, intensity=1.0)
|
||||
|
||||
registry.register(EnabledEffect())
|
||||
registry.register(DisabledEffect())
|
||||
enabled = registry.list_enabled()
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0].name == "enabled_effect"
|
||||
|
||||
def test_configure(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||
registry.configure("mock", new_config)
|
||||
assert effect.config.enabled is False
|
||||
assert effect.config.intensity == 0.3
|
||||
|
||||
def test_is_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
assert registry.is_enabled("mock") is True
|
||||
assert registry.is_enabled("nonexistent") is False
|
||||
|
||||
|
||||
class TestEffectChain:
|
||||
def test_init(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
assert chain.get_order() == []
|
||||
|
||||
def test_set_order(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2"])
|
||||
assert chain.get_order() == ["effect1", "effect2"]
|
||||
|
||||
def test_add_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.add_effect("test_effect")
|
||||
assert "test_effect" in chain.get_order()
|
||||
|
||||
def test_add_effect_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
result = chain.add_effect("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_remove_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
chain.remove_effect("test_effect")
|
||||
assert "test_effect" not in chain.get_order()
|
||||
|
||||
def test_reorder(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
effect3 = MockEffect()
|
||||
effect3.name = "effect3"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
registry.register(effect3)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2", "effect3"])
|
||||
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||
assert result is True
|
||||
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||
|
||||
def test_reorder_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "effect1"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
result = chain.reorder(["effect1", "nonexistent"])
|
||||
assert result is False
|
||||
|
||||
def test_process_empty_chain(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == buf
|
||||
|
||||
def test_process_with_effects(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1", "line2", "processed"]
|
||||
assert effect.processed is True
|
||||
assert effect.last_ctx is ctx
|
||||
|
||||
def test_process_disabled_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1"]
|
||||
assert effect.processed is False
|
||||
|
||||
|
||||
class TestEffectsExports:
|
||||
def test_all_exports_are_importable(self):
|
||||
"""Verify all exports in __all__ can actually be imported."""
|
||||
import engine.effects as effects_module
|
||||
|
||||
for name in effects_module.__all__:
|
||||
getattr(effects_module, name)
|
||||
|
||||
|
||||
class TestPerformanceMonitor:
|
||||
def test_empty_stats(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
def test_record_and_retrieve(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||
monitor.end_frame(1, 2.0)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["frame_count"] == 1
|
||||
assert "test_effect" in stats["effects"]
|
||||
|
||||
def test_multiple_frames(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=3)
|
||||
for i in range(5):
|
||||
monitor.start_frame(i)
|
||||
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||
monitor.end_frame(i, 1.5)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert stats["frame_count"] == 3
|
||||
assert "effect1" in stats["effects"]
|
||||
assert "effect2" in stats["effects"]
|
||||
|
||||
def test_reset(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test", 1.0, 100, 100)
|
||||
monitor.end_frame(1, 1.0)
|
||||
|
||||
monitor.reset()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
|
||||
class TestEffectPipelinePerformance:
|
||||
def test_pipeline_stays_within_frame_budget(self):
|
||||
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class DummyEffect:
|
||||
name = "dummy"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
return [line * 2 for line in buf]
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(DummyEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=10)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["dummy"])
|
||||
|
||||
buf = ["x" * 80] * 20
|
||||
|
||||
for i in range(10):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["pipeline"]["max_ms"] < 33.0
|
||||
|
||||
def test_individual_effects_performance(self):
|
||||
"""Verify individual effects don't exceed 10ms per frame."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class SlowEffect:
|
||||
name = "slow"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
result = []
|
||||
for line in buf:
|
||||
result.append(line)
|
||||
result.append(line + line)
|
||||
return result
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(SlowEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=5)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["slow"])
|
||||
|
||||
buf = ["x" * 80] * 10
|
||||
|
||||
for i in range(5):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||
Reference in New Issue
Block a user