use std::fmt;
use std::fmt::Write;
use crate::frame::{CellColor, TerminalFrame};
use crate::locator::MatchedSpan;
use crate::proof::report::ProofReport;
use crate::region::Region;
pub type MatchResult = Result<(), Box<AssertionFailure>>;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AssertionFailure {
pub message: String,
pub expected: Expected,
pub region: Option<Region>,
pub matched_spans: Vec<MatchedSpan>,
pub frame_excerpt: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Expected {
TextInRegion { needle: String },
NotVisible { needle: String },
MatchCount { needle: String, count: usize },
ForegroundColor { needle: String, color: CellColor },
BackgroundColor { needle: String, color: CellColor },
Highlighted { needle: String },
NotHighlighted { needle: String },
}
impl fmt::Display for AssertionFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for AssertionFailure {}
pub fn match_text_in_region(frame: &TerminalFrame, needle: &str, region: &Region) -> MatchResult {
let matches = frame.find_text_in_region(needle, region);
if matches.is_empty() {
let message = format_text_not_found(frame, needle, *region);
let frame_excerpt = frame.text_in_region(region);
return Err(Box::new(AssertionFailure {
message,
expected: Expected::TextInRegion {
needle: needle.to_string(),
},
region: Some(*region),
matched_spans: frame.find_text(needle),
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_text_in_region(frame: &TerminalFrame, needle: &str, region: &Region) {
let result = match_text_in_region(frame, needle, region);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub fn match_not_visible(frame: &TerminalFrame, needle: &str) -> MatchResult {
let matches = frame.find_text(needle);
if !matches.is_empty() {
let message = format!(
"Expected text '{needle}' to NOT be visible, but found {} occurrence(s):\n{}",
matches.len(),
format_span_list(&matches)
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::NotVisible {
needle: needle.to_string(),
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_not_visible(frame: &TerminalFrame, needle: &str) {
let result = match_not_visible(frame, needle);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub fn match_match_count(
frame: &TerminalFrame,
needle: &str,
expected_count: usize,
) -> MatchResult {
let matches = frame.find_text(needle);
if matches.len() != expected_count {
let message = format!(
"Expected '{needle}' to appear {expected_count} time(s), but found {}:\n{}",
matches.len(),
format_span_list(&matches)
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::MatchCount {
needle: needle.to_string(),
count: expected_count,
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_match_count(frame: &TerminalFrame, needle: &str, expected_count: usize) {
let matches = frame.find_text(needle);
let actual = matches.len();
assert_eq!(
actual,
expected_count,
"Expected '{needle}' to appear {expected_count} time(s), but found {actual}:\n{}",
format_span_list(&matches)
);
}
pub fn match_text_has_fg_color(
frame: &TerminalFrame,
needle: &str,
expected_color: &CellColor,
) -> MatchResult {
let matches = frame.find_text(needle);
if matches.is_empty() {
return Err(Box::new(AssertionFailure {
message: format!("Cannot check color: text '{needle}' not found in frame"),
expected: Expected::ForegroundColor {
needle: needle.to_string(),
color: *expected_color,
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: frame.all_text(),
}));
}
let span = &matches[0];
if !span.has_fg(expected_color) {
let message = format!(
"Text '{needle}' at ({}, {}) has foreground {:?}, expected {:?}",
span.rect.col, span.rect.row, span.foreground, expected_color
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::ForegroundColor {
needle: needle.to_string(),
color: *expected_color,
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_text_has_fg_color(frame: &TerminalFrame, needle: &str, expected_color: &CellColor) {
let result = match_text_has_fg_color(frame, needle, expected_color);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub fn match_text_has_bg_color(
frame: &TerminalFrame,
needle: &str,
expected_color: &CellColor,
) -> MatchResult {
let matches = frame.find_text(needle);
if matches.is_empty() {
return Err(Box::new(AssertionFailure {
message: format!("Cannot check color: text '{needle}' not found in frame"),
expected: Expected::BackgroundColor {
needle: needle.to_string(),
color: *expected_color,
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: frame.all_text(),
}));
}
let span = &matches[0];
if !span.has_bg(expected_color) {
let message = format!(
"Text '{needle}' at ({}, {}) has background {:?}, expected {:?}",
span.rect.col, span.rect.row, span.background, expected_color
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::BackgroundColor {
needle: needle.to_string(),
color: *expected_color,
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_text_has_bg_color(frame: &TerminalFrame, needle: &str, expected_color: &CellColor) {
let result = match_text_has_bg_color(frame, needle, expected_color);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub fn match_span_is_highlighted(frame: &TerminalFrame, needle: &str) -> MatchResult {
let matches = frame.find_text(needle);
if matches.is_empty() {
return Err(Box::new(AssertionFailure {
message: format!("Cannot check highlight: text '{needle}' not found in frame"),
expected: Expected::Highlighted {
needle: needle.to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: frame.all_text(),
}));
}
let span = &matches[0];
if !span.is_highlighted() {
let message = format!(
"Text '{needle}' at ({}, {}) is not highlighted. Style: {:?}, fg: {:?}, bg: {:?}",
span.rect.col, span.rect.row, span.style, span.foreground, span.background
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::Highlighted {
needle: needle.to_string(),
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_span_is_highlighted(frame: &TerminalFrame, needle: &str) {
let result = match_span_is_highlighted(frame, needle);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub fn match_span_is_not_highlighted(frame: &TerminalFrame, needle: &str) -> MatchResult {
let matches = frame.find_text(needle);
if matches.is_empty() {
return Err(Box::new(AssertionFailure {
message: format!("Cannot check highlight: text '{needle}' not found in frame"),
expected: Expected::NotHighlighted {
needle: needle.to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: frame.all_text(),
}));
}
let span = &matches[0];
if span.is_highlighted() {
let message = format!(
"Text '{needle}' at ({}, {}) is highlighted but should not be. Style: {:?}, fg: {:?}, \
bg: {:?}",
span.rect.col, span.rect.row, span.style, span.foreground, span.background
);
let frame_excerpt = frame.all_text();
return Err(Box::new(AssertionFailure {
message,
expected: Expected::NotHighlighted {
needle: needle.to_string(),
},
region: None,
matched_spans: matches,
frame_excerpt,
}));
}
Ok(())
}
pub fn assert_span_is_not_highlighted(frame: &TerminalFrame, needle: &str) {
let result = match_span_is_not_highlighted(frame, needle);
assert!(
result.is_ok(),
"{}",
result.err().map(|f| f.message).unwrap_or_default()
);
}
pub struct SoftAssertions<'r> {
failures: Vec<AssertionFailure>,
consumed: bool,
report: Option<&'r mut ProofReport>,
}
impl SoftAssertions<'static> {
pub fn new() -> Self {
Self {
failures: Vec::new(),
consumed: false,
report: None,
}
}
}
impl Default for SoftAssertions<'static> {
fn default() -> Self {
Self::new()
}
}
impl<'r> SoftAssertions<'r> {
pub fn with_report(report: &'r mut ProofReport) -> Self {
assert!(
!report.captures.is_empty(),
"SoftAssertions::with_report requires at least one capture on the report; call \
ProofReport::add_capture before binding so soft failures can be routed into \
ProofCapture::assertions"
);
Self {
failures: Vec::new(),
consumed: false,
report: Some(report),
}
}
pub fn check(&mut self, result: MatchResult) {
if let Err(failure) = result {
let failure = *failure;
if let Some(report) = self.report.as_mut() {
report.record_soft_failure(&failure);
}
self.failures.push(failure);
}
}
pub fn len(&self) -> usize {
self.failures.len()
}
pub fn is_empty(&self) -> bool {
self.failures.is_empty()
}
pub fn into_failures(mut self) -> Vec<AssertionFailure> {
self.consumed = true;
std::mem::take(&mut self.failures)
}
fn format_aggregated_message(failures: &[AssertionFailure]) -> String {
let count = failures.len();
let mut message = format!("SoftAssertions: {count} failure(s) recorded:\n");
for (index, failure) in failures.iter().enumerate() {
let mut lines = failure.message.lines();
let first = lines.next().unwrap_or("");
let _ = writeln!(message, "[{}/{count}] {first}", index + 1);
for line in lines {
let _ = writeln!(message, " {line}");
}
}
message
}
}
impl Drop for SoftAssertions<'_> {
fn drop(&mut self) {
if self.consumed || self.failures.is_empty() {
return;
}
if std::thread::panicking() {
return;
}
let message = Self::format_aggregated_message(&self.failures);
assert!(self.failures.is_empty(), "{message}");
}
}
fn format_text_not_found(frame: &TerminalFrame, needle: &str, region: Region) -> String {
let all_matches = frame.find_text(needle);
let region_text = frame.text_in_region(®ion);
let mut message = String::new();
let _ = writeln!(
message,
"Text '{needle}' not found in region (col:{}, row:{}, {}x{})",
region.col, region.row, region.width, region.height
);
if all_matches.is_empty() {
let _ = writeln!(message, " Text is not visible anywhere in the frame.");
} else {
let _ = writeln!(
message,
" Text found {} time(s) outside the region:",
all_matches.len()
);
for span in &all_matches {
let _ = writeln!(
message,
" - at (col:{}, row:{})",
span.rect.col, span.rect.row
);
}
}
let _ = writeln!(message, " Region content:\n{region_text}");
message
}
fn format_span_list(spans: &[MatchedSpan]) -> String {
let mut output = String::new();
for span in spans {
let _ = writeln!(
output,
" - '{}' at (col:{}, row:{}) fg:{:?} bg:{:?} style:{:?}",
span.text, span.rect.col, span.rect.row, span.foreground, span.background, span.style
);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assert_text_in_region_passes_when_found() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(0, 0, 80, 1);
assert_text_in_region(&frame, "Hello", ®ion);
}
#[test]
#[should_panic(expected = "not found in region")]
fn assert_text_in_region_panics_when_not_found() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
assert_text_in_region(&frame, "Hello", ®ion);
}
#[test]
fn match_text_in_region_returns_ok_when_found() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(0, 0, 80, 1);
let result = match_text_in_region(&frame, "Hello", ®ion);
assert!(result.is_ok());
}
#[test]
fn match_text_in_region_returns_structured_failure_when_missing() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
let failure = match_text_in_region(&frame, "Hello", ®ion).expect_err("should be Err");
assert_eq!(failure.region, Some(region));
assert!(failure.message.contains("not found in region"));
assert!(failure.frame_excerpt.is_empty() || !failure.frame_excerpt.contains("Hello"));
assert!(
matches!(&failure.expected, Expected::TextInRegion { needle, .. } if needle == "Hello"),
"unexpected expected variant: {:?}",
failure.expected
);
assert_eq!(failure.matched_spans.len(), 1);
}
#[test]
fn assert_not_visible_passes_when_absent() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
assert_not_visible(&frame, "Goodbye");
}
#[test]
#[should_panic(expected = "NOT be visible")]
fn assert_not_visible_panics_when_present() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
assert_not_visible(&frame, "Hello");
}
#[test]
fn match_not_visible_returns_structured_failure_when_present() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let failure = match_not_visible(&frame, "Hello").expect_err("should be Err");
assert!(failure.region.is_none());
assert_eq!(failure.matched_spans.len(), 1);
assert!(
matches!(&failure.expected, Expected::NotVisible { needle, .. } if needle == "Hello"),
"unexpected expected variant: {:?}",
failure.expected
);
assert!(failure.frame_excerpt.contains("Hello World"));
}
#[test]
fn assert_match_count_passes_with_correct_count() {
let frame = TerminalFrame::new(80, 24, b"foo bar foo");
assert_match_count(&frame, "foo", 2);
}
#[test]
#[should_panic(expected = "appear 1 time(s)")]
fn assert_match_count_panics_with_wrong_count() {
let frame = TerminalFrame::new(80, 24, b"foo bar foo");
assert_match_count(&frame, "foo", 1);
}
#[test]
fn match_match_count_returns_ok_for_correct_count() {
let frame = TerminalFrame::new(80, 24, b"foo bar foo");
let result = match_match_count(&frame, "foo", 2);
assert!(result.is_ok());
}
#[test]
fn match_match_count_returns_failure_for_wrong_count() {
let frame = TerminalFrame::new(80, 24, b"foo bar foo");
let failure = match_match_count(&frame, "foo", 1).expect_err("should be Err");
assert!(
matches!(
&failure.expected,
Expected::MatchCount { needle, count, .. } if needle == "foo" && *count == 1
),
"unexpected expected variant: {:?}",
failure.expected
);
assert_eq!(failure.matched_spans.len(), 2);
}
#[test]
fn assert_span_is_highlighted_detects_bold() {
let frame = TerminalFrame::new(80, 24, b"\x1b[1mBold\x1b[0m");
assert_span_is_highlighted(&frame, "Bold");
}
#[test]
fn match_span_is_highlighted_returns_failure_when_missing() {
let frame = TerminalFrame::new(80, 24, b"plain text");
let failure = match_span_is_highlighted(&frame, "missing").expect_err("should be Err");
assert!(failure.matched_spans.is_empty());
assert!(
matches!(&failure.expected, Expected::Highlighted { needle, .. } if needle == "missing"),
"unexpected expected variant: {:?}",
failure.expected
);
assert!(failure.frame_excerpt.contains("plain text"));
}
#[test]
fn match_span_is_highlighted_returns_failure_for_plain_text() {
let frame = TerminalFrame::new(80, 24, b"plain text");
let failure = match_span_is_highlighted(&frame, "plain").expect_err("should be Err");
assert!(
matches!(&failure.expected, Expected::Highlighted { needle, .. } if needle == "plain"),
"unexpected expected variant: {:?}",
failure.expected
);
assert_eq!(failure.matched_spans.len(), 1);
}
#[test]
fn assert_span_is_not_highlighted_for_plain_text() {
let frame = TerminalFrame::new(80, 24, b"plain text");
assert_span_is_not_highlighted(&frame, "plain");
}
#[test]
fn match_span_is_not_highlighted_returns_failure_for_bold() {
let frame = TerminalFrame::new(80, 24, b"\x1b[1mBold\x1b[0m");
let failure = match_span_is_not_highlighted(&frame, "Bold").expect_err("should be Err");
assert!(
matches!(&failure.expected, Expected::NotHighlighted { needle, .. } if needle == "Bold"),
"unexpected expected variant: {:?}",
failure.expected
);
}
#[test]
fn assert_text_has_fg_color_passes() {
let frame = TerminalFrame::new(80, 24, b"\x1b[31mRed\x1b[0m");
assert_text_has_fg_color(&frame, "Red", &CellColor::new(128, 0, 0));
}
#[test]
#[should_panic(expected = "foreground")]
fn assert_text_has_fg_color_panics_on_mismatch() {
let frame = TerminalFrame::new(80, 24, b"\x1b[31mRed\x1b[0m");
assert_text_has_fg_color(&frame, "Red", &CellColor::new(0, 255, 0));
}
#[test]
fn match_text_has_fg_color_returns_failure_when_text_missing() {
let frame = TerminalFrame::new(80, 24, b"plain");
let failure = match_text_has_fg_color(&frame, "missing", &CellColor::new(0, 255, 0))
.expect_err("should be Err");
assert!(failure.message.contains("not found in frame"));
assert!(failure.matched_spans.is_empty());
assert!(failure.frame_excerpt.contains("plain"));
}
#[test]
fn match_text_has_fg_color_returns_failure_on_mismatch() {
let frame = TerminalFrame::new(80, 24, b"\x1b[31mRed\x1b[0m");
let failure = match_text_has_fg_color(&frame, "Red", &CellColor::new(0, 255, 0))
.expect_err("should be Err");
assert!(
matches!(
&failure.expected,
Expected::ForegroundColor { needle, color, .. }
if needle == "Red" && *color == CellColor::new(0, 255, 0)
),
"unexpected expected variant: {:?}",
failure.expected
);
assert_eq!(failure.matched_spans.len(), 1);
}
#[test]
fn match_text_has_bg_color_returns_failure_when_text_missing() {
let frame = TerminalFrame::new(80, 24, b"plain");
let failure = match_text_has_bg_color(&frame, "missing", &CellColor::new(0, 0, 0))
.expect_err("should be Err");
assert!(failure.message.contains("not found in frame"));
assert!(failure.matched_spans.is_empty());
assert!(failure.frame_excerpt.contains("plain"));
}
#[test]
fn match_text_has_bg_color_returns_ok_when_match() {
let frame = TerminalFrame::new(80, 24, b"\x1b[41mActive\x1b[0m");
let result = match_text_has_bg_color(&frame, "Active", &CellColor::new(128, 0, 0));
assert!(result.is_ok());
}
#[test]
fn match_text_has_bg_color_returns_failure_on_mismatch() {
let frame = TerminalFrame::new(80, 24, b"\x1b[41mActive\x1b[0m");
let failure = match_text_has_bg_color(&frame, "Active", &CellColor::new(0, 0, 128))
.expect_err("should be Err");
assert!(
matches!(
&failure.expected,
Expected::BackgroundColor { needle, color, .. }
if needle == "Active" && *color == CellColor::new(0, 0, 128)
),
"unexpected expected variant: {:?}",
failure.expected
);
assert_eq!(failure.matched_spans.len(), 1);
assert!(failure.frame_excerpt.contains("Active"));
}
#[test]
fn match_span_is_not_highlighted_returns_failure_when_missing() {
let frame = TerminalFrame::new(80, 24, b"plain text");
let failure = match_span_is_not_highlighted(&frame, "missing").expect_err("should be Err");
assert!(failure.matched_spans.is_empty());
assert!(failure.message.contains("not found in frame"));
assert!(
matches!(&failure.expected, Expected::NotHighlighted { needle, .. } if needle == "missing"),
"unexpected expected variant: {:?}",
failure.expected
);
assert!(failure.frame_excerpt.contains("plain text"));
}
#[test]
fn soft_assertions_empty_does_not_panic_on_drop() {
{
let _soft = SoftAssertions::new();
}
}
#[test]
#[should_panic(expected = "SoftAssertions: 1 failure(s)")]
fn soft_assertions_single_failure_panics_on_drop_with_message() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
let mut soft = SoftAssertions::new();
soft.check(match_text_in_region(&frame, "Hello", ®ion));
}
#[test]
fn soft_assertions_format_aggregated_message_lists_each_failure_in_order() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let empty_region = Region::new(20, 0, 60, 1);
let visible_region = Region::new(0, 0, 80, 1);
let mut soft = SoftAssertions::new();
soft.check(match_text_in_region(&frame, "Hello", &empty_region));
soft.check(match_text_in_region(&frame, "Hello", &visible_region));
soft.check(match_not_visible(&frame, "Hello"));
let failures = soft.into_failures();
let message = SoftAssertions::format_aggregated_message(&failures);
assert_eq!(failures.len(), 2);
assert!(
message.starts_with("SoftAssertions: 2 failure(s) recorded:\n"),
"unexpected header: {message}"
);
let not_found_index = message
.find("[1/2]")
.expect("first failure should be indexed [1/2]");
let not_visible_index = message
.find("[2/2]")
.expect("second failure should be indexed [2/2]");
assert!(not_found_index < not_visible_index);
assert!(message[not_found_index..not_visible_index].contains("not found in region"));
assert!(message[not_visible_index..].contains("NOT be visible"));
}
#[test]
fn soft_assertions_into_failures_suppresses_drop_panic() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
let mut soft = SoftAssertions::new();
soft.check(match_text_in_region(&frame, "Hello", ®ion));
let failures = soft.into_failures();
assert_eq!(failures.len(), 1);
}
#[test]
#[should_panic(expected = "requires at least one capture")]
fn soft_assertions_with_report_panics_when_no_capture_exists() {
let mut report = ProofReport::new("empty-report");
let _soft = SoftAssertions::with_report(&mut report);
}
#[test]
fn soft_assertions_len_and_is_empty_track_recorded_failures() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
let mut soft = SoftAssertions::new();
assert!(soft.is_empty());
assert_eq!(soft.len(), 0);
soft.check(match_text_in_region(&frame, "Hello", ®ion));
assert!(!soft.is_empty());
assert_eq!(soft.len(), 1);
let _ = soft.into_failures();
}
#[test]
fn assertion_failure_display_matches_legacy_panic_message() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let region = Region::new(20, 0, 60, 1);
let failure = match_text_in_region(&frame, "Hello", ®ion).expect_err("should be Err");
assert_eq!(failure.to_string(), failure.message);
}
}