//! Runs every case of every file in `contracts/test-vectors/` (SPEC §14.5.5) against `kvitt-domain`. //! //! The directory is read at test time (path from `CARGO_MANIFEST_DIR`), so a new vector file is picked up without a //! code change; a file for a function this crate does not know fails the test unless it is one of the Kotlin-only //! functions (SPEC §14.5.4 analytics and the category suggestion). use std::collections::BTreeMap; use std::path::PathBuf; use kvitt_domain::{ AccountPosition, Allocation, BalanceExpense, BalanceSettlement, SplitSpec, compute_balances, compute_split, over_settled_accounts, propose_allocation, validate_settlement, }; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; /// Functions that exist only in Kotlin (SPEC §14.5.4, the category suggestion); their files are parsed but not run here. const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range", "suggest_category"]; /// Every Rust function of SPEC §14.5.2; each must have a vector file. const RUST_FUNCTIONS: &[&str] = &[ "compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts", ]; #[derive(Deserialize)] struct VectorFile { format: u32, function: String, cases: Vec, } #[derive(Deserialize)] struct Case { name: String, input: Value, expected: Value, } #[derive(Deserialize)] struct SplitInput { amount_ore: i64, payer_user_id: String, user_ids: Vec, spec: SplitSpec, } #[derive(Deserialize)] struct BalancesInput { user_ids: Vec, account_ids: Vec, expenses: Vec, settlements: Vec, } #[derive(Deserialize)] struct ProposalInput { amount_ore: i64, accounts: Vec, } #[derive(Deserialize)] struct SettlementInput { amount_ore: i64, from_user_id: String, to_user_id: String, user_ids: Vec, allocations: Vec, } #[derive(Deserialize)] struct OverSettledInput { allocations: Vec, accounts: Vec, } fn vectors_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/test-vectors") } fn input(file: &str, case: &Case) -> T { serde_json::from_value(case.input.clone()) .unwrap_or_else(|e| panic!("{file} / {}: bad input: {e}", case.name)) } fn outcome(result: Result) -> Value { match result { Ok(v) => json!({ "ok": v }), Err(e) => json!({ "error": e }), } } fn run(function: &str, file: &str, case: &Case) -> Value { match function { "compute_split" => { let i: SplitInput = input(file, case); outcome(compute_split( i.amount_ore, &i.payer_user_id, &i.user_ids, &i.spec, )) } "compute_balances" => { let i: BalancesInput = input(file, case); let b = compute_balances(&i.user_ids, &i.account_ids, &i.expenses, &i.settlements); json!({ "ok": b }) } "propose_allocation" => { let i: ProposalInput = input(file, case); outcome(propose_allocation(i.amount_ore, &i.accounts)) } "validate_settlement" => { let i: SettlementInput = input(file, case); outcome(validate_settlement( i.amount_ore, &i.from_user_id, &i.to_user_id, &i.user_ids, &i.allocations, )) } "over_settled_accounts" => { let i: OverSettledInput = input(file, case); json!({ "ok": over_settled_accounts(&i.allocations, &i.accounts) }) } other => panic!("{file}: no Rust implementation for function {other}"), } } #[test] fn every_vector_file_passes() { let dir = vectors_dir(); let mut entries: Vec = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())) .map(|entry| entry.expect("directory entry").path()) .filter(|p| p.extension().is_some_and(|x| x == "json")) .collect(); entries.sort(); assert!(!entries.is_empty(), "no vector files in {}", dir.display()); let mut seen: BTreeMap = BTreeMap::new(); let mut failures: Vec = Vec::new(); for path in &entries { let file = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); let text = std::fs::read_to_string(path).expect("read vector file"); let vectors: VectorFile = serde_json::from_str(&text) .unwrap_or_else(|e| panic!("{file}: not a vector file (SPEC §14.5.5): {e}")); assert_eq!(vectors.format, 1, "{file}: unknown format"); assert_eq!( file, format!("{}.json", vectors.function), "{file}: file name must be .json" ); assert!(!vectors.cases.is_empty(), "{file}: no cases"); if KOTLIN_ONLY.contains(&vectors.function.as_str()) { seen.insert(vectors.function.clone(), 0); continue; } for case in &vectors.cases { let actual = run(&vectors.function, &file, case); if actual != case.expected { failures.push(format!( "{file} / {}: expected {}, got {}", case.name, case.expected, actual )); } } seen.insert(vectors.function.clone(), vectors.cases.len()); } for function in RUST_FUNCTIONS { assert!( seen.get(*function).is_some_and(|n| *n > 0), "no vector cases for {function}" ); } for (function, cases) in &seen { println!("{function}: {cases} cases run"); } assert!( failures.is_empty(), "{} failing cases:\n{}", failures.len(), failures.join("\n") ); }