use std::borrow::Cow;
use test_better_core::{ContextFrame, ErrorKind, Payload, TestError, TestResult};
use crate::matcher::Matcher;
#[track_caller]
pub fn soft<F>(f: F) -> TestResult
where
F: FnOnce(&mut SoftAsserter),
{
let mut asserter = SoftAsserter::new();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut asserter)));
let result = asserter.into_result();
match outcome {
Ok(()) => result,
Err(panic) => {
if let Err(ref soft_failures) = result {
eprintln!("soft assertions recorded before the panic:\n{soft_failures}");
}
std::panic::resume_unwind(panic);
}
}
}
#[derive(Default)]
pub struct SoftAsserter {
errors: Vec<TestError>,
context: Vec<ContextFrame>,
}
impl SoftAsserter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[track_caller]
pub fn check<T, M>(&mut self, actual: &T, matcher: M)
where
T: ?Sized,
M: Matcher<T>,
{
if let Some(mismatch) = matcher.check(actual).failure {
self.collect(TestError::new(ErrorKind::Assertion).with_payload(
Payload::ExpectedActual {
expected: mismatch.expected.to_string(),
actual: mismatch.actual,
diff: mismatch.diff,
},
));
}
}
#[track_caller]
pub fn record(&mut self, result: TestResult) {
if let Err(error) = result {
self.collect(error);
}
}
#[track_caller]
pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
self.context.push(ContextFrame::new(message));
SoftScope { asserter: self }
}
fn collect(&mut self, mut error: TestError) {
if !self.context.is_empty() {
let mut frames = self.context.clone();
frames.append(&mut error.context);
error.context = frames;
}
self.errors.push(error);
}
#[track_caller]
fn into_result(self) -> TestResult {
if self.errors.is_empty() {
return Ok(());
}
let count = self.errors.len();
let noun = if count == 1 {
"soft assertion"
} else {
"soft assertions"
};
Err(TestError::new(ErrorKind::Assertion)
.with_message(format!("{count} {noun} failed"))
.with_payload(Payload::Multiple(self.errors)))
}
}
pub struct SoftScope<'a> {
asserter: &'a mut SoftAsserter,
}
impl SoftScope<'_> {
#[track_caller]
pub fn check<T, M>(&mut self, actual: &T, matcher: M)
where
T: ?Sized,
M: Matcher<T>,
{
self.asserter.check(actual, matcher);
}
#[track_caller]
pub fn record(&mut self, result: TestResult) {
self.asserter.record(result);
}
#[track_caller]
pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
self.asserter.context(message)
}
}
impl Drop for SoftScope<'_> {
fn drop(&mut self) {
self.asserter.context.pop();
}
}
#[cfg(test)]
mod tests {
use test_better_core::{Payload, TestError, TestResult};
use super::*;
use crate::{check, contains_str, eq, is_true};
#[test]
fn soft_with_no_failures_returns_ok() -> TestResult {
let result = soft(|s| {
s.check(&2, eq(2));
s.record(Ok(()));
});
check!(result.is_ok()).satisfies(is_true())?;
Ok(())
}
#[test]
fn soft_collects_every_failure_each_with_its_own_location() -> TestResult {
let result = soft(|s| {
s.check(&1, eq(2));
s.check(&3, eq(4));
s.check(&5, eq(6));
});
let error = result.expect_err("three soft assertions failed");
let rendered = error.to_string();
check!(rendered.contains("3 soft assertions failed")).satisfies(is_true())?;
check!(rendered.contains("3 failures")).satisfies(is_true())?;
match error.payload.as_deref() {
Some(Payload::Multiple(errors)) => {
check!(errors.len()).satisfies(eq(3))?;
let lines: Vec<u32> = errors.iter().map(|e| e.location.line()).collect();
check!(lines[0] != lines[1] && lines[1] != lines[2] && lines[0] != lines[2])
.satisfies(is_true())?;
}
_ => return Err(TestError::assertion("expected a Multiple payload")),
}
Ok(())
}
#[test]
fn soft_check_records_an_err_and_ignores_ok() -> TestResult {
let result = soft(|s| {
s.record(Ok(()));
s.record(Err(TestError::assertion("a recorded failure")));
});
let error = result.expect_err("one recorded check failed");
check!(error.to_string().contains("a recorded failure")).satisfies(is_true())?;
Ok(())
}
#[test]
fn soft_check_preserves_the_recorded_error_location() -> TestResult {
let recorded = TestError::assertion("from elsewhere");
let recorded_line = recorded.location.line();
let result = soft(|s| s.record(Err(recorded)));
let error = result.expect_err("one recorded check failed");
match error.payload.as_deref() {
Some(Payload::Multiple(errors)) => {
check!(errors[0].location.line()).satisfies(eq(recorded_line))?;
}
_ => return Err(TestError::assertion("expected a Multiple payload")),
}
Ok(())
}
#[test]
fn context_scope_attaches_a_frame_to_recorded_failures() -> TestResult {
let result = soft(|s| {
let mut scope = s.context("while validating the user");
scope.check(&1, eq(2));
});
let error = result.expect_err("one soft assertion failed");
match error.payload.as_deref() {
Some(Payload::Multiple(errors)) => {
let frames: Vec<&str> = errors[0]
.context
.iter()
.map(|frame| frame.message.as_ref())
.collect();
check!(frames).satisfies(eq(vec!["while validating the user"]))?;
}
_ => return Err(TestError::assertion("expected a Multiple payload")),
}
Ok(())
}
#[test]
fn context_scope_ends_when_the_scope_is_dropped() -> TestResult {
let result = soft(|s| {
{
let mut scope = s.context("inside the scope");
scope.check(&1, eq(2));
}
s.check(&3, eq(4));
});
let error = result.expect_err("two soft assertions failed");
match error.payload.as_deref() {
Some(Payload::Multiple(errors)) => {
check!(errors[0].context.len()).satisfies(eq(1usize))?;
check!(errors[1].context.len()).satisfies(eq(0usize))?;
}
_ => return Err(TestError::assertion("expected a Multiple payload")),
}
Ok(())
}
#[test]
fn nested_context_scopes_stack_outermost_first() -> TestResult {
let result = soft(|s| {
let mut outer = s.context("while validating the user");
outer.check(&1, eq(2));
let mut inner = outer.context("while checking the email");
inner.check(&"bad", contains_str("@"));
});
let error = result.expect_err("two soft assertions failed");
let rendered = error.to_string();
check!(rendered.contains("while validating the user")).satisfies(is_true())?;
check!(rendered.contains("while checking the email")).satisfies(is_true())?;
match error.payload.as_deref() {
Some(Payload::Multiple(errors)) => {
let outer_frames: Vec<&str> = errors[0]
.context
.iter()
.map(|frame| frame.message.as_ref())
.collect();
let inner_frames: Vec<&str> = errors[1]
.context
.iter()
.map(|frame| frame.message.as_ref())
.collect();
check!(outer_frames).satisfies(eq(vec!["while validating the user"]))?;
check!(inner_frames).satisfies(eq(vec![
"while validating the user",
"while checking the email",
]))?;
}
_ => return Err(TestError::assertion("expected a Multiple payload")),
}
Ok(())
}
}