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:
2026-03-18 03:34:36 -07:00
parent a65fb50464
commit b926b346ad
30 changed files with 1472 additions and 40 deletions

31
tests/kitty_test.py Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Test script for Kitty graphics display."""
import sys
def test_kitty_simple():
"""Test simple Kitty graphics output with embedded PNG."""
import base64
# Minimal 1x1 red pixel PNG (pre-encoded)
# This is a tiny valid PNG with a red pixel
png_red_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00"
b"\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00"
b"\x03\x00\x01\x00\x05\xfe\xd4\x00\x00\x00\x00IEND\xaeB`\x82"
)
encoded = base64.b64encode(png_red_1x1).decode("ascii")
graphic = f"\x1b_Gf=100,t=d,s=1,v=1,c=1,r=1;{encoded}\x1b\\"
sys.stdout.buffer.write(graphic.encode("utf-8"))
sys.stdout.flush()
print("\n[If you see a red dot above, Kitty graphics is working!]")
print("[If you see nothing or garbage, it's not working]")
if __name__ == "__main__":
test_kitty_simple()

View File

@@ -2,6 +2,7 @@
Tests for engine.display module.
"""
import sys
from unittest.mock import MagicMock
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay
@@ -115,6 +116,83 @@ class TestTerminalDisplay:
display = TerminalDisplay()
display.cleanup()
def test_get_dimensions_returns_cached_value(self):
"""get_dimensions returns cached dimensions for stability."""
display = TerminalDisplay()
display.init(80, 24)
# First call should set cache
d1 = display.get_dimensions()
assert d1 == (80, 24)
def test_show_clears_screen_before_each_frame(self):
"""show clears previous frame to prevent visual wobble.
Regression test: Previously show() didn't clear the screen,
causing old content to remain and creating visual wobble.
The fix adds \\033[H\\033[J (cursor home + erase down) before each frame.
"""
from io import BytesIO
from unittest.mock import patch
display = TerminalDisplay()
display.init(80, 24)
buffer = ["line1", "line2", "line3"]
fake_buffer = BytesIO()
fake_stdout = MagicMock()
fake_stdout.buffer = fake_buffer
with patch.object(sys, "stdout", fake_stdout):
display.show(buffer)
output = fake_buffer.getvalue().decode("utf-8")
assert output.startswith("\033[H\033[J"), (
f"Output should start with clear sequence, got: {repr(output[:20])}"
)
def test_show_clears_screen_on_subsequent_frames(self):
"""show clears screen on every frame, not just the first.
Regression test: Ensures each show() call includes the clear sequence.
"""
from io import BytesIO
from unittest.mock import patch
# Use target_fps=0 to disable frame skipping in test
display = TerminalDisplay(target_fps=0)
display.init(80, 24)
buffer = ["line1", "line2"]
for i in range(3):
fake_buffer = BytesIO()
fake_stdout = MagicMock()
fake_stdout.buffer = fake_buffer
with patch.object(sys, "stdout", fake_stdout):
display.show(buffer)
output = fake_buffer.getvalue().decode("utf-8")
assert output.startswith("\033[H\033[J"), (
f"Frame {i} should start with clear sequence"
)
def test_get_dimensions_stable_across_rapid_calls(self):
"""get_dimensions should not fluctuate when called rapidly.
This test catches the bug where os.get_terminal_size() returns
inconsistent values, causing visual wobble.
"""
display = TerminalDisplay()
display.init(80, 24)
# Get dimensions 10 times rapidly (simulating frame loop)
dims = [display.get_dimensions() for _ in range(10)]
# All should be the same - this would fail if os.get_terminal_size()
# returns different values each call
assert len(set(dims)) == 1, f"Dimensions should be stable, got: {set(dims)}"
class TestNullDisplay:
"""Tests for NullDisplay class."""
@@ -141,6 +219,27 @@ class TestNullDisplay:
display = NullDisplay()
display.cleanup()
def test_show_stores_last_buffer(self):
"""show stores last buffer for testing inspection."""
display = NullDisplay()
display.init(80, 24)
buffer = ["line1", "line2", "line3"]
display.show(buffer)
assert display._last_buffer == buffer
def test_show_tracks_last_buffer_across_calls(self):
"""show updates last_buffer on each call."""
display = NullDisplay()
display.init(80, 24)
display.show(["first"])
assert display._last_buffer == ["first"]
display.show(["second"])
assert display._last_buffer == ["second"]
class TestMultiDisplay:
"""Tests for MultiDisplay class."""

238
tests/test_glitch_effect.py Normal file
View 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])}"
)

View File

@@ -483,7 +483,7 @@ class TestPipelineParams:
assert params.source == "headlines"
assert params.display == "terminal"
assert params.camera_mode == "vertical"
assert params.effect_order == ["noise", "fade", "glitch", "firehose", "hud"]
assert params.effect_order == ["noise", "fade", "glitch", "firehose"]
def test_effect_config(self):
"""PipelineParams effect config methods work."""
@@ -634,6 +634,33 @@ class TestStageAdapters:
assert "camera" in stage.capabilities
assert "render.output" in stage.dependencies # Depends on rendered content
def test_camera_stage_does_not_error_on_process(self):
"""CameraStage.process should not error when setting viewport.
Regression test: Previously CameraStage tried to set viewport_width
and viewport_height as writable properties, but they are computed
from canvas_size / zoom. This caused an AttributeError each frame.
"""
from engine.camera import Camera, CameraMode
from engine.pipeline.adapters import CameraStage
from engine.pipeline.core import PipelineContext
from engine.pipeline.params import PipelineParams
camera = Camera(mode=CameraMode.FEED)
stage = CameraStage(camera, name="vertical")
ctx = PipelineContext()
ctx.params = PipelineParams(viewport_width=80, viewport_height=24)
buffer = ["line" + str(i) for i in range(24)]
# This should not raise AttributeError
result = stage.process(buffer, ctx)
# Should return the buffer (unchanged for FEED mode)
assert result is not None
assert len(result) == 24
class TestDataSourceStage:
"""Tests for DataSourceStage adapter."""