forked from genewildish/Mainline
feat(tests): Add acceptance tests and HTML report generator
This commit is contained in:
473
tests/acceptance_report.py
Normal file
473
tests/acceptance_report.py
Normal file
@@ -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"<span{style}>{text}</span>"
|
||||
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"<span{style}>{text}</span>"
|
||||
|
||||
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'<div class="frame-line" data-line="{i}">{rendered}</div>')
|
||||
|
||||
return f"""<div class="frame" id="frame-{frame_number}">
|
||||
<div class="frame-header">Frame {frame_number} ({len(frame)} lines)</div>
|
||||
<div class="frame-content">
|
||||
{"".join(html_lines)}
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
|
||||
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 = '<div class="metadata">'
|
||||
for key, value in metadata.items():
|
||||
metadata_html += f'<div class="meta-row"><span class="meta-key">{key}:</span> <span class="meta-value">{value}</span></div>'
|
||||
metadata_html += "</div>"
|
||||
|
||||
status_class = "pass" if status == "PASS" else "fail"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{test_name} - Acceptance Test Report</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}}
|
||||
.test-report {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
.test-header {{
|
||||
background: #16213e;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}}
|
||||
.test-name {{
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}}
|
||||
.status {{
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.status.pass {{
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}}
|
||||
.status.fail {{
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}}
|
||||
.frame {{
|
||||
background: #0f0f1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.frame-header {{
|
||||
background: #16213e;
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #333;
|
||||
}}
|
||||
.frame-content {{
|
||||
padding: 15px;
|
||||
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}}
|
||||
.frame-line {{
|
||||
min-height: 1.4em;
|
||||
}}
|
||||
.metadata {{
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.meta-row {{
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.meta-key {{
|
||||
color: #888;
|
||||
}}
|
||||
.meta-value {{
|
||||
color: #fff;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-top: 40px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-report">
|
||||
<div class="test-header">
|
||||
<div class="test-name">{test_name}</div>
|
||||
<div class="status {status_class}">{status}</div>
|
||||
</div>
|
||||
{metadata_html}
|
||||
{frames_html}
|
||||
<div class="footer">
|
||||
Generated: {datetime.now().isoformat()}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</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"""
|
||||
<tr>
|
||||
<td><a href="{filename}">{report["test_name"]}</a></td>
|
||||
<td class="status {status_class}">{report["status"]}</td>
|
||||
<td>{report.get("duration_ms", 0):.1f}ms</td>
|
||||
<td>{report.get("frame_count", 0)}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Acceptance Test Reports</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
}}
|
||||
h1 {{
|
||||
color: #fff;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #333;
|
||||
}}
|
||||
th {{
|
||||
background: #16213e;
|
||||
color: #888;
|
||||
font-weight: normal;
|
||||
}}
|
||||
a {{
|
||||
color: #4dabf7;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
.status {{
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.status.pass {{
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}}
|
||||
.status.fail {{
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Acceptance Test Reports</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Test</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Frames</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
index_path = output_path / "index.html"
|
||||
index_path.write_text(html)
|
||||
return str(index_path)
|
||||
290
tests/test_acceptance.py
Normal file
290
tests/test_acceptance.py
Normal file
@@ -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}"
|
||||
Reference in New Issue
Block a user