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"""
+
Frame {frame_number} ({len(frame)} lines)
+
+ {"".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 + + + +
+
+
{test_name}
+
{status}
+
+ {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

+ + + + + + + + + + + {rows} + +
TestStatusDurationFrames
+ +""" + + 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}"