use std::fmt::Write;
use std::io::Cursor;
use base64::Engine;
use image::ImageFormat;
use unicode_width::UnicodeWidthStr;
use super::backend::{ProofBackend, RenderContext};
use super::report::{AssertionResult, ProofCapture, ProofError, ProofReport};
use crate::assertion::{AssertionFailure, Expected};
use crate::frame::{CellColor, CellStyle, TerminalFrame};
use crate::locator::MatchedSpan;
use crate::renderer;
pub struct HtmlBackend;
impl ProofBackend for HtmlBackend {
fn render(&self, context: &RenderContext<'_>) -> Result<(), ProofError> {
let html = build_html(context.report)?;
std::fs::write(context.output, html)?;
Ok(())
}
}
fn build_html(report: &ProofReport) -> Result<String, ProofError> {
let mut html = String::with_capacity(8192);
write_html_header(&mut html, &report.scenario_name);
for (index, capture) in report.captures.iter().enumerate() {
let step_number = index + 1;
let diff_summary = if index > 0 && index - 1 < report.diffs.len() {
Some(report.diffs[index - 1].summary())
} else {
None
};
write_step_card(&mut html, step_number, capture, diff_summary.as_deref())?;
}
write_html_footer(&mut html, report.captures.len());
Ok(html)
}
fn write_html_header(html: &mut String, scenario_name: &str) {
let escaped_name = escape_html(scenario_name);
let _ = write!(
html,
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proof Report: {escaped_name}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #1a1a2e; color: #e0e0e0; margin: 0; padding: 20px; }}
h1 {{ color: #00d4ff; border-bottom: 2px solid #00d4ff; padding-bottom: 10px; }}
.step-card {{ background: #16213e; border: 1px solid #0f3460; border-radius: 8px; margin: 20px 0; padding: 20px; }}
.step-header {{ display: flex; align-items: center; gap: 12px; margin-bottom: 15px; }}
.step-number {{ background: #00d4ff; color: #1a1a2e; font-weight: bold; padding: 4px 12px; border-radius: 4px; font-size: 14px; }}
.step-label {{ color: #e94560; font-weight: bold; font-size: 16px; }}
.step-desc {{ color: #a0a0a0; font-size: 14px; }}
.frame-img {{ max-width: 100%; border: 1px solid #0f3460; border-radius: 4px; }}
.diff-section {{ background: #0d1b2a; padding: 12px; border-radius: 4px; margin-top: 12px; font-size: 13px; }}
.diff-section h4 {{ color: #ffd700; margin: 0 0 8px 0; }}
.diff-item {{ color: #b0b0b0; padding: 2px 0; }}
.assertions {{ margin-top: 12px; }}
.assertion {{ padding: 4px 0; font-size: 13px; }}
.pass {{ color: #00e676; }}
.pass::before {{ content: "\2713 "; }}
.fail {{ color: #ff5252; }}
.fail::before {{ content: "\2717 "; }}
.failure-detail {{ display: flex; flex-wrap: wrap; gap: 16px; margin: 6px 0 10px 18px; padding: 12px; background: #0d1b2a; border-left: 3px solid #ff5252; border-radius: 4px; font-size: 12px; color: #d0d0d0; }}
.failure-context {{ flex: 1 1 240px; min-width: 220px; }}
.failure-context-row {{ margin-bottom: 4px; }}
.failure-context-label {{ color: #ffd700; font-weight: bold; margin-right: 4px; }}
.failure-spans {{ margin-top: 6px; }}
.failure-spans-title {{ color: #ffd700; font-weight: bold; }}
.failure-spans ul {{ margin: 4px 0 0 0; padding-left: 18px; }}
.failure-spans li {{ color: #b0b0b0; padding: 1px 0; }}
.failure-frame {{ flex: 2 1 320px; min-width: 280px; }}
.failure-frame-title {{ color: #ffd700; font-weight: bold; margin-bottom: 4px; }}
.frame-excerpt {{ background: #0a0f1a; border: 1px solid #0f3460; border-radius: 4px; padding: 8px; margin: 0; font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.3; white-space: pre; overflow-x: auto; color: #c8c8c8; }}
.row-gutter {{ color: #606060; user-select: none; }}
.col-ruler {{ color: #606060; user-select: none; }}
.needle-hit {{ background: #5a1a1a; color: #ffd0d0; }}
.footer {{ text-align: center; color: #606060; margin-top: 30px; padding-top: 15px; border-top: 1px solid #0f3460; font-size: 12px; }}
.terminal-info {{ color: #606060; font-size: 12px; margin-bottom: 8px; }}
</style>
</head>
<body>
<h1>Proof Report: {escaped_name}</h1>
"#
);
}
fn write_step_card(
html: &mut String,
step_number: usize,
capture: &ProofCapture,
diff_summary: Option<&[String]>,
) -> Result<(), ProofError> {
let _ = writeln!(html, "<div class=\"step-card\">");
let _ = writeln!(html, "<div class=\"step-header\">");
let _ = writeln!(
html,
"<span class=\"step-number\">Step {step_number}</span>"
);
let _ = writeln!(
html,
"<span class=\"step-label\">[{}]</span>",
escape_html(&capture.label)
);
let _ = writeln!(
html,
"<span class=\"step-desc\">{}</span>",
escape_html(&capture.description)
);
let _ = writeln!(html, "</div>");
let _ = writeln!(
html,
"<div class=\"terminal-info\">Terminal: {}x{}</div>",
capture.cols, capture.rows
);
let base64_image = render_capture_to_base64(capture)?;
let _ = writeln!(
html,
"<img class=\"frame-img\" src=\"data:image/png;base64,{base64_image}\" alt=\"Step \
{step_number}: {}\" />",
escape_html(&capture.label)
);
if let Some(summaries) = diff_summary
&& !summaries.is_empty()
{
let _ = writeln!(html, "<div class=\"diff-section\">");
let _ = writeln!(html, "<h4>Changes from previous step</h4>");
for summary_line in summaries {
let _ = writeln!(
html,
"<div class=\"diff-item\">{}</div>",
escape_html(summary_line)
);
}
let _ = writeln!(html, "</div>");
}
if !capture.assertions.is_empty() {
let _ = writeln!(html, "<div class=\"assertions\">");
for assertion in &capture.assertions {
write_assertion(html, assertion);
}
let _ = writeln!(html, "</div>");
}
let _ = writeln!(html, "</div>");
Ok(())
}
fn write_assertion(html: &mut String, assertion: &AssertionResult) {
let css_class = if assertion.passed { "pass" } else { "fail" };
let _ = writeln!(
html,
"<div class=\"assertion {css_class}\">{}</div>",
escape_html(&assertion.description)
);
if let Some(failure) = assertion.failure.as_deref() {
write_failure_detail(html, failure);
}
}
fn write_failure_detail(html: &mut String, failure: &AssertionFailure) {
let _ = writeln!(html, "<div class=\"failure-detail\">");
write_failure_context(html, failure);
write_failure_frame(html, failure);
let _ = writeln!(html, "</div>");
}
fn write_failure_context(html: &mut String, failure: &AssertionFailure) {
let _ = writeln!(html, "<div class=\"failure-context\">");
let _ = writeln!(
html,
"<div class=\"failure-context-row\"><span \
class=\"failure-context-label\">Expected:</span> {}</div>",
escape_html(&format_expected(&failure.expected))
);
if let Some(region) = failure.region {
let _ = writeln!(
html,
"<div class=\"failure-context-row\"><span \
class=\"failure-context-label\">Region:</span> col={} row={} width={} height={}</div>",
region.col, region.row, region.width, region.height
);
}
if !failure.matched_spans.is_empty() {
let _ = writeln!(html, "<div class=\"failure-spans\">");
let _ = writeln!(
html,
"<div class=\"failure-spans-title\">Matched spans ({})</div>",
failure.matched_spans.len()
);
let _ = writeln!(html, "<ul>");
for span in &failure.matched_spans {
let _ = writeln!(html, "<li>{}</li>", escape_html(&format_span(span)));
}
let _ = writeln!(html, "</ul>");
let _ = writeln!(html, "</div>");
}
let _ = writeln!(html, "</div>");
}
fn write_failure_frame(html: &mut String, failure: &AssertionFailure) {
let _ = writeln!(html, "<div class=\"failure-frame\">");
let _ = writeln!(
html,
"<div class=\"failure-frame-title\">Frame excerpt</div>"
);
let lines = excerpt_lines(failure);
if lines.is_empty() {
let _ = writeln!(html, "<pre class=\"frame-excerpt\"></pre>");
let _ = writeln!(html, "</div>");
return;
}
let (start_col, start_row) = failure
.region
.map_or((0u16, 0u16), |region| (region.col, region.row));
let needle = needle_from_expected(&failure.expected);
let needle_for_line = (!needle.is_empty()).then_some(needle);
let mut remaining_hits = highlight_limit(&failure.expected);
let max_width = lines
.iter()
.map(|line| UnicodeWidthStr::width(line.as_str()))
.max()
.unwrap_or(0);
let _ = writeln!(html, "<pre class=\"frame-excerpt\">");
write_column_ruler(html, start_col, max_width);
for (offset, line) in lines.iter().enumerate() {
let row_label = u32::from(start_row) + u32::try_from(offset).unwrap_or(u32::MAX);
let _ = write!(html, "<span class=\"row-gutter\">{row_label:>4} </span>");
let consumed = write_line_with_highlights(html, line, needle_for_line, remaining_hits);
if let Some(remaining) = remaining_hits.as_mut() {
*remaining = remaining.saturating_sub(consumed);
}
let _ = writeln!(html);
}
let _ = writeln!(html, "</pre>");
let _ = writeln!(html, "</div>");
}
fn excerpt_lines(failure: &AssertionFailure) -> Vec<String> {
let Some(region) = failure.region else {
return failure.frame_excerpt.lines().map(str::to_string).collect();
};
let target_width = usize::from(region.width);
let target_height = usize::from(region.height);
let mut lines: Vec<String> = failure
.frame_excerpt
.lines()
.map(|line| pad_to_display_width(line, target_width))
.collect();
while lines.len() < target_height {
lines.push(" ".repeat(target_width));
}
lines
}
fn pad_to_display_width(line: &str, target: usize) -> String {
let current = UnicodeWidthStr::width(line);
if current >= target {
return line.to_string();
}
let mut padded = String::with_capacity(line.len() + (target - current));
padded.push_str(line);
for _ in 0..(target - current) {
padded.push(' ');
}
padded
}
fn write_column_ruler(html: &mut String, start_col: u16, width: usize) {
if width == 0 {
return;
}
let last_offset = u32::try_from(width.saturating_sub(1)).unwrap_or(u32::MAX);
let max_col = u32::from(start_col).saturating_add(last_offset);
if max_col >= 100 {
let _ = write!(html, "<span class=\"col-ruler\"> ");
for offset in 0..width {
let col = u32::from(start_col) + u32::try_from(offset).unwrap_or(u32::MAX);
if col >= 100 && col % 10 == 0 {
let _ = write!(html, "{}", (col / 100) % 10);
} else {
let _ = write!(html, " ");
}
}
let _ = writeln!(html, "</span>");
}
let _ = write!(html, "<span class=\"col-ruler\"> ");
for offset in 0..width {
let col = u32::from(start_col) + u32::try_from(offset).unwrap_or(u32::MAX);
if col % 10 == 0 {
let _ = write!(html, "{}", (col / 10) % 10);
} else {
let _ = write!(html, " ");
}
}
let _ = writeln!(html, "</span>");
let _ = write!(html, "<span class=\"col-ruler\"> ");
for offset in 0..width {
let col = u32::from(start_col) + u32::try_from(offset).unwrap_or(u32::MAX);
let _ = write!(html, "{}", col % 10);
}
let _ = writeln!(html, "</span>");
}
fn write_line_with_highlights(
html: &mut String,
line: &str,
needle: Option<&str>,
max_hits: Option<usize>,
) -> usize {
let Some(needle) = needle.filter(|n| !n.is_empty()) else {
let _ = write!(html, "{}", escape_html(line));
return 0;
};
if max_hits == Some(0) {
let _ = write!(html, "{}", escape_html(line));
return 0;
}
let (ranges, raw_count) = needle_byte_ranges(line, needle, max_hits);
if ranges.is_empty() {
let _ = write!(html, "{}", escape_html(line));
return raw_count;
}
let mut cursor = 0;
for (start, end) in ranges {
if cursor < start {
let _ = write!(html, "{}", escape_html(&line[cursor..start]));
}
let _ = write!(
html,
"<span class=\"needle-hit\">{}</span>",
escape_html(&line[start..end])
);
cursor = end;
}
if cursor < line.len() {
let _ = write!(html, "{}", escape_html(&line[cursor..]));
}
raw_count
}
fn needle_byte_ranges(
line: &str,
needle: &str,
max_hits: Option<usize>,
) -> (Vec<(usize, usize)>, usize) {
let mut raw = Vec::new();
let mut search_start = 0;
while let Some(offset) = line.get(search_start..).and_then(|tail| tail.find(needle)) {
let match_start = search_start + offset;
let match_end = match_start + needle.len();
raw.push((match_start, match_end));
if max_hits.is_some_and(|max| raw.len() >= max) {
break;
}
let advance = line[match_start..].chars().next().map_or(1, char::len_utf8);
search_start = match_start + advance;
}
let raw_count = raw.len();
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(raw.len());
for (start, end) in raw {
if let Some(last) = merged.last_mut()
&& start < last.1
{
last.1 = last.1.max(end);
continue;
}
merged.push((start, end));
}
(merged, raw_count)
}
fn highlight_limit(expected: &Expected) -> Option<usize> {
match expected {
Expected::TextInRegion { .. }
| Expected::NotVisible { .. }
| Expected::MatchCount { .. } => None,
Expected::ForegroundColor { .. }
| Expected::BackgroundColor { .. }
| Expected::Highlighted { .. }
| Expected::NotHighlighted { .. } => Some(1),
}
}
fn format_expected(expected: &Expected) -> String {
match expected {
Expected::TextInRegion { needle } => {
format!("text '{needle}' visible in region")
}
Expected::NotVisible { needle } => {
format!("text '{needle}' not visible anywhere in frame")
}
Expected::MatchCount { needle, count } => {
format!("text '{needle}' to appear exactly {count} time(s)")
}
Expected::ForegroundColor { needle, color } => {
format!(
"first match of '{needle}' with foreground color {}",
format_color(*color)
)
}
Expected::BackgroundColor { needle, color } => {
format!(
"first match of '{needle}' with background color {}",
format_color(*color)
)
}
Expected::Highlighted { needle } => {
format!("first match of '{needle}' to be highlighted")
}
Expected::NotHighlighted { needle } => {
format!("first match of '{needle}' to not be highlighted")
}
}
}
fn needle_from_expected(expected: &Expected) -> &str {
match expected {
Expected::TextInRegion { needle }
| Expected::NotVisible { needle }
| Expected::MatchCount { needle, .. }
| Expected::ForegroundColor { needle, .. }
| Expected::BackgroundColor { needle, .. }
| Expected::Highlighted { needle }
| Expected::NotHighlighted { needle } => needle.as_str(),
}
}
fn format_span(span: &MatchedSpan) -> String {
let mut out = format!(
"'{}' at col={} row={} width={}",
span.text, span.rect.col, span.rect.row, span.rect.width
);
if let Some(fg) = span.foreground {
let _ = write!(out, " fg={}", format_color(fg));
}
if let Some(bg) = span.background {
let _ = write!(out, " bg={}", format_color(bg));
}
let style_label = format_style(span.style);
if !style_label.is_empty() {
let _ = write!(out, " style={style_label}");
}
out
}
fn format_style(style: CellStyle) -> String {
let mut flags: Vec<&'static str> = Vec::new();
if style.bold() {
flags.push("bold");
}
if style.italic() {
flags.push("italic");
}
if style.underline() {
flags.push("underline");
}
if style.inverse() {
flags.push("inverse");
}
if style.dim() {
flags.push("dim");
}
flags.join(",")
}
fn format_color(color: CellColor) -> String {
format!("rgb({}, {}, {})", color.red, color.green, color.blue)
}
fn write_html_footer(html: &mut String, capture_count: usize) {
let _ = write!(
html,
r#"<div class="footer">Total captures: {capture_count} | Generated by testty</div>
</body>
</html>
"#
);
}
fn render_capture_to_base64(capture: &ProofCapture) -> Result<String, ProofError> {
let frame = TerminalFrame::new(capture.cols, capture.rows, &capture.frame_bytes);
let image = renderer::render_to_image(&frame);
let mut png_bytes = Cursor::new(Vec::new());
image
.write_to(&mut png_bytes, ImageFormat::Png)
.map_err(|err| ProofError::Format(err.to_string()))?;
let encoded = base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner());
Ok(encoded)
}
pub(super) fn escape_html(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::CellStyle;
use crate::region::Region;
#[test]
fn html_contains_step_cards() {
let frame = TerminalFrame::new(20, 3, b"Hello HTML");
let mut report = ProofReport::new("html_test");
report.add_capture("init", "Initial state", &frame);
report.add_capture("done", "Final state", &frame);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("Step 1"));
assert!(html.contains("Step 2"));
assert!(html.contains("[init]"));
assert!(html.contains("[done]"));
assert!(html.contains("Initial state"));
assert!(html.contains("Final state"));
}
#[test]
fn html_contains_embedded_images() {
let frame = TerminalFrame::new(10, 2, b"Img");
let mut report = ProofReport::new("image_test");
report.add_capture("snap", "Snapshot", &frame);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("data:image/png;base64,"));
}
#[test]
fn html_contains_assertion_results() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("assert_html");
report.add_capture("check", "Check", &frame);
report.add_assertion("check", true, "text visible");
report.add_assertion("check", false, "color match");
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("class=\"assertion pass\""));
assert!(html.contains("class=\"assertion fail\""));
assert!(html.contains("text visible"));
assert!(html.contains("color match"));
}
#[test]
fn html_contains_diff_summaries() {
let frame_a = TerminalFrame::new(20, 3, b"Before");
let frame_b = TerminalFrame::new(20, 3, b"After!");
let mut report = ProofReport::new("diff_html");
report.add_capture("before", "Before", &frame_a);
report.add_capture("after", "After", &frame_b);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("Changes from previous step"));
}
#[test]
fn html_is_self_contained() {
let frame = TerminalFrame::new(10, 2, b"SC");
let mut report = ProofReport::new("self_contained");
report.add_capture("snap", "Snapshot", &frame);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<style>"));
assert!(html.contains("</html>"));
}
#[test]
fn html_backend_writes_file() {
let frame = TerminalFrame::new(10, 2, b"File");
let mut report = ProofReport::new("file_test");
report.add_capture("snap", "Snapshot", &frame);
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let output_path = temp_dir.path().join("report.html");
let backend = HtmlBackend;
backend
.render(&RenderContext::new(&report, &output_path))
.expect("render should succeed");
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path).expect("failed to read");
assert!(content.contains("Proof Report: file_test"));
}
#[test]
fn escape_html_handles_special_chars() {
assert_eq!(escape_html("<script>"), "<script>");
assert_eq!(escape_html("a&b"), "a&b");
assert_eq!(escape_html("\"quoted\""), ""quoted"");
}
#[test]
fn html_escapes_scenario_name_in_header() {
let frame = TerminalFrame::new(10, 2, b"X");
let mut report = ProofReport::new("<script>alert('xss')</script>");
report.add_capture("snap", "Snapshot", &frame);
let html = build_html(&report).expect("should build HTML");
assert!(!html.contains("<script>alert"));
assert!(html.contains("<script>"));
}
fn report_with_structured_failure(failure: &AssertionFailure) -> ProofReport {
let frame = TerminalFrame::new(20, 3, b"Hello World");
let mut report = ProofReport::new("structured_failure");
report.add_capture("only", "Only capture", &frame);
report.record_soft_failure(failure);
report
}
#[test]
fn html_renders_structured_failure_expected_and_region() {
let region = Region::new(0, 0, 20, 1);
let failure = AssertionFailure {
message: "text 'Goodbye' not found in region (col=0, row=0, 20x1)".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(region),
matched_spans: Vec::new(),
frame_excerpt: "Hello World ".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("class=\"failure-detail\""));
assert!(html.contains("Expected:"));
assert!(html.contains("text 'Goodbye' visible in region"));
assert!(html.contains("Region:"));
assert!(html.contains("col=0 row=0 width=20 height=1"));
}
#[test]
fn html_renders_matched_spans_when_present() {
let span_a = MatchedSpan {
text: "Hello".to_string(),
rect: Region::new(0, 0, 5, 1),
foreground: None,
background: None,
style: CellStyle::default(),
};
let span_b = MatchedSpan {
text: "Hello".to_string(),
rect: Region::new(6, 1, 5, 1),
foreground: None,
background: None,
style: CellStyle::default(),
};
let failure = AssertionFailure {
message: "Expected text 'Hello' to NOT be visible".to_string(),
expected: Expected::NotVisible {
needle: "Hello".to_string(),
},
region: None,
matched_spans: vec![span_a, span_b],
frame_excerpt: "Hello World".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("Matched spans (2)"));
assert!(html.contains("'Hello' at col=0 row=0 width=5"));
assert!(html.contains("'Hello' at col=6 row=1 width=5"));
}
#[test]
fn html_frame_excerpt_uses_region_offsets_for_row_gutter() {
let region = Region::new(10, 5, 11, 2);
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(region),
matched_spans: Vec::new(),
frame_excerpt: "first line \nsecond line".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("class=\"row-gutter\"> 5 "));
assert!(html.contains("class=\"row-gutter\"> 6 "));
assert!(html.contains("class=\"col-ruler\""));
}
#[test]
fn html_highlights_needle_occurrences_in_frame_excerpt() {
let failure = AssertionFailure {
message: "found two".to_string(),
expected: Expected::NotVisible {
needle: "Hello".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: "Hello world Hello again".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert_eq!(html.matches("class=\"needle-hit\">Hello").count(), 2);
}
#[test]
fn html_legacy_assertions_have_no_failure_detail() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("legacy_no_detail");
report.add_capture("check", "Check", &frame);
report.add_assertion("check", false, "color match");
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("class=\"assertion fail\""));
assert!(!html.contains("class=\"failure-detail\""));
}
#[test]
fn html_renders_match_count_expectation() {
let failure = AssertionFailure {
message: "wrong count".to_string(),
expected: Expected::MatchCount {
needle: "TODO".to_string(),
count: 3,
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("text 'TODO' to appear exactly 3 time(s)"));
}
#[test]
fn html_renders_color_expectation_with_rgb() {
let failure = AssertionFailure {
message: "wrong color".to_string(),
expected: Expected::ForegroundColor {
needle: "Save".to_string(),
color: CellColor::new(255, 0, 0),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("first match of 'Save' with foreground color rgb(255, 0, 0)"));
}
#[test]
fn html_format_span_includes_actual_fg_bg_and_style() {
let foreground = CellColor::new(10, 20, 30);
let background = CellColor::new(40, 50, 60);
let span = MatchedSpan {
text: "Save".to_string(),
rect: Region::new(2, 1, 4, 1),
foreground: Some(foreground),
background: Some(background),
style: CellStyle::from_raw(0b0000_0001),
};
let failure = AssertionFailure {
message: "Text 'Save' at (2, 1) has foreground Some(...), expected ...".to_string(),
expected: Expected::ForegroundColor {
needle: "Save".to_string(),
color: CellColor::new(255, 0, 0),
},
region: None,
matched_spans: vec![span],
frame_excerpt: "Save".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(
html.contains(
"'Save' at col=2 row=1 width=4 fg=rgb(10, 20, 30) bg=rgb(40, 50, 60) style=bold"
),
"expected matched-span to include actual fg/bg/style in:\n{html}"
);
}
#[test]
fn html_format_span_omits_unset_color_and_style_fields() {
let span = MatchedSpan {
text: "Save".to_string(),
rect: Region::new(0, 0, 4, 1),
foreground: None,
background: None,
style: CellStyle::default(),
};
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::NotVisible {
needle: "Save".to_string(),
},
region: None,
matched_spans: vec![span],
frame_excerpt: "Save".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("'Save' at col=0 row=0 width=4</li>"));
assert!(!html.contains("fg="));
assert!(!html.contains("bg="));
assert!(!html.contains("style="));
}
#[test]
fn html_highlights_overlapping_needle_matches() {
let failure = AssertionFailure {
message: "found two".to_string(),
expected: Expected::NotVisible {
needle: "ana".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: "banana".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(
html.contains("<span class=\"needle-hit\">anana</span>"),
"expected merged overlapping highlight in:\n{html}"
);
}
#[test]
fn html_column_ruler_uses_terminal_cell_width_for_wide_glyphs() {
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(Region::new(0, 0, 5, 1)),
matched_spans: Vec::new(),
frame_excerpt: "中a".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(
html.contains("<span class=\"col-ruler\"> 01234</span>"),
"expected width-5 ones-row ruler in:\n{html}"
);
}
#[test]
fn html_column_ruler_renders_hundreds_row_past_column_99() {
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(Region::new(100, 0, 21, 1)),
matched_spans: Vec::new(),
frame_excerpt: "x".repeat(21),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(
html.contains("<span class=\"col-ruler\"> 1 1 1</span>"),
"expected hundreds row marking cols 100/110/120 in:\n{html}"
);
assert!(
html.contains("<span class=\"col-ruler\"> 0 1 2</span>"),
"expected tens row marking cols 100/110/120 in:\n{html}"
);
assert!(
html.contains("<span class=\"col-ruler\"> 012345678901234567890</span>"),
"expected ones row spanning 21 cells in:\n{html}"
);
}
#[test]
fn html_column_ruler_omits_hundreds_row_under_column_100() {
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(Region::new(0, 0, 3, 1)),
matched_spans: Vec::new(),
frame_excerpt: "abc".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert_eq!(html.matches("class=\"col-ruler\"").count(), 2);
}
#[test]
fn html_renders_adjacent_needle_matches_as_separate_spans() {
let failure = AssertionFailure {
message: "found two".to_string(),
expected: Expected::NotVisible {
needle: "ab".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: "abab".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert_eq!(
html.matches("<span class=\"needle-hit\">ab</span>").count(),
2,
"expected two adjacent highlights in:\n{html}"
);
assert!(
!html.contains("<span class=\"needle-hit\">abab</span>"),
"adjacent matches must not collapse into one span in:\n{html}"
);
}
#[test]
fn html_first_match_only_expectation_highlights_only_first_occurrence() {
let failure = AssertionFailure {
message: "wrong fg".to_string(),
expected: Expected::ForegroundColor {
needle: "Save".to_string(),
color: CellColor::new(255, 0, 0),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: "Save first\nSave second".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert_eq!(
html.matches("<span class=\"needle-hit\">Save</span>")
.count(),
1,
"expected only the first occurrence to be highlighted in:\n{html}"
);
}
#[test]
fn html_highlight_limit_classifies_expected_variants() {
assert_eq!(
highlight_limit(&Expected::TextInRegion {
needle: "x".to_string(),
}),
None
);
assert_eq!(
highlight_limit(&Expected::NotVisible {
needle: "x".to_string(),
}),
None
);
assert_eq!(
highlight_limit(&Expected::MatchCount {
needle: "x".to_string(),
count: 2,
}),
None
);
assert_eq!(
highlight_limit(&Expected::ForegroundColor {
needle: "x".to_string(),
color: CellColor::new(0, 0, 0),
}),
Some(1)
);
assert_eq!(
highlight_limit(&Expected::BackgroundColor {
needle: "x".to_string(),
color: CellColor::new(0, 0, 0),
}),
Some(1)
);
assert_eq!(
highlight_limit(&Expected::Highlighted {
needle: "x".to_string(),
}),
Some(1)
);
assert_eq!(
highlight_limit(&Expected::NotHighlighted {
needle: "x".to_string(),
}),
Some(1)
);
}
#[test]
fn html_renders_all_blank_region_with_full_dimensions() {
let region = Region::new(5, 3, 8, 2);
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(region),
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(html.contains("class=\"row-gutter\"> 3 "));
assert!(html.contains("class=\"row-gutter\"> 4 "));
assert!(html.contains("class=\"col-ruler\""));
assert!(!html.contains("<pre class=\"frame-excerpt\"></pre>"));
}
#[test]
fn html_pads_partially_blank_region_to_full_width() {
let region = Region::new(0, 0, 12, 1);
let failure = AssertionFailure {
message: "missing".to_string(),
expected: Expected::TextInRegion {
needle: "Goodbye".to_string(),
},
region: Some(region),
matched_spans: Vec::new(),
frame_excerpt: "hi".to_string(),
};
let report = report_with_structured_failure(&failure);
let html = build_html(&report).expect("should build HTML");
assert!(
html.contains("<span class=\"col-ruler\"> 012345678901</span>"),
"expected width-12 ones-row ruler in:\n{html}"
);
}
}