feat(effects): add plugin architecture for visual effects
- Extract effects as fully decoupled plugins in engine/effects/ - Add EffectConfig, EffectContext dataclasses and EffectPlugin protocol - Add EffectRegistry for plugin discovery and management - Add EffectChain for ordered pipeline execution - Move built-in effects to effects_plugins/ directory - Add interactive effects config picker during startup - Add NTFY command handler for /effects commands - Add tests for effects system (24 new tests) - Update AGENTS.md with effects plugin documentation - Add conventional commits section to AGENTS.md chore: add coverage.xml to .gitignore
This commit is contained in:
284
tests/test_effects.py
Normal file
284
tests/test_effects.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user