use lemma::{DateTimeValue, Engine};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
const PRICING_SPEC: &str = r#"
spec pricing
data quantity: number
data price: 10
rule total: quantity * price
rule discount: 0
unless quantity >= 10 then 5
unless quantity >= 50 then 15
"#;
const CALC_SPEC: &str = r#"
spec calc
data money: measure
-> decimals 2
-> unit eur 1
data hourly_rate: 85.00 eur
data hours_worked: 37.5
data is_rush: boolean
data is_super_rush: boolean
rule labor: hourly_rate * hours_worked
rule rush_surcharge: 0 eur
unless is_rush then labor * 25%
unless is_super_rush then labor * 50%
rule subtotal: labor + rush_surcharge
rule vat: subtotal * 21%
rule total: subtotal + vat
"#;
fn load_engine(code: &str, path: &str) -> Engine {
let mut engine = Engine::new();
engine
.load([(
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(path))),
code.to_string(),
)])
.expect("load spec");
engine
}
fn run_measure(engine: &Engine, measure: &str) -> lemma::Response {
let now = DateTimeValue::now();
let mut data = HashMap::new();
data.insert("quantity".to_string(), measure.to_string());
engine
.run(None, "pricing", Some(&now), data, None, false)
.expect("run pricing")
}
fn run_calc(data: HashMap<String, String>) -> lemma::Response {
let engine = load_engine(CALC_SPEC, "calc.lemma");
let now = DateTimeValue::now();
engine
.run(None, "calc", Some(&now), data, None, false)
.expect("run calc")
}
fn rule_number(response: &lemma::Response, rule: &str) -> Decimal {
let result = response.get(rule).unwrap_or_else(|_| panic!("rule {rule}"));
assert!(!result.vetoed, "rule {rule} vetoed");
Decimal::from_str(result.number.as_ref().expect("number payload")).expect("decimal")
}
fn rule_display(response: &lemma::Response, rule: &str) -> String {
response
.get(rule)
.unwrap_or_else(|_| panic!("rule {rule}"))
.display
.clone()
.expect("display")
}
fn rule_measure_display(response: &lemma::Response, rule: &str) -> String {
response
.get(rule)
.unwrap_or_else(|_| panic!("rule {rule}"))
.display
.clone()
.expect("display")
}
#[test]
fn discount_unless_measure_below_ten() {
let engine = load_engine(PRICING_SPEC, "pricing.lemma");
let response = run_measure(&engine, "5");
assert_eq!(rule_number(&response, "discount"), Decimal::ZERO);
assert_eq!(rule_number(&response, "total"), Decimal::from(50));
}
#[test]
fn discount_unless_measure_ten() {
let engine = load_engine(PRICING_SPEC, "pricing.lemma");
let response = run_measure(&engine, "10");
assert_eq!(rule_number(&response, "discount"), Decimal::from(5));
}
#[test]
fn discount_unless_measure_fifty() {
let engine = load_engine(PRICING_SPEC, "pricing.lemma");
let response = run_measure(&engine, "50");
assert_eq!(rule_number(&response, "discount"), Decimal::from(15));
}
#[test]
fn discount_unless_measure_between_tiers() {
let engine = load_engine(PRICING_SPEC, "pricing.lemma");
let response = run_measure(&engine, "25");
assert_eq!(rule_number(&response, "discount"), Decimal::from(5));
}
#[test]
fn nested_rule_reference_in_arithmetic() {
let code = r#"
spec chain
data x: number
rule base: x * 2
rule offset: 1
unless x >= 5 then 10
rule total: base + offset
"#;
let engine = load_engine(code, "chain.lemma");
let now = DateTimeValue::now();
let mut data = HashMap::new();
data.insert("x".to_string(), "3".to_string());
let response = engine
.run(None, "chain", Some(&now), data, None, false)
.expect("run");
assert_eq!(rule_number(&response, "total"), Decimal::from(7));
}
#[test]
fn inlined_piecewise_subexpression_does_not_return_early_from_parent_rule() {
let mut data = HashMap::new();
data.insert("is_rush".into(), "true".into());
data.insert("is_super_rush".into(), "false".into());
let response = run_calc(data);
assert_eq!(rule_display(&response, "total"), "4821.09 eur");
assert_eq!(
rule_measure_display(&response, "rush_surcharge"),
"796.88 eur"
);
assert_ne!(
rule_display(&response, "total"),
rule_measure_display(&response, "rush_surcharge"),
"total must not equal inlined rush_surcharge alone"
);
}
#[test]
fn is_veto_detects_transitive_veto() {
let code = r#"
spec deep_veto
data input: number
rule step1: input
unless input < 0 then veto "bad input"
rule step2: step1 * 2
rule step3: step2 + 1
rule check_step3: step3 is veto
"#;
let engine = load_engine(code, "deep_veto.lemma");
let now = DateTimeValue::now();
let mut bad = HashMap::new();
bad.insert("input".to_string(), "-1".to_string());
let resp = engine
.run(None, "deep_veto", Some(&now), bad, None, false)
.expect("run");
assert_eq!(rule_display(&resp, "check_step3"), "true");
let mut good = HashMap::new();
good.insert("input".to_string(), "5".to_string());
let resp = engine
.run(None, "deep_veto", Some(&now), good, None, false)
.expect("run");
assert_eq!(rule_display(&resp, "check_step3"), "false");
}
#[test]
fn unless_skips_vetoed_condition_and_matches_is_veto_arm() {
let code = r#"
spec unless_veto_cond
data input: number
rule validated: input
unless input > 1000 then veto "too large"
rule result: 0
unless validated > 50 then 100
unless validated is veto then 0
"#;
let engine = load_engine(code, "unless_veto.lemma");
let now = DateTimeValue::now();
let mut large = HashMap::new();
large.insert("input".to_string(), "2000".to_string());
let resp = engine
.run(None, "unless_veto_cond", Some(&now), large, None, false)
.expect("run");
assert_eq!(rule_display(&resp, "result"), "0");
let mut ok = HashMap::new();
ok.insert("input".to_string(), "100".to_string());
let resp = engine
.run(None, "unless_veto_cond", Some(&now), ok, None, false)
.expect("run");
assert_eq!(rule_display(&resp, "result"), "100");
}
#[test]
fn calc_total_without_rush_is_not_zero_surcharge_path() {
let mut data = HashMap::new();
data.insert("is_rush".into(), "false".into());
data.insert("is_super_rush".into(), "false".into());
let response = run_calc(data);
assert_eq!(rule_display(&response, "total"), "3856.88 eur");
assert_eq!(
rule_measure_display(&response, "rush_surcharge"),
"0.00 eur"
);
}