use lemma::DateTimeValue;
use lemma::ValueKind;
use lemma::{Engine, LiteralValue};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
fn decimal_lit(s: &str) -> Decimal {
Decimal::from_str(s).expect("BUG: test decimal literal must parse")
}
fn rule_literal(rule: &lemma::RuleResult) -> &lemma::LiteralValue {
assert!(!rule.vetoed, "unexpected veto: {:?}", rule.veto_reason);
rule.explanation
.as_ref()
.expect("explanation")
.result
.value()
.expect("value")
}
#[test]
fn in_percent_produces_ratio_and_compares_with_percent_literal() {
let code = r#"
spec savings
data savings_amount: 75
data total_amount: 300
rule savings_ratio: (savings_amount / total_amount) as percent
rule is_above_20: savings_ratio > 20%
rule is_above_30: savings_ratio > 30%
"#;
let mut engine = Engine::new();
engine.load(code, lemma::SourceType::Volatile).unwrap();
let now = DateTimeValue::now();
let response = engine
.run(None, "savings", Some(&now), HashMap::new(), true, None)
.unwrap();
let ratio_result = response
.results
.get("savings_ratio")
.expect("savings_ratio rule");
let lit = rule_literal(ratio_result);
match &lit.value {
ValueKind::Ratio(r, u) => {
assert_eq!(
lemma::ValueKind::Number(r.clone())
.as_decimal_magnitude()
.unwrap(),
Decimal::new(25, 2),
"75/300 = 0.25"
);
assert_eq!(u.as_deref(), Some("percent"));
}
other => panic!("savings_ratio must be Ratio, got {:?}", other),
}
let above_20 = response.results.get("is_above_20").expect("is_above_20");
let above_30 = response.results.get("is_above_30").expect("is_above_30");
assert_eq!(above_20.boolean, Some(true));
assert_eq!(above_30.boolean, Some(false));
}
#[test]
fn in_percent_then_chained_comparison_with_multiple_thresholds() {
let code = r#"
spec summary
data part: 18
data whole: 60
rule pct: (part / whole) as percent
rule tier: "low"
unless pct > 25% then "mid"
unless pct > 50% then "high"
"#;
let mut engine = Engine::new();
engine.load(code, lemma::SourceType::Volatile).unwrap();
let now = DateTimeValue::now();
let response = engine
.run(None, "summary", Some(&now), HashMap::new(), true, None)
.unwrap();
let tier = response.results.get("tier").expect("tier");
assert_eq!(tier.text.as_deref(), Some("mid"));
}
#[test]
fn in_permille_produces_ratio() {
let code = r#"
spec permille_spec
data value: 0.025
rule as_permille: value as permille
rule above_20_permille: as_permille > 20 permille
"#;
let mut engine = Engine::new();
engine.load(code, lemma::SourceType::Volatile).unwrap();
let now = DateTimeValue::now();
let response = engine
.run(
None,
"permille_spec",
Some(&now),
HashMap::new(),
true,
None,
)
.unwrap();
let as_permille = response.results.get("as_permille").expect("as_permille");
let lit = rule_literal(as_permille);
match &lit.value {
ValueKind::Ratio(r, u) => {
assert_eq!(
lemma::ValueKind::Number(r.clone())
.as_decimal_magnitude()
.unwrap(),
Decimal::new(25, 3)
);
assert_eq!(u.as_deref(), Some("permille"));
}
other => panic!("as_permille must be Ratio, got {:?}", other),
}
let above = response.results.get("above_20_permille").expect("above");
assert_eq!(above.boolean, Some(true));
}
#[test]
fn unknown_unit_in_conversion_fails_planning() {
let code = r#"
spec bad
data x: 100
rule bad_conv: x as not_a_unit
"#;
let mut engine = Engine::new();
let err = engine.load(code, lemma::SourceType::Volatile).unwrap_err();
let msg = format!("{:?}", err);
assert!(
msg.contains("Unknown unit") && msg.contains("not_a_unit"),
"expected unknown unit error, got: {}",
msg
);
}
#[test]
fn number_minus_ratio_data_is_rejected() {
let code = r#"
spec pricing
data discount: ratio
rule price: 100 - discount
"#;
let mut engine = Engine::new();
let result = engine.load(code, lemma::SourceType::Volatile);
assert!(result.is_err(), "number - ratio should be rejected");
let msg = result
.unwrap_err()
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
assert!(
msg.contains("scale explicitly"),
"expected scale-explicitly hint, got: {}",
msg
);
}
#[test]
fn ratio_display_with_none_unit_shows_number_only() {
let lit = LiteralValue::ratio_from_decimal(decimal_lit("0.5"), None);
let display = lit.display_value();
assert!(
!display.contains("percent") && display.contains("0.5"),
"ratio with None unit should display number only, got: {}",
display
);
let with_unit =
LiteralValue::ratio_from_decimal(decimal_lit("0.5"), Some("percent".to_string()));
let display_with = with_unit.display_value();
assert!(
display_with.contains('%'),
"ratio with Some(percent) should show % symbol, got: {}",
display_with
);
}
#[test]
fn chained_ratio_conversion_and_arithmetic() {
let code = r#"
spec chained
data a: 10
data b: 40
rule pct: (a / b) as percent
rule plus_five: pct + 5%
rule compared: plus_five > 25%
"#;
let mut engine = Engine::new();
engine.load(code, lemma::SourceType::Volatile).unwrap();
let now = DateTimeValue::now();
let response = engine
.run(None, "chained", Some(&now), HashMap::new(), true, None)
.unwrap();
let pct = response.results.get("pct").expect("pct");
let plus_five = response.results.get("plus_five").expect("plus_five");
let compared = response.results.get("compared").expect("compared");
if let ValueKind::Ratio(r, _) = &rule_literal(pct).value {
assert_eq!(
lemma::ValueKind::Number(r.clone())
.as_decimal_magnitude()
.unwrap(),
Decimal::new(25, 2)
);
} else {
panic!("pct must be Ratio");
}
if let ValueKind::Ratio(r, _) = &rule_literal(plus_five).value {
assert_eq!(
lemma::ValueKind::Number(r.clone())
.as_decimal_magnitude()
.unwrap(),
Decimal::new(30, 2)
);
} else {
panic!("plus_five must be Ratio");
}
assert_eq!(compared.boolean, Some(true));
}
#[test]
fn measure_and_ratio_conversion_in_same_spec() {
let code = r#"
spec mixed
data money: measure
-> unit eur 1
data amount: 200
data part: 50
rule as_eur: amount as eur
rule share_pct: (part / amount) as percent
rule share_above_20: share_pct > 20%
"#;
let mut engine = Engine::new();
engine.load(code, lemma::SourceType::Volatile).unwrap();
let now = DateTimeValue::now();
let response = engine
.run(None, "mixed", Some(&now), HashMap::new(), true, None)
.unwrap();
let as_eur = response.results.get("as_eur").expect("as_eur");
let share_pct = response.results.get("share_pct").expect("share_pct");
let share_above_20 = response
.results
.get("share_above_20")
.expect("share_above_20");
let lit = rule_literal(as_eur);
match &lit.value {
ValueKind::Measure(n, signature) => {
assert_eq!(
ValueKind::Number(n.clone()).as_decimal_magnitude().unwrap(),
Decimal::from(200)
);
assert_eq!(
signature.first().map(|(name, _)| name.as_str()),
Some("eur")
);
}
other => panic!("as_eur: expected Measure, got {other:?}"),
}
let lit = rule_literal(share_pct);
if let ValueKind::Ratio(r, u) = &lit.value {
assert_eq!(
lemma::ValueKind::Number(r.clone())
.as_decimal_magnitude()
.unwrap(),
Decimal::new(25, 2)
);
assert_eq!(u.as_deref(), Some("percent"));
} else {
panic!("share_pct must be Ratio");
}
assert_eq!(share_above_20.boolean, Some(true));
}