- 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
285 lines
8.4 KiB
Python
285 lines
8.4 KiB
Python
"""
|
|
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)
|