feat(pipeline): integrate BorderMode and add UI preset

- params.py: border field now accepts bool | BorderMode
- presets.py: add UI_PRESET with BorderMode.UI, remove SIXEL_PRESET
- __init__.py: export UI_PRESET, drop SIXEL_PRESET
- registry.py: auto-register FrameBufferStage on discovery
- New FrameBufferStage for frame history and intensity maps
- Tests: update test_pipeline for UI preset, add test_framebuffer_stage.py

This sets the foundation for interactive UI panel and modern pipeline composition.
This commit is contained in:
2026-03-18 12:19:10 -07:00
parent 36afbacb6b
commit 21fb210c6e
7 changed files with 449 additions and 20 deletions

View File

@@ -0,0 +1,236 @@
"""
Tests for FrameBufferStage.
"""
import pytest
from engine.pipeline.core import DataType, PipelineContext
from engine.pipeline.params import PipelineParams
from engine.pipeline.stages.framebuffer import FrameBufferConfig, FrameBufferStage
def make_ctx(width: int = 80, height: int = 24) -> PipelineContext:
"""Create a PipelineContext for testing."""
ctx = PipelineContext()
params = PipelineParams()
params.viewport_width = width
params.viewport_height = height
ctx.params = params
return ctx
class TestFrameBufferStage:
"""Tests for FrameBufferStage."""
def test_init(self):
"""FrameBufferStage initializes with default config."""
stage = FrameBufferStage()
assert stage.name == "framebuffer"
assert stage.category == "effect"
assert stage.config.history_depth == 2
def test_capabilities(self):
"""Stage provides framebuffer.history capability."""
stage = FrameBufferStage()
assert "framebuffer.history" in stage.capabilities
def test_dependencies(self):
"""Stage depends on render.output."""
stage = FrameBufferStage()
assert "render.output" in stage.dependencies
def test_inlet_outlet_types(self):
"""Stage accepts and produces TEXT_BUFFER."""
stage = FrameBufferStage()
assert DataType.TEXT_BUFFER in stage.inlet_types
assert DataType.TEXT_BUFFER in stage.outlet_types
def test_init_context(self):
"""init initializes context state."""
stage = FrameBufferStage()
ctx = make_ctx()
result = stage.init(ctx)
assert result is True
assert ctx.get("frame_history") == []
assert ctx.get("intensity_history") == []
def test_process_stores_buffer_in_history(self):
"""process stores buffer in history."""
stage = FrameBufferStage()
ctx = make_ctx()
stage.init(ctx)
buffer = ["line1", "line2", "line3"]
result = stage.process(buffer, ctx)
assert result == buffer # Pass-through
history = ctx.get("frame_history")
assert len(history) == 1
assert history[0] == buffer
def test_process_computes_intensity(self):
"""process computes intensity map."""
stage = FrameBufferStage()
ctx = make_ctx()
stage.init(ctx)
buffer = ["hello world", "test line", ""]
stage.process(buffer, ctx)
intensity = ctx.get("current_intensity")
assert intensity is not None
assert len(intensity) == 3 # Three rows
# Non-empty lines should have intensity > 0
assert intensity[0] > 0
assert intensity[1] > 0
# Empty line should have intensity 0
assert intensity[2] == 0.0
def test_process_keeps_multiple_frames(self):
"""process keeps configured depth of frames."""
config = FrameBufferConfig(history_depth=3)
stage = FrameBufferStage(config)
ctx = make_ctx()
stage.init(ctx)
# Process several frames
for i in range(5):
buffer = [f"frame {i}"]
stage.process(buffer, ctx)
history = ctx.get("frame_history")
assert len(history) == 3 # Only last 3 kept
# Should be in reverse chronological order (most recent first)
assert history[0] == ["frame 4"]
assert history[1] == ["frame 3"]
assert history[2] == ["frame 2"]
def test_process_keeps_intensity_sync(self):
"""process keeps intensity history in sync with frame history."""
config = FrameBufferConfig(history_depth=3)
stage = FrameBufferStage(config)
ctx = make_ctx()
stage.init(ctx)
buffers = [
["a"],
["bb"],
["ccc"],
]
for buf in buffers:
stage.process(buf, ctx)
frame_hist = ctx.get("frame_history")
intensity_hist = ctx.get("intensity_history")
assert len(frame_hist) == len(intensity_hist) == 3
# Each frame's intensity should match
for i, frame in enumerate(frame_hist):
computed_intensity = stage._compute_buffer_intensity(frame, len(frame))
assert intensity_hist[i] == pytest.approx(computed_intensity)
def test_get_frame(self):
"""get_frame retrieves frames from history by index."""
config = FrameBufferConfig(history_depth=3)
stage = FrameBufferStage(config)
ctx = make_ctx()
stage.init(ctx)
buffers = [["f1"], ["f2"], ["f3"]]
for buf in buffers:
stage.process(buf, ctx)
assert stage.get_frame(0, ctx) == ["f3"] # Most recent
assert stage.get_frame(1, ctx) == ["f2"]
assert stage.get_frame(2, ctx) == ["f1"]
assert stage.get_frame(3, ctx) is None # Out of range
def test_get_intensity(self):
"""get_intensity retrieves intensity maps by index."""
stage = FrameBufferStage()
ctx = make_ctx()
stage.init(ctx)
buffers = [["line"], ["longer line"]]
for buf in buffers:
stage.process(buf, ctx)
intensity0 = stage.get_intensity(0, ctx)
intensity1 = stage.get_intensity(1, ctx)
assert intensity0 is not None
assert intensity1 is not None
# Longer line should have higher intensity (more non-space chars)
assert sum(intensity1) > sum(intensity0)
def test_compute_buffer_intensity_simple(self):
"""_compute_buffer_intensity computes simple density."""
stage = FrameBufferStage()
buf = ["abc", " ", "de"]
intensities = stage._compute_buffer_intensity(buf, max_rows=3)
assert len(intensities) == 3
# "abc" -> 3/3 = 1.0
assert pytest.approx(intensities[0]) == 1.0
# " " -> 0/2 = 0.0
assert pytest.approx(intensities[1]) == 0.0
# "de" -> 2/2 = 1.0
assert pytest.approx(intensities[2]) == 1.0
def test_compute_buffer_intensity_with_ansi(self):
"""_compute_buffer_intensity strips ANSI codes."""
stage = FrameBufferStage()
# Line with ANSI color codes
buf = ["\033[31mred\033[0m", "normal"]
intensities = stage._compute_buffer_intensity(buf, max_rows=2)
assert len(intensities) == 2
# Should treat "red" as 3 non-space chars
assert pytest.approx(intensities[0]) == 1.0 # "red" = 3/3
assert pytest.approx(intensities[1]) == 1.0 # "normal" = 6/6
def test_compute_buffer_intensity_padding(self):
"""_compute_buffer_intensity pads to max_rows."""
stage = FrameBufferStage()
buf = ["short"]
intensities = stage._compute_buffer_intensity(buf, max_rows=5)
assert len(intensities) == 5
assert intensities[0] > 0
assert all(i == 0.0 for i in intensities[1:])
def test_thread_safety(self):
"""process is thread-safe."""
from threading import Thread
stage = FrameBufferStage()
ctx = make_ctx()
stage.init(ctx)
results = []
def worker(idx):
buffer = [f"thread {idx}"]
stage.process(buffer, ctx)
results.append(len(ctx.get("frame_history", [])))
threads = [Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# All threads should see consistent state
assert len(ctx.get("frame_history")) <= 2 # Depth limit
# All worker threads should have completed without errors
assert len(results) == 10
def test_cleanup(self):
"""cleanup does nothing but can be called."""
stage = FrameBufferStage()
# Should not raise
stage.cleanup()

View File

@@ -45,7 +45,8 @@ class TestStageRegistry:
assert "pygame" in displays
assert "websocket" in displays
assert "null" in displays
assert "sixel" in displays
# sixel and kitty removed; should not be present
assert "sixel" not in displays
def test_create_source_stage(self):
"""StageRegistry.create creates source stages."""
@@ -546,7 +547,7 @@ class TestPipelinePresets:
FIREHOSE_PRESET,
PIPELINE_VIZ_PRESET,
POETRY_PRESET,
SIXEL_PRESET,
UI_PRESET,
WEBSOCKET_PRESET,
)
@@ -554,8 +555,8 @@ class TestPipelinePresets:
assert POETRY_PRESET.name == "poetry"
assert FIREHOSE_PRESET.name == "firehose"
assert PIPELINE_VIZ_PRESET.name == "pipeline"
assert SIXEL_PRESET.name == "sixel"
assert WEBSOCKET_PRESET.name == "websocket"
assert UI_PRESET.name == "ui"
def test_preset_to_params(self):
"""Presets convert to PipelineParams correctly."""