aiken/crates/aiken-lang/src/test_framework.rs

1290 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::{
ast::{BinOp, DataTypeKey, IfBranch, OnTestFailure, Span, TypedArg, TypedDataType, TypedTest},
expr::{TypedExpr, UntypedExpr},
format::Formatter,
gen_uplc::CodeGenerator,
plutus_version::PlutusVersion,
tipo::{convert_opaque_type, Type},
};
use cryptoxide::{blake2b::Blake2b, digest::Digest};
use indexmap::IndexMap;
use itertools::Itertools;
use owo_colors::{OwoColorize, Stream};
use pallas_primitives::alonzo::{Constr, PlutusData};
use patricia_tree::PatriciaMap;
use std::{
borrow::Borrow, collections::BTreeMap, convert::TryFrom, fmt::Debug, ops::Deref, path::PathBuf,
rc::Rc,
};
use uplc::{
ast::{Constant, Data, Name, NamedDeBruijn, Program, Term},
machine::{cost_model::ExBudget, eval_result::EvalResult},
};
use vec1::{vec1, Vec1};
/// ----- Test -----------------------------------------------------------------
///
/// Aiken supports two kinds of tests: unit and property. A unit test is a simply
/// UPLC program which returns must be a lambda that returns a boolean.
///
/// A property on the other-hand is a template for generating tests, which is also
/// a lambda but that takes an extra argument. The argument is generated from a
/// fuzzer which is meant to yield random values in a pseudo-random (albeit seeded)
/// sequence. On failures, the value that caused a failure is simplified using an
/// approach similar to what's described in MiniThesis<https://github.com/DRMacIver/minithesis>,
/// which is a simplified version of Hypothesis, a property-based testing framework
/// with integrated shrinking.
///
/// Our approach could perhaps be called "microthesis", as it implements a subset of
/// minithesis. More specifically, we do not currently support pre-conditions, nor
/// targets.
///
#[derive(Debug, Clone)]
pub enum Test {
UnitTest(UnitTest),
PropertyTest(PropertyTest),
}
unsafe impl Send for Test {}
impl Test {
pub fn unit_test(
generator: &mut CodeGenerator<'_>,
test: TypedTest,
module_name: String,
input_path: PathBuf,
) -> Test {
let program = generator.generate_raw(&test.body, &[], &module_name);
let assertion = match test.body.try_into() {
Err(..) => None,
Ok(Assertion { bin_op, head, tail }) => {
let as_constant = |generator: &mut CodeGenerator<'_>, side| {
Program::<NamedDeBruijn>::try_from(generator.generate_raw(
&side,
&[],
&module_name,
))
.expect("failed to convert assertion operaand to NamedDeBruijn")
.eval(ExBudget::max())
.unwrap_constant()
.map(|cst| (cst, side.tipo()))
};
// Assertion at this point is evaluated so it's not just a normal assertion
Some(Assertion {
bin_op,
head: as_constant(generator, head.expect("cannot be Err at this point")),
tail: tail
.expect("cannot be Err at this point")
.try_mapped(|e| as_constant(generator, e)),
})
}
};
Test::UnitTest(UnitTest {
input_path,
module: module_name,
name: test.name,
program,
assertion,
on_test_failure: test.on_test_failure,
})
}
pub fn property_test(
input_path: PathBuf,
module: String,
name: String,
on_test_failure: OnTestFailure,
program: Program<Name>,
fuzzer: Fuzzer<Name>,
) -> Test {
Test::PropertyTest(PropertyTest {
input_path,
module,
name,
program,
on_test_failure,
fuzzer,
})
}
pub fn from_function_definition(
generator: &mut CodeGenerator<'_>,
test: TypedTest,
module_name: String,
input_path: PathBuf,
) -> Test {
if test.arguments.is_empty() {
Self::unit_test(generator, test, module_name, input_path)
} else {
let parameter = test.arguments.first().unwrap().to_owned();
let via = parameter.via.clone();
let type_info = parameter.arg.tipo.clone();
let stripped_type_info = convert_opaque_type(&type_info, generator.data_types(), true);
let program = generator.clone().generate_raw(
&test.body,
&[TypedArg {
tipo: stripped_type_info.clone(),
..parameter.clone().into()
}],
&module_name,
);
// NOTE: We need not to pass any parameter to the fuzzer here because the fuzzer
// argument is a Data constructor which needs not any conversion. So we can just safely
// apply onto it later.
let fuzzer = generator.clone().generate_raw(&via, &[], &module_name);
Self::property_test(
input_path,
module_name,
test.name,
test.on_test_failure,
program,
Fuzzer {
program: fuzzer,
stripped_type_info,
type_info,
},
)
}
}
}
/// ----- UnitTest -----------------------------------------------------------------
///
#[derive(Debug, Clone)]
pub struct UnitTest {
pub input_path: PathBuf,
pub module: String,
pub name: String,
pub on_test_failure: OnTestFailure,
pub program: Program<Name>,
pub assertion: Option<Assertion<(Constant, Rc<Type>)>>,
}
unsafe impl Send for UnitTest {}
impl UnitTest {
pub fn run<T>(self, plutus_version: &PlutusVersion) -> TestResult<(Constant, Rc<Type>), T> {
let mut eval_result = Program::<NamedDeBruijn>::try_from(self.program.clone())
.unwrap()
.eval_version(ExBudget::max(), &plutus_version.into());
let success = !eval_result.failed(match self.on_test_failure {
OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately => true,
OnTestFailure::FailImmediately => false,
});
TestResult::UnitTestResult(UnitTestResult {
success,
test: self.to_owned(),
spent_budget: eval_result.cost(),
traces: eval_result.logs(),
assertion: self.assertion,
})
}
}
/// ----- PropertyTest -----------------------------------------------------------------
///
#[derive(Debug, Clone)]
pub struct PropertyTest {
pub input_path: PathBuf,
pub module: String,
pub name: String,
pub on_test_failure: OnTestFailure,
pub program: Program<Name>,
pub fuzzer: Fuzzer<Name>,
}
unsafe impl Send for PropertyTest {}
#[derive(Debug, Clone)]
pub struct Fuzzer<T> {
pub program: Program<T>,
pub type_info: Rc<Type>,
/// A version of the Fuzzer's type that has gotten rid of
/// all erasable opaque type. This is needed in order to
/// generate Plutus data with the appropriate shape.
pub stripped_type_info: Rc<Type>,
}
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
#[error("Fuzzer exited unexpectedly: {uplc_error}")]
pub struct FuzzerError {
traces: Vec<String>,
uplc_error: uplc::machine::Error,
}
impl PropertyTest {
pub const DEFAULT_MAX_SUCCESS: usize = 100;
/// Run a property test from a given seed. The property is run at most DEFAULT_MAX_SUCCESS times. It
/// may stops earlier on failure; in which case a 'counterexample' is returned.
pub fn run<U>(
self,
seed: u32,
n: usize,
plutus_version: &PlutusVersion,
) -> TestResult<U, PlutusData> {
let mut labels = BTreeMap::new();
let mut remaining = n;
let (traces, counterexample, iterations) = match self.run_n_times(
&mut remaining,
Prng::from_seed(seed),
&mut labels,
plutus_version,
) {
Ok(None) => (Vec::new(), Ok(None), n),
Ok(Some(counterexample)) => (
self.eval(&counterexample.value, plutus_version)
.logs()
.into_iter()
.filter(|s| PropertyTest::extract_label(s).is_none())
.collect(),
Ok(Some(counterexample.value)),
n - remaining,
),
Err(FuzzerError { traces, uplc_error }) => (
traces
.into_iter()
.filter(|s| PropertyTest::extract_label(s).is_none())
.collect(),
Err(uplc_error),
n - remaining + 1,
),
};
TestResult::PropertyTestResult(PropertyTestResult {
test: self,
counterexample,
iterations,
labels,
traces,
})
}
pub fn run_n_times<'a>(
&'a self,
remaining: &mut usize,
initial_prng: Prng,
labels: &mut BTreeMap<String, usize>,
plutus_version: &'a PlutusVersion,
) -> Result<Option<Counterexample<'a>>, FuzzerError> {
let mut prng = initial_prng;
let mut counterexample = None;
while *remaining > 0 && counterexample.is_none() {
(prng, counterexample) = self.run_once(prng, labels, plutus_version)?;
*remaining -= 1;
}
Ok(counterexample)
}
fn run_once<'a>(
&'a self,
prng: Prng,
labels: &mut BTreeMap<String, usize>,
plutus_version: &'a PlutusVersion,
) -> Result<(Prng, Option<Counterexample<'a>>), FuzzerError> {
use OnTestFailure::*;
let (next_prng, value) = prng
.sample(&self.fuzzer.program)?
.expect("A seeded PRNG returned 'None' which indicates a fuzzer is ill-formed and implemented wrongly; please contact library's authors.");
let mut result = self.eval(&value, plutus_version);
for s in result.logs() {
// NOTE: There may be other log outputs that interefere with labels. So *by
// convention*, we treat as label strings that starts with a NUL byte, which
// should be a guard sufficient to prevent inadvertent clashes.
if let Some(label) = PropertyTest::extract_label(&s) {
labels
.entry(label)
.and_modify(|count| *count += 1)
.or_insert(1);
}
}
let is_failure = result.failed(false);
let is_success = !is_failure;
let keep_counterexample = match self.on_test_failure {
FailImmediately | SucceedImmediately => is_failure,
SucceedEventually => is_success,
};
if keep_counterexample {
let mut counterexample = Counterexample {
value,
choices: next_prng.choices(),
cache: Cache::new(|choices| {
match Prng::from_choices(choices).sample(&self.fuzzer.program) {
Err(..) => Status::Invalid,
Ok(None) => Status::Invalid,
Ok(Some((_, value))) => {
let result = self.eval(&value, plutus_version);
let is_failure = result.failed(false);
match self.on_test_failure {
FailImmediately | SucceedImmediately => {
if is_failure {
Status::Keep(value)
} else {
Status::Ignore
}
}
SucceedEventually => {
if is_failure {
Status::Ignore
} else {
Status::Keep(value)
}
}
}
}
}
}),
};
if !counterexample.choices.is_empty() {
counterexample.simplify();
}
Ok((next_prng, Some(counterexample)))
} else {
Ok((next_prng, None))
}
}
pub fn eval(&self, value: &PlutusData, plutus_version: &PlutusVersion) -> EvalResult {
let program = self.program.apply_data(value.clone());
Program::<NamedDeBruijn>::try_from(program)
.unwrap()
.eval_version(ExBudget::max(), &plutus_version.into())
}
fn extract_label(s: &str) -> Option<String> {
if s.starts_with('\0') {
Some(s.split_at(1).1.to_string())
} else {
None
}
}
}
/// ----- PRNG -----------------------------------------------------------------
///
/// A Pseudo-random generator (PRNG) used to produce random values for fuzzers.
/// Note that the randomness isn't actually managed by the Rust framework, it
/// entirely relies on properties of hashing algorithm on-chain (e.g. blake2b).
///
/// The PRNG can have two forms:
///
/// 1. Seeded: which occurs during the initial run of a property. Each time a
/// number is drawn from the PRNG, a new seed is created. We retain all the
/// choices drawn in a _choices_ vector.
///
/// 2. Replayed: which is used to replay a Prng sequenced from a list of known
/// choices. This happens when shrinking an example. Instead of trying to
/// shrink the value directly, we shrink the PRNG sequence with the hope that
/// it will generate a smaller value. This implies that generators tend to
/// generate smaller values when drawing smaller numbers.
///
#[derive(Debug)]
pub enum Prng {
Seeded { choices: Vec<u8>, uplc: PlutusData },
Replayed { choices: Vec<u8>, uplc: PlutusData },
}
impl Prng {
/// Constructor tag for Prng's 'Seeded'
const SEEDED: u64 = 0;
/// Constructor tag for Prng's 'Replayed'
const REPLAYED: u64 = 1;
/// Constructor tag for Option's 'Some'
const SOME: u64 = 0;
/// Constructor tag for Option's 'None'
const NONE: u64 = 1;
pub fn uplc(&self) -> PlutusData {
match self {
Prng::Seeded { uplc, .. } => uplc.clone(),
Prng::Replayed { uplc, .. } => uplc.clone(),
}
}
pub fn choices(&self) -> Vec<u8> {
match self {
Prng::Seeded { choices, .. } => {
let mut choices = choices.to_vec();
choices.reverse();
choices
}
Prng::Replayed { choices, .. } => choices.to_vec(),
}
}
/// Construct a Pseudo-random number generator from a seed.
pub fn from_seed(seed: u32) -> Prng {
let mut digest = [0u8; 32];
let mut context = Blake2b::new(32);
context.input(&seed.to_be_bytes()[..]);
context.result(&mut digest);
Prng::Seeded {
choices: vec![],
uplc: Data::constr(
Prng::SEEDED,
vec![
Data::bytestring(digest.to_vec()), // Prng's seed
Data::bytestring(vec![]), // Random choices
],
),
}
}
/// Construct a Pseudo-random number generator from a pre-defined list of choices.
pub fn from_choices(choices: &[u8]) -> Prng {
Prng::Replayed {
uplc: Data::constr(
Prng::REPLAYED,
vec![
Data::integer(choices.len().into()),
Data::bytestring(choices.iter().rev().cloned().collect::<Vec<_>>()),
],
),
choices: choices.to_vec(),
}
}
/// Generate a pseudo-random value from a fuzzer using the given PRNG.
pub fn sample(
&self,
fuzzer: &Program<Name>,
) -> Result<Option<(Prng, PlutusData)>, FuzzerError> {
let program = Program::<NamedDeBruijn>::try_from(fuzzer.apply_data(self.uplc())).unwrap();
let mut result = program.eval(ExBudget::max());
result
.result()
.map_err(|uplc_error| FuzzerError {
traces: result.logs(),
uplc_error,
})
.map(Prng::from_result)
}
/// Obtain a Prng back from a fuzzer execution. As a reminder, fuzzers have the following
/// signature:
///
/// `type Fuzzer<a> = fn(Prng) -> Option<(Prng, a)>`
///
/// In nominal scenarios (i.e. when the fuzzer is made from a seed and evolve pseudo-randomly),
/// it cannot yield 'None'. When replayed however, we can't easily guarantee that the changes
/// made during shrinking aren't breaking underlying invariants (if only, because we run out of
/// values to replay). In such case, the replayed sequence is simply invalid and the fuzzer
/// aborted altogether with 'None'.
pub fn from_result(result: Term<NamedDeBruijn>) -> Option<(Self, PlutusData)> {
/// Interpret the given 'PlutusData' as one of two Prng constructors.
fn as_prng(cst: &PlutusData) -> Prng {
if let PlutusData::Constr(Constr { tag, fields, .. }) = cst {
if *tag == 121 + Prng::SEEDED {
if let [PlutusData::BoundedBytes(bytes), PlutusData::BoundedBytes(choices)] =
&fields[..]
{
return Prng::Seeded {
choices: choices.to_vec(),
uplc: PlutusData::Constr(Constr {
tag: 121 + Prng::SEEDED,
fields: vec![
PlutusData::BoundedBytes(bytes.to_owned()),
// Clear choices between seeded runs, to not
// accumulate ALL choices ever made.
PlutusData::BoundedBytes(vec![].into()),
],
any_constructor: None,
}),
};
}
}
if *tag == 121 + Prng::REPLAYED {
if let [PlutusData::BigInt(..), PlutusData::BoundedBytes(choices)] = &fields[..]
{
return Prng::Replayed {
choices: choices.to_vec(),
uplc: cst.clone(),
};
}
}
}
unreachable!("malformed Prng: {cst:#?}")
}
if let Term::Constant(rc) = &result {
if let Constant::Data(PlutusData::Constr(Constr { tag, fields, .. })) = &rc.borrow() {
if *tag == 121 + Prng::SOME {
if let [PlutusData::Array(elems)] = &fields[..] {
if let [new_seed, value] = &elems[..] {
return Some((as_prng(new_seed), value.clone()));
}
}
}
// May occurs when replaying a fuzzer from a shrinked sequence of
// choices. If we run out of choices, or a choice end up being
// invalid as per the expectation, the fuzzer can't go further and
// fail.
if *tag == 121 + Prng::NONE {
return None;
}
}
}
unreachable!("Fuzzer yielded a malformed result? {result:#?}")
}
}
/// ----- Counterexample -----------------------------------------------------------------
///
/// A counterexample is constructed from a test failure. It holds a value, and a sequence
/// of random choices that led to this value. It holds a reference to the underlying
/// property and fuzzer. In many cases, a counterexample can be simplified (a.k.a "shrinked")
/// into a smaller counterexample.
pub struct Counterexample<'a> {
pub value: PlutusData,
pub choices: Vec<u8>,
pub cache: Cache<'a, PlutusData>,
}
impl<'a> Counterexample<'a> {
fn consider(&mut self, choices: &[u8]) -> bool {
if choices == self.choices {
return true;
}
match self.cache.get(choices) {
Status::Invalid | Status::Ignore => false,
Status::Keep(value) => {
// If these new choices are shorter or smaller, then we pick them
// as new choices and inform that it's been an improvement.
if choices.len() <= self.choices.len() || choices < &self.choices[..] {
self.value = value;
self.choices = choices.to_vec();
true
} else {
false
}
}
}
}
/// Try to simplify a 'Counterexample' by manipulating the random sequence of generated values
/// (a.k.a. choices). While the implementation is quite involved, the strategy is rather simple
/// at least conceptually:
///
/// Each time a (seeded) fuzzer generates a new value and a new seed, it also stores the
/// generated value in a vector, which we call 'choices'. If we re-run the test case with this
/// exact choice sequence, we end up with the exact same outcome.
///
/// But, we can tweak chunks of this sequence in hope to generate a _smaller sequence_, thus
/// generally resulting in a _smaller counterexample_. Each transformations is applied on
/// chunks of size 8, 4, 2 and 1; until we no longer make progress (i.e. hit a fix point).
///
/// As per MiniThesis, we consider the following transformations:
///
/// - Deleting chunks
/// - Transforming chunks into sequence of zeroes
/// - Replacing chunks of values with smaller values
/// - Sorting chunks in ascending order
/// - Swapping nearby pairs
/// - Redistributing values between nearby pairs
pub fn simplify(&mut self) {
let mut prev;
loop {
prev = self.choices.clone();
// First try deleting each choice we made in chunks. We try longer chunks because this
// allows us to delete whole composite elements: e.g. deleting an element from a
// generated list requires us to delete both the choice of whether to include it and
// also the element itself, which may involve more than one choice.
let mut k = 8;
while k > 0 {
let (mut i, mut underflow) = if self.choices.len() < k {
(0, true)
} else {
(self.choices.len() - k, false)
};
while !underflow {
if i >= self.choices.len() {
(i, underflow) = i.overflowing_sub(1);
continue;
}
let j = i + k;
let mut choices = [
&self.choices[..i],
if j < self.choices.len() {
&self.choices[j..]
} else {
&[]
},
]
.concat();
if !self.consider(&choices) {
// Perform an extra reduction step that decrease the size of choices near
// the end, to cope with dependencies between choices, e.g. drawing a
// number as a list length, and then drawing that many elements.
//
// This isn't perfect, but allows to make progresses in many cases.
if i > 0 && choices[i - 1] > 0 {
choices[i - 1] -= 1;
if self.consider(&choices) {
i += 1;
};
}
(i, underflow) = i.overflowing_sub(1);
}
}
k /= 2
}
if !self.choices.is_empty() {
// Now we try replacing region of choices with zeroes. Note that unlike the above we
// skip k = 1 because we handle that in the next step. Often (but not always) a block
// of all zeroes is the smallest value that a region can be.
let mut k = 8;
while k > 1 {
let mut i = self.choices.len();
while i >= k {
let ivs = (i - k..i).map(|j| (j, 0)).collect::<Vec<_>>();
i -= if self.replace(ivs) { k } else { 1 }
}
k /= 2
}
// Replace choices with smaller value, by doing a binary search. This will replace n
// with 0 or n - 1, if possible, but will also more efficiently replace it with, a
// smaller number than doing multiple subtractions would.
let (mut i, mut underflow) = (self.choices.len() - 1, false);
while !underflow {
self.binary_search_replace(0, self.choices[i], |v| vec![(i, v)]);
(i, underflow) = i.overflowing_sub(1);
}
// Sort out of orders chunks in ascending order
let mut k = 8;
while k > 1 {
let mut i = self.choices.len() - 1;
while i >= k {
let (from, to) = (i - k, i);
self.replace(
(from..to)
.zip(self.choices[from..to].iter().cloned().sorted())
.collect(),
);
i -= 1;
}
k /= 2
}
// Try adjusting nearby pairs by:
//
// - Swapping them if they are out-of-order
// - Redistributing values between them.
for k in [2, 1] {
let mut j = self.choices.len() - 1;
while j >= k {
let i = j - k;
// Swap
if self.choices[i] > self.choices[j] {
self.replace(vec![(i, self.choices[j]), (j, self.choices[i])]);
}
let iv = self.choices[i];
let jv = self.choices[j];
// Replace
if iv > 0 && jv <= u8::MAX - iv {
self.binary_search_replace(0, iv, |v| vec![(i, v), (j, jv + (iv - v))]);
}
j -= 1
}
}
}
// If we've reached a fixed point, then we cannot shrink further. We've reached a
// (local) minimum, which is as good as a counterexample we'll get with this approach.
if prev.as_slice() == self.choices.as_slice() {
break;
}
}
}
/// Try to replace a value with a smaller value by doing a binary search between
/// two extremes. This converges relatively fast in order to shrink down values.
fn binary_search_replace<F>(&mut self, lo: u8, hi: u8, f: F) -> u8
where
F: Fn(u8) -> Vec<(usize, u8)>,
{
if self.replace(f(lo)) {
return lo;
}
let mut lo = lo;
let mut hi = hi;
while lo + 1 < hi {
let mid = lo + (hi - lo) / 2;
if self.replace(f(mid)) {
hi = mid;
} else {
lo = mid;
}
}
hi
}
// Replace values in the choices vector, based on the index-value list provided
// and consider the resulting choices.
fn replace(&mut self, ivs: Vec<(usize, u8)>) -> bool {
let mut choices = self.choices.clone();
for (i, v) in ivs {
if i >= choices.len() {
return false;
}
choices[i] = v;
}
self.consider(&choices)
}
}
/// ----- Cache -----------------------------------------------------------------------
///
/// A simple cache as a Patricia-trie to look for already explored options. The simplification
/// steps does often generate the same paths and the generation of new test values as well as the
/// properties can take a significant time.
///
/// Yet, sequences have interesting properties:
///
/// 1. The generation and test execution is entirely deterministic.
///
///
pub struct Cache<'a, T> {
db: PatriciaMap<Status<T>>,
#[allow(clippy::type_complexity)]
run: Box<dyn Fn(&[u8]) -> Status<T> + 'a>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Status<T> {
Keep(T),
Ignore,
Invalid,
}
impl<'a, T> Cache<'a, T>
where
T: PartialEq + Clone,
{
pub fn new<F>(run: F) -> Cache<'a, T>
where
F: Fn(&[u8]) -> Status<T> + 'a,
{
Cache {
db: PatriciaMap::new(),
run: Box::new(run),
}
}
pub fn size(&self) -> usize {
self.db.len()
}
pub fn get(&mut self, choices: &[u8]) -> Status<T> {
if let Some((prefix, status)) = self.db.get_longest_common_prefix(choices) {
let status = status.clone();
if status != Status::Invalid || prefix == choices {
return status;
}
}
let status = self.run.deref()(choices);
// Clear longer path on non-invalid cases, as we will never reach them
// again due to a now-shorter prefix found.
//
// This hopefully keeps the cache under a reasonable size as we prune
// the tree as we discover shorter paths.
if status != Status::Invalid {
let keys = self
.db
.iter_prefix(choices)
.map(|(k, _)| k)
.collect::<Vec<_>>();
for k in keys {
self.db.remove(k);
}
}
self.db.insert(choices, status.clone());
status
}
}
// ----------------------------------------------------------------------------
//
// TestResult
//
// ----------------------------------------------------------------------------
#[derive(Debug)]
pub enum TestResult<U, T> {
UnitTestResult(UnitTestResult<U>),
PropertyTestResult(PropertyTestResult<T>),
}
unsafe impl<U, T> Send for TestResult<U, T> {}
impl TestResult<(Constant, Rc<Type>), PlutusData> {
pub fn reify(
self,
data_types: &IndexMap<&DataTypeKey, &TypedDataType>,
) -> TestResult<UntypedExpr, UntypedExpr> {
match self {
TestResult::UnitTestResult(test) => TestResult::UnitTestResult(test.reify(data_types)),
TestResult::PropertyTestResult(test) => {
TestResult::PropertyTestResult(test.reify(data_types))
}
}
}
}
impl<U, T> TestResult<U, T> {
pub fn is_success(&self) -> bool {
match self {
TestResult::UnitTestResult(UnitTestResult { success, .. }) => *success,
TestResult::PropertyTestResult(PropertyTestResult {
counterexample: Err(..),
..
}) => false,
TestResult::PropertyTestResult(PropertyTestResult {
counterexample: Ok(counterexample),
test,
..
}) => match test.on_test_failure {
OnTestFailure::FailImmediately | OnTestFailure::SucceedEventually => {
counterexample.is_none()
}
OnTestFailure::SucceedImmediately => counterexample.is_some(),
},
}
}
pub fn module(&self) -> &str {
match self {
TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.module.as_str(),
TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => {
test.module.as_str()
}
}
}
pub fn title(&self) -> &str {
match self {
TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.name.as_str(),
TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => {
test.name.as_str()
}
}
}
pub fn traces(&self) -> &[String] {
match self {
TestResult::UnitTestResult(UnitTestResult { ref traces, .. })
| TestResult::PropertyTestResult(PropertyTestResult { ref traces, .. }) => {
traces.as_slice()
}
}
}
}
#[derive(Debug)]
pub struct UnitTestResult<T> {
pub success: bool,
pub spent_budget: ExBudget,
pub traces: Vec<String>,
pub test: UnitTest,
pub assertion: Option<Assertion<T>>,
}
unsafe impl<T> Send for UnitTestResult<T> {}
impl UnitTestResult<(Constant, Rc<Type>)> {
pub fn reify(
self,
data_types: &IndexMap<&DataTypeKey, &TypedDataType>,
) -> UnitTestResult<UntypedExpr> {
UnitTestResult {
success: self.success,
spent_budget: self.spent_budget,
traces: self.traces,
test: self.test,
assertion: self.assertion.and_then(|assertion| {
// No need to spend time/cpu on reifying assertions for successful
// tests since they aren't shown.
if self.success {
return None;
}
Some(Assertion {
bin_op: assertion.bin_op,
head: assertion.head.map(|(cst, tipo)| {
UntypedExpr::reify_constant(data_types, cst, &tipo)
.expect("failed to reify assertion operand?")
}),
tail: assertion.tail.map(|xs| {
xs.mapped(|(cst, tipo)| {
UntypedExpr::reify_constant(data_types, cst, &tipo)
.expect("failed to reify assertion operand?")
})
}),
})
}),
}
}
}
#[derive(Debug)]
pub struct PropertyTestResult<T> {
pub test: PropertyTest,
pub counterexample: Result<Option<T>, uplc::machine::Error>,
pub iterations: usize,
pub labels: BTreeMap<String, usize>,
pub traces: Vec<String>,
}
unsafe impl<T> Send for PropertyTestResult<T> {}
impl PropertyTestResult<PlutusData> {
pub fn reify(
self,
data_types: &IndexMap<&DataTypeKey, &TypedDataType>,
) -> PropertyTestResult<UntypedExpr> {
PropertyTestResult {
counterexample: self.counterexample.map(|ok| {
ok.map(|counterexample| {
UntypedExpr::reify_data(data_types, counterexample, &self.test.fuzzer.type_info)
.expect("Failed to reify counterexample?")
})
}),
iterations: self.iterations,
test: self.test,
labels: self.labels,
traces: self.traces,
}
}
}
#[derive(Debug, Clone)]
pub struct Assertion<T> {
pub bin_op: BinOp,
pub head: Result<T, ()>,
pub tail: Result<Vec1<T>, ()>,
}
impl TryFrom<TypedExpr> for Assertion<TypedExpr> {
type Error = ();
fn try_from(body: TypedExpr) -> Result<Self, Self::Error> {
match body {
TypedExpr::BinOp {
name,
tipo,
left,
right,
..
} if tipo == Type::bool() => {
// 'and' and 'or' are left-associative operators.
match (*right).clone().try_into() {
Ok(Assertion {
bin_op,
head: Ok(head),
tail: Ok(tail),
..
}) if bin_op == name => {
let mut both = vec1![head];
both.extend(tail);
Ok(Assertion {
bin_op: name,
head: Ok(*left),
tail: Ok(both),
})
}
_ => Ok(Assertion {
bin_op: name,
head: Ok(*left),
tail: Ok(vec1![*right]),
}),
}
}
// NOTE drill through trace-if-false operators for better errors.
TypedExpr::If {
branches,
final_else,
..
} => {
if let [IfBranch {
condition, body, ..
}] = &branches[..]
{
let then_is_true = match body {
TypedExpr::Var {
name, constructor, ..
} => name == "True" && constructor.tipo == Type::bool(),
_ => false,
};
let else_is_wrapped_false = match *final_else {
TypedExpr::Trace { then, .. } => match *then {
TypedExpr::Var {
name, constructor, ..
} => name == "False" && constructor.tipo == Type::bool(),
_ => false,
},
_ => false,
};
if then_is_true && else_is_wrapped_false {
return condition.to_owned().try_into();
}
}
Err(())
}
TypedExpr::Trace { then, .. } => (*then).try_into(),
TypedExpr::Sequence { expressions, .. } | TypedExpr::Pipeline { expressions, .. } => {
if let Ok(Assertion {
bin_op,
head: Ok(head),
tail: Ok(tail),
}) = expressions.last().unwrap().to_owned().try_into()
{
let replace = |expr| {
let mut expressions = expressions.clone();
expressions.pop();
expressions.push(expr);
TypedExpr::Sequence {
expressions,
location: Span::empty(),
}
};
Ok(Assertion {
bin_op,
head: Ok(replace(head)),
tail: Ok(tail.mapped(replace)),
})
} else {
Err(())
}
}
_ => Err(()),
}
}
}
impl Assertion<UntypedExpr> {
#[allow(clippy::just_underscores_and_digits)]
pub fn to_string(&self, stream: Stream, expect_failure: bool) -> String {
let red = |s: &str| {
format!("× {s}")
.if_supports_color(stream, |s| s.red())
.if_supports_color(stream, |s| s.bold())
.to_string()
};
// head did not map to a constant
if self.head.is_err() {
return red("program failed");
}
// any value in tail did not map to a constant
if self.tail.is_err() {
return red("program failed");
}
fn fmt_side(side: &UntypedExpr, stream: Stream) -> String {
let __ = "".if_supports_color(stream, |s| s.red());
Formatter::new()
.expr(side, false)
.to_pretty_string(60)
.lines()
.map(|line| format!("{__} {line}"))
.collect::<Vec<String>>()
.join("\n")
}
let left = fmt_side(self.head.as_ref().unwrap(), stream);
let tail = self.tail.as_ref().unwrap();
let right = fmt_side(tail.first(), stream);
format!(
"{}{}{}",
red("expected"),
if expect_failure && self.bin_op == BinOp::Or {
" neither\n"
.if_supports_color(stream, |s| s.red())
.if_supports_color(stream, |s| s.bold())
.to_string()
} else {
"\n".to_string()
},
if expect_failure {
match self.bin_op {
BinOp::And => [
left,
red("and"),
[
tail.mapped_ref(|s| fmt_side(s, stream))
.join(format!("\n{}\n", red("and")).as_str()),
if tail.len() > 1 {
red("to not all be true")
} else {
red("to not both be true")
},
]
.join("\n"),
],
BinOp::Or => [
left,
red("nor"),
[
tail.mapped_ref(|s| fmt_side(s, stream))
.join(format!("\n{}\n", red("nor")).as_str()),
red("to be true"),
]
.join("\n"),
],
BinOp::Eq => [left, red("to not equal"), right],
BinOp::NotEq => [left, red("to not be different"), right],
BinOp::LtInt => [left, red("to not be lower than"), right],
BinOp::LtEqInt => [left, red("to not be lower than or equal to"), right],
BinOp::GtInt => [left, red("to not be greater than"), right],
BinOp::GtEqInt => [left, red("to not be greater than or equal to"), right],
_ => unreachable!("unexpected non-boolean binary operator in assertion?"),
}
.join("\n")
} else {
match self.bin_op {
BinOp::And => [
left,
red("and"),
[
tail.mapped_ref(|s| fmt_side(s, stream))
.join(format!("\n{}\n", red("and")).as_str()),
if tail.len() > 1 {
red("to all be true")
} else {
red("to both be true")
},
]
.join("\n"),
],
BinOp::Or => [
left,
red("or"),
[
tail.mapped_ref(|s| fmt_side(s, stream))
.join(format!("\n{}\n", red("or")).as_str()),
red("to be true"),
]
.join("\n"),
],
BinOp::Eq => [left, red("to equal"), right],
BinOp::NotEq => [left, red("to not equal"), right],
BinOp::LtInt => [left, red("to be lower than"), right],
BinOp::LtEqInt => [left, red("to be lower than or equal to"), right],
BinOp::GtInt => [left, red("to be greater than"), right],
BinOp::GtEqInt => [left, red("to be greater than or equal to"), right],
_ => unreachable!("unexpected non-boolean binary operator in assertion?"),
}
.join("\n")
}
)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_cache() {
let called = std::cell::RefCell::new(0);
let mut cache = Cache::new(|choices| {
called.replace_with(|n| *n + 1);
match choices {
[0, 0, 0] => Status::Keep(true),
_ => {
if choices.len() <= 2 {
Status::Invalid
} else {
Status::Ignore
}
}
}
});
assert_eq!(cache.get(&[1, 1]), Status::Invalid); // Fn executed
assert_eq!(cache.get(&[1, 1, 2, 3]), Status::Ignore); // Fn executed
assert_eq!(cache.get(&[1, 1, 2]), Status::Ignore); // Fnexecuted
assert_eq!(cache.get(&[1, 1, 2, 2]), Status::Ignore); // Cached result
assert_eq!(cache.get(&[1, 1, 2, 1]), Status::Ignore); // Cached result
assert_eq!(cache.get(&[0, 1, 2]), Status::Ignore); // Fn executed
assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Fn executed
assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Cached result
assert_eq!(called.borrow().deref().to_owned(), 5, "execution calls");
assert_eq!(cache.size(), 4, "cache size");
}
}