fix: resolve terminal display wobble and effect dimension stability
- Fix TerminalDisplay: add screen clear each frame (cursor home + erase down) - Fix CameraStage: use set_canvas_size instead of read-only viewport properties - Fix Glitch effect: preserve visible line lengths, remove cursor positioning - Fix Fade effect: return original line when fade=0 instead of empty string - Fix Noise effect: use input line length instead of terminal_width - Remove HUD effect from all presets (redundant with border FPS display) - Add regression tests for effect dimension stability - Add docs/ARCHITECTURE.md with Mermaid diagrams - Add mise tasks: diagram-ascii, diagram-validate, diagram-check - Move markdown docs to docs/ (ARCHITECTURE, Refactor, hardware specs) - Remove redundant requirements files (use pyproject.toml) - Add *.dot and *.png to .gitignore Closes #25
This commit is contained in:
238
tests/test_glitch_effect.py
Normal file
238
tests/test_glitch_effect.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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 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 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 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 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 effects_plugins.fade import FadeEffect
|
||||
from effects_plugins.glitch import GlitchEffect
|
||||
from 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"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"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])}"
|
||||
)
|
||||
Reference in New Issue
Block a user