Merge pull request #160 from txpipe/pairing_testing

Aiken Testing Framework
This commit is contained in:
Matthias Benkort 2022-12-09 16:03:59 +01:00 committed by GitHub
commit 0449c818e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 504 additions and 37 deletions

View File

@ -2,6 +2,30 @@
## [next] - 2022-MM-DD
### Added
- **aiken-lang**: integrated unit tests
Aiken now supports writing unit tests directly in source files using the new
`test` keyword. Tests are functions with no arguments that are implicitly typed
to `bool`. For example:
```gleam
test foo () {
1 + 1 == 2
}
```
- **aiken**: new `--skip-tests` flag for the `check` command
### Changed
- **aiken**: `check` now also runs and reports on any `test` found in the project
### Removed
N/A
## [v0.0.26] - 2022-11-23
### Added

1
Cargo.lock generated
View File

@ -59,6 +59,7 @@ dependencies = [
"ignore",
"indoc",
"miette",
"owo-colors",
"pallas-addresses",
"pallas-codec",
"pallas-crypto",

View File

@ -15,6 +15,7 @@ hex = "0.4.3"
ignore = "0.4.18"
indoc = "1.0"
miette = { version = "5.3.0", features = ["fancy"] }
owo-colors = "3.5.0"
pallas-addresses = "0.14.0"
pallas-codec = "0.14.0"
pallas-crypto = "0.14.0"

View File

@ -6,8 +6,17 @@ pub struct Args {
/// Path to project
#[clap(short, long)]
directory: Option<PathBuf>,
/// Skip tests; run only the type-checker
#[clap(short, long)]
skip_tests: bool,
}
pub fn exec(Args { directory }: Args) -> miette::Result<()> {
crate::with_project(directory, |p| p.check())
pub fn exec(
Args {
directory,
skip_tests,
}: Args,
) -> miette::Result<()> {
crate::with_project(directory, |p| p.check(skip_tests))
}

View File

@ -61,7 +61,9 @@ pub fn exec(
program = program.apply_term(&term);
}
let (term, cost, logs) = program.eval();
let budget = ExBudget::default();
let (term, cost, logs) = program.eval(budget);
match term {
Ok(term) => {
@ -74,8 +76,6 @@ pub fn exec(
}
}
let budget = ExBudget::default();
println!(
"\nCosts\n-----\ncpu: {}\nmemory: {}",
budget.cpu - cost.cpu,

View File

@ -1,13 +1,19 @@
pub mod cmd;
use std::{env, path::PathBuf};
use aiken_project::{config::Config, Project};
use aiken_project::{
config::Config,
telemetry::{self, TestInfo},
Project,
};
use miette::IntoDiagnostic;
use std::env;
use std::path::PathBuf;
use owo_colors::OwoColorize;
use uplc::machine::cost_model::ExBudget;
pub mod cmd;
pub fn with_project<A>(directory: Option<PathBuf>, mut action: A) -> miette::Result<()>
where
A: FnMut(&mut Project) -> Result<(), aiken_project::error::Error>,
A: FnMut(&mut Project<Terminal>) -> Result<(), aiken_project::error::Error>,
{
let project_path = if let Some(d) = directory {
d
@ -17,7 +23,7 @@ where
let config = Config::load(project_path.clone()).into_diagnostic()?;
let mut project = Project::new(config, project_path);
let mut project = Project::new(config, project_path, Terminal::default());
let build_result = action(&mut project);
@ -30,10 +36,129 @@ where
if let Err(err) = build_result {
err.report();
miette::bail!("failed: {} error(s), {warning_count} warning(s)", err.len(),);
miette::bail!("Failed: {} error(s), {warning_count} warning(s)", err.len(),);
};
println!("\nfinished with {warning_count} warning(s)\n");
println!("\nFinished with {warning_count} warning(s)\n");
Ok(())
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Terminal;
impl telemetry::EventListener for Terminal {
fn handle_event(&self, event: telemetry::Event) {
match event {
telemetry::Event::StartingCompilation {
name,
version,
root,
} => {
println!(
"{} {} {} ({})",
"Compiling".bold().purple(),
name.bold(),
version,
root.to_str().unwrap_or("").bright_blue()
);
}
telemetry::Event::ParsingProjectFiles => {
println!("{}", "...Parsing project files".bold().purple());
}
telemetry::Event::TypeChecking => {
println!("{}", "...Type-checking project".bold().purple());
}
telemetry::Event::GeneratingUPLC { output_path } => {
println!(
"{} in {}",
"...Generating Untyped Plutus Core".bold().purple(),
output_path.to_str().unwrap_or("").bright_blue()
);
}
telemetry::Event::RunningTests => {
println!("{}\n", "...Running tests".bold().purple());
}
telemetry::Event::FinishedTests { tests } => {
let (max_mem, max_cpu) = tests.iter().fold(
(0, 0),
|(max_mem, max_cpu), TestInfo { spent_budget, .. }| {
if spent_budget.mem >= max_mem && spent_budget.cpu >= max_cpu {
(spent_budget.mem, spent_budget.cpu)
} else if spent_budget.mem > max_mem {
(spent_budget.mem, max_cpu)
} else if spent_budget.cpu > max_cpu {
(max_mem, spent_budget.cpu)
} else {
(max_mem, max_cpu)
}
},
);
let max_mem = max_mem.to_string().len() as i32;
let max_cpu = max_cpu.to_string().len() as i32;
for test_info in &tests {
println!("{}", fmt_test(test_info, max_mem, max_cpu))
}
let (n_passed, n_failed) =
tests
.iter()
.fold((0, 0), |(n_passed, n_failed), test_info| {
if test_info.is_passing {
(n_passed + 1, n_failed)
} else {
(n_passed, n_failed + 1)
}
});
println!(
"{}",
format!(
"\n Summary: {} test(s), {}; {}.",
tests.len(),
format!("{} passed", n_passed).bright_green(),
format!("{} failed", n_failed).bright_red()
)
.bold()
)
}
}
}
}
fn fmt_test(test_info: &TestInfo, max_mem: i32, max_cpu: i32) -> String {
let TestInfo {
is_passing,
test,
spent_budget,
} = test_info;
let ExBudget { mem, cpu } = spent_budget;
format!(
" [{}] [mem: {}, cpu: {}] {}::{}",
if *is_passing {
"PASS".bold().green().to_string()
} else {
"FAIL".bold().red().to_string()
},
pad_left(mem.to_string(), max_mem, " "),
pad_left(cpu.to_string(), max_cpu, " "),
test.module.blue(),
test.name.bright_blue()
)
}
fn pad_left(mut text: String, n: i32, delimiter: &str) -> String {
let diff = n - text.len() as i32;
if diff.is_positive() {
for _ in 0..diff {
text.insert_str(0, delimiter);
}
}
text
}

View File

@ -138,6 +138,8 @@ pub enum Definition<T, Expr, ConstantRecordTag, PackageName> {
Use(Use<PackageName>),
ModuleConstant(ModuleConstant<T, ConstantRecordTag>),
Test(Function<T, Expr>),
}
impl<A, B, C, E> Definition<A, B, C, E> {
@ -147,7 +149,8 @@ impl<A, B, C, E> Definition<A, B, C, E> {
| Definition::Use(Use { location, .. })
| Definition::TypeAlias(TypeAlias { location, .. })
| Definition::DataType(DataType { location, .. })
| Definition::ModuleConstant(ModuleConstant { location, .. }) => *location,
| Definition::ModuleConstant(ModuleConstant { location, .. })
| Definition::Test(Function { location, .. }) => *location,
}
}
@ -157,7 +160,8 @@ impl<A, B, C, E> Definition<A, B, C, E> {
Definition::Fn(Function { doc, .. })
| Definition::TypeAlias(TypeAlias { doc, .. })
| Definition::DataType(DataType { doc, .. })
| Definition::ModuleConstant(ModuleConstant { doc, .. }) => {
| Definition::ModuleConstant(ModuleConstant { doc, .. })
| Definition::Test(Function { doc, .. }) => {
let _ = std::mem::replace(doc, Some(new_doc));
}
}

View File

@ -215,7 +215,23 @@ impl<'comments> Formatter<'comments> {
return_annotation,
end_position,
..
}) => self.definition_fn(public, name, args, return_annotation, body, *end_position),
}) => self.definition_fn(
public,
"fn",
name,
args,
return_annotation,
body,
*end_position,
),
Definition::Test(Function {
name,
arguments: args,
body,
end_position,
..
}) => self.definition_fn(&false, "test", name, args, &None, body, *end_position),
Definition::TypeAlias(TypeAlias {
alias,
@ -493,9 +509,11 @@ impl<'comments> Formatter<'comments> {
commented(doc, comments)
}
#[allow(clippy::too_many_arguments)]
fn definition_fn<'a>(
&mut self,
public: &'a bool,
keyword: &'a str,
name: &'a str,
args: &'a [UntypedArg],
return_annotation: &'a Option<Annotation>,
@ -504,7 +522,8 @@ impl<'comments> Formatter<'comments> {
) -> Document<'a> {
// Fn name and args
let head = pub_(*public)
.append("fn ")
.append(keyword)
.append(" ")
.append(name)
.append(wrap_args(args.iter().map(|e| (self.fn_arg(e), false))));

View File

@ -74,6 +74,7 @@ fn module_parser() -> impl Parser<Token, Vec<UntypedDefinition>, Error = ParseEr
data_parser(),
type_alias_parser(),
fn_parser(),
test_parser(),
constant_parser(),
))
.repeated()
@ -266,6 +267,36 @@ pub fn fn_parser() -> impl Parser<Token, ast::UntypedDefinition, Error = ParseEr
)
}
pub fn test_parser() -> impl Parser<Token, ast::UntypedDefinition, Error = ParseError> {
just(Token::Test)
.ignore_then(select! {Token::Name {name} => name})
.then_ignore(just(Token::LeftParen))
.then_ignore(just(Token::RightParen))
.map_with_span(|name, span| (name, span))
.then(
expr_seq_parser()
.or_not()
.delimited_by(just(Token::LeftBrace), just(Token::RightBrace)),
)
.map_with_span(|((name, span_end), body), span| {
ast::UntypedDefinition::Test(ast::Function {
arguments: vec![],
body: body.unwrap_or(expr::UntypedExpr::Todo {
kind: TodoKind::EmptyFunction,
location: span,
label: None,
}),
doc: None,
location: span_end,
end_position: span.end - 1,
name,
public: true,
return_annotation: None,
return_type: (),
})
})
}
fn constant_parser() -> impl Parser<Token, ast::UntypedDefinition, Error = ParseError> {
pub_parser()
.or_not()

View File

@ -67,6 +67,7 @@ pub fn lexer() -> impl Parser<char, Vec<(Token, Span)>, Error = ParseError> {
"check" => Token::Assert,
"const" => Token::Const,
"fn" => Token::Fn,
"test" => Token::Test,
"if" => Token::If,
"else" => Token::Else,
"is" => Token::Is,

View File

@ -68,6 +68,7 @@ pub enum Token {
Opaque,
Pub,
Use,
Test,
Todo,
Trace,
Type,
@ -145,6 +146,7 @@ impl fmt::Display for Token {
Token::Todo => "todo",
Token::Trace => "try",
Token::Type => "type",
Token::Test => "test",
};
write!(f, "\"{}\"", s)
}

View File

@ -255,6 +255,7 @@ impl<'a> Environment<'a> {
definition @ (Definition::TypeAlias { .. }
| Definition::DataType { .. }
| Definition::Use { .. }
| Definition::Test { .. }
| Definition::ModuleConstant { .. }) => definition,
}
}
@ -911,7 +912,10 @@ impl<'a> Environment<'a> {
}
}
Definition::Fn { .. } | Definition::Use { .. } | Definition::ModuleConstant { .. } => {}
Definition::Fn { .. }
| Definition::Test { .. }
| Definition::Use { .. }
| Definition::ModuleConstant { .. } => {}
}
Ok(())
@ -990,6 +994,24 @@ impl<'a> Environment<'a> {
}
}
Definition::Test(Function { name, location, .. }) => {
hydrators.insert(name.clone(), Hydrator::new());
let arg_types = vec![];
let return_type = builtins::bool();
self.insert_variable(
name.clone(),
ValueConstructorVariant::ModuleFn {
name: name.clone(),
field_map: None,
module: module_name.to_owned(),
arity: 0,
location: *location,
builtin: None,
},
function(arg_types, return_type),
);
}
Definition::DataType(DataType {
location,
public,

View File

@ -6,6 +6,7 @@ use crate::{
RecordConstructorArg, TypeAlias, TypedDefinition, TypedModule, UntypedDefinition,
UntypedModule, Use,
},
builtins,
builtins::function,
parser::token::Token,
IdGenerator,
@ -66,8 +67,8 @@ impl UntypedModule {
for def in self.definitions().cloned() {
match def {
Definition::ModuleConstant { .. } => consts.push(def),
Definition::Fn { .. }
| Definition::Test { .. }
| Definition::TypeAlias { .. }
| Definition::DataType { .. }
| Definition::Use { .. } => not_consts.push(def),
@ -233,6 +234,17 @@ fn infer_definition(
}))
}
Definition::Test(f) => {
if let Definition::Fn(f) =
infer_definition(Definition::Fn(f), module_name, hydrators, environment)?
{
environment.unify(f.return_type.clone(), builtins::bool(), f.location)?;
Ok(Definition::Test(f))
} else {
unreachable!("test defintion inferred as something else than a function?")
}
}
Definition::TypeAlias(TypeAlias {
doc,
location,

View File

@ -293,7 +293,7 @@ impl Diagnostic for Warning {
fn code<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
match self {
Warning::Type { .. } => Some(Box::new("aiken::typecheck")),
Warning::Type { .. } => Some(Box::new("aiken::check")),
}
}
}

View File

@ -9,6 +9,7 @@ pub mod error;
pub mod format;
pub mod module;
pub mod script;
pub mod telemetry;
use aiken_lang::{
ast::{Definition, Function, ModuleKind, TypedFunction},
@ -25,12 +26,17 @@ use pallas::{
use pallas_traverse::ComputeHash;
use script::Script;
use serde_json::json;
use uplc::ast::{DeBruijn, Program};
use telemetry::{EventListener, TestInfo};
use uplc::{
ast::{DeBruijn, Program},
machine::cost_model::ExBudget,
};
use crate::{
config::Config,
error::{Error, Warning},
module::{CheckedModule, CheckedModules, ParsedModule, ParsedModules},
telemetry::Event,
};
#[derive(Debug)]
@ -47,7 +53,10 @@ pub const MINT: &str = "mint";
pub const WITHDRAWL: &str = "withdrawl";
pub const VALIDATOR_NAMES: [&str; 4] = [SPEND, CERT, MINT, WITHDRAWL];
pub struct Project {
pub struct Project<T>
where
T: EventListener,
{
config: Config,
defined_modules: HashMap<String, PathBuf>,
id_gen: IdGenerator,
@ -55,10 +64,14 @@ pub struct Project {
root: PathBuf,
sources: Vec<Source>,
pub warnings: Vec<Warning>,
event_listener: T,
}
impl Project {
pub fn new(config: Config, root: PathBuf) -> Project {
impl<T> Project<T>
where
T: EventListener,
{
pub fn new(config: Config, root: PathBuf, event_listener: T) -> Project<T> {
let id_gen = IdGenerator::new();
let mut module_types = HashMap::new();
@ -74,34 +87,60 @@ impl Project {
root,
sources: vec![],
warnings: vec![],
event_listener,
}
}
pub fn build(&mut self, uplc: bool) -> Result<(), Error> {
self.compile(true, uplc)
self.compile(true, uplc, false)
}
pub fn check(&mut self) -> Result<(), Error> {
self.compile(false, false)
pub fn check(&mut self, skip_tests: bool) -> Result<(), Error> {
self.compile(false, false, !skip_tests)
}
pub fn compile(&mut self, uplc_gen: bool, uplc_dump: bool) -> Result<(), Error> {
pub fn compile(
&mut self,
uplc_gen: bool,
uplc_dump: bool,
run_tests: bool,
) -> Result<(), Error> {
self.event_listener
.handle_event(Event::StartingCompilation {
root: self.root.clone(),
name: self.config.name.clone(),
version: self.config.version.clone(),
});
self.event_listener.handle_event(Event::ParsingProjectFiles);
self.read_source_files()?;
let parsed_modules = self.parse_sources()?;
let processing_sequence = parsed_modules.sequence()?;
self.event_listener.handle_event(Event::TypeChecking);
let mut checked_modules = self.type_check(parsed_modules, processing_sequence)?;
let validators = self.validate_validators(&mut checked_modules)?;
// TODO: In principle, uplc_gen and run_tests can't be true together. We probably want to
// model the options differently to make it obvious at the type-level.
if uplc_gen {
self.event_listener.handle_event(Event::GeneratingUPLC {
output_path: self.output_path(),
});
let programs = self.code_gen(validators, &checked_modules)?;
self.write_build_outputs(programs, uplc_dump)?;
}
if run_tests {
let tests = self.test_gen(&checked_modules)?;
self.run_tests(tests);
}
Ok(())
}
@ -336,6 +375,7 @@ impl Project {
func,
);
}
Definition::Test(_) => {}
Definition::TypeAlias(ta) => {
type_aliases.insert((module.name.clone(), ta.alias.clone()), ta);
}
@ -385,11 +425,135 @@ impl Project {
Ok(programs)
}
fn write_build_outputs(&self, programs: Vec<Script>, uplc_dump: bool) -> Result<(), Error> {
let assets = self.root.join("assets");
// TODO: revisit ownership and lifetimes of data in this function
fn test_gen(&mut self, checked_modules: &CheckedModules) -> Result<Vec<Script>, Error> {
let mut programs = Vec::new();
let mut functions = HashMap::new();
let mut type_aliases = HashMap::new();
let mut data_types = HashMap::new();
let mut imports = HashMap::new();
let mut constants = HashMap::new();
// let mut indices_to_remove = Vec::new();
let mut tests = Vec::new();
for module in checked_modules.values() {
for (_index, def) in module.ast.definitions().enumerate() {
match def {
Definition::Fn(func) => {
functions.insert(
FunctionAccessKey {
module_name: module.name.clone(),
function_name: func.name.clone(),
},
func,
);
}
Definition::Test(func) => {
tests.push((module.name.clone(), func));
// indices_to_remove.push(index);
}
Definition::TypeAlias(ta) => {
type_aliases.insert((module.name.clone(), ta.alias.clone()), ta);
}
Definition::DataType(dt) => {
data_types.insert(
DataTypeKey {
module_name: module.name.clone(),
defined_type: dt.name.clone(),
},
dt,
);
}
Definition::Use(import) => {
imports.insert((module.name.clone(), import.module.join("/")), import);
}
Definition::ModuleConstant(mc) => {
constants.insert((module.name.clone(), mc.name.clone()), mc);
}
}
}
// for index in indices_to_remove.drain(0..) {
// module.ast.definitions.remove(index);
// }
}
for (module_name, func_def) in tests {
let Function {
arguments,
name,
body,
..
} = func_def;
let mut generator = CodeGenerator::new(
&functions,
// &type_aliases,
&data_types,
// &imports,
// &constants,
&self.module_types,
);
let program = generator.generate(body.clone(), arguments.clone());
let script = Script::new(module_name, name.to_string(), program.try_into().unwrap());
programs.push(script);
}
Ok(programs)
}
fn run_tests(&self, tests: Vec<Script>) {
// TODO: in the future we probably just want to be able to
// tell the machine to not explode on budget consumption.
let initial_budget = ExBudget {
mem: i64::MAX,
cpu: i64::MAX,
};
if !tests.is_empty() {
self.event_listener.handle_event(Event::RunningTests);
}
let mut results = Vec::new();
for test in tests {
match test.program.eval(initial_budget) {
(Ok(..), remaining_budget, _) => {
let test_info = TestInfo {
is_passing: true,
test,
spent_budget: initial_budget - remaining_budget,
};
results.push(test_info);
}
(Err(_), remaining_budget, _) => {
let test_info = TestInfo {
is_passing: false,
test,
spent_budget: initial_budget - remaining_budget,
};
results.push(test_info);
}
}
}
self.event_listener
.handle_event(Event::FinishedTests { tests: results });
}
fn output_path(&self) -> PathBuf {
self.root.join("assets")
}
fn write_build_outputs(&self, programs: Vec<Script>, uplc_dump: bool) -> Result<(), Error> {
for script in programs {
let script_output_dir = assets.join(script.module).join(script.name);
let script_output_dir = self.output_path().join(script.module).join(script.name);
fs::create_dir_all(&script_output_dir)?;

View File

@ -0,0 +1,30 @@
use crate::script::Script;
use std::path::PathBuf;
use uplc::machine::cost_model::ExBudget;
pub trait EventListener: std::fmt::Debug {
fn handle_event(&self, event: Event);
}
pub enum Event {
StartingCompilation {
name: String,
version: String,
root: PathBuf,
},
ParsingProjectFiles,
TypeChecking,
GeneratingUPLC {
output_path: PathBuf,
},
RunningTests,
FinishedTests {
tests: Vec<TestInfo>,
},
}
pub struct TestInfo {
pub is_passing: bool,
pub test: Script,
pub spent_budget: ExBudget,
}

View File

@ -495,6 +495,7 @@ impl From<Term<FakeNamedDeBruijn>> for Term<NamedDeBruijn> {
impl Program<NamedDeBruijn> {
pub fn eval(
&self,
initial_budget: ExBudget,
) -> (
Result<Term<NamedDeBruijn>, crate::machine::Error>,
ExBudget,
@ -503,7 +504,7 @@ impl Program<NamedDeBruijn> {
let mut machine = Machine::new(
Language::PlutusV2,
CostModel::default(),
ExBudget::default(),
initial_budget,
200,
);
@ -558,6 +559,7 @@ impl Program<NamedDeBruijn> {
impl Program<DeBruijn> {
pub fn eval(
&self,
initial_budget: ExBudget,
) -> (
Result<Term<NamedDeBruijn>, crate::machine::Error>,
ExBudget,
@ -565,7 +567,7 @@ impl Program<DeBruijn> {
) {
let program: Program<NamedDeBruijn> = self.clone().into();
program.eval()
program.eval(initial_budget)
}
}

View File

@ -47,6 +47,17 @@ impl Default for ExBudget {
}
}
impl std::ops::Sub for ExBudget {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
ExBudget {
mem: self.mem - rhs.mem,
cpu: self.cpu - rhs.cpu,
}
}
}
#[derive(Default)]
pub struct CostModel {
pub machine_costs: MachineCosts,

View File

@ -786,7 +786,7 @@ pub fn eval_redeemer(
program.eval_as(&Language::PlutusV2, costs, initial_budget)
} else {
program.eval()
program.eval(ExBudget::default())
};
match result {
@ -889,7 +889,7 @@ pub fn eval_redeemer(
program.eval_as(&Language::PlutusV2, costs, initial_budget)
} else {
program.eval()
program.eval(ExBudget::default())
};
match result {

View File

@ -0,0 +1,5 @@
use aiken/builtin
test bar() {
builtin.length_of_bytearray(#[2, 2, 3]) == 3
}

View File

@ -1,7 +1,7 @@
use sample
pub fn spend(datum: sample.Datum, rdmr: sample.Redeemer, _ctx: Nil) -> Bool {
let x = #(datum, #[244])
let _x = #(datum, rdmr, #[244])
let y = [#(#[222], #[222]), #(#[233], #[52])]
@ -11,3 +11,7 @@ pub fn spend(datum: sample.Datum, rdmr: sample.Redeemer, _ctx: Nil) -> Bool {
z == #(#[222], #[222])
}
test foo() {
1 + 1 == 2
}