Skip to content
1 change: 1 addition & 0 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ impl Display for Tx3Type {
ir::Type::List => write!(f, "list"),
ir::Type::Map => write!(f, "map"),
ir::Type::Custom(name) => write!(f, "custom({})", name),
_ => unreachable!(),
}
}
}
103 changes: 103 additions & 0 deletions src/commands/expect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::path::Path;

use miette::Result;

use crate::spawn::cshell;

// Import Expect types from the `test` module
use crate::commands::test::ExpectUtxo;

/// Run all checks for a slice of `ExpectUtxo` expectations.
/// Returns `Ok(true)` when any expectation failed, `Ok(false)` when all passed.
///
///
pub fn expect_utxo(expects: &[ExpectUtxo], test_home: &Path) -> Result<bool> {
let mut failed_any = false;

for expect in expects.iter() {
let mut failed = false;

let utxos = cshell::wallet_utxos(&test_home, &expect.from)?;

if expect.datum_equals.is_none() && expect.min_amount.is_empty() {
if utxos.is_empty() {
failed_any = true;
eprintln!("Test Failed: No UTXOs found for wallet `{}`.", expect.from);
}
continue;
}

// Find UTXOs that match the datum if specified
let matching_utxos: Vec<_> = if let Some(expected_datum) = &expect.datum_equals {
utxos
.iter()
.filter(|utxo| {
if let Some(datum) = &utxo.datum {
match expected_datum {
serde_json::Value::String(s) => hex::encode(&datum.hash) == *s,
_ => false,
}
} else {
false
}
})
.collect()
} else {
// If no datum_equals specified, consider all UTXOs
utxos.iter().collect()
};

for min_req in &expect.min_amount {
let total_amount: u64 =
if let (Some(policy), Some(name)) = (&min_req.policy, &min_req.name) {
// Check for specific asset
matching_utxos
.iter()
.flat_map(|utxo| utxo.assets.iter())
.map(|bal| {
let policy_hex = hex::encode(&bal.policy_id);
if policy_hex == *policy {
bal.assets
.iter()
.filter(|asset| String::from_utf8_lossy(&asset.name) == *name)
.map(|asset| asset.output_coin.parse::<u64>().unwrap_or(0))
.sum::<u64>()
} else {
0u64
}
})
.sum()
} else {
// Check for lovelace
matching_utxos
.iter()
.map(|utxo| utxo.coin.parse::<u64>().unwrap_or(0))
.sum()
};

if total_amount < min_req.amount {
failed = true;

let asset_desc =
if let (Some(policy), Some(name)) = (&min_req.policy, &min_req.name) {
format!("asset {}.{}", policy, name)
} else {
"lovelace".to_string()
};

eprintln!(
"Test Failed: wallet `{}` with insufficient {}.",
expect.from, asset_desc
);
eprintln!("Expected minimum: {}", min_req.amount);
eprintln!("Found: {}", total_amount);
}
}

if failed {
failed_any = true;
}
}

Ok(failed_any)
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod bindgen;
pub mod build;
pub mod check;
pub mod devnet;
pub mod expect;
pub mod init;
pub mod inspect;
pub mod publish;
Expand Down
147 changes: 79 additions & 68 deletions src/commands/test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::{
collections::HashMap,
fmt::Display,
path::{Path, PathBuf},
thread::sleep,
time::Duration,
Expand Down Expand Up @@ -30,7 +29,7 @@ struct Test {
file: PathBuf,
wallets: Vec<Wallet>,
transactions: Vec<Transaction>,
expect: Vec<Expect>,
expect: Vec<ExpectUtxo>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand All @@ -48,53 +47,17 @@ struct Transaction {
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Expect {
Balance(ExpectBalance),
// TODO: improve expect adding more options
pub(crate) struct ExpectUtxo {
pub(crate) from: String,
pub(crate) datum_equals: Option<serde_json::Value>,
pub(crate) min_amount: Vec<ExpectMinAmount>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ExpectBalance {
wallet: String,
amount: ExpectAmount,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum ExpectAmount {
Absolute(u64),
Aprox(ExpectAmountAprox),
}

impl ExpectAmount {
pub fn matches(&self, value: u64) -> bool {
match self {
ExpectAmount::Absolute(x) => x.eq(&value),
ExpectAmount::Aprox(x) => {
let lower = x.target.saturating_sub(x.threshold);
let upper = x.target + x.threshold;
value >= lower && value <= upper
}
}
}
}

#[derive(Debug, Serialize, Deserialize)]
struct ExpectAmountAprox {
target: u64,
threshold: u64,
}

impl Display for ExpectAmount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExpectAmount::Absolute(value) => write!(f, "{value}"),
ExpectAmount::Aprox(value) => {
write!(f, "target: ~{} (+/- {})", value.target, value.threshold)
}
}
}
pub(crate) struct ExpectMinAmount {
pub(crate) policy: Option<String>,
pub(crate) name: Option<String>,
pub(crate) amount: u64,
}

fn ensure_test_home(test: &Test, hashable: &[u8]) -> Result<PathBuf> {
Expand Down Expand Up @@ -207,6 +170,75 @@ fn trigger_transaction(
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

#[test]
fn parse_expect_utxo_toml() {
let toml = r#"
file = "./main.tx3"

[[wallets]]
name = "oracle"
balance = 10000000

[[wallets]]
name = "operator"
balance = 5000000

[[transactions]]
description = "Simple Oracle"
template = "create"
signers = ["operator"]
args = { rate = 42, operator = "@operator", oracle = "@oracle" }

[[expect]]
from = "@oracle"
datum_equals = 42

[[expect.min_amount]]
amount = 123

[[expect.min_amount]]
policy = "xyz"
name = "abc"
amount = 456
"#;

let parsed: Test = toml::from_str(toml).expect("parse toml");

assert_eq!(parsed.file, PathBuf::from("./main.tx3"));
assert_eq!(parsed.wallets.len(), 2);

assert_eq!(parsed.transactions.len(), 1);

assert_eq!(parsed.expect.len(), 1);
let e = &parsed.expect[0];
assert_eq!(e.from, "@oracle");

assert!(e.datum_equals.is_some());
let datum = e.datum_equals.as_ref().unwrap();
match datum {
serde_json::Value::Number(n) => {
assert_eq!(n.as_i64(), Some(42));
}
other => panic!("unexpected datum kind: {other:?}"),
}

let mins = &e.min_amount;
assert_eq!(mins.len(), 2);

assert_eq!(mins[0].amount, 123);
assert!(mins[0].policy.is_none() && mins[0].name.is_none());

assert_eq!(mins[1].policy.as_ref().unwrap(), "xyz");
assert_eq!(mins[1].name.as_ref().unwrap(), "abc");
assert_eq!(mins[1].amount, 456);
}
}

pub fn run(args: Args, _config: &Config, profile: &ProfileConfig) -> Result<()> {
println!("== Starting tests ==\n");
let test_content = std::fs::read_to_string(args.path).into_diagnostic()?;
Expand All @@ -232,28 +264,7 @@ pub fn run(args: Args, _config: &Config, profile: &ProfileConfig) -> Result<()>
sleep(Duration::from_secs(BLOCK_PRODUCTION_INTERVAL_SECONDS));
}

for expect in test.expect.iter() {
match expect {
Expect::Balance(expect) => {
let balance = crate::spawn::cshell::wallet_balance(&test_home, &expect.wallet)?;

let r#match = expect.amount.matches(balance.coin);

if !r#match {
failed = true;

eprintln!(
"Test Failed: `{}` Balance did not match the expected result.",
expect.wallet
);
eprintln!("Expected: {}", expect.amount);
eprintln!("Received: {}", balance.coin);

eprintln!("Hint: Check the tx3 file or the test file.");
}
}
}
}
failed |= crate::commands::expect::expect_utxo(&test.expect, &test_home)?;

if !failed {
println!("Test Passed\n");
Expand Down
71 changes: 71 additions & 0 deletions src/spawn/cshell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,37 @@ pub struct OutputBalance {
pub coin: u64,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Asset {
#[serde(with = "hex::serde")]
pub name: Vec<u8>,
pub output_coin: String,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Datum {
#[serde(with = "hex::serde")]
pub hash: Vec<u8>,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct UtxoAsset {
#[serde(with = "hex::serde")]
pub policy_id: Vec<u8>,
pub assets: Vec<Asset>,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct UTxO {
#[serde(with = "hex::serde")]
pub tx: Vec<u8>,
pub tx_index: u64,
pub address: String,
pub coin: String, // To avoid overflow
pub assets: Vec<UtxoAsset>,
pub datum: Option<Datum>,
}

pub fn initialize_config(root: &Path) -> miette::Result<PathBuf> {
let config_path = root.join("cshell.toml");

Expand Down Expand Up @@ -285,6 +316,46 @@ pub fn wallet_balance(home: &Path, wallet_name: &str) -> miette::Result<OutputBa
serde_json::from_slice(&output.stdout).into_diagnostic()
}

pub fn wallet_utxos(home: &Path, wallet_name: &str) -> miette::Result<Vec<UTxO>> {
let mut cmd = new_generic_command(home)?;
let output = cmd
.args([
"wallet",
"balance",
wallet_name,
"--detail",
"--output-format",
"json",
])
.stdout(Stdio::piped())
.output()
.into_diagnostic()
.context("running CShell wallet utxos")?;

if !output.status.success() {
bail!("CShell failed to get wallet utxos");
}

match serde_json::from_slice::<Vec<UTxO>>(&output.stdout) {
Ok(list) => Ok(list),
Err(_) => {
let v: serde_json::Value = serde_json::from_slice(&output.stdout).into_diagnostic()?;
if let Some(utxos_val) = v.get("utxos") {
let list: Vec<UTxO> =
serde_json::from_value(utxos_val.clone()).into_diagnostic()?;
Ok(list)
} else {
if v.is_array() {
let list: Vec<UTxO> = serde_json::from_value(v).into_diagnostic()?;
Ok(list)
} else {
bail!("unexpected CShell wallet balance output shape")
}
}
}
}
}

pub fn explorer(home: &Path) -> miette::Result<Child> {
let mut cmd = new_generic_command(home)?;

Expand Down
Loading
Loading