forked from genewildish/Mainline
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
241 lines
8.6 KiB
Python
241 lines
8.6 KiB
Python
"""
|
|
Tests for Glitch effect - regression tests for stability issues.
|
|
"""
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from engine.display import NullDisplay
|
|
from engine.effects.types import EffectConfig, EffectContext
|
|
|
|
|
|
def strip_ansi(s: str) -> str:
|
|
"""Remove ANSI escape sequences from string."""
|
|
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
|
|
|
|
|
class TestGlitchEffectStability:
|
|
"""Regression tests for Glitch effect stability."""
|
|
|
|
@pytest.fixture
|
|
def effect_context(self):
|
|
"""Create a consistent effect context for testing."""
|
|
return EffectContext(
|
|
terminal_width=80,
|
|
terminal_height=24,
|
|
scroll_cam=0,
|
|
ticker_height=20,
|
|
frame_number=0,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def stable_buffer(self):
|
|
"""Create a stable buffer for testing."""
|
|
return ["line" + str(i).zfill(2) + " " * 60 for i in range(24)]
|
|
|
|
def test_glitch_preserves_line_count(self, effect_context, stable_buffer):
|
|
"""Glitch should not change the number of lines in buffer."""
|
|
from engine.effects.plugins.glitch import GlitchEffect
|
|
|
|
effect = GlitchEffect()
|
|
result = effect.process(stable_buffer, effect_context)
|
|
|
|
assert len(result) == len(stable_buffer), (
|
|
f"Line count changed from {len(stable_buffer)} to {len(result)}"
|
|
)
|
|
|
|
def test_glitch_preserves_line_lengths(self, effect_context, stable_buffer):
|
|
"""Glitch should not change individual line lengths - prevents viewport jumping.
|
|
|
|
Note: Effects may add ANSI color codes, so we check VISIBLE length (stripped).
|
|
"""
|
|
from engine.effects.plugins.glitch import GlitchEffect
|
|
|
|
effect = GlitchEffect()
|
|
|
|
# Run multiple times to catch randomness
|
|
for _ in range(10):
|
|
result = effect.process(stable_buffer, effect_context)
|
|
for i, (orig, new) in enumerate(zip(stable_buffer, result, strict=False)):
|
|
visible_new = strip_ansi(new)
|
|
assert len(visible_new) == len(orig), (
|
|
f"Line {i} visible length changed from {len(orig)} to {len(visible_new)}"
|
|
)
|
|
|
|
def test_glitch_no_cursor_positioning(self, effect_context, stable_buffer):
|
|
"""Glitch should not use cursor positioning escape sequences.
|
|
|
|
Regression test: Previously glitch used \\033[{row};1H which caused
|
|
conflicts with HUD and border rendering.
|
|
"""
|
|
from engine.effects.plugins.glitch import GlitchEffect
|
|
|
|
effect = GlitchEffect()
|
|
result = effect.process(stable_buffer, effect_context)
|
|
|
|
# Check no cursor positioning in output
|
|
cursor_pos_pattern = re.compile(r"\033\[[0-9]+;[0-9]+H")
|
|
for i, line in enumerate(result):
|
|
match = cursor_pos_pattern.search(line)
|
|
assert match is None, (
|
|
f"Line {i} contains cursor positioning: {repr(line[:50])}"
|
|
)
|
|
|
|
def test_glitch_output_deterministic_given_seed(
|
|
self, effect_context, stable_buffer
|
|
):
|
|
"""Glitch output should be deterministic given the same random seed."""
|
|
from engine.effects.plugins.glitch import GlitchEffect
|
|
|
|
effect = GlitchEffect()
|
|
effect.config = EffectConfig(enabled=True, intensity=1.0)
|
|
|
|
# With fixed random state, should get same result
|
|
import random
|
|
|
|
random.seed(42)
|
|
result1 = effect.process(stable_buffer, effect_context)
|
|
|
|
random.seed(42)
|
|
result2 = effect.process(stable_buffer, effect_context)
|
|
|
|
assert result1 == result2, (
|
|
"Glitch should be deterministic with fixed random seed"
|
|
)
|
|
|
|
|
|
class TestEffectViewportStability:
|
|
"""Tests to catch effects that cause viewport instability."""
|
|
|
|
def test_null_display_stable_without_effects(self):
|
|
"""NullDisplay should produce identical output without effects."""
|
|
display = NullDisplay()
|
|
display.init(80, 24)
|
|
|
|
buffer = ["test line " + "x" * 60 for _ in range(24)]
|
|
|
|
display.show(buffer)
|
|
output1 = display._last_buffer
|
|
|
|
display.show(buffer)
|
|
output2 = display._last_buffer
|
|
|
|
assert output1 == output2, (
|
|
"NullDisplay output should be identical for identical inputs"
|
|
)
|
|
|
|
def test_effect_chain_preserves_dimensions(self):
|
|
"""Effect chain should preserve buffer dimensions."""
|
|
from engine.effects.plugins.fade import FadeEffect
|
|
from engine.effects.plugins.glitch import GlitchEffect
|
|
from engine.effects.plugins.noise import NoiseEffect
|
|
|
|
ctx = EffectContext(
|
|
terminal_width=80,
|
|
terminal_height=24,
|
|
scroll_cam=0,
|
|
ticker_height=20,
|
|
)
|
|
|
|
buffer = ["x" * 80 for _ in range(24)]
|
|
original_len = len(buffer)
|
|
original_widths = [len(line) for line in buffer]
|
|
|
|
effects = [NoiseEffect(), FadeEffect(), GlitchEffect()]
|
|
|
|
for effect in effects:
|
|
buffer = effect.process(buffer, ctx)
|
|
|
|
# Check dimensions preserved (check VISIBLE length, not raw)
|
|
# Effects may add ANSI codes which increase raw length but not visible width
|
|
assert len(buffer) == original_len, (
|
|
f"{effect.name} changed line count from {original_len} to {len(buffer)}"
|
|
)
|
|
for i, (orig_w, new_line) in enumerate(
|
|
zip(original_widths, buffer, strict=False)
|
|
):
|
|
visible_len = len(strip_ansi(new_line))
|
|
assert visible_len == orig_w, (
|
|
f"{effect.name} changed line {i} visible width from {orig_w} to {visible_len}"
|
|
)
|
|
|
|
|
|
class TestEffectTestMatrix:
|
|
"""Effect test matrix - test each effect for stability."""
|
|
|
|
@pytest.fixture
|
|
def effect_names(self):
|
|
"""List of all effect names to test."""
|
|
return ["noise", "fade", "glitch", "firehose", "border"]
|
|
|
|
@pytest.fixture
|
|
def stable_input_buffer(self):
|
|
"""A predictable buffer for testing."""
|
|
return [f"row{i:02d}" + " " * 70 for i in range(24)]
|
|
|
|
@pytest.mark.parametrize("effect_name", ["noise", "fade", "glitch"])
|
|
def test_effect_preserves_buffer_dimensions(self, effect_name, stable_input_buffer):
|
|
"""Each effect should preserve input buffer dimensions."""
|
|
try:
|
|
if effect_name == "border":
|
|
# Border is handled differently
|
|
pytest.skip("Border handled by display")
|
|
else:
|
|
effect_module = __import__(
|
|
f"engine.effects.plugins.{effect_name}",
|
|
fromlist=[f"{effect_name.title()}Effect"],
|
|
)
|
|
effect_class = getattr(effect_module, f"{effect_name.title()}Effect")
|
|
effect = effect_class()
|
|
except ImportError:
|
|
pytest.skip(f"Effect {effect_name} not available")
|
|
|
|
ctx = EffectContext(
|
|
terminal_width=80,
|
|
terminal_height=24,
|
|
scroll_cam=0,
|
|
ticker_height=20,
|
|
)
|
|
|
|
result = effect.process(stable_input_buffer, ctx)
|
|
|
|
# Check dimensions preserved (check VISIBLE length)
|
|
assert len(result) == len(stable_input_buffer), (
|
|
f"{effect_name} changed line count"
|
|
)
|
|
for i, (orig, new) in enumerate(zip(stable_input_buffer, result, strict=False)):
|
|
visible_new = strip_ansi(new)
|
|
assert len(visible_new) == len(orig), (
|
|
f"{effect_name} changed line {i} visible length from {len(orig)} to {len(visible_new)}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("effect_name", ["noise", "fade", "glitch"])
|
|
def test_effect_no_cursor_positioning(self, effect_name, stable_input_buffer):
|
|
"""Effects should not use cursor positioning (causes display conflicts)."""
|
|
try:
|
|
effect_module = __import__(
|
|
f"engine.effects.plugins.{effect_name}",
|
|
fromlist=[f"{effect_name.title()}Effect"],
|
|
)
|
|
effect_class = getattr(effect_module, f"{effect_name.title()}Effect")
|
|
effect = effect_class()
|
|
except ImportError:
|
|
pytest.skip(f"Effect {effect_name} not available")
|
|
|
|
ctx = EffectContext(
|
|
terminal_width=80,
|
|
terminal_height=24,
|
|
scroll_cam=0,
|
|
ticker_height=20,
|
|
)
|
|
|
|
result = effect.process(stable_input_buffer, ctx)
|
|
|
|
cursor_pos_pattern = re.compile(r"\033\[[0-9]+;[0-9]+H")
|
|
for i, line in enumerate(result):
|
|
match = cursor_pos_pattern.search(line)
|
|
assert match is None, (
|
|
f"{effect_name} uses cursor positioning on line {i}: {repr(line[:50])}"
|
|
)
|