// Soft assertions
//
// `expect_all` runs a block of assertions and reports every failure
// together, instead of stopping at the first one. Expectations created with
// `Scope::expect` record their failures in the scope. A failure that leaves
// no value to go on with, such as `unwrap_some` on `None`, stops the block.
///|
/// Raised to stop a soft-assertion block after a failure has been recorded.
priv suberror ScopeStopped
///|
/// The failures that a scope and its nested scopes record.
priv struct ScopeState {
/// Each failure as its location and message.
failures : Array[(String, String)]
/// Notes about blocks that stopped early.
notes : Array[String]
/// The number of assertions that ran.
mut assertions : Int
}
///|
/// A soft-assertion scope. Create expectations with `expect` and
/// `expect_call`: their failures are collected, and `expect_all` reports
/// them together.
pub struct Scope {
priv state : ScopeState
priv nested : Bool
}
///|
fn Scope::record(self : Scope, loc : String, message : String) -> Unit {
self.state.failures.push((loc, message))
}
///|
/// Create an expectation whose failures this scope collects.
pub fn[T] Scope::expect(
self : Scope,
actual : T,
label? : String = "",
) -> Expectation[T] {
{
actual,
negated: false,
label,
reason: "",
scope: Some(self),
source: None,
}
}
///|
/// Create an expectation on a function whose failures this scope collects.
pub fn[T] Scope::expect_call(
self : Scope,
f : () -> T raise Error,
label? : String = "",
) -> Expectation[() -> T raise Error] {
{
actual: f,
negated: false,
label,
reason: "",
scope: Some(self),
source: None,
}
}
///|
/// Run `block` in a nested scope. Its failures join this scope. When the
/// nested block stops early, this block goes on.
pub fn Scope::expect_all(
self : Scope,
block : (Scope) -> Unit raise Error,
) -> Unit {
run_scope({ state: self.state, nested: true, }, block)
}
///|
/// Run `block` in `scope`, and record an error that stops it.
fn run_scope(scope : Scope, block : (Scope) -> Unit raise Error) -> Unit {
block(scope) catch {
ScopeStopped => stopped(scope)
Failure(message) => {
// A failure outside the scope, such as a plain `expect`, stops the
// block too. Split ` FAILED: `.
let marker = " FAILED: "
match message.find(marker) {
Some(index) =>
scope.record(
message.view(end_offset=index).to_owned(),
message.view(start_offset=index + marker.length()).to_owned(),
)
None => scope.record("", message)
}
scope.state.assertions += 1
stopped(scope)
}
error => {
scope.record("", "error: \{error}")
stopped(scope)
}
}
}
///|
/// Note that the block of `scope` stopped at its last failure.
fn stopped(scope : Scope) -> Unit {
let number = scope.state.failures.length()
let note = if scope.nested {
"(\{number}) stopped the inner block, so later assertions in it did not run."
} else {
"(\{number}) stopped the block, so later assertions did not run."
}
scope.state.notes.push(note)
}
///|
/// Run a block of soft assertions. Create expectations with `s.expect` and
/// `s.expect_call`: their failures are collected, and when the block ends,
/// one failure lists all of them.
///
/// A failure that leaves no value to go on with, such as `unwrap_some` on
/// `None`, stops the block. So does any other error, such as a failure of a
/// plain `@expect.expect` in the block.
#callsite(autofill(loc))
pub fn expect_all(
block : (Scope) -> Unit raise Error,
loc~ : SourceLoc,
) -> Unit raise Error {
let scope = {
state: { failures: [], notes: [], assertions: 0, },
nested: false,
}
run_scope(scope, block)
let failures = scope.state.failures
if failures.is_empty() {
return
}
let builder = StringBuilder()
builder.write_string(
paint(
"\{failures.length()} of \{scope.state.assertions} assertions failed",
Bold,
color_enabled(),
),
)
for i, failure in failures {
let (at, message) = failure
let lines = message.split("\n").map(line => line.to_owned()).collect()
let prefix = if at.is_empty() { "" } else { "\{at}: " }
builder.write_string("\n\n(\{i + 1}) \{prefix}\{lines[0]}")
for line in lines[1:] {
builder.write_string("\n \{line}")
}
}
for note in scope.state.notes {
builder.write_string("\n\n\{note}")
}
fail(builder.to_string(), loc~)
}