diff --git a/tests/acceptance_report.py b/tests/acceptance_report.py
new file mode 100644
index 0000000..463503a
--- /dev/null
+++ b/tests/acceptance_report.py
@@ -0,0 +1,473 @@
+"""
+HTML Acceptance Test Report Generator
+
+Generates HTML reports showing frame buffers from acceptance tests.
+Uses NullDisplay to capture frames and renders them with monospace font.
+"""
+
+import html
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+ANSI_256_TO_RGB = {
+ 0: (0, 0, 0),
+ 1: (128, 0, 0),
+ 2: (0, 128, 0),
+ 3: (128, 128, 0),
+ 4: (0, 0, 128),
+ 5: (128, 0, 128),
+ 6: (0, 128, 128),
+ 7: (192, 192, 192),
+ 8: (128, 128, 128),
+ 9: (255, 0, 0),
+ 10: (0, 255, 0),
+ 11: (255, 255, 0),
+ 12: (0, 0, 255),
+ 13: (255, 0, 255),
+ 14: (0, 255, 255),
+ 15: (255, 255, 255),
+}
+
+
+def ansi_to_rgb(color_code: int) -> tuple[int, int, int]:
+ """Convert ANSI 256-color code to RGB tuple."""
+ if 0 <= color_code <= 15:
+ return ANSI_256_TO_RGB.get(color_code, (255, 255, 255))
+ elif 16 <= color_code <= 231:
+ color_code -= 16
+ r = (color_code // 36) * 51
+ g = ((color_code % 36) // 6) * 51
+ b = (color_code % 6) * 51
+ return (r, g, b)
+ elif 232 <= color_code <= 255:
+ gray = (color_code - 232) * 10 + 8
+ return (gray, gray, gray)
+ return (255, 255, 255)
+
+
+def parse_ansi_line(line: str) -> list[dict[str, Any]]:
+ """Parse a single line with ANSI escape codes into styled segments.
+
+ Returns list of dicts with 'text', 'fg', 'bg', 'bold' keys.
+ """
+ import re
+
+ segments = []
+ current_fg = None
+ current_bg = None
+ current_bold = False
+ pos = 0
+
+ # Find all ANSI escape sequences
+ escape_pattern = re.compile(r"\x1b\[([0-9;]*)m")
+
+ while pos < len(line):
+ match = escape_pattern.search(line, pos)
+ if not match:
+ # Remaining text with current styling
+ if pos < len(line):
+ text = line[pos:]
+ if text:
+ segments.append(
+ {
+ "text": text,
+ "fg": current_fg,
+ "bg": current_bg,
+ "bold": current_bold,
+ }
+ )
+ break
+
+ # Add text before escape sequence
+ if match.start() > pos:
+ text = line[pos : match.start()]
+ if text:
+ segments.append(
+ {
+ "text": text,
+ "fg": current_fg,
+ "bg": current_bg,
+ "bold": current_bold,
+ }
+ )
+
+ # Parse escape sequence
+ codes = match.group(1).split(";") if match.group(1) else ["0"]
+ for code in codes:
+ code = code.strip()
+ if not code or code == "0":
+ current_fg = None
+ current_bg = None
+ current_bold = False
+ elif code == "1":
+ current_bold = True
+ elif code.isdigit():
+ code_int = int(code)
+ if 30 <= code_int <= 37:
+ current_fg = ansi_to_rgb(code_int - 30 + 8)
+ elif 90 <= code_int <= 97:
+ current_fg = ansi_to_rgb(code_int - 90)
+ elif code_int == 38:
+ current_fg = (255, 255, 255)
+ elif code_int == 39:
+ current_fg = None
+
+ pos = match.end()
+
+ return segments
+
+
+def render_line_to_html(line: str) -> str:
+ """Render a single terminal line to HTML with styling."""
+ import re
+
+ result = ""
+ pos = 0
+ current_fg = None
+ current_bg = None
+ current_bold = False
+
+ escape_pattern = re.compile(r"(\x1b\[[0-9;]*m)|(\x1b\[([0-9]+);([0-9]+)H)")
+
+ while pos < len(line):
+ match = escape_pattern.search(line, pos)
+ if not match:
+ # Remaining text
+ if pos < len(line):
+ text = html.escape(line[pos:])
+ if text:
+ style = _build_style(current_fg, current_bg, current_bold)
+ result += f"{text}"
+ break
+
+ # Handle cursor positioning - just skip it for rendering
+ if match.group(2): # Cursor positioning \x1b[row;colH
+ pos = match.end()
+ continue
+
+ # Handle style codes
+ if match.group(1):
+ codes = match.group(1)[2:-1].split(";") if match.group(1) else ["0"]
+ for code in codes:
+ code = code.strip()
+ if not code or code == "0":
+ current_fg = None
+ current_bg = None
+ current_bold = False
+ elif code == "1":
+ current_bold = True
+ elif code.isdigit():
+ code_int = int(code)
+ if 30 <= code_int <= 37:
+ current_fg = ansi_to_rgb(code_int - 30 + 8)
+ elif 90 <= code_int <= 97:
+ current_fg = ansi_to_rgb(code_int - 90)
+
+ pos = match.end()
+ continue
+
+ pos = match.end()
+
+ # Handle remaining text without escape codes
+ if pos < len(line):
+ text = html.escape(line[pos:])
+ if text:
+ style = _build_style(current_fg, current_bg, current_bold)
+ result += f"{text}"
+
+ return result or html.escape(line)
+
+
+def _build_style(
+ fg: tuple[int, int, int] | None, bg: tuple[int, int, int] | None, bold: bool
+) -> str:
+ """Build CSS style string from color values."""
+ styles = []
+ if fg:
+ styles.append(f"color: rgb({fg[0]},{fg[1]},{fg[2]})")
+ if bg:
+ styles.append(f"background-color: rgb({bg[0]},{bg[1]},{bg[2]})")
+ if bold:
+ styles.append("font-weight: bold")
+ if not styles:
+ return ""
+ return f' style="{"; ".join(styles)}"'
+
+
+def render_frame_to_html(frame: list[str], frame_number: int = 0) -> str:
+ """Render a complete frame (list of lines) to HTML."""
+ html_lines = []
+ for i, line in enumerate(frame):
+ # Strip ANSI cursor positioning but preserve colors
+ clean_line = (
+ line.replace("\x1b[1;1H", "")
+ .replace("\x1b[2;1H", "")
+ .replace("\x1b[3;1H", "")
+ )
+ rendered = render_line_to_html(clean_line)
+ html_lines.append(f'
{rendered}
')
+
+ return f"""
+
+
+ {"".join(html_lines)}
+
+
"""
+
+
+def generate_test_report(
+ test_name: str,
+ frames: list[list[str]],
+ status: str = "PASS",
+ duration_ms: float = 0.0,
+ metadata: dict[str, Any] | None = None,
+) -> str:
+ """Generate HTML report for a single test."""
+ frames_html = ""
+ for i, frame in enumerate(frames):
+ frames_html += render_frame_to_html(frame, i)
+
+ metadata_html = ""
+ if metadata:
+ metadata_html = '"
+
+ status_class = "pass" if status == "PASS" else "fail"
+
+ return f"""
+
+
+
+ {test_name} - Acceptance Test Report
+
+
+
+
+
+ {metadata_html}
+ {frames_html}
+
+
+
+"""
+
+
+def save_report(
+ test_name: str,
+ frames: list[list[str]],
+ output_dir: str = "test-reports",
+ status: str = "PASS",
+ duration_ms: float = 0.0,
+ metadata: dict[str, Any] | None = None,
+) -> str:
+ """Save HTML report to disk and return the file path."""
+ output_path = Path(output_dir)
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ # Sanitize test name for filename
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in test_name)
+ filename = f"{safe_name}.html"
+ filepath = output_path / filename
+
+ html_content = generate_test_report(
+ test_name, frames, status, duration_ms, metadata
+ )
+ filepath.write_text(html_content)
+
+ return str(filepath)
+
+
+def save_index_report(
+ reports: list[dict[str, Any]],
+ output_dir: str = "test-reports",
+) -> str:
+ """Generate an index HTML page linking to all test reports."""
+ output_path = Path(output_dir)
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ rows = ""
+ for report in reports:
+ safe_name = "".join(
+ c if c.isalnum() or c in "-_" else "_" for c in report["test_name"]
+ )
+ filename = f"{safe_name}.html"
+ status_class = "pass" if report["status"] == "PASS" else "fail"
+ rows += f"""
+
+ | {report["test_name"]} |
+ {report["status"]} |
+ {report.get("duration_ms", 0):.1f}ms |
+ {report.get("frame_count", 0)} |
+
+ """
+
+ html = f"""
+
+
+
+ Acceptance Test Reports
+
+
+
+ Acceptance Test Reports
+
+
+
+ | Test |
+ Status |
+ Duration |
+ Frames |
+
+
+
+ {rows}
+
+
+
+"""
+
+ index_path = output_path / "index.html"
+ index_path.write_text(html)
+ return str(index_path)
diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py
new file mode 100644
index 0000000..a94c9ca
--- /dev/null
+++ b/tests/test_acceptance.py
@@ -0,0 +1,290 @@
+"""
+Acceptance tests for HUD visibility and positioning.
+
+These tests verify that HUD appears in the final output frame.
+Frames are captured and saved as HTML reports for visual verification.
+"""
+
+import queue
+
+from engine.data_sources.sources import ListDataSource, SourceItem
+from engine.effects.plugins.hud import HudEffect
+from engine.pipeline import Pipeline, PipelineConfig
+from engine.pipeline.adapters import (
+ DataSourceStage,
+ DisplayStage,
+ EffectPluginStage,
+ SourceItemsToBufferStage,
+)
+from engine.pipeline.core import PipelineContext
+from engine.pipeline.params import PipelineParams
+from tests.acceptance_report import save_report
+
+
+class FrameCaptureDisplay:
+ """Display that captures frames for HTML report generation."""
+
+ def __init__(self):
+ self.frames: queue.Queue[list[str]] = queue.Queue()
+ self.width = 80
+ self.height = 24
+ self._recorded_frames: list[list[str]] = []
+
+ def init(self, width: int, height: int, reuse: bool = False) -> None:
+ self.width = width
+ self.height = height
+
+ def show(self, buffer: list[str], border: bool = False) -> None:
+ self._recorded_frames.append(list(buffer))
+ self.frames.put(list(buffer))
+
+ def clear(self) -> None:
+ pass
+
+ def cleanup(self) -> None:
+ pass
+
+ def get_dimensions(self) -> tuple[int, int]:
+ return (self.width, self.height)
+
+ def get_recorded_frames(self) -> list[list[str]]:
+ return self._recorded_frames
+
+
+def _build_pipeline_with_hud(
+ items: list[SourceItem],
+) -> tuple[Pipeline, FrameCaptureDisplay, PipelineContext]:
+ """Build a pipeline with HUD effect."""
+ display = FrameCaptureDisplay()
+
+ ctx = PipelineContext()
+ params = PipelineParams()
+ params.viewport_width = display.width
+ params.viewport_height = display.height
+ params.frame_number = 0
+ params.effect_order = ["noise", "hud"]
+ params.effect_enabled = {"noise": False}
+ ctx.params = params
+
+ pipeline = Pipeline(
+ config=PipelineConfig(
+ source="list",
+ display="terminal",
+ effects=["hud"],
+ enable_metrics=True,
+ ),
+ context=ctx,
+ )
+
+ source = ListDataSource(items, name="test-source")
+ pipeline.add_stage("source", DataSourceStage(source, name="test-source"))
+ pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
+
+ hud_effect = HudEffect()
+ pipeline.add_stage("hud", EffectPluginStage(hud_effect, name="hud"))
+
+ pipeline.add_stage("display", DisplayStage(display, name="terminal"))
+
+ pipeline.build()
+ pipeline.initialize()
+
+ return pipeline, display, ctx
+
+
+class TestHUDAcceptance:
+ """Acceptance tests for HUD visibility."""
+
+ def test_hud_appears_in_final_output(self):
+ """Test that HUD appears in the final display output.
+
+ This is the key regression test for Issue #47 - HUD was running
+ AFTER the display stage, making it invisible. Now it should appear
+ in the frame captured by the display.
+ """
+ items = [SourceItem(content="Test content line", source="test", timestamp="0")]
+ pipeline, display, ctx = _build_pipeline_with_hud(items)
+
+ result = pipeline.execute(items)
+ assert result.success, f"Pipeline execution failed: {result.error}"
+
+ frame = display.frames.get(timeout=1)
+ frame_text = "\n".join(frame)
+
+ assert "MAINLINE" in frame_text, "HUD header not found in final output"
+ assert "EFFECT:" in frame_text, "EFFECT line not found in final output"
+ assert "PIPELINE:" in frame_text, "PIPELINE line not found in final output"
+
+ save_report(
+ test_name="test_hud_appears_in_final_output",
+ frames=display.get_recorded_frames(),
+ status="PASS",
+ metadata={
+ "description": "Verifies HUD appears in final display output (Issue #47 fix)",
+ "frame_lines": len(frame),
+ "has_mainline": "MAINLINE" in frame_text,
+ "has_effect": "EFFECT:" in frame_text,
+ "has_pipeline": "PIPELINE:" in frame_text,
+ },
+ )
+
+ def test_hud_cursor_positioning(self):
+ """Test that HUD uses correct cursor positioning."""
+ items = [SourceItem(content="Sample content", source="test", timestamp="0")]
+ pipeline, display, ctx = _build_pipeline_with_hud(items)
+
+ result = pipeline.execute(items)
+ assert result.success
+
+ frame = display.frames.get(timeout=1)
+ has_cursor_pos = any("\x1b[" in line and "H" in line for line in frame)
+
+ save_report(
+ test_name="test_hud_cursor_positioning",
+ frames=display.get_recorded_frames(),
+ status="PASS",
+ metadata={
+ "description": "Verifies HUD uses cursor positioning",
+ "has_cursor_positioning": has_cursor_pos,
+ },
+ )
+
+
+class TestCameraSpeedAcceptance:
+ """Acceptance tests for camera speed modulation."""
+
+ def test_camera_speed_modulation(self):
+ """Test that camera speed can be modulated at runtime.
+
+ This verifies the camera speed modulation feature added in Phase 1.
+ """
+ from engine.camera import Camera
+ from engine.pipeline.adapters import CameraClockStage, CameraStage
+
+ display = FrameCaptureDisplay()
+ items = [
+ SourceItem(content=f"Line {i}", source="test", timestamp=str(i))
+ for i in range(50)
+ ]
+
+ ctx = PipelineContext()
+ params = PipelineParams()
+ params.viewport_width = display.width
+ params.viewport_height = display.height
+ params.frame_number = 0
+ params.camera_speed = 1.0
+ ctx.params = params
+
+ pipeline = Pipeline(
+ config=PipelineConfig(
+ source="list",
+ display="terminal",
+ camera="scroll",
+ enable_metrics=False,
+ ),
+ context=ctx,
+ )
+
+ source = ListDataSource(items, name="test")
+ pipeline.add_stage("source", DataSourceStage(source, name="test"))
+ pipeline.add_stage("render", SourceItemsToBufferStage(name="render"))
+
+ camera = Camera.scroll(speed=0.5)
+ pipeline.add_stage(
+ "camera_update", CameraClockStage(camera, name="camera-clock")
+ )
+ pipeline.add_stage("camera", CameraStage(camera, name="camera"))
+ pipeline.add_stage("display", DisplayStage(display, name="terminal"))
+
+ pipeline.build()
+ pipeline.initialize()
+
+ initial_camera_speed = camera.speed
+
+ for _ in range(3):
+ pipeline.execute(items)
+
+ speed_after_first_run = camera.speed
+
+ params.camera_speed = 5.0
+ ctx.params = params
+
+ for _ in range(3):
+ pipeline.execute(items)
+
+ speed_after_increase = camera.speed
+
+ assert speed_after_increase == 5.0, (
+ f"Camera speed should be modulated to 5.0, got {speed_after_increase}"
+ )
+
+ params.camera_speed = 0.0
+ ctx.params = params
+
+ for _ in range(3):
+ pipeline.execute(items)
+
+ speed_after_stop = camera.speed
+ assert speed_after_stop == 0.0, (
+ f"Camera speed should be 0.0, got {speed_after_stop}"
+ )
+
+ save_report(
+ test_name="test_camera_speed_modulation",
+ frames=display.get_recorded_frames()[:5],
+ status="PASS",
+ metadata={
+ "description": "Verifies camera speed can be modulated at runtime",
+ "initial_camera_speed": initial_camera_speed,
+ "speed_after_first_run": speed_after_first_run,
+ "speed_after_increase": speed_after_increase,
+ "speed_after_stop": speed_after_stop,
+ },
+ )
+
+
+class TestEmptyLinesAcceptance:
+ """Acceptance tests for empty line handling."""
+
+ def test_empty_lines_remain_empty(self):
+ """Test that empty lines remain empty in output (regression for padding bug)."""
+ items = [
+ SourceItem(content="Line1\n\nLine3\n\nLine5", source="test", timestamp="0")
+ ]
+
+ display = FrameCaptureDisplay()
+ ctx = PipelineContext()
+ params = PipelineParams()
+ params.viewport_width = display.width
+ params.viewport_height = display.height
+ ctx.params = params
+
+ pipeline = Pipeline(
+ config=PipelineConfig(enable_metrics=False),
+ context=ctx,
+ )
+
+ source = ListDataSource(items, name="test")
+ pipeline.add_stage("source", DataSourceStage(source, name="test"))
+ pipeline.add_stage("render", SourceItemsToBufferStage(name="render"))
+ pipeline.add_stage("display", DisplayStage(display, name="terminal"))
+
+ pipeline.build()
+ pipeline.initialize()
+
+ result = pipeline.execute(items)
+ assert result.success
+
+ frame = display.frames.get(timeout=1)
+ has_truly_empty = any(not line for line in frame)
+
+ save_report(
+ test_name="test_empty_lines_remain_empty",
+ frames=display.get_recorded_frames(),
+ status="PASS",
+ metadata={
+ "description": "Verifies empty lines remain empty (not padded)",
+ "has_truly_empty_lines": has_truly_empty,
+ },
+ )
+
+ assert has_truly_empty, f"Expected at least one empty line, got: {frame[1]!r}"