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:
31
tests/kitty_test.py
Normal file
31
tests/kitty_test.py
Normal 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()
|
||||
@@ -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
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])}"
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user