mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 00:02:18 +00:00
548 lines
18 KiB
Rust
548 lines
18 KiB
Rust
use serde_json::{json, Value};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use zen_engine::loader::MemoryLoader;
|
|
use zen_engine::model::{DecisionContent, PolicyContent};
|
|
use zen_engine::policy::{Diagnostic, DiagnosticCode, PolicyWorkspace, Workspace};
|
|
use zen_engine::DecisionEngine;
|
|
|
|
fn with_code(diagnostics: &[Diagnostic], code: DiagnosticCode) -> Vec<Diagnostic> {
|
|
diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == code)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn arg(diagnostic: &Diagnostic, key: &str) -> Option<String> {
|
|
diagnostic.args.get(key).cloned()
|
|
}
|
|
|
|
fn applicant_model() -> Value {
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "applicant",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "target", "type": "number", "array": false, "optional": true },
|
|
{ "id": "p3", "name": "vip", "type": "boolean", "array": false, "optional": false }
|
|
]
|
|
} } })
|
|
}
|
|
|
|
fn policy_diagnostics(blocks: Vec<Value>) -> Vec<Diagnostic> {
|
|
let mut all = vec![applicant_model()];
|
|
all.extend(blocks);
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({ "blocks": all })).expect("policy"),
|
|
);
|
|
ws.diagnostics("p")
|
|
}
|
|
|
|
fn policy_expression(value: &str) -> Vec<Diagnostic> {
|
|
policy_diagnostics(vec![
|
|
json!({ "id": "calc", "type": "expression", "props": { "data": { "key": "applicant.total", "value": value } } }),
|
|
])
|
|
}
|
|
|
|
fn graph_expression(value: &str) -> Vec<Diagnostic> {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": { "amount": { "type": "number" }, "target": { "type": "number" } },
|
|
"required": ["amount"]
|
|
});
|
|
let content: DecisionContent = serde_json::from_value(json!({
|
|
"nodes": [
|
|
{ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": schema.to_string() } },
|
|
{ "id": "calc", "name": "calc", "type": "expressionNode", "content": {
|
|
"expressions": [ { "id": "x", "key": "total", "value": value } ],
|
|
"passThrough": true
|
|
} },
|
|
{ "id": "out", "name": "out", "type": "outputNode", "content": {} }
|
|
],
|
|
"edges": [
|
|
{ "id": "e1", "sourceId": "in", "targetId": "calc" },
|
|
{ "id": "e2", "sourceId": "calc", "targetId": "out" }
|
|
]
|
|
}))
|
|
.expect("graph");
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", content);
|
|
ws.diagnostics("g")
|
|
}
|
|
|
|
fn cell_table(field: &str, cell: &str) -> Value {
|
|
json!({ "id": "dt", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i0", "name": "In", "field": field } ],
|
|
"outputs": [ { "id": "o0", "name": "Rate", "field": "applicant.rate" } ],
|
|
"rules": [
|
|
{ "_id": "r1", "i0": cell, "o0": "1" },
|
|
{ "_id": "r2", "i0": "", "o0": "2" }
|
|
]
|
|
} } })
|
|
}
|
|
|
|
#[test]
|
|
fn unary_fallback_fix_keeps_boolean_semantics() {
|
|
for cell in [
|
|
"applicant.vip ?? false",
|
|
"$ ?? false",
|
|
"(applicant.vip ?? true)",
|
|
] {
|
|
let found = with_code(
|
|
&policy_diagnostics(vec![cell_table("applicant.vip", cell)]),
|
|
DiagnosticCode::RedundantNullish,
|
|
);
|
|
assert_eq!(found.len(), 1, "{cell}: {found:?}");
|
|
assert_eq!(arg(&found[0], "fixSource"), None, "{cell}: {found:?}");
|
|
}
|
|
|
|
let found = with_code(
|
|
&policy_diagnostics(vec![cell_table(
|
|
"applicant.age",
|
|
"(applicant.age ?? 0) + 1",
|
|
)]),
|
|
DiagnosticCode::RedundantNullish,
|
|
);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource").as_deref(),
|
|
Some("applicant.age + 1")
|
|
);
|
|
|
|
let found = with_code(
|
|
&policy_diagnostics(vec![cell_table(
|
|
"applicant.age",
|
|
"(applicant.age ?? 0) + (applicant.age ?? 1)",
|
|
)]),
|
|
DiagnosticCode::RedundantNullish,
|
|
);
|
|
assert_eq!(found.len(), 2, "{found:?}");
|
|
for d in &found {
|
|
assert_eq!(arg(d, "fixSource"), None, "{d:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fixes_splice_by_byte_offsets() {
|
|
for text in ["café", "é🎉"] {
|
|
let source = format!("\"{text}\" != \"x\" and applicant.target > 0");
|
|
let found = with_code(&policy_expression(&source), DiagnosticCode::TypeMismatch);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource"),
|
|
Some(format!(
|
|
"\"{text}\" != \"x\" and (applicant.target ?? 0) > 0"
|
|
))
|
|
);
|
|
assert_eq!(
|
|
arg(&found[0], "fixOperand").as_deref(),
|
|
Some("applicant.target")
|
|
);
|
|
|
|
let source = format!("\"{text}\" != \"x\" and (applicant.age ?? 0) > 1");
|
|
let found = with_code(
|
|
&policy_expression(&source),
|
|
DiagnosticCode::RedundantNullish,
|
|
);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource"),
|
|
Some(format!("\"{text}\" != \"x\" and applicant.age > 1"))
|
|
);
|
|
assert_eq!(arg(&found[0], "fixFallback").as_deref(), Some("0"));
|
|
|
|
let source = format!("\"{text}\" != \"x\" and (applicant.age) > 1");
|
|
let found = with_code(
|
|
&policy_expression(&source),
|
|
DiagnosticCode::RedundantParentheses,
|
|
);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource"),
|
|
Some(format!("\"{text}\" != \"x\" and applicant.age > 1"))
|
|
);
|
|
assert_eq!(
|
|
found[0].message,
|
|
"unnecessary parentheses around 'applicant.age'"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn quick_fixes_cover_every_finding_in_long_expressions() {
|
|
let source = vec!["(amount ?? 0) + (1)"; 40].join(" + ");
|
|
let diagnostics = graph_expression(&source);
|
|
|
|
for (code, all, first) in [
|
|
(
|
|
DiagnosticCode::RedundantNullish,
|
|
vec!["amount + (1)"; 40].join(" + "),
|
|
"amount + (1) + (amount ?? 0) + (1)",
|
|
),
|
|
(
|
|
DiagnosticCode::RedundantParentheses,
|
|
vec!["(amount ?? 0) + 1"; 40].join(" + "),
|
|
"(amount ?? 0) + 1 + (amount ?? 0) + (1)",
|
|
),
|
|
] {
|
|
let found = with_code(&diagnostics, code);
|
|
assert_eq!(found.len(), 40);
|
|
for d in &found {
|
|
assert_eq!(arg(d, "fixOriginal").as_deref(), Some(source.as_str()));
|
|
assert_eq!(arg(d, "fixAll").as_deref(), Some(all.as_str()));
|
|
}
|
|
let fixed = arg(&found[0], "fixSource").expect("fix");
|
|
assert!(fixed.starts_with(first), "{}", &fixed[..60]);
|
|
assert_eq!(fixed.len(), source.len() - (source.len() - all.len()) / 40);
|
|
}
|
|
|
|
let source = vec!["target * 2"; 40].join(" + ");
|
|
let found = with_code(&graph_expression(&source), DiagnosticCode::TypeMismatch);
|
|
assert_eq!(found.len(), 40);
|
|
assert!(found.iter().all(|d| d.args.contains_key("fixSource")));
|
|
let fixed = arg(&found[0], "fixSource").expect("fix");
|
|
assert!(
|
|
fixed.starts_with("(target ?? 0) * 2 + target * 2"),
|
|
"{}",
|
|
&fixed[..60]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_fallbacks_are_proven_separately() {
|
|
let found = with_code(
|
|
&graph_expression("-(amount ?? 0 ?? 1)"),
|
|
DiagnosticCode::RedundantNullish,
|
|
);
|
|
let mut fixes: Vec<String> = found
|
|
.iter()
|
|
.map(|d| arg(d, "fixSource").expect("fix"))
|
|
.collect();
|
|
fixes.sort();
|
|
assert_eq!(fixes, vec!["-(amount ?? 0)", "-(amount ?? 1)"]);
|
|
for d in &found {
|
|
assert_eq!(arg(d, "fixAll").as_deref(), Some("-(amount)"));
|
|
}
|
|
}
|
|
|
|
fn number_schema() -> String {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"applicant": {
|
|
"type": "object",
|
|
"properties": { "age": { "type": "number", "minimum": 0, "maximum": 120 } },
|
|
"required": ["age"]
|
|
}
|
|
},
|
|
"required": ["applicant"]
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
fn age_table() -> Value {
|
|
json!({ "id": "dt", "name": "dt", "type": "decisionTableNode", "content": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i0", "name": "Age", "field": "applicant.age" } ],
|
|
"outputs": [ { "id": "o0", "name": "Rate", "field": "rate" } ],
|
|
"rules": [ { "_id": "r1", "i0": "<= 120", "o0": "1" } ]
|
|
} })
|
|
}
|
|
|
|
fn expression_node(id: &str, key: &str, value: &str) -> Value {
|
|
json!({ "id": id, "name": id, "type": "expressionNode", "content": {
|
|
"expressions": [ { "id": format!("{id}-x"), "key": key, "value": value } ],
|
|
"passThrough": true
|
|
} })
|
|
}
|
|
|
|
fn graph_diagnostics(nodes: Vec<Value>, edges: &[(&str, &str)]) -> Vec<Diagnostic> {
|
|
let mut all = vec![
|
|
json!({ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": number_schema() } }),
|
|
];
|
|
all.extend(nodes);
|
|
all.push(json!({ "id": "out", "name": "out", "type": "outputNode", "content": {} }));
|
|
let edges: Vec<Value> = edges
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (a, b))| json!({ "id": format!("e{i}"), "sourceId": a, "targetId": b, "sourceHandle": null }))
|
|
.collect();
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
serde_json::from_value(json!({ "nodes": all, "edges": edges })).expect("graph"),
|
|
);
|
|
ws.diagnostics("g")
|
|
}
|
|
|
|
#[test]
|
|
fn schema_ranges_ignore_rewritten_fields() {
|
|
let direct = graph_diagnostics(vec![age_table()], &[("in", "dt"), ("dt", "out")]);
|
|
assert_eq!(
|
|
with_code(&direct, DiagnosticCode::CellCoversDomain).len(),
|
|
1,
|
|
"{direct:?}"
|
|
);
|
|
|
|
let rewritten = graph_diagnostics(
|
|
vec![
|
|
expression_node("calc", "applicant.age", "applicant.age + 1000"),
|
|
age_table(),
|
|
],
|
|
&[("in", "calc"), ("calc", "dt"), ("dt", "out")],
|
|
);
|
|
assert!(
|
|
with_code(&rewritten, DiagnosticCode::CellCoversDomain).is_empty(),
|
|
"{rewritten:?}"
|
|
);
|
|
}
|
|
|
|
fn diamonds(count: usize, rewrite: bool) -> Vec<Diagnostic> {
|
|
let mut nodes = Vec::new();
|
|
let mut edges: Vec<(String, String)> = Vec::new();
|
|
let mut previous = "in".to_string();
|
|
for i in 0..count {
|
|
let (a, b, join) = (format!("a{i}"), format!("b{i}"), format!("j{i}"));
|
|
let key = if rewrite && i == 0 {
|
|
"applicant.age"
|
|
} else {
|
|
"applicant.seen"
|
|
};
|
|
nodes.push(expression_node(&a, key, "applicant.age + 1"));
|
|
nodes.push(expression_node(&b, "applicant.seen", "applicant.age"));
|
|
nodes.push(expression_node(&join, "applicant.seen", "applicant.age"));
|
|
edges.push((previous.clone(), a.clone()));
|
|
edges.push((previous.clone(), b.clone()));
|
|
edges.push((a, join.clone()));
|
|
edges.push((b, join.clone()));
|
|
previous = join;
|
|
}
|
|
nodes.push(age_table());
|
|
edges.push((previous, "dt".to_string()));
|
|
edges.push(("dt".to_string(), "out".to_string()));
|
|
let edges: Vec<(&str, &str)> = edges
|
|
.iter()
|
|
.map(|(a, b)| (a.as_str(), b.as_str()))
|
|
.collect();
|
|
graph_diagnostics(nodes, &edges)
|
|
}
|
|
|
|
fn summary(diagnostics: &[Diagnostic]) -> Vec<String> {
|
|
let mut out: Vec<String> = diagnostics
|
|
.iter()
|
|
.map(|d| format!("{:?} {:?} {}", d.code, d.severity, d.message))
|
|
.collect();
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn chained_diamonds_stay_linear() {
|
|
let started = Instant::now();
|
|
let found = diamonds(30, false);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(1),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
let covers = vec![
|
|
"CellCoversDomain Hint this condition accepts every possible Age value, so the cell can be empty"
|
|
.to_string(),
|
|
];
|
|
assert_eq!(summary(&found), covers);
|
|
assert_eq!(summary(&diamonds(2, false)), covers);
|
|
assert_eq!(
|
|
summary(&diamonds(2, true)),
|
|
vec!["MissingCases Hint no row matches 1 input case: Age > 120".to_string()]
|
|
);
|
|
}
|
|
|
|
fn policy_content(blocks: Vec<Value>) -> DecisionContent {
|
|
let mut all = vec![applicant_model()];
|
|
all.extend(blocks);
|
|
let policy: zen_engine::policy::PolicyDocument =
|
|
serde_json::from_value(json!({ "blocks": all })).expect("policy");
|
|
DecisionContent::Policy(PolicyContent(Arc::new(policy)))
|
|
}
|
|
|
|
fn gapped_table() -> Value {
|
|
json!({ "id": "dt", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i0", "name": "Age", "field": "applicant.age" } ],
|
|
"outputs": [ { "id": "o0", "name": "Rate", "field": "applicant.rate" } ],
|
|
"rules": [
|
|
{ "_id": "r1", "i0": "< 18", "o0": "1" },
|
|
{ "_id": "r2", "i0": "< 10", "o0": "2" }
|
|
]
|
|
} } })
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_skips_table_checks_but_keeps_errors() {
|
|
let table_codes = |diagnostics: &[Diagnostic]| {
|
|
diagnostics
|
|
.iter()
|
|
.filter(|d| {
|
|
matches!(
|
|
d.code,
|
|
DiagnosticCode::MissingCases | DiagnosticCode::UnreachableRule
|
|
)
|
|
})
|
|
.count()
|
|
};
|
|
assert_eq!(table_codes(&policy_diagnostics(vec![gapped_table()])), 2);
|
|
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("ok", policy_content(vec![gapped_table()]));
|
|
loader.add(
|
|
"broken",
|
|
policy_content(vec![
|
|
gapped_table(),
|
|
json!({ "id": "calc", "type": "expression", "props": { "data": { "key": "applicant.total", "value": "applicant.missing > 50" } } }),
|
|
]),
|
|
);
|
|
let engine = DecisionEngine::default().with_loader(loader);
|
|
|
|
let result = engine
|
|
.evaluate("ok", json!({ "applicant": { "age": 5 } }).into())
|
|
.await
|
|
.expect("evaluate");
|
|
let output: Value = result.result.into();
|
|
assert_eq!(output.pointer("/applicant/rate"), Some(&json!(1)));
|
|
|
|
let result = engine
|
|
.evaluate("broken", json!({ "applicant": { "age": 5 } }).into())
|
|
.await;
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"{result:?}"
|
|
);
|
|
}
|
|
|
|
fn raw_graph(
|
|
schema: Value,
|
|
nodes: Vec<Value>,
|
|
edges: &[(&str, &str, Option<&str>)],
|
|
) -> Vec<Diagnostic> {
|
|
let mut all = vec![
|
|
json!({ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": schema.to_string() } }),
|
|
];
|
|
all.extend(nodes);
|
|
all.push(json!({ "id": "out", "name": "out", "type": "outputNode", "content": {} }));
|
|
let edges: Vec<Value> = edges
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (a, b, handle))| json!({ "id": format!("e{i}"), "sourceId": a, "targetId": b, "sourceHandle": handle }))
|
|
.collect();
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
serde_json::from_value(json!({ "nodes": all, "edges": edges })).expect("graph"),
|
|
);
|
|
ws.diagnostics("g")
|
|
}
|
|
|
|
fn first_hit_switch() -> Value {
|
|
json!({ "id": "sw", "name": "sw", "type": "switchNode", "content": {
|
|
"hitPolicy": "first",
|
|
"statements": [ { "id": "arm1", "condition": "x > 0" }, { "id": "arm2", "condition": "" } ]
|
|
} })
|
|
}
|
|
|
|
#[test]
|
|
fn keys_set_on_every_path_within_a_switch_arm_stay_required() {
|
|
let schema =
|
|
json!({ "type": "object", "properties": { "x": { "type": "number" } }, "required": ["x"] });
|
|
let nodes = || {
|
|
vec![
|
|
first_hit_switch(),
|
|
expression_node("a", "base", "x"),
|
|
expression_node("left", "l", "1"),
|
|
expression_node("right", "r", "2"),
|
|
expression_node("join", "sum", "l + r"),
|
|
]
|
|
};
|
|
let fan_out = raw_graph(
|
|
schema.clone(),
|
|
nodes(),
|
|
&[
|
|
("in", "sw", None),
|
|
("sw", "a", Some("arm1")),
|
|
("a", "left", None),
|
|
("a", "right", None),
|
|
("left", "join", None),
|
|
("right", "join", None),
|
|
("join", "out", None),
|
|
],
|
|
);
|
|
assert!(
|
|
with_code(&fan_out, DiagnosticCode::TypeMismatch).is_empty(),
|
|
"{fan_out:?}"
|
|
);
|
|
|
|
let same_arm = raw_graph(
|
|
schema,
|
|
vec![
|
|
first_hit_switch(),
|
|
expression_node("a", "l", "1"),
|
|
expression_node("join", "sum", "l + 1"),
|
|
],
|
|
&[
|
|
("in", "sw", None),
|
|
("sw", "a", Some("arm1")),
|
|
("sw", "join", Some("arm1")),
|
|
("a", "join", None),
|
|
("join", "out", None),
|
|
],
|
|
);
|
|
assert!(
|
|
with_code(&same_arm, DiagnosticCode::TypeMismatch).is_empty(),
|
|
"{same_arm:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn table_header_errors_get_no_default_fix() {
|
|
let schema = json!({ "type": "object", "properties": { "s": { "type": ["number", "null"] } } });
|
|
let table = json!({ "id": "dt", "name": "dt", "type": "decisionTableNode", "content": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "c", "name": "h", "field": "s + 1" } ],
|
|
"outputs": [ { "id": "o", "name": "r", "field": "r" } ],
|
|
"rules": [ { "_id": "r1", "c": "> 5", "o": "1" }, { "_id": "r2", "c": "", "o": "2" } ]
|
|
} });
|
|
let found = raw_graph(
|
|
schema,
|
|
vec![table],
|
|
&[("in", "dt", None), ("dt", "out", None)],
|
|
);
|
|
let mismatches = with_code(&found, DiagnosticCode::TypeMismatch);
|
|
assert_eq!(mismatches.len(), 1, "{found:?}");
|
|
assert!(arg(&mismatches[0], "fixSource").is_none(), "{mismatches:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn integer_gaps_are_reported_as_integers() {
|
|
let schema = json!({ "type": "object", "properties": { "n": { "type": "integer" } }, "required": ["n"] });
|
|
let table = json!({ "id": "dt", "name": "dt", "type": "decisionTableNode", "content": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "c", "name": "n", "field": "n" } ],
|
|
"outputs": [ { "id": "o", "name": "r", "field": "r" } ],
|
|
"rules": [ { "_id": "r1", "c": "< 2.5", "o": "1" }, { "_id": "r2", "c": "> 3.5", "o": "2" } ]
|
|
} });
|
|
let found = raw_graph(
|
|
schema,
|
|
vec![table],
|
|
&[("in", "dt", None), ("dt", "out", None)],
|
|
);
|
|
let missing = with_code(&found, DiagnosticCode::MissingCases);
|
|
assert_eq!(missing.len(), 1, "{found:?}");
|
|
assert!(
|
|
missing[0].message.ends_with("n 3"),
|
|
"{}",
|
|
missing[0].message
|
|
);
|
|
}
|