mirror of
https://github.com/gorules/zen.git
synced 2026-10-05 16:02:40 +00:00
559 lines
18 KiB
Rust
559 lines
18 KiB
Rust
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use zen_engine::loader::{LoaderError, MemoryLoader};
|
|
use zen_engine::model::{DecisionContent, PolicyContent};
|
|
use zen_engine::{
|
|
DecisionEngine, EvaluationError, EvaluationOptions, EvaluationSerializedOptions,
|
|
EvaluationTraceKind,
|
|
};
|
|
|
|
mod support;
|
|
use support::load_test_data;
|
|
|
|
fn simple_policy_json() -> serde_json::Value {
|
|
json!({
|
|
"blocks": [
|
|
{
|
|
"id": "dm-customer",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
},
|
|
{
|
|
"id": "assert1",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": "customer.isAdult",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn importing_policy_json(import_path: &str, output: &str) -> serde_json::Value {
|
|
json!({
|
|
"imports": [import_path],
|
|
"blocks": [
|
|
{
|
|
"id": format!("assert-{output}"),
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": format!("customer.{output}"),
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn make_policy_content(json: serde_json::Value) -> DecisionContent {
|
|
let policy: zen_engine::policy::PolicyDocument =
|
|
serde_json::from_value(json).expect("valid policy fixture");
|
|
DecisionContent::Policy(PolicyContent(Arc::new(policy)))
|
|
}
|
|
|
|
fn engine_with(loader: Arc<MemoryLoader>) -> DecisionEngine {
|
|
DecisionEngine::default().with_loader(loader)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_single_policy() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("policy", json!({ "customer": { "age": 30 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(
|
|
result_json.pointer("/customer/isAdult"),
|
|
Some(&json!(true)),
|
|
"expected customer.isAdult=true, got {result_json:#?}",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_with_single_import() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("base", make_policy_content(simple_policy_json()));
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("base", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 70 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromEntry"),
|
|
Some(&json!(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_chained_imports() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("base", make_policy_content(simple_policy_json()));
|
|
loader.add(
|
|
"middle",
|
|
make_policy_content(importing_policy_json("base", "fromMiddle")),
|
|
);
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("middle", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 80 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromMiddle"),
|
|
Some(&json!(true))
|
|
);
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromEntry"),
|
|
Some(&json!(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cycle_terminates() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("a", make_policy_content(importing_policy_json("b", "aOut")));
|
|
loader.add("b", make_policy_content(importing_policy_json("a", "bOut")));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
|
|
.await;
|
|
let _ = result;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn self_import_terminates() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"a",
|
|
make_policy_content(importing_policy_json("a", "selfOut")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
|
|
.await;
|
|
let _ = result;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_importing_graph_fails() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("g", "fromEntry")),
|
|
);
|
|
loader.add("g", load_test_data("table.json"));
|
|
let engine = engine_with(loader);
|
|
|
|
let err = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
|
|
.await
|
|
.expect_err("policy importing a graph should fail");
|
|
|
|
match *err {
|
|
EvaluationError::ContentKindMismatch { expected, got, .. } => {
|
|
assert_eq!(expected, "policy");
|
|
assert_eq!(got, "graph");
|
|
}
|
|
other => panic!("expected ContentKindMismatch, got: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_import_is_loader_error() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("does-not-exist", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let err = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
|
|
.await
|
|
.expect_err("missing import should surface loader error");
|
|
|
|
assert!(
|
|
matches!(*err, EvaluationError::LoaderError(_)),
|
|
"expected LoaderError, got: {err:?}"
|
|
);
|
|
}
|
|
|
|
fn assert_string_trace(value: serde_json::Value) {
|
|
let trace = value.pointer("/trace").expect("trace key present");
|
|
let trace_str = trace.as_str().expect("trace must serialize as a string");
|
|
let parsed: serde_json::Value = serde_json::from_str(trace_str).expect("trace string is JSON");
|
|
assert!(parsed.pointer("/properties").is_some());
|
|
assert!(parsed.pointer("/executions").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn serialized_string_trace_for_policies() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let options = EvaluationSerializedOptions {
|
|
trace: EvaluationTraceKind::String,
|
|
..Default::default()
|
|
};
|
|
let lazy = engine
|
|
.evaluate_serialized(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
options,
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
assert_string_trace(lazy);
|
|
|
|
engine.compile();
|
|
let precompiled = engine
|
|
.evaluate_serialized(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
options,
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
assert_string_trace(precompiled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn diamond_imports_load_each_key_once() {
|
|
let mut contents: HashMap<String, Arc<DecisionContent>> = HashMap::new();
|
|
contents.insert(
|
|
"entry".into(),
|
|
Arc::new(make_policy_content(json!({
|
|
"imports": ["a", "b"],
|
|
"blocks": [
|
|
{
|
|
"id": "assert-entry",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": "customer.fromEntry",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}))),
|
|
);
|
|
contents.insert(
|
|
"a".into(),
|
|
Arc::new(make_policy_content(importing_policy_json("base", "fromA"))),
|
|
);
|
|
contents.insert(
|
|
"b".into(),
|
|
Arc::new(make_policy_content(importing_policy_json("base", "fromB"))),
|
|
);
|
|
contents.insert(
|
|
"base".into(),
|
|
Arc::new(make_policy_content(simple_policy_json())),
|
|
);
|
|
|
|
let counts: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
|
|
let counts_in_loader = counts.clone();
|
|
let engine = DecisionEngine::default().with_closure_loader(move |key| {
|
|
let counts = counts_in_loader.clone();
|
|
let content = contents.get(&key).cloned();
|
|
async move {
|
|
*counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1;
|
|
content.ok_or_else(|| LoaderError::NotFound(key))
|
|
}
|
|
});
|
|
|
|
engine
|
|
.evaluate("entry", json!({ "customer": { "age": 30 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let counts = counts.lock().unwrap();
|
|
for key in ["entry", "a", "b", "base"] {
|
|
assert_eq!(
|
|
counts.get(key),
|
|
Some(&1),
|
|
"key {key} should load exactly once, got {counts:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn trace_serializes_for_policies() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate_with_opts(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
EvaluationOptions {
|
|
trace: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
assert!(result.trace.is_some(), "trace requested but None returned");
|
|
let serialized = serde_json::to_value(&result).expect("serialize ok");
|
|
let trace = serialized.pointer("/trace").expect("trace key present");
|
|
assert!(
|
|
trace.pointer("/properties").is_some(),
|
|
"policy trace should serialize with `properties` field"
|
|
);
|
|
assert!(
|
|
trace.pointer("/executions").is_some(),
|
|
"policy trace should serialize with `executions` field"
|
|
);
|
|
}
|
|
|
|
fn parent_declares_child_reads(child_condition: &str) -> Arc<MemoryLoader> {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["shared"],
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "name", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} }, "children": [] },
|
|
{ "id": "s1", "type": "expression", "props": { "data": {
|
|
"key": "customer.discount", "value": "customer.tier"
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"shared",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "m", "type": "match", "props": { "data": {
|
|
"key": "customer.tier",
|
|
"arms": [
|
|
{ "id": "a1", "condition": child_condition, "value": "\"gold\"" },
|
|
{ "id": "a2", "condition": "", "value": "\"silver\"" }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_child_reading_parent_declared_fields() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/discount"),
|
|
Some(&json!("gold"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_child_with_real_error_is_refused() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_refuses_child_block_mixed_under_parent_data_model() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
let data_model = |id: &str, name: &str| {
|
|
json!({ "id": id, "type": "dataModel", "props": { "data": {
|
|
"name": name,
|
|
"properties": [
|
|
{ "id": format!("{id}-p"), "name": "name", "type": "string", "array": false, "optional": false }
|
|
]
|
|
} }, "children": [] })
|
|
};
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["child"],
|
|
"blocks": [data_model("dm-customer", "customer"), data_model("dm-company", "company")]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"child",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "bad-block", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [],
|
|
"outputs": [
|
|
{ "id": "s1", "name": "", "field": "customer.label" },
|
|
{ "id": "s2", "name": "", "field": "company.label" }
|
|
],
|
|
"rules": [{ "_id": "r1", "s1": "\"x\"", "s2": "\"y\"" }]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "a" }, "company": { "name": "b" } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_refuses_child_unreachable_read_under_parent_data_model() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["child"],
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": {
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"child",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "assert", "type": "assertion", "props": { "data": {
|
|
"output": "globalFlag",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "companies": [{ "revenue": 5 }] } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compile_accepts_child_reading_parent_declared_fields() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
|
|
let failures = engine.compile();
|
|
let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect();
|
|
assert_eq!(
|
|
reported,
|
|
vec![("shared", "policyImportOnly")],
|
|
"{failures:#?}"
|
|
);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compile_reports_child_with_real_error() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
|
|
let failures = engine.compile();
|
|
let keys: Vec<&str> = failures.iter().map(|f| f.key.as_ref()).collect();
|
|
assert!(keys.contains(&"main"), "{failures:#?}");
|
|
}
|