mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 16:02:18 +00:00
Verify tables in policies and graphs (unreachable, duplicate, missing cases, compress), budgeted live checks with an on-demand full check, and quick-fix data for redundant parentheses and ?? fallbacks.
151 lines
5.5 KiB
Rust
151 lines
5.5 KiB
Rust
use serde_json::{json, Value};
|
|
use zen_engine::model::DecisionContent;
|
|
use zen_engine::policy::{Diagnostic, DiagnosticCode, PolicyWorkspace, Workspace};
|
|
|
|
fn mismatches(diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
|
|
diagnostics
|
|
.into_iter()
|
|
.filter(|d| d.code == DiagnosticCode::TypeMismatch)
|
|
.collect()
|
|
}
|
|
|
|
fn arg(diagnostic: &Diagnostic, key: &str) -> Option<String> {
|
|
diagnostic.args.get(key).cloned()
|
|
}
|
|
|
|
fn policy(expression: &str, table: bool) -> Value {
|
|
let mut blocks = vec![
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "applicant",
|
|
"properties": [
|
|
{ "id": "p1", "name": "target", "type": "number", "array": false, "optional": true },
|
|
{ "id": "p2", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } }),
|
|
];
|
|
if table {
|
|
blocks.push(
|
|
json!({ "id": "dt", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i0", "name": "", "field": "applicant.age" } ],
|
|
"outputs": [ { "id": "o0", "name": "", "field": "applicant.rate" } ],
|
|
"rules": [ { "_id": "r1", "i0": ">= 18", "o0": "0.1" } ]
|
|
} } }),
|
|
);
|
|
}
|
|
blocks.push(
|
|
json!({ "id": "calc", "type": "expression", "props": { "data": {
|
|
"key": "applicant.result", "value": expression
|
|
} } }),
|
|
);
|
|
json!({ "blocks": blocks })
|
|
}
|
|
|
|
fn policy_mismatches(expression: &str, table: bool) -> Vec<Diagnostic> {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(policy(expression, table)).expect("policy"),
|
|
);
|
|
mismatches(ws.diagnostics("p"))
|
|
}
|
|
|
|
#[test]
|
|
fn optional_input_offers_default_and_points_at_the_declaration() {
|
|
let found = policy_mismatches("applicant.target > 0 ? 1 : 0", false);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
let d = &found[0];
|
|
assert_eq!(arg(d, "nullablePath").as_deref(), Some("applicant.target"));
|
|
assert_eq!(arg(d, "sourceId").as_deref(), Some("dm"));
|
|
let fixed = arg(d, "fixSource").expect("fix");
|
|
assert_eq!(fixed, "(applicant.target ?? 0) > 0 ? 1 : 0");
|
|
assert!(policy_mismatches(&fixed, false).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn uncovered_table_output_points_at_the_table() {
|
|
let found = policy_mismatches("applicant.rate * 2", true);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
let d = &found[0];
|
|
assert_eq!(arg(d, "nullablePath").as_deref(), Some("applicant.rate"));
|
|
assert_eq!(arg(d, "sourceId").as_deref(), Some("dt"));
|
|
let fixed = arg(d, "fixSource").expect("fix");
|
|
assert_eq!(fixed, "(applicant.rate ?? 0) * 2");
|
|
assert!(policy_mismatches(&fixed, true).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn nullable_divisor_gets_no_default() {
|
|
let found = policy_mismatches("10 / applicant.target", false);
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(arg(&found[0], "fixSource"), None);
|
|
assert_eq!(arg(&found[0], "sourceId").as_deref(), Some("dm"));
|
|
|
|
let found = policy_mismatches("applicant.target / 10", false);
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource").as_deref(),
|
|
Some("(applicant.target ?? 0) / 10")
|
|
);
|
|
}
|
|
|
|
fn graph(middle: Vec<Value>, edges: Vec<(&str, &str)>) -> DecisionContent {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": { "target": { "type": "number" }, "age": { "type": "number" } },
|
|
"required": ["age"]
|
|
});
|
|
let mut nodes = vec![
|
|
json!({ "id": "in", "name": "in", "type": "inputNode", "content": { "schema": schema.to_string() } }),
|
|
];
|
|
nodes.extend(middle);
|
|
nodes.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();
|
|
serde_json::from_value(json!({ "nodes": nodes, "edges": edges })).expect("graph")
|
|
}
|
|
|
|
fn expression(id: &str, value: &str) -> Value {
|
|
json!({ "id": id, "name": id, "type": "expressionNode", "content": {
|
|
"expressions": [ { "id": format!("{id}-x"), "key": "result", "value": value } ],
|
|
"passThrough": true
|
|
} })
|
|
}
|
|
|
|
fn graph_mismatches(content: DecisionContent) -> Vec<Diagnostic> {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", content);
|
|
mismatches(ws.diagnostics("g"))
|
|
}
|
|
|
|
#[test]
|
|
fn graph_input_and_upstream_table_sources() {
|
|
let found = graph_mismatches(graph(
|
|
vec![expression("calc", "target + 1")],
|
|
vec![("in", "calc"), ("calc", "out")],
|
|
));
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(arg(&found[0], "sourceId").as_deref(), Some("in"));
|
|
assert_eq!(
|
|
arg(&found[0], "fixSource").as_deref(),
|
|
Some("(target ?? 0) + 1")
|
|
);
|
|
|
|
let table = json!({ "id": "dt", "name": "dt", "type": "decisionTableNode", "content": {
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i0", "name": "Age", "field": "age" } ],
|
|
"outputs": [ { "id": "o0", "name": "Rate", "field": "rate" } ],
|
|
"rules": [ { "_id": "r1", "i0": ">= 18", "o0": "0.1" } ],
|
|
"passThrough": true
|
|
} });
|
|
let found = graph_mismatches(graph(
|
|
vec![table, expression("calc", "rate * 2")],
|
|
vec![("in", "dt"), ("dt", "calc"), ("calc", "out")],
|
|
));
|
|
assert_eq!(found.len(), 1, "{found:?}");
|
|
assert_eq!(arg(&found[0], "nullablePath").as_deref(), Some("rate"));
|
|
assert_eq!(arg(&found[0], "sourceId").as_deref(), Some("dt"));
|
|
}
|