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, /// 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::::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, fuzzer: Fuzzer, ) -> 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, pub assertion: Option)>>, } unsafe impl Send for UnitTest {} impl UnitTest { pub fn run(self, plutus_version: &PlutusVersion) -> TestResult<(Constant, Rc), T> { let mut eval_result = Program::::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, pub fuzzer: Fuzzer, } unsafe impl Send for PropertyTest {} #[derive(Debug, Clone)] pub struct Fuzzer { pub program: Program, pub type_info: Rc, /// 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, } #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] #[error("Fuzzer exited unexpectedly: {uplc_error}")] pub struct FuzzerError { traces: Vec, 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( self, seed: u32, n: usize, plutus_version: &PlutusVersion, ) -> TestResult { 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, plutus_version: &'a PlutusVersion, ) -> Result>, 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, plutus_version: &'a PlutusVersion, ) -> Result<(Prng, Option>), 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::::try_from(program) .unwrap() .eval_version(ExBudget::max(), &plutus_version.into()) } fn extract_label(s: &str) -> Option { 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, uplc: PlutusData }, Replayed { choices: Vec, 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 { 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::>()), ], ), choices: choices.to_vec(), } } /// Generate a pseudo-random value from a fuzzer using the given PRNG. pub fn sample( &self, fuzzer: &Program, ) -> Result, FuzzerError> { let program = Program::::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 = 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) -> 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, 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::>(); 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(&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>, #[allow(clippy::type_complexity)] run: Box Status + 'a>, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum Status { Keep(T), Ignore, Invalid, } impl<'a, T> Cache<'a, T> where T: PartialEq + Clone, { pub fn new(run: F) -> Cache<'a, T> where F: Fn(&[u8]) -> Status + '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 { 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::>(); for k in keys { self.db.remove(k); } } self.db.insert(choices, status.clone()); status } } // ---------------------------------------------------------------------------- // // TestResult // // ---------------------------------------------------------------------------- #[derive(Debug)] pub enum TestResult { UnitTestResult(UnitTestResult), PropertyTestResult(PropertyTestResult), } unsafe impl Send for TestResult {} impl TestResult<(Constant, Rc), PlutusData> { pub fn reify( self, data_types: &IndexMap<&DataTypeKey, &TypedDataType>, ) -> TestResult { match self { TestResult::UnitTestResult(test) => TestResult::UnitTestResult(test.reify(data_types)), TestResult::PropertyTestResult(test) => { TestResult::PropertyTestResult(test.reify(data_types)) } } } } impl TestResult { 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 { pub success: bool, pub spent_budget: ExBudget, pub traces: Vec, pub test: UnitTest, pub assertion: Option>, } unsafe impl Send for UnitTestResult {} impl UnitTestResult<(Constant, Rc)> { pub fn reify( self, data_types: &IndexMap<&DataTypeKey, &TypedDataType>, ) -> UnitTestResult { 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 { pub test: PropertyTest, pub counterexample: Result, uplc::machine::Error>, pub iterations: usize, pub labels: BTreeMap, pub traces: Vec, } unsafe impl Send for PropertyTestResult {} impl PropertyTestResult { pub fn reify( self, data_types: &IndexMap<&DataTypeKey, &TypedDataType>, ) -> PropertyTestResult { 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 { pub bin_op: BinOp, pub head: Result, pub tail: Result, ()>, } impl TryFrom for Assertion { type Error = (); fn try_from(body: TypedExpr) -> Result { 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 { #[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::>() .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"); } }