""" 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])}" )