mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 08:02:28 +00:00
- graph input and output validation treats null optional properties as absent (including inside anyOf/oneOf objects) and reports the real error when validation still fails - remove the NULLABILITY_DIVERGENCE diagnostic - policies enforce required inputs also without goals; a path is missing when absent or null unless a step along it is optional, checked after reference hydration, and computed entity fields are not inputs
3722 lines
114 KiB
Rust
3722 lines
114 KiB
Rust
use std::sync::Arc;
|
|
|
|
use serde_json::{json, Value};
|
|
use zen_engine::model::DecisionContent;
|
|
use zen_engine::policy::{
|
|
Cursor, CursorTarget, DiagnosticCode, EvaluateRequest, EvaluationError, ScopeRequest, Severity,
|
|
Workspace,
|
|
};
|
|
use zen_expression::variable::VariableType;
|
|
|
|
fn document(value: Value) -> DecisionContent {
|
|
serde_json::from_value(value).expect("valid decision content")
|
|
}
|
|
|
|
fn person_schema() -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"age": { "type": "number" },
|
|
"name": { "type": "string" }
|
|
},
|
|
"required": ["age", "name"]
|
|
})
|
|
}
|
|
|
|
fn node(id: &str, kind: &str, content: Value) -> Value {
|
|
json!({ "id": id, "name": id, "type": kind, "content": content })
|
|
}
|
|
|
|
fn edge(id: &str, source: &str, target: &str) -> Value {
|
|
json!({ "id": id, "sourceId": source, "targetId": target, "sourceHandle": null })
|
|
}
|
|
|
|
fn expression_node(id: &str, rows: &[(&str, &str)]) -> Value {
|
|
let expressions: Vec<Value> = rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (key, value))| json!({ "id": format!("{id}-e{i}"), "key": key, "value": value }))
|
|
.collect();
|
|
node(id, "expressionNode", json!({ "expressions": expressions }))
|
|
}
|
|
|
|
fn linear_graph(input_schema: Option<Value>, middle: Vec<Value>) -> Value {
|
|
let mut nodes = vec![node(
|
|
"in",
|
|
"inputNode",
|
|
match &input_schema {
|
|
Some(schema) => json!({ "schema": schema.to_string() }),
|
|
None => json!({}),
|
|
},
|
|
)];
|
|
let mut edges = Vec::new();
|
|
let mut prev = "in".to_string();
|
|
for n in middle {
|
|
let id = n["id"].as_str().unwrap().to_string();
|
|
edges.push(edge(&format!("edge-{prev}-{id}"), &prev, &id));
|
|
nodes.push(n);
|
|
prev = id;
|
|
}
|
|
nodes.push(node("out", "outputNode", json!({})));
|
|
edges.push(edge(&format!("edge-{prev}-out"), &prev, "out"));
|
|
json!({ "nodes": nodes, "edges": edges })
|
|
}
|
|
|
|
fn error_codes(ws: &Workspace, path: &str) -> Vec<DiagnosticCode> {
|
|
ws.diagnostics(path)
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.code)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn valid_graph_has_no_diagnostics() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
assert!(ws.is_graph("g"));
|
|
assert!(!ws.is_graph("missing"));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn untyped_schema_positions_are_reported() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"legs": { "type": "array" },
|
|
"meta": {},
|
|
"age": { "type": "number" }
|
|
},
|
|
"required": ["legs", "age"]
|
|
});
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(schema),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let implicit: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| matches!(d.code, DiagnosticCode::ImplicitAny))
|
|
.map(|d| d.message.as_ref())
|
|
.collect();
|
|
assert_eq!(implicit.len(), 2, "{diagnostics:?}");
|
|
assert!(
|
|
implicit.iter().any(|m| m.contains("`legs[]`")),
|
|
"{implicit:?}"
|
|
);
|
|
assert!(
|
|
implicit.iter().any(|m| m.contains("`meta`")),
|
|
"{implicit:?}"
|
|
);
|
|
|
|
ws.set_document(
|
|
"typed",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
assert!(
|
|
ws.diagnostics("typed")
|
|
.iter()
|
|
.all(|d| !matches!(d.code, DiagnosticCode::ImplicitAny)),
|
|
"typed schema must not report ImplicitAny"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_property_read_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "salary * 2")])],
|
|
)),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(!codes.is_empty(), "expected an error for unknown property");
|
|
let diagnostics = ws.diagnostics("g");
|
|
let diag = diagnostics
|
|
.iter()
|
|
.find(|d| d.severity == Severity::Error)
|
|
.unwrap();
|
|
assert_eq!(diag.location.block_id.as_deref(), Some("calc"));
|
|
assert_eq!(diag.location.expression_id.as_deref(), Some("calc-e0"));
|
|
}
|
|
|
|
#[test]
|
|
fn expression_rows_see_previous_rows_via_dollar() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "age * 2"), ("grand", "$.total + 1")],
|
|
)],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn downstream_node_sees_only_upstream_output() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
expression_node("first", &[("total", "age * 2")]),
|
|
expression_node("second", &[("doubled", "total * 2")]),
|
|
expression_node("third", &[("bad", "age * 2")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert_eq!(errors[0].location.block_id.as_deref(), Some("third"));
|
|
}
|
|
|
|
#[test]
|
|
fn pass_through_preserves_input_downstream() {
|
|
let mut ws = Workspace::new();
|
|
let mut first = expression_node("first", &[("total", "age * 2")]);
|
|
first["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
first,
|
|
expression_node("second", &[("bonus", "age + total")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn function_node_taints_downstream() {
|
|
let mut ws = Workspace::new();
|
|
let function = node(
|
|
"fn",
|
|
"functionNode",
|
|
json!({ "source": "export const handler = (input) => input;" }),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
function,
|
|
expression_node("after", &[("x", "whatever.deeply.unknown + 1")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
!diagnostics.iter().any(|d| d.severity == Severity::Error),
|
|
"{diagnostics:?}"
|
|
);
|
|
assert!(
|
|
diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::UnresolvedFunctionType),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
let unchecked = ws.unchecked_nodes("g");
|
|
let unchecked: Vec<&str> = unchecked.iter().map(|s| s.as_ref()).collect();
|
|
assert!(unchecked.contains(&"fn"));
|
|
assert!(unchecked.contains(&"after"));
|
|
assert!(unchecked.contains(&"out"));
|
|
assert!(!unchecked.contains(&"in"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_still_reported_downstream_of_function() {
|
|
let mut ws = Workspace::new();
|
|
let function = node("fn", "functionNode", json!({ "source": "" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![function, expression_node("after", &[("x", "1 +")])],
|
|
)),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::ParseError),
|
|
"expected parse error, got {codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn switch_condition_must_be_boolean() {
|
|
let mut ws = Workspace::new();
|
|
let switch = node(
|
|
"sw",
|
|
"switchNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"statements": [
|
|
{ "id": "s1", "condition": "age" },
|
|
{ "id": "s2", "condition": "age > 10" },
|
|
{ "id": "s3", "condition": "" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![switch])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert_eq!(errors[0].code, DiagnosticCode::TypeMismatch);
|
|
assert_eq!(errors[0].location.expression_id.as_deref(), Some("s1"));
|
|
}
|
|
|
|
#[test]
|
|
fn decision_table_cells_are_checked() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "c1", "name": "Age", "field": "age" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "Result", "field": "result" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" },
|
|
{ "_id": "r2", "c1": "", "o1": "\"minor\"" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().all(|d| d.severity == Severity::Hint),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let result = outputs.iter().find(|o| o.path.as_ref() == "result");
|
|
assert!(result.is_some(), "{outputs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn decision_table_incompatible_output_cells_reported() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "c1", "name": "Age", "field": "age" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "Result", "field": "result" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" },
|
|
{ "_id": "r2", "c1": "<= 18", "o1": "{value: 1}" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::TypeMismatch),
|
|
"expected type mismatch, got {codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_table_column_collect_types_as_array() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "c1", "name": "Age", "field": "age" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "Tier", "field": "tier" },
|
|
{ "id": "o2", "name": "Tags", "field": "tags[]" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"gold\"", "o2": "\"adult\"" },
|
|
{ "_id": "r2", "c1": "", "o1": "\"basic\"", "o2": "" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().all(|d| d.severity == Severity::Hint),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let tags = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "tags")
|
|
.expect("tags output");
|
|
assert!(
|
|
matches!(&tags.resolved_type, VariableType::Array(_)),
|
|
"{:?}",
|
|
tags.resolved_type
|
|
);
|
|
let tier = outputs.iter().find(|o| o.path.as_ref() == "tier");
|
|
assert!(tier.is_some(), "{outputs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn decision_table_invalid_collect_marker_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "c1", "name": "Age", "field": "age" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "Bare", "field": "[]" },
|
|
{ "id": "o2", "name": "Interior", "field": "tags[].value" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "", "o1": "1", "o2": "2" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert_eq!(
|
|
codes
|
|
.iter()
|
|
.filter(|c| **c == DiagnosticCode::InvalidWritePath)
|
|
.count(),
|
|
2,
|
|
"expected invalid write paths, got {codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transform_loop_iterates_elements() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"customers": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "age": { "type": "number" } },
|
|
"required": ["age"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["customers"]
|
|
});
|
|
let mut looped = expression_node("loop", &[("doubled", "age * 2")]);
|
|
looped["content"]["inputField"] = json!("customers");
|
|
looped["content"]["executionMode"] = json!("loop");
|
|
looped["content"]["outputPath"] = json!("results");
|
|
ws.set_document("g", document(linear_graph(Some(schema), vec![looped])));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let results = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "results")
|
|
.expect("results output");
|
|
assert!(
|
|
matches!(results.resolved_type, VariableType::Array(_)),
|
|
"{:?}",
|
|
results.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transform_loop_over_non_array_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let mut looped = expression_node("loop", &[("doubled", "age * 2")]);
|
|
looped["content"]["inputField"] = json!("name");
|
|
looped["content"]["executionMode"] = json!("loop");
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![looped])),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::TypeMismatch),
|
|
"expected loop type mismatch, got {codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_node_uses_referenced_policy_outputs() {
|
|
let mut ws = Workspace::new();
|
|
let policy = json!({
|
|
"blocks": [
|
|
{
|
|
"id": "b1",
|
|
"type": "expression",
|
|
"props": { "data": { "key": "score", "value": "10" } }
|
|
}
|
|
]
|
|
});
|
|
ws.set_document("scoring", document(policy));
|
|
|
|
let decision = node("call", "decisionNode", json!({ "key": "scoring" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
decision,
|
|
expression_node("after", &[("final", "score + 1")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_decision_reference_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let decision = node("call", "decisionNode", json!({ "key": "nowhere" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![decision])),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::ImportNotFound),
|
|
"expected import not found, got {codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_node_between_graphs() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
decision,
|
|
expression_node("after", &[("ok", "verdict and true")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("parent");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn cyclic_graph_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let content = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({})),
|
|
expression_node("a", &[("x", "1")]),
|
|
expression_node("b", &[("y", "2")]),
|
|
],
|
|
"edges": [
|
|
edge("e1", "in", "a"),
|
|
edge("e2", "a", "b"),
|
|
edge("e3", "b", "a"),
|
|
]
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::CyclicDependency),
|
|
"{codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edge_to_missing_node_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let content = json!({
|
|
"nodes": [node("in", "inputNode", json!({}))],
|
|
"edges": [edge("e1", "in", "ghost")]
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::InvalidGraphStructure),
|
|
"{codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_input_node_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
let content = json!({
|
|
"nodes": [expression_node("a", &[("x", "1")])],
|
|
"edges": []
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::InvalidGraphStructure),
|
|
"{codes:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inputs_derive_from_schema() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("g"));
|
|
let paths: Vec<&str> = inputs.iter().map(|i| i.path.as_ref()).collect();
|
|
assert_eq!(paths, vec!["age", "name"]);
|
|
assert!(matches!(inputs[0].resolved_type, VariableType::Number));
|
|
}
|
|
|
|
#[test]
|
|
fn inputs_inferred_from_reads_without_schema() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
None,
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "customer.age * factor")],
|
|
)],
|
|
)),
|
|
);
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("g"));
|
|
let paths: Vec<&str> = inputs.iter().map(|i| i.path.as_ref()).collect();
|
|
assert_eq!(paths, vec!["customer.age", "factor"]);
|
|
}
|
|
|
|
#[test]
|
|
fn outputs_flow_through_output_node() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "age * 2"), ("label", "name + '!'")],
|
|
)],
|
|
)),
|
|
);
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let mut paths: Vec<&str> = outputs.iter().map(|o| o.path.as_ref()).collect();
|
|
paths.sort();
|
|
assert_eq!(paths, vec!["label", "total"]);
|
|
let total = outputs.iter().find(|o| o.path.as_ref() == "total").unwrap();
|
|
assert!(matches!(total.resolved_type, VariableType::Number));
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_and_completions_in_graph_expression() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "calc".into(),
|
|
pos: 2,
|
|
target: CursorTarget::Expression {
|
|
id: "calc-e0".into(),
|
|
},
|
|
};
|
|
let inspected = ws.inspect(&cursor).expect("inspect result");
|
|
assert!(matches!(inspected.kind, VariableType::Number));
|
|
|
|
let completions = ws.completions(&cursor);
|
|
assert!(completions.iter().any(|c| c.label == "age"));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_evaluation_is_rejected_by_workspace() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", document(linear_graph(Some(person_schema()), vec![])));
|
|
let result = ws.evaluate(&EvaluateRequest {
|
|
policy_path: "g".into(),
|
|
input: json!({}).into(),
|
|
goals: vec![],
|
|
trace: false,
|
|
});
|
|
assert!(matches!(result, Err(EvaluationError::GraphNotEvaluable(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_workspace_diagnostics_cover_both_kinds() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "salary * 2")])],
|
|
)),
|
|
);
|
|
let policy = json!({
|
|
"blocks": [
|
|
{
|
|
"id": "b1",
|
|
"type": "expression",
|
|
"props": { "data": { "key": "score", "value": "unknownRoot + 1" } }
|
|
}
|
|
]
|
|
});
|
|
ws.set_document("p", document(policy));
|
|
|
|
let all = ws.all_diagnostics();
|
|
let doc_paths: Vec<&str> = all
|
|
.iter()
|
|
.map(|d| d.location.policy_path.as_ref())
|
|
.collect();
|
|
assert!(doc_paths.contains(&"g"), "{all:?}");
|
|
assert!(doc_paths.contains(&"p"), "{all:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn output_schema_mismatch_reported() {
|
|
let mut ws = Workspace::new();
|
|
let out_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"total": { "type": "string" },
|
|
"verdict": { "type": "boolean" }
|
|
},
|
|
"required": ["total", "verdict"]
|
|
});
|
|
let content = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": person_schema().to_string() })),
|
|
expression_node("calc", &[("total", "age * 2")]),
|
|
node("out", "outputNode", json!({ "schema": out_schema.to_string() })),
|
|
],
|
|
"edges": [edge("e1", "in", "calc"), edge("e2", "calc", "out")]
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 2, "{errors:?}");
|
|
assert!(errors.iter().any(|m| m.contains("'total'")), "{errors:?}");
|
|
assert!(errors.iter().any(|m| m.contains("'verdict'")), "{errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn output_schema_satisfied_is_quiet() {
|
|
let mut ws = Workspace::new();
|
|
let out_schema = json!({
|
|
"type": "object",
|
|
"properties": { "total": { "type": "number" } },
|
|
"required": ["total"]
|
|
});
|
|
let content = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": person_schema().to_string() })),
|
|
expression_node("calc", &[("total", "age * 2")]),
|
|
node("out", "outputNode", json!({ "schema": out_schema.to_string() })),
|
|
],
|
|
"edges": [edge("e1", "in", "calc"), edge("e2", "calc", "out")]
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
fn simple_table(rules: Value) -> Value {
|
|
node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [{ "id": "c1", "name": "Age", "field": "age" }],
|
|
"outputs": [{ "id": "o1", "name": "Result", "field": "result" }],
|
|
"rules": rules
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn uncovered_first_hit_table_is_nullable() {
|
|
let mut ws = Workspace::new();
|
|
let table = simple_table(json!([
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" }
|
|
]));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let analysis = ws.graph_analysis("g").expect("analysis");
|
|
assert!(
|
|
matches!(analysis.signature.output, VariableType::Nullable(_)),
|
|
"{:?}",
|
|
analysis.signature.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn covered_first_hit_table_is_not_nullable() {
|
|
let mut ws = Workspace::new();
|
|
let table = simple_table(json!([
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" },
|
|
{ "_id": "r2", "c1": "<= 18", "o1": "\"minor\"" }
|
|
]));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let analysis = ws.graph_analysis("g").expect("analysis");
|
|
assert!(
|
|
matches!(analysis.signature.output, VariableType::Object(_)),
|
|
"{:?}",
|
|
analysis.signature.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn catch_all_row_covers_table_and_empty_cell_makes_column_nullable() {
|
|
let mut ws = Workspace::new();
|
|
let table = simple_table(json!([
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" },
|
|
{ "_id": "r2", "c1": "", "o1": "" }
|
|
]));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let analysis = ws.graph_analysis("g").expect("analysis");
|
|
assert!(
|
|
matches!(analysis.signature.output, VariableType::Object(_)),
|
|
"{:?}",
|
|
analysis.signature.output
|
|
);
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let result = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "result")
|
|
.unwrap();
|
|
assert!(
|
|
matches!(result.resolved_type, VariableType::Nullable(_)),
|
|
"{:?}",
|
|
result.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unreachable_node_is_hinted() {
|
|
let mut ws = Workspace::new();
|
|
let content = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({})),
|
|
expression_node("connected", &[("x", "1")]),
|
|
expression_node("orphan", &[("y", "2")]),
|
|
],
|
|
"edges": [edge("e1", "in", "connected")]
|
|
});
|
|
ws.set_document("g", document(content));
|
|
let hints: Vec<_> = ws
|
|
.diagnostics("g")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Hint)
|
|
.collect();
|
|
assert_eq!(hints.len(), 1, "{hints:?}");
|
|
assert_eq!(hints[0].code, DiagnosticCode::UnreachableNode);
|
|
assert_eq!(hints[0].location.block_id.as_deref(), Some("orphan"));
|
|
}
|
|
|
|
#[test]
|
|
fn redundant_parentheses_are_hinted() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "(age) * 2")])],
|
|
)),
|
|
);
|
|
let hints: Vec<_> = ws
|
|
.diagnostics("g")
|
|
.into_iter()
|
|
.filter(|d| d.code == DiagnosticCode::RedundantParentheses)
|
|
.collect();
|
|
assert_eq!(hints.len(), 1, "{hints:?}");
|
|
assert_eq!(hints[0].location.block_id.as_deref(), Some("calc"));
|
|
assert_eq!(hints[0].location.expression_id.as_deref(), Some("calc-e0"));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_property_rename_rewrites_writers_and_readers() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
expression_node("first", &[("total", "age * 2"), ("doubled", "$.total * 2")]),
|
|
expression_node("second", &[("grand", "total + 1")]),
|
|
],
|
|
)),
|
|
);
|
|
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "second".into(),
|
|
pos: 2,
|
|
target: CursorTarget::Expression {
|
|
id: "second-e0".into(),
|
|
},
|
|
};
|
|
let prepared = ws.prepare_rename(&cursor).expect("prepare rename");
|
|
let RenameTarget::GraphProperty { document, path } = &prepared.target else {
|
|
panic!("unexpected target {:?}", prepared.target);
|
|
};
|
|
assert_eq!(document.as_ref(), "g");
|
|
assert_eq!(path.as_ref(), "total");
|
|
assert_eq!(prepared.span, (0, 5));
|
|
|
|
let sites = ws.references(&prepared.target);
|
|
let kinds: Vec<String> = sites
|
|
.iter()
|
|
.map(|s| format!("{}:{:?}", s.block_id, s.kind))
|
|
.collect();
|
|
assert_eq!(sites.len(), 3, "{kinds:?}");
|
|
|
|
let edits = ws.rename(&prepared.target, "sum");
|
|
assert_eq!(edits.len(), 2, "{edits:?}");
|
|
let rendered: Vec<String> = edits
|
|
.iter()
|
|
.map(|e| serde_json::to_string(e).unwrap())
|
|
.collect();
|
|
let first_edit = rendered.iter().find(|r| r.contains("\"first\"")).unwrap();
|
|
assert!(first_edit.contains("\"key\":\"sum\""), "{first_edit}");
|
|
assert!(first_edit.contains("$.sum * 2"), "{first_edit}");
|
|
let second_edit = rendered.iter().find(|r| r.contains("\"second\"")).unwrap();
|
|
assert!(second_edit.contains("sum + 1"), "{second_edit}");
|
|
for edit in &edits {
|
|
assert!(matches!(edit, EngineEdit::ReplaceNode { .. }));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn input_property_rename_rewrites_readers_and_schema() {
|
|
use zen_engine::policy::{EngineEdit, ReferenceKind, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "age * 2"), ("adult", "age >= 18")],
|
|
)],
|
|
)),
|
|
);
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "calc".into(),
|
|
pos: 2,
|
|
target: CursorTarget::Expression {
|
|
id: "calc-e0".into(),
|
|
},
|
|
};
|
|
let prepared = ws.prepare_rename(&cursor).expect("prepare rename");
|
|
let RenameTarget::GraphProperty { document, path } = &prepared.target else {
|
|
panic!("unexpected target {:?}", prepared.target);
|
|
};
|
|
assert_eq!(document.as_ref(), "g");
|
|
assert_eq!(path.as_ref(), "age");
|
|
assert_eq!(prepared.span, (0, 3));
|
|
|
|
let sites = ws.references(&prepared.target);
|
|
let declared = sites
|
|
.iter()
|
|
.filter(|s| s.kind == ReferenceKind::DataModel)
|
|
.count();
|
|
let reads = sites
|
|
.iter()
|
|
.filter(|s| s.kind == ReferenceKind::ExpressionRead)
|
|
.count();
|
|
assert_eq!((declared, reads), (1, 2), "{sites:?}");
|
|
|
|
let edits = ws.rename(&prepared.target, "years");
|
|
let rendered: Vec<String> = edits
|
|
.iter()
|
|
.map(|e| serde_json::to_string(e).unwrap())
|
|
.collect();
|
|
let calc = rendered
|
|
.iter()
|
|
.find(|r| r.contains("\"calc\""))
|
|
.expect("calc edit");
|
|
assert!(
|
|
calc.contains("years * 2") && calc.contains("years >= 18"),
|
|
"{calc}"
|
|
);
|
|
let input = rendered
|
|
.iter()
|
|
.find(|r| r.contains("\"in\""))
|
|
.expect("schema edit");
|
|
assert!(
|
|
input.contains("years") && !input.contains("\\\"age\\\""),
|
|
"{input}"
|
|
);
|
|
for edit in &edits {
|
|
assert!(matches!(edit, EngineEdit::ReplaceNode { .. }));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn undeclared_input_rename_rewrites_every_reader() {
|
|
use zen_engine::policy::RenameTarget;
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
None,
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "age * 2"), ("adult", "age >= 18")],
|
|
)],
|
|
)),
|
|
);
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "calc".into(),
|
|
pos: 1,
|
|
target: CursorTarget::Expression {
|
|
id: "calc-e1".into(),
|
|
},
|
|
};
|
|
let prepared = ws.prepare_rename(&cursor).expect("prepare rename");
|
|
assert!(
|
|
matches!(&prepared.target, RenameTarget::GraphProperty { path, .. } if path.as_ref() == "age")
|
|
);
|
|
assert_eq!(ws.references(&prepared.target).len(), 2);
|
|
let edits = ws.rename(&prepared.target, "years");
|
|
let rendered = serde_json::to_string(&edits).unwrap();
|
|
assert!(
|
|
rendered.contains("years * 2") && rendered.contains("years >= 18"),
|
|
"{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_property_rename_through_output_path_and_input_field() {
|
|
use zen_engine::policy::RenameTarget;
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut producer = expression_node("producer", &[("total", "age * 2")]);
|
|
producer["content"]["outputPath"] = json!("results");
|
|
let mut consumer = expression_node("consumer", &[("grand", "total + 1")]);
|
|
consumer["content"]["inputField"] = json!("results");
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![producer, consumer],
|
|
)),
|
|
);
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: "g".into(),
|
|
path: "results.total".into(),
|
|
};
|
|
let sites = ws.references(&target);
|
|
assert_eq!(sites.len(), 2, "{sites:?}");
|
|
|
|
let edits = ws.rename(&target, "sum");
|
|
let rendered: Vec<String> = edits
|
|
.iter()
|
|
.map(|e| serde_json::to_string(e).unwrap())
|
|
.collect();
|
|
let producer_edit = rendered
|
|
.iter()
|
|
.find(|r| r.contains("\"producer\""))
|
|
.unwrap();
|
|
assert!(producer_edit.contains("\"key\":\"sum\""), "{producer_edit}");
|
|
let consumer_edit = rendered
|
|
.iter()
|
|
.find(|r| r.contains("\"consumer\""))
|
|
.unwrap();
|
|
assert!(consumer_edit.contains("sum + 1"), "{consumer_edit}");
|
|
}
|
|
|
|
#[test]
|
|
fn nodes_scope_is_typed_from_ancestors() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
expression_node("first", &[("total", "age * 2")]),
|
|
expression_node("second", &[("viaNodes", "$nodes.first.total")]),
|
|
],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let via = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "viaNodes")
|
|
.expect("viaNodes output");
|
|
assert!(
|
|
matches!(via.resolved_type, VariableType::Number),
|
|
"{:?}",
|
|
via.resolved_type
|
|
);
|
|
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "second".into(),
|
|
pos: 8,
|
|
target: CursorTarget::Expression {
|
|
id: "second-e0".into(),
|
|
},
|
|
};
|
|
let completions = ws.completions(&Cursor {
|
|
pos: 7,
|
|
..cursor.clone()
|
|
});
|
|
assert!(
|
|
completions.iter().any(|c| c.label == "first"),
|
|
"{completions:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_input_schema_warns_and_marks_unchecked() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
None,
|
|
vec![expression_node("calc", &[("total", "anything.at.all * 2")])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let warnings: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Warning)
|
|
.collect();
|
|
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
|
assert_eq!(warnings[0].code, DiagnosticCode::MissingInputSchema);
|
|
assert_eq!(warnings[0].location.block_id.as_deref(), Some("in"));
|
|
assert!(
|
|
!diagnostics.iter().any(|d| d.severity == Severity::Error),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
let unchecked = ws.unchecked_nodes("g");
|
|
let unchecked: Vec<&str> = unchecked.iter().map(|s| s.as_ref()).collect();
|
|
assert!(unchecked.contains(&"in"), "{unchecked:?}");
|
|
assert!(unchecked.contains(&"calc"), "{unchecked:?}");
|
|
assert!(unchecked.contains(&"out"), "{unchecked:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn decision_node_missing_required_input_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let parent_schema = json!({
|
|
"type": "object",
|
|
"properties": { "name": { "type": "string" } },
|
|
"required": ["name"]
|
|
});
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(Some(parent_schema), vec![decision])),
|
|
);
|
|
let diagnostics = ws.diagnostics("parent");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert!(errors[0].contains("requires input 'age'"), "{errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn decision_node_extra_input_is_allowed() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let parent_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"age": { "type": "number" },
|
|
"name": { "type": "string" },
|
|
"extra": { "type": "boolean" }
|
|
},
|
|
"required": ["age", "name", "extra"]
|
|
});
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(Some(parent_schema), vec![decision])),
|
|
);
|
|
let diagnostics = ws.diagnostics("parent");
|
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn decision_node_incompatible_input_type_is_reported() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let parent_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"age": { "type": "string" },
|
|
"name": { "type": "string" }
|
|
},
|
|
"required": ["age", "name"]
|
|
});
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(Some(parent_schema), vec![decision])),
|
|
);
|
|
let diagnostics = ws.diagnostics("parent");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert!(
|
|
errors[0].contains("input 'age'") && errors[0].contains("`string`"),
|
|
"{errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_analysis_is_cached_across_unrelated_edits() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 2")])],
|
|
)),
|
|
);
|
|
let first = ws.graph_analysis("g").expect("analysis");
|
|
|
|
ws.set_document(
|
|
"unrelated",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("x", &[("y", "age")])],
|
|
)),
|
|
);
|
|
let second = ws.graph_analysis("g").expect("analysis");
|
|
assert!(
|
|
Arc::ptr_eq(&first, &second),
|
|
"unrelated edit must not invalidate"
|
|
);
|
|
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("total", "age * 3")])],
|
|
)),
|
|
);
|
|
let third = ws.graph_analysis("g").expect("analysis");
|
|
assert!(!Arc::ptr_eq(&second, &third), "own edit must invalidate");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_analysis_invalidated_by_referenced_document_edit() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![decision, expression_node("after", &[("ok", "verdict")])],
|
|
)),
|
|
);
|
|
let first = ws.graph_analysis("parent").expect("analysis");
|
|
let unchanged = ws.graph_analysis("parent").expect("analysis");
|
|
assert!(Arc::ptr_eq(&first, &unchanged));
|
|
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict2", "age >= 21")])],
|
|
)),
|
|
);
|
|
let after_child_edit = ws.graph_analysis("parent").expect("analysis");
|
|
assert!(
|
|
!Arc::ptr_eq(&first, &after_child_edit),
|
|
"referenced doc edit must invalidate the caller"
|
|
);
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("parent")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn cross_document_rename_from_callee_to_caller() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("calc", &[("verdict", "age >= 18")])],
|
|
)),
|
|
);
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
decision,
|
|
expression_node("after", &[("ok", "verdict and true")]),
|
|
],
|
|
)),
|
|
);
|
|
|
|
let cursor = Cursor {
|
|
policy_path: "parent".into(),
|
|
block_id: "after".into(),
|
|
pos: 2,
|
|
target: CursorTarget::Expression {
|
|
id: "after-e0".into(),
|
|
},
|
|
};
|
|
let prepared = ws.prepare_rename(&cursor).expect("prepare rename");
|
|
let RenameTarget::GraphProperty { document, path } = &prepared.target else {
|
|
panic!("unexpected target {:?}", prepared.target);
|
|
};
|
|
assert_eq!(document.as_ref(), "child");
|
|
assert_eq!(path.as_ref(), "verdict");
|
|
|
|
let sites = ws.references(&prepared.target);
|
|
let docs: Vec<&str> = sites.iter().map(|s| s.policy_path.as_ref()).collect();
|
|
assert!(docs.contains(&"child"), "{docs:?}");
|
|
assert!(docs.contains(&"parent"), "{docs:?}");
|
|
|
|
let edits = ws.rename(&prepared.target, "isAdult");
|
|
let mut edited: Vec<(&str, &str)> = edits
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
EngineEdit::ReplaceNode {
|
|
document, node_id, ..
|
|
} => Some((document.as_ref(), node_id.as_ref())),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
edited.sort();
|
|
assert_eq!(edited, vec![("child", "calc"), ("parent", "after")]);
|
|
let rendered = serde_json::to_string(&edits).unwrap();
|
|
assert!(rendered.contains("isAdult and true"), "{rendered}");
|
|
assert!(rendered.contains("\"key\":\"isAdult\""), "{rendered}");
|
|
}
|
|
|
|
#[test]
|
|
fn policy_property_rename_reaches_calling_graphs() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let policy = json!({
|
|
"blocks": [
|
|
{
|
|
"id": "b1",
|
|
"type": "expression",
|
|
"props": { "data": { "key": "score", "value": "10" } }
|
|
}
|
|
]
|
|
});
|
|
ws.set_document("scoring", document(policy));
|
|
let decision = node("call", "decisionNode", json!({ "key": "scoring" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
decision,
|
|
expression_node("after", &[("final", "score + 1")]),
|
|
],
|
|
)),
|
|
);
|
|
|
|
let target = RenameTarget::Global {
|
|
name: "score".into(),
|
|
};
|
|
let sites = ws.references(&target);
|
|
let docs: Vec<&str> = sites.iter().map(|s| s.policy_path.as_ref()).collect();
|
|
assert!(docs.contains(&"scoring"), "{docs:?}");
|
|
assert!(docs.contains(&"g"), "{docs:?}");
|
|
|
|
let edits = ws.rename(&target, "points");
|
|
let has_block_edit = edits
|
|
.iter()
|
|
.any(|e| matches!(e, EngineEdit::ReplaceBlock { .. }));
|
|
let has_node_edit = edits
|
|
.iter()
|
|
.any(|e| matches!(e, EngineEdit::ReplaceNode { .. }));
|
|
assert!(has_block_edit, "{edits:?}");
|
|
assert!(has_node_edit, "{edits:?}");
|
|
let rendered = serde_json::to_string(&edits).unwrap();
|
|
assert!(rendered.contains("points + 1"), "{rendered}");
|
|
}
|
|
|
|
#[test]
|
|
fn cross_document_rename_from_graph_cursor_to_policy_target() {
|
|
use zen_engine::policy::RenameTarget;
|
|
|
|
let mut ws = Workspace::new();
|
|
let policy = json!({
|
|
"blocks": [
|
|
{
|
|
"id": "b1",
|
|
"type": "expression",
|
|
"props": { "data": { "key": "score", "value": "10" } }
|
|
}
|
|
]
|
|
});
|
|
ws.set_document("scoring", document(policy));
|
|
let decision = node("call", "decisionNode", json!({ "key": "scoring" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
decision,
|
|
expression_node("after", &[("final", "score + 1")]),
|
|
],
|
|
)),
|
|
);
|
|
|
|
let cursor = Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "after".into(),
|
|
pos: 2,
|
|
target: CursorTarget::Expression {
|
|
id: "after-e0".into(),
|
|
},
|
|
};
|
|
let prepared = ws.prepare_rename(&cursor).expect("prepare rename");
|
|
assert!(
|
|
matches!(&prepared.target, RenameTarget::Global { name } if name.as_ref() == "score"),
|
|
"{:?}",
|
|
prepared.target
|
|
);
|
|
}
|
|
|
|
fn typed_function_graph() -> Value {
|
|
let function = node(
|
|
"fn",
|
|
"functionNode",
|
|
json!({ "source": "export const handler = async (input: { age: number }) => ({ total: input.age * 2 });" }),
|
|
);
|
|
linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
function,
|
|
expression_node("after", &[("grand", "total + 1"), ("bad", "missing + 1")]),
|
|
],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_types_function_nodes_and_downstream_is_checked() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_function_resolver(|source, input| {
|
|
assert!(source.contains("handler"));
|
|
assert!(matches!(input, VariableType::Object(_)));
|
|
Some("Promise<{ total: number }>".to_string())
|
|
});
|
|
ws.set_document("g", document(typed_function_graph()));
|
|
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert_eq!(errors[0].location.block_id.as_deref(), Some("after"));
|
|
assert!(errors[0].message.contains("missing"), "{errors:?}");
|
|
assert!(
|
|
!diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::UnresolvedFunctionType),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let grand = outputs.iter().find(|o| o.path.as_ref() == "grand").unwrap();
|
|
assert!(matches!(grand.resolved_type, VariableType::Number));
|
|
assert!(ws.unchecked_nodes("g").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_any_is_an_error() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_function_resolver(|_, _| Some("any".to_string()));
|
|
ws.set_document("g", document(typed_function_graph()));
|
|
let diagnostics = ws.diagnostics("g");
|
|
let function_errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error && d.location.block_id.as_deref() == Some("fn"))
|
|
.collect();
|
|
assert_eq!(function_errors.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
function_errors[0].message.contains("`any`"),
|
|
"{function_errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_null_keeps_node_opaque_with_warning() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_function_resolver(|_, _| None);
|
|
ws.set_document("g", document(typed_function_graph()));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().any(|d| d.severity == Severity::Warning
|
|
&& d.code == DiagnosticCode::UnresolvedFunctionType
|
|
&& d.location.block_id.as_deref() == Some("fn")),
|
|
"{diagnostics:?}"
|
|
);
|
|
let unchecked = ws.unchecked_nodes("g");
|
|
assert!(!unchecked.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_is_cached_per_source_and_input() {
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
let calls = Rc::new(Cell::new(0));
|
|
let seen = calls.clone();
|
|
let mut ws = Workspace::new();
|
|
ws.set_function_resolver(move |_, _| {
|
|
seen.set(seen.get() + 1);
|
|
Some("{ total: number }".to_string())
|
|
});
|
|
ws.set_document("g", document(typed_function_graph()));
|
|
let _ = ws.diagnostics("g");
|
|
let _ = ws.diagnostics("g");
|
|
let _ = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
assert_eq!(calls.get(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn request_and_push_flow_without_native_resolver() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", document(typed_function_graph()));
|
|
|
|
let requests = ws.function_resolution_requests();
|
|
assert_eq!(requests.len(), 1, "{requests:?}");
|
|
assert!(requests[0].source.contains("handler"));
|
|
|
|
ws.set_function_type(
|
|
&requests[0].source,
|
|
&requests[0].input,
|
|
Some("Promise<{ total: number }>"),
|
|
);
|
|
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
!diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::UnresolvedFunctionType),
|
|
"{diagnostics:?}"
|
|
);
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
assert!(outputs.iter().any(|o| o.path.as_ref() == "grand"));
|
|
assert!(ws.function_resolution_requests().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_sees_dynamic_input_types() {
|
|
use std::cell::RefCell as StdRefCell;
|
|
use std::rc::Rc;
|
|
|
|
let seen_inputs = Rc::new(StdRefCell::new(Vec::new()));
|
|
let sink = seen_inputs.clone();
|
|
let mut ws = Workspace::new();
|
|
ws.set_function_resolver(move |_, input| {
|
|
sink.borrow_mut().push(format!("{input}"));
|
|
Some("{ ok: boolean }".to_string())
|
|
});
|
|
let function = node(
|
|
"fn",
|
|
"functionNode",
|
|
json!({ "source": "export const handler = (input) => ({ ok: true });" }),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![expression_node("pre", &[("total", "age * 2")]), function],
|
|
)),
|
|
);
|
|
let _ = ws.diagnostics("g");
|
|
let inputs = seen_inputs.borrow();
|
|
assert_eq!(inputs.len(), 1, "{inputs:?}");
|
|
assert_eq!(inputs[0], "object");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_mode_flags_nested_and_closure_members() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"customer": {
|
|
"type": "object",
|
|
"properties": { "age": { "type": "number" } },
|
|
"required": ["age"]
|
|
},
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "qty": { "type": "number" } },
|
|
"required": ["qty"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["customer", "items"]
|
|
});
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(schema),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[
|
|
("nested", "customer.salary"),
|
|
("closure", "map(items, #.missing)"),
|
|
("ok", "customer.age + sum(map(items, #.qty))"),
|
|
],
|
|
)],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
let by_expr = |id: &str| {
|
|
errors
|
|
.iter()
|
|
.filter(|d| d.location.expression_id.as_deref() == Some(id))
|
|
.count()
|
|
};
|
|
assert_eq!(by_expr("calc-e0"), 1, "{errors:?}");
|
|
assert_eq!(by_expr("calc-e1"), 1, "{errors:?}");
|
|
assert_eq!(by_expr("calc-e2"), 0, "{errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_mode_requires_nullish_on_optional_properties() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"age": { "type": "number" },
|
|
"bonus": { "type": "number" }
|
|
},
|
|
"required": ["age"]
|
|
});
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(schema),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("bad", "bonus + 1"), ("good", "(bonus ?? 0) + 1")],
|
|
)],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert_eq!(errors[0].location.expression_id.as_deref(), Some("calc-e0"));
|
|
}
|
|
|
|
fn switch_graph(hit_policy: &str) -> Value {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": { "type": "string", "enum": ["shipping", "billing", "support"] },
|
|
"amount": { "type": "number" }
|
|
},
|
|
"required": ["kind", "amount"]
|
|
});
|
|
json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": schema.to_string() })),
|
|
node("sw", "switchNode", json!({
|
|
"hitPolicy": hit_policy,
|
|
"statements": [
|
|
{ "id": "s1", "condition": "kind == 'shipping'" },
|
|
{ "id": "s2", "condition": "kind == 'billing'" },
|
|
{ "id": "s3", "condition": "" }
|
|
]
|
|
})),
|
|
expression_node("nShip", &[("k", "kind")]),
|
|
expression_node("nBill", &[("k", "kind")]),
|
|
expression_node("nRest", &[("k", "kind")]),
|
|
node("out", "outputNode", json!({})),
|
|
],
|
|
"edges": [
|
|
edge("e0", "in", "sw"),
|
|
{ "id": "e1", "sourceId": "sw", "targetId": "nShip", "sourceHandle": "s1" },
|
|
{ "id": "e2", "sourceId": "sw", "targetId": "nBill", "sourceHandle": "s2" },
|
|
{ "id": "e3", "sourceId": "sw", "targetId": "nRest", "sourceHandle": "s3" },
|
|
edge("e4", "nShip", "out"),
|
|
edge("e5", "nBill", "out"),
|
|
edge("e6", "nRest", "out"),
|
|
]
|
|
})
|
|
}
|
|
|
|
fn node_input_kind(ws: &Workspace, path: &str, node_id: &str) -> VariableType {
|
|
let analysis = ws.graph_analysis(path).expect("analysis");
|
|
let node = analysis.nodes.get(node_id).expect("node");
|
|
let VariableType::Object(fields) = &node.input else {
|
|
panic!("{:?}", node.input);
|
|
};
|
|
let fields = fields.borrow();
|
|
fields.get("kind").expect("kind").shallow_clone()
|
|
}
|
|
|
|
#[test]
|
|
fn switch_first_hit_narrows_branches_and_default() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", document(switch_graph("first")));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().all(|d| d.severity == Severity::Hint),
|
|
"{diagnostics:?}"
|
|
);
|
|
|
|
assert!(
|
|
matches!(node_input_kind(&ws, "g", "nShip"), VariableType::Const(ref c) if c.as_ref() == "shipping")
|
|
);
|
|
assert!(
|
|
matches!(node_input_kind(&ws, "g", "nBill"), VariableType::Const(ref c) if c.as_ref() == "billing")
|
|
);
|
|
assert!(
|
|
matches!(node_input_kind(&ws, "g", "nRest"), VariableType::Const(ref c) if c.as_ref() == "support")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn switch_collect_narrows_positive_only() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", document(switch_graph("collect")));
|
|
|
|
assert!(
|
|
matches!(node_input_kind(&ws, "g", "nBill"), VariableType::Const(ref c) if c.as_ref() == "billing")
|
|
);
|
|
let rest = node_input_kind(&ws, "g", "nRest");
|
|
assert!(
|
|
matches!(rest, VariableType::Enum(_, ref v) if v.len() == 3),
|
|
"{rest:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merged_branches_rewiden_after_join() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("g", document(switch_graph("first")));
|
|
let analysis = ws.graph_analysis("g").expect("analysis");
|
|
let out = analysis.nodes.get("out").expect("out node");
|
|
let VariableType::Object(fields) = &out.input else {
|
|
panic!("{:?}", out.input);
|
|
};
|
|
let fields = fields.borrow();
|
|
let joined = fields.get("k").expect("k");
|
|
assert!(
|
|
matches!(joined, VariableType::Enum(_, v) if v.len() == 3),
|
|
"{joined:?}"
|
|
);
|
|
}
|
|
|
|
fn typed_table(column_type: &str, cells: &[&str]) -> Value {
|
|
let rules: Vec<Value> = cells
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, cell)| json!({ "_id": format!("r{i}"), "c1": "", "o1": cell }))
|
|
.collect();
|
|
node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "c1", "name": "Age", "field": "age" } ],
|
|
"outputs": [ { "id": "o1", "name": "Score", "field": "score", "type": column_type } ],
|
|
"rules": rules
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn graph_typed_output_column_rejects_mismatched_cells() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("number", &["10", "'high'"])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
|
|
assert_eq!(diagnostics[0].code, DiagnosticCode::TypeMismatch);
|
|
assert!(
|
|
diagnostics[0]
|
|
.message
|
|
.contains("output cell must be `number`"),
|
|
"{diagnostics:?}"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
&diagnostics[0].location.target,
|
|
Some(CursorTarget::DecisionTableCell { row, col })
|
|
if row.as_ref() == "r1" && col.as_ref() == "o1"
|
|
),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_typed_output_column_drives_schema() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("number", &["10", "20"])],
|
|
)),
|
|
);
|
|
assert!(ws.diagnostics("g").is_empty(), "{:?}", ws.diagnostics("g"));
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let score = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "score")
|
|
.unwrap_or_else(|| panic!("{outputs:?}"));
|
|
assert!(
|
|
matches!(score.resolved_type, VariableType::Number),
|
|
"{:?}",
|
|
score.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_typed_output_column_nullable_on_empty_cell() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("number", &["10", ""])],
|
|
)),
|
|
);
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("g"));
|
|
let score = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "score")
|
|
.unwrap_or_else(|| panic!("{outputs:?}"));
|
|
assert!(
|
|
matches!(
|
|
&score.resolved_type,
|
|
VariableType::Nullable(inner) if matches!(inner.as_ref(), VariableType::Number)
|
|
),
|
|
"{:?}",
|
|
score.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_typed_array_output_column() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("string[]", &["['a', 'b']", "[1]"])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
diagnostics[0]
|
|
.message
|
|
.contains("output cell must be `string[]`"),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_unknown_output_type_diagnosed_on_head() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("customerTier", &["'VIP'"])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
diagnostics[0]
|
|
.message
|
|
.contains("unknown output type 'customerTier'"),
|
|
"{diagnostics:?}"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
&diagnostics[0].location.target,
|
|
Some(CursorTarget::DecisionTableHead { col }) if col.as_ref() == "o1"
|
|
),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_malformed_output_type_diagnosed() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("number[", &["10"])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
diagnostics[0].message.contains("invalid output type"),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_untyped_output_column_keeps_inferred_behavior() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![typed_table("", &["10", "20"])],
|
|
)),
|
|
);
|
|
assert!(ws.diagnostics("g").is_empty(), "{:?}", ws.diagnostics("g"));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_dependencies_traverse_writers() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"cart": { "type": "object", "properties": { "total": { "type": "number" } }, "required": ["total"] }
|
|
},
|
|
"required": ["cart"]
|
|
});
|
|
let mut calc = expression_node("calc", &[("fee", "cart.total * 0.1")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
let table = node(
|
|
"tbl",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"outputPath": "price",
|
|
"passThrough": true,
|
|
"inputs": [
|
|
{ "id": "c1", "name": "Fee", "field": "fee" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "Discount", "field": "discount" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "> 5", "o1": "0.2" },
|
|
{ "_id": "r2", "c1": "", "o1": "0.1" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(schema), vec![calc, table])),
|
|
);
|
|
|
|
let tree = ws.dependencies_scoped("price.discount", Some("doc"));
|
|
assert_eq!(tree.property.as_ref(), "price.discount");
|
|
let written_by = tree.written_by.as_ref().expect("table writes discount");
|
|
assert_eq!(written_by.block_id.as_ref(), "tbl");
|
|
assert_eq!(written_by.policy_path.as_ref(), "doc");
|
|
|
|
let fee = tree
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "fee")
|
|
.expect("discount depends on fee");
|
|
assert_eq!(
|
|
fee.written_by.as_ref().map(|w| w.block_id.as_ref()),
|
|
Some("calc")
|
|
);
|
|
let total = fee
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "cart.total")
|
|
.expect("fee depends on cart.total");
|
|
assert!(total.written_by.is_none());
|
|
assert!(total.deps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn graph_dependencies_resolve_array_element_types() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "price": { "type": "number" } },
|
|
"required": ["price"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["items"]
|
|
});
|
|
let mut looped = expression_node("calc", &[("taxed", "price * 1.2")]);
|
|
looped["content"]["inputField"] = json!("items");
|
|
looped["content"]["executionMode"] = json!("loop");
|
|
looped["content"]["outputPath"] = json!("results");
|
|
ws.set_document("doc", document(linear_graph(Some(schema), vec![looped])));
|
|
|
|
let tree = ws.dependencies_scoped("results", Some("doc"));
|
|
let price = tree
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "items.price")
|
|
.expect("results depend on items.price");
|
|
assert!(
|
|
matches!(price.resolved_type, VariableType::Number),
|
|
"{:?}",
|
|
price.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_outputs_report_written_by() {
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("fee", "age * 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![calc])),
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("doc"));
|
|
let fee = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "fee")
|
|
.expect("fee output present");
|
|
assert_eq!(
|
|
fee.written_by.as_ref().map(|w| w.block_id.as_ref()),
|
|
Some("calc")
|
|
);
|
|
let age = outputs.iter().find(|o| o.path.as_ref() == "age");
|
|
if let Some(age) = age {
|
|
assert!(age.written_by.is_none(), "{age:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn graph_rename_rewrites_schema_declaration_and_reads() {
|
|
use zen_engine::policy::{EngineEdit, ReferenceKind, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("fee", "age * 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![calc])),
|
|
);
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
};
|
|
|
|
let sites = ws.references(&target);
|
|
assert!(
|
|
sites
|
|
.iter()
|
|
.any(|s| s.kind == ReferenceKind::DataModel && s.block_id.as_ref() == "in"),
|
|
"{sites:?}"
|
|
);
|
|
assert!(
|
|
sites
|
|
.iter()
|
|
.any(|s| s.kind == ReferenceKind::ExpressionRead && s.block_id.as_ref() == "calc"),
|
|
"{sites:?}"
|
|
);
|
|
|
|
let edits = ws.rename(&target, "years");
|
|
let mut saw_schema = false;
|
|
let mut saw_expression = false;
|
|
for edit in &edits {
|
|
let EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} = edit
|
|
else {
|
|
continue;
|
|
};
|
|
match node_id.as_ref() {
|
|
"in" => {
|
|
let schema = &new_node["content"]["schema"];
|
|
assert!(schema["properties"]["years"].is_object(), "{schema}");
|
|
assert!(schema["properties"]["age"].is_null(), "{schema}");
|
|
assert_eq!(schema["required"][0], json!("years"), "{schema}");
|
|
saw_schema = true;
|
|
}
|
|
"calc" => {
|
|
let value = new_node["content"]["expressions"][0]["value"]
|
|
.as_str()
|
|
.unwrap();
|
|
assert_eq!(value, "years * 2");
|
|
saw_expression = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(saw_schema, "{edits:?}");
|
|
assert!(saw_expression, "{edits:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_node_rename_rewrites_nodes_references() {
|
|
use zen_engine::policy::{EngineEdit, ReferenceKind, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut score = expression_node("score", &[("score", "age * 2")]);
|
|
score["name"] = json!("Score");
|
|
score["content"]["passThrough"] = json!(true);
|
|
let mut merge = expression_node(
|
|
"merge",
|
|
&[
|
|
("viaDot", "$nodes.Score.score + 1"),
|
|
("viaBracket", "$nodes['Score'].score + 2"),
|
|
],
|
|
);
|
|
merge["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![score, merge])),
|
|
);
|
|
|
|
let target = RenameTarget::GraphNode {
|
|
document: Arc::from("doc"),
|
|
node_id: Arc::from("score"),
|
|
};
|
|
|
|
let sites = ws.references(&target);
|
|
assert_eq!(
|
|
sites
|
|
.iter()
|
|
.filter(|s| s.kind == ReferenceKind::ExpressionRead)
|
|
.count(),
|
|
2,
|
|
"{sites:?}"
|
|
);
|
|
|
|
let edits = ws.rename(&target, "Scorer");
|
|
let mut renamed_node = false;
|
|
let mut rewrote_refs = false;
|
|
for edit in &edits {
|
|
let EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} = edit
|
|
else {
|
|
continue;
|
|
};
|
|
match node_id.as_ref() {
|
|
"score" => {
|
|
assert_eq!(new_node["name"], json!("Scorer"));
|
|
renamed_node = true;
|
|
}
|
|
"merge" => {
|
|
let rows = new_node["content"]["expressions"].as_array().unwrap();
|
|
assert_eq!(rows[0]["value"], json!("$nodes.Scorer.score + 1"));
|
|
assert_eq!(rows[1]["value"], json!("$nodes['Scorer'].score + 2"));
|
|
rewrote_refs = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(renamed_node, "{edits:?}");
|
|
assert!(rewrote_refs, "{edits:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_property_rename_rewrites_function_sources() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let function = node(
|
|
"fn",
|
|
"functionNode",
|
|
json!({ "source": "export const handler = async (input) => {\n return { score: input.age * 2 };\n};\n" }),
|
|
);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![function])),
|
|
);
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
};
|
|
|
|
let sites = ws.references(&target);
|
|
let fn_site = sites
|
|
.iter()
|
|
.find(|s| s.block_id.as_ref() == "fn")
|
|
.expect("function read site present");
|
|
assert!(!fn_site.source.contains('\n'), "{fn_site:?}");
|
|
assert!(fn_site.source.contains("input.age"), "{fn_site:?}");
|
|
|
|
let edits = ws.rename(&target, "years");
|
|
let fn_edit = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "fn" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("function node rewritten");
|
|
let source = fn_edit["content"]["source"].as_str().unwrap();
|
|
assert!(source.contains("input.years * 2"), "{source}");
|
|
assert!(!source.contains("input.age"), "{source}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_dependencies_survive_cyclic_calls() {
|
|
let mut ws = Workspace::new();
|
|
let call_b = node("call-b", "decisionNode", json!({ "key": "b" }));
|
|
let call_a = node("call-a", "decisionNode", json!({ "key": "a" }));
|
|
ws.set_document("a", document(linear_graph(None, vec![call_b])));
|
|
ws.set_document("b", document(linear_graph(None, vec![call_a])));
|
|
|
|
let tree = ws.dependencies_scoped("x", Some("a"));
|
|
assert_eq!(tree.property.as_ref(), "x");
|
|
assert!(tree.written_by.is_none());
|
|
let _ = ws.outputs(&ScopeRequest::for_policy("a"));
|
|
|
|
let mut ws = Workspace::new();
|
|
let self_call = node("call", "decisionNode", json!({ "key": "loop" }));
|
|
ws.set_document("loop", document(linear_graph(None, vec![self_call])));
|
|
let tree = ws.dependencies_scoped("x", Some("loop"));
|
|
assert!(tree.written_by.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn graph_opaque_output_path_not_reported_as_writer() {
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("total", "1 + 1")]);
|
|
calc["content"]["outputPath"] = json!("price-list");
|
|
ws.set_document("doc", document(linear_graph(None, vec![calc])));
|
|
|
|
let tree = ws.dependencies_scoped("total", Some("doc"));
|
|
assert!(tree.written_by.is_none(), "{tree:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_dependencies_diamond_keeps_written_by() {
|
|
let mut ws = Workspace::new();
|
|
let mut wc = expression_node("wc", &[("c", "1")]);
|
|
wc["content"]["passThrough"] = json!(true);
|
|
let mut wab = expression_node("wab", &[("a", "c + 1"), ("b", "c + 2")]);
|
|
wab["content"]["passThrough"] = json!(true);
|
|
let mut wx = expression_node("wx", &[("x", "a + b")]);
|
|
wx["content"]["passThrough"] = json!(true);
|
|
ws.set_document("doc", document(linear_graph(None, vec![wc, wab, wx])));
|
|
|
|
let tree = ws.dependencies_scoped("x", Some("doc"));
|
|
let a = tree
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "a")
|
|
.expect("x depends on a");
|
|
let b = tree
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "b")
|
|
.expect("x depends on b");
|
|
let c_under_a = a
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "c")
|
|
.expect("a depends on c");
|
|
let c_under_b = b
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "c")
|
|
.expect("b depends on c");
|
|
assert_eq!(
|
|
c_under_a.written_by.as_ref().map(|w| w.block_id.as_ref()),
|
|
Some("wc")
|
|
);
|
|
assert_eq!(
|
|
c_under_b.written_by.as_ref().map(|w| w.block_id.as_ref()),
|
|
Some("wc"),
|
|
"second diamond occurrence must keep written_by"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_property_rename_preserves_node_identity() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("total", &[("total", "1 + 1")]);
|
|
calc["name"] = json!("total");
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document("doc", document(linear_graph(None, vec![calc])));
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("total"),
|
|
};
|
|
let edits = ws.rename(&target, "sum");
|
|
let new_node = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "total" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("node rewritten");
|
|
assert_eq!(new_node["id"], json!("total"), "{new_node}");
|
|
assert_eq!(new_node["name"], json!("total"), "{new_node}");
|
|
assert_eq!(new_node["content"]["expressions"][0]["key"], json!("sum"));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_node_rename_preserves_equal_strings_and_handles_empty_name() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"tbl",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"passThrough": true,
|
|
"inputs": [],
|
|
"outputs": [{ "id": "o1", "name": "Total", "field": "Total" }],
|
|
"rules": [{ "_id": "r1", "o1": "1" }]
|
|
}),
|
|
);
|
|
let mut table = table;
|
|
table["name"] = json!("Total");
|
|
ws.set_document("doc", document(linear_graph(None, vec![table])));
|
|
|
|
let target = RenameTarget::GraphNode {
|
|
document: Arc::from("doc"),
|
|
node_id: Arc::from("tbl"),
|
|
};
|
|
let edits = ws.rename(&target, "Score");
|
|
let new_node = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "tbl" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("node renamed");
|
|
assert_eq!(new_node["name"], json!("Score"), "{new_node}");
|
|
assert_eq!(new_node["content"]["outputs"][0]["field"], json!("Total"));
|
|
assert_eq!(new_node["content"]["outputs"][0]["name"], json!("Total"));
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut anon = expression_node("anon", &[("", "")]);
|
|
anon["name"] = json!("");
|
|
ws.set_document("doc", document(linear_graph(None, vec![anon])));
|
|
let target = RenameTarget::GraphNode {
|
|
document: Arc::from("doc"),
|
|
node_id: Arc::from("anon"),
|
|
};
|
|
let edits = ws.rename(&target, "Named");
|
|
let new_node = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "anon" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("empty-named node renamed");
|
|
assert_eq!(new_node["name"], json!("Named"), "{new_node}");
|
|
assert_eq!(new_node["content"]["expressions"][0]["key"], json!(""));
|
|
assert_eq!(new_node["content"]["expressions"][0]["value"], json!(""));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_property_rename_skips_function_strings_and_comments() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let source = "export const handler = async (input) => {\n // input.age is doubled\n const msg = \"input.age missing\";\n return { score: input.age * 2, msg };\n};\n";
|
|
let function = node("fn", "functionNode", json!({ "source": source }));
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![function])),
|
|
);
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
};
|
|
let edits = ws.rename(&target, "years");
|
|
let fn_edit = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "fn" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("function node rewritten");
|
|
let rewritten = fn_edit["content"]["source"].as_str().unwrap();
|
|
assert!(rewritten.contains("input.years * 2"), "{rewritten}");
|
|
assert!(rewritten.contains("// input.age is doubled"), "{rewritten}");
|
|
assert!(rewritten.contains("\"input.age missing\""), "{rewritten}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_rename_handles_multibyte_sources() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("flagged", "name == 'é' and age > 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![calc])),
|
|
);
|
|
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
};
|
|
let edits = ws.rename(&target, "years");
|
|
let calc_edit = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "calc" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("expression rewritten");
|
|
assert_eq!(
|
|
calc_edit["content"]["expressions"][0]["value"],
|
|
json!("name == 'é' and years > 2")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_schema_rename_guards_conflicts_and_descends_items() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("first", "age * 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![calc])),
|
|
);
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
};
|
|
let edits = ws.rename(&target, "name");
|
|
let schema_edit = edits.iter().find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "in" => Some(new_node),
|
|
_ => None,
|
|
});
|
|
assert!(
|
|
schema_edit.is_none(),
|
|
"conflicting rename must not touch schema: {edits:?}"
|
|
);
|
|
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"orders": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "price": { "type": "number" } },
|
|
"required": ["price"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["orders"]
|
|
});
|
|
let mut calc = expression_node("calc", &[("total", "sum(map(orders, #.price))")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
ws.set_document("doc", document(linear_graph(Some(schema), vec![calc])));
|
|
let target = RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("orders.price"),
|
|
};
|
|
let edits = ws.rename(&target, "amount");
|
|
let schema_edit = edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == "in" => Some(new_node),
|
|
_ => None,
|
|
})
|
|
.expect("schema rewritten for array items: {edits:?}");
|
|
let schema = &schema_edit["content"]["schema"];
|
|
assert!(
|
|
schema["properties"]["orders"]["items"]["properties"]["amount"].is_object(),
|
|
"{schema}"
|
|
);
|
|
assert!(
|
|
schema["properties"]["orders"]["items"]["properties"]["price"].is_null(),
|
|
"{schema}"
|
|
);
|
|
assert_eq!(
|
|
schema["properties"]["orders"]["items"]["required"][0],
|
|
json!("amount"),
|
|
"{schema}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_collect_table_output_type_flows_downstream() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"tbl",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "collect",
|
|
"inputs": [{ "id": "c1", "name": "Age", "field": "age" }],
|
|
"outputs": [{ "id": "o1", "name": "Result", "field": "result" }],
|
|
"rules": [{ "_id": "r1", "c1": "> 1", "o1": "1" }]
|
|
}),
|
|
);
|
|
let post = expression_node("post", &[("x", "result + 1")]);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![table, post])),
|
|
);
|
|
|
|
let codes = error_codes(&ws, "doc");
|
|
assert!(
|
|
!codes.is_empty(),
|
|
"reading a property off the collect array must be flagged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_pass_through_uncovered_table_fields_are_optional() {
|
|
let make = |expr: &str| {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"tbl",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"passThrough": true,
|
|
"inputs": [{ "id": "c1", "name": "Age", "field": "age" }],
|
|
"outputs": [{ "id": "o1", "name": "Result", "field": "result" }],
|
|
"rules": [{ "_id": "r1", "c1": "> 100", "o1": "1" }]
|
|
}),
|
|
);
|
|
let mut post = expression_node("post", &[("x", expr)]);
|
|
post["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![table, post])),
|
|
);
|
|
ws
|
|
};
|
|
|
|
let guarded = make("(result ?? 0) + 1");
|
|
let codes = error_codes(&guarded, "doc");
|
|
assert!(codes.is_empty(), "guarded read must be clean: {codes:?}");
|
|
assert!(
|
|
guarded
|
|
.diagnostics("doc")
|
|
.iter()
|
|
.all(|d| !matches!(d.code, DiagnosticCode::RedundantNullish)),
|
|
"?? on an uncovered table field is meaningful, not redundant"
|
|
);
|
|
|
|
let bare = make("result + 1");
|
|
assert!(
|
|
error_codes(&bare, "doc").contains(&DiagnosticCode::TypeMismatch),
|
|
"bare arithmetic on a possibly-missing table field must be flagged"
|
|
);
|
|
|
|
let outputs = guarded.outputs(&ScopeRequest::for_policy("doc"));
|
|
assert!(
|
|
outputs.iter().any(|o| o.path.as_ref() == "result"),
|
|
"written field stays a derived output"
|
|
);
|
|
assert!(
|
|
outputs
|
|
.iter()
|
|
.all(|o| o.path.as_ref() != "age" && o.path.as_ref() != "name"),
|
|
"untouched pass-through inputs must not appear as outputs: {:?}",
|
|
outputs.iter().map(|o| o.path.as_ref()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_typed_column_null_cells_are_nullable() {
|
|
let make_ws = |cells: [&str; 2], column_type: Option<&str>| {
|
|
let mut ws = Workspace::new();
|
|
let mut output_col = json!({ "id": "o1", "name": "Score", "field": "score" });
|
|
if let Some(ct) = column_type {
|
|
output_col["type"] = json!(ct);
|
|
}
|
|
let table = node(
|
|
"tbl",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"passThrough": true,
|
|
"inputs": [{ "id": "c1", "name": "Age", "field": "age" }],
|
|
"outputs": [output_col],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "> 10", "o1": cells[0] },
|
|
{ "_id": "r2", "c1": "", "o1": cells[1] }
|
|
]
|
|
}),
|
|
);
|
|
let post = expression_node("post", &[("x", "score + 1")]);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![table, post])),
|
|
);
|
|
ws
|
|
};
|
|
|
|
let untyped = error_codes(&make_ws(["10", "null"], None), "doc");
|
|
let typed = error_codes(&make_ws(["10", "null"], Some("number")), "doc");
|
|
assert_eq!(
|
|
typed, untyped,
|
|
"typed column with null cells must behave like untyped"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_signature_excludes_unreachable_sinks() {
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("fee", "age * 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
let mut graph = linear_graph(Some(person_schema()), vec![calc]);
|
|
let mut junk = expression_node("junk", &[("debug", "1")]);
|
|
junk["content"]["passThrough"] = json!(true);
|
|
graph["nodes"].as_array_mut().unwrap().push(junk);
|
|
ws.set_document("doc", document(graph));
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("doc"));
|
|
assert!(
|
|
!outputs.iter().any(|o| o.path.as_ref() == "debug"),
|
|
"unreachable sink must not leak into the signature: {outputs:?}"
|
|
);
|
|
assert!(
|
|
outputs.iter().any(|o| o.path.as_ref() == "fee"),
|
|
"{outputs:?}"
|
|
);
|
|
}
|
|
|
|
fn rates_loop_graph(hit_policy: &str, rules: Value, pass_through: bool) -> Value {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"rates": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "region": { "type": "string" }, "amount": { "type": "number" } },
|
|
"required": ["region", "amount"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["rates"]
|
|
});
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": hit_policy,
|
|
"passThrough": pass_through,
|
|
"inputField": "rates",
|
|
"executionMode": "loop",
|
|
"outputPath": "results",
|
|
"inputs": [{ "id": "c1", "name": "Amount", "field": "amount" }],
|
|
"outputs": [{ "id": "o1", "name": "Rate", "field": "rate" }],
|
|
"rules": rules
|
|
}),
|
|
);
|
|
let reader = node(
|
|
"read",
|
|
"expressionNode",
|
|
json!({
|
|
"passThrough": true,
|
|
"expressions": [
|
|
{ "id": "e1", "key": "phantom", "value": "map(results, #.inclusive ?? false)" },
|
|
{ "id": "e2", "key": "real", "value": "map(results, #.rate ?? 0)" },
|
|
{ "id": "e3", "key": "carried", "value": "map(results, #.amount)" }
|
|
]
|
|
}),
|
|
);
|
|
json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": schema.to_string() })),
|
|
table,
|
|
reader,
|
|
node("out", "outputNode", json!({})),
|
|
],
|
|
"edges": [
|
|
edge("g1", "in", "dt"),
|
|
edge("g2", "dt", "read"),
|
|
edge("g3", "read", "out"),
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn loop_table_output_columns_propagate_into_element_type() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(rates_loop_graph(
|
|
"first",
|
|
json!([
|
|
{ "_id": "r1", "c1": "> 100", "o1": "0.12" },
|
|
{ "_id": "r2", "c1": "", "o1": "0.02" }
|
|
]),
|
|
true,
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let phantom: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == DiagnosticCode::UndefinedVariable)
|
|
.collect();
|
|
assert_eq!(phantom.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
phantom[0].message.contains("inclusive"),
|
|
"the never-produced field must be the one flagged: {diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn loop_collect_table_elements_are_row_arrays() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(rates_loop_graph(
|
|
"collect",
|
|
json!([{ "_id": "r1", "c1": "> 100", "o1": "0.12" }]),
|
|
true,
|
|
)),
|
|
);
|
|
let analysis = ws.graph_analysis("g").expect("analysis");
|
|
let dt = analysis.nodes.get("dt").expect("dt node");
|
|
let results = dt.output.get("results");
|
|
let element = match &results {
|
|
VariableType::Array(inner) => inner.as_ref().shallow_clone(),
|
|
other => panic!("results must be an array, got {other:?}"),
|
|
};
|
|
assert!(
|
|
matches!(element, VariableType::Array(_)),
|
|
"collect in loop must produce row arrays per element, got {element:?}"
|
|
);
|
|
let member_errors = analysis
|
|
.diagnostics
|
|
.iter()
|
|
.filter(|d| d.location.block_id.as_deref() == Some("read") && d.severity == Severity::Error)
|
|
.count();
|
|
assert!(
|
|
member_errors >= 3,
|
|
"member reads on row arrays must be flagged: {:?}",
|
|
analysis.diagnostics
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pass_through_nullable_patch_merges_fields_as_optional() {
|
|
let mut ws = Workspace::new();
|
|
let child_table = simple_table(json!([
|
|
{ "_id": "r1", "c1": "> 18", "o1": "\"adult\"" }
|
|
]));
|
|
let mut child = linear_graph(Some(person_schema()), vec![child_table]);
|
|
child["nodes"].as_array_mut().unwrap()[1]["content"]["passThrough"] = json!(false);
|
|
ws.set_document("child", document(child));
|
|
|
|
let decision = node(
|
|
"call",
|
|
"decisionNode",
|
|
json!({ "key": "child", "passThrough": true }),
|
|
);
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(Some(person_schema()), vec![decision])),
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("parent"));
|
|
let result = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "result")
|
|
.expect("result output");
|
|
assert!(
|
|
matches!(result.resolved_type, VariableType::Nullable(_)),
|
|
"a nullable pass-through patch must merge its fields as optional, got {:?}",
|
|
result.resolved_type
|
|
);
|
|
}
|
|
|
|
fn line_items_child(required_item_fields: Value) -> Value {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"lineItems": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": { "type": "number" },
|
|
"inclusive": { "type": "boolean" }
|
|
},
|
|
"required": required_item_fields
|
|
}
|
|
}
|
|
},
|
|
"required": ["lineItems"]
|
|
});
|
|
linear_graph(
|
|
Some(schema),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[("total", "sum(map(lineItems, #.amount))")],
|
|
)],
|
|
)
|
|
}
|
|
|
|
fn line_items_parent() -> Value {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"source": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": { "amount": { "type": "number" } },
|
|
"required": ["amount"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["source"]
|
|
});
|
|
let mut mk = expression_node("mk", &[("lineItems", "map(source, { amount: #.amount })")]);
|
|
mk["content"]["passThrough"] = json!(true);
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
linear_graph(Some(schema), vec![mk, decision])
|
|
}
|
|
|
|
#[test]
|
|
fn decision_boundary_reports_item_level_diff() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(line_items_child(json!(["amount", "inclusive"]))),
|
|
);
|
|
ws.set_document("parent", document(line_items_parent()));
|
|
let diagnostics = ws.diagnostics("parent");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert!(
|
|
errors[0].contains("lineItems[].inclusive") && errors[0].contains("`bool`"),
|
|
"the diff must name the item field, not flatten to object[]: {errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_boundary_allows_missing_optional_item_field() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document("child", document(line_items_child(json!(["amount"]))));
|
|
ws.set_document("parent", document(line_items_parent()));
|
|
let diagnostics = ws.diagnostics("parent");
|
|
assert!(
|
|
diagnostics.is_empty(),
|
|
"an optional item field the parent never produces must not break the boundary: {diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_boundary_names_nullability_delta() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(line_items_child(json!(["amount", "inclusive"]))),
|
|
);
|
|
let parent_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"lineItems": {
|
|
"type": ["array", "null"],
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": { "type": "number" },
|
|
"inclusive": { "type": "boolean" }
|
|
},
|
|
"required": ["amount", "inclusive"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["lineItems"]
|
|
});
|
|
let decision = node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"parent",
|
|
document(linear_graph(Some(parent_schema), vec![decision])),
|
|
);
|
|
let diagnostics = ws.diagnostics("parent");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
|
assert!(
|
|
errors[0].contains("lineItems") && errors[0].contains("may be null"),
|
|
"the nullability delta must be stated, not flattened: {errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn any_typed_graph_output_is_an_error_in_strict_graphs() {
|
|
let mut ws = Workspace::new();
|
|
let graph = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": person_schema().to_string() })),
|
|
expression_node("a", &[("x", "$nodes.b.marker")]),
|
|
expression_node("b", &[("marker", "1")]),
|
|
],
|
|
"edges": [edge("e1", "in", "a"), edge("e2", "in", "b")]
|
|
});
|
|
ws.set_document("g", document(graph));
|
|
let diagnostics = ws.diagnostics("g");
|
|
let implicit: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == DiagnosticCode::ImplicitAny && d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(implicit.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
implicit[0].contains("`x`"),
|
|
"the any-typed output path must be named: {implicit:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_decision_any_output_is_an_error() {
|
|
let mut ws = Workspace::new();
|
|
let decision = node("call", "decisionNode", json!({ "key": "g" }));
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![decision])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let implicit: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == DiagnosticCode::ImplicitAny && d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert!(
|
|
implicit.iter().any(|m| m.contains("resolves to `any`")),
|
|
"a recursive sub-decision degrades the result to any and must error: {diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schemaless_graph_output_any_stays_warning_only() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
None,
|
|
vec![expression_node("calc", &[("double", "value * 2")])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().all(|d| d.severity != Severity::Error),
|
|
"without an input schema the graph stays warning-only: {diagnostics:?}"
|
|
);
|
|
assert!(
|
|
diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::MissingInputSchema),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn string_literal_columns_hint_dictionary_candidates() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [{ "id": "c1", "name": "Name", "field": "name" }],
|
|
"outputs": [{ "id": "o1", "name": "Verdict", "field": "verdict" }],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "\"gold\"", "o1": "\"approve\"" },
|
|
{ "_id": "r2", "c1": "\"silver\"", "o1": "\"review\"" },
|
|
{ "_id": "r3", "c1": "", "o1": "\"reject\"" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let hints: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == DiagnosticCode::PreferDictionary)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(hints.len(), 2, "{diagnostics:?}");
|
|
assert!(
|
|
hints
|
|
.iter()
|
|
.any(|m| m.contains("'name'") && m.contains("\"gold\" | \"silver\"")),
|
|
"{hints:?}"
|
|
);
|
|
assert!(
|
|
hints
|
|
.iter()
|
|
.any(|m| m.contains("'verdict'") && m.contains("\"approve\" | \"review\" | \"reject\"")),
|
|
"{hints:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inline_schema_enum_hints_dictionary() {
|
|
let mut ws = Workspace::new();
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"status": { "type": "string", "enum": ["active", "suspended"] },
|
|
"tier": { "$dictionary": "customerTier" }
|
|
},
|
|
"required": ["status"]
|
|
});
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(schema),
|
|
vec![expression_node("calc", &[("s", "status")])],
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let hints: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.code == DiagnosticCode::PreferDictionary)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(hints.len(), 1, "{diagnostics:?}");
|
|
assert!(hints[0].contains("`status`"), "{hints:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn date_literal_cells_do_not_hint_dictionary() {
|
|
let mut ws = Workspace::new();
|
|
let table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [{ "id": "c1", "name": "Name", "field": "name" }],
|
|
"outputs": [{ "id": "o1", "name": "Rate", "field": "rate" }],
|
|
"rules": [
|
|
{ "_id": "r1", "c1": "\"2024-01-01\"", "o1": "0.1" },
|
|
{ "_id": "r2", "c1": "\"2018-01-01\"", "o1": "0.2" }
|
|
]
|
|
}),
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(person_schema()), vec![table])),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
!diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::PreferDictionary),
|
|
"date-keyed columns are calendars, not enums: {diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schemaless_graph_suppresses_type_derived_expression_errors() {
|
|
let mut ws = Workspace::new();
|
|
let graph = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({})),
|
|
expression_node("first", &[("count", "len(items)")]),
|
|
expression_node("second", &[("label", "$nodes.first.count + \"!\"")]),
|
|
],
|
|
"edges": [edge("e1", "in", "first"), edge("e2", "first", "second")]
|
|
});
|
|
ws.set_document("g", document(graph));
|
|
let diagnostics = ws.diagnostics("g");
|
|
assert!(
|
|
diagnostics.iter().all(|d| d.severity != Severity::Error),
|
|
"an unchecked graph must not raise type-derived errors: {diagnostics:?}"
|
|
);
|
|
assert!(
|
|
diagnostics
|
|
.iter()
|
|
.any(|d| d.code == DiagnosticCode::MissingInputSchema),
|
|
"{diagnostics:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strict_graph_still_reports_nodes_scope_type_errors() {
|
|
let mut ws = Workspace::new();
|
|
let graph = json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": person_schema().to_string() })),
|
|
expression_node("first", &[("count", "age * 2")]),
|
|
expression_node("second", &[("label", "$nodes.first.count + \"!\"")]),
|
|
],
|
|
"edges": [edge("e1", "in", "first"), edge("e2", "first", "second")]
|
|
});
|
|
ws.set_document("g", document(graph));
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.contains(&DiagnosticCode::TypeMismatch),
|
|
"strict graphs keep type checking: {codes:?}"
|
|
);
|
|
}
|
|
|
|
fn grouped_items_schema() -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"items": { "type": "array", "items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"grp": { "type": ["string", "null"] },
|
|
"lines": { "type": ["array", "null"], "items": {
|
|
"type": "object", "properties": { "idx": { "type": ["number", "null"] } } } }
|
|
}
|
|
} }
|
|
},
|
|
"required": ["items"]
|
|
})
|
|
}
|
|
|
|
fn single_expression_graph(schema: Value, expr: &str) -> Value {
|
|
json!({
|
|
"nodes": [
|
|
node("in", "inputNode", json!({ "schema": schema.to_string() })),
|
|
node("ex", "expressionNode", json!({ "expressions": [{ "id": "e1", "key": "out", "value": expr }] })),
|
|
],
|
|
"edges": [edge("e1", "in", "ex")]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn assignment_bound_locals_keep_element_types_in_closures() {
|
|
let cases = [
|
|
r#"map(items as m, (gp = filter(items as x, x.grp == m.grp)[0]; mw = filter(gp.lines ?? [] as c, c.idx == 0)[0]; mw))"#,
|
|
r#"(loc = filter(items as x, x.grp == "a"); map(loc as e, e.grp))"#,
|
|
r#"map(items as m, (gp = filter(items as x, x.grp == m.grp); len(gp) + (gp[0].grp == "a" ? 1 : 0)))"#,
|
|
];
|
|
for expr in cases {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(single_expression_graph(grouped_items_schema(), expr)),
|
|
);
|
|
let codes = error_codes(&ws, "g");
|
|
assert!(
|
|
codes.is_empty(),
|
|
"closure over a ;-bound local must type-check: {expr}\n{codes:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_member_through_local_names_the_member_not_the_alias() {
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(single_expression_graph(
|
|
grouped_items_schema(),
|
|
r#"(loc = filter(items as x, x.grp == "a"); map(loc as e, e.doesNotExist))"#,
|
|
)),
|
|
);
|
|
let diagnostics = ws.diagnostics("g");
|
|
let errors: Vec<&str> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| d.message.as_str())
|
|
.collect();
|
|
assert_eq!(errors.len(), 1, "{diagnostics:?}");
|
|
assert!(
|
|
errors[0].contains("doesNotExist") && !errors[0].contains("'e'"),
|
|
"the specific member must be blamed, not the alias: {errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn member_writes_do_not_define_declared_input_objects() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"applicant": {
|
|
"type": "object",
|
|
"properties": { "age": { "type": "number" } },
|
|
"required": ["age"]
|
|
}
|
|
},
|
|
"required": ["applicant"]
|
|
});
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(schema),
|
|
vec![expression_node(
|
|
"calc",
|
|
&[
|
|
("applicant.adult", "applicant.age >= 18"),
|
|
("result.band", "'A'"),
|
|
],
|
|
)],
|
|
)),
|
|
);
|
|
|
|
let writer = |path: &str| {
|
|
ws.dependencies_scoped(path, Some("g"))
|
|
.written_by
|
|
.map(|block| block.block_id.to_string())
|
|
};
|
|
assert_eq!(writer("applicant"), None);
|
|
assert_eq!(writer("applicant.age"), None);
|
|
assert_eq!(writer("applicant.adult").as_deref(), Some("calc"));
|
|
assert_eq!(writer("result").as_deref(), Some("calc"));
|
|
}
|
|
|
|
#[test]
|
|
fn expression_key_segments_resolve_in_graph_rows() {
|
|
use zen_engine::policy::RenameTarget;
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(
|
|
Some(person_schema()),
|
|
vec![
|
|
expression_node("calc", &[("result.adult", "age >= 18")]),
|
|
expression_node("use", &[("flag", "result.adult")]),
|
|
],
|
|
)),
|
|
);
|
|
let key_cursor = |pos: u32| Cursor {
|
|
policy_path: "g".into(),
|
|
block_id: "calc".into(),
|
|
pos,
|
|
target: CursorTarget::ExpressionKey {
|
|
id: Some("calc-e0".into()),
|
|
},
|
|
};
|
|
|
|
let prepared = ws.prepare_rename(&key_cursor(8)).expect("member segment");
|
|
let RenameTarget::GraphProperty { path, .. } = &prepared.target else {
|
|
panic!("unexpected target {:?}", prepared.target);
|
|
};
|
|
assert_eq!(path.as_ref(), "result.adult");
|
|
assert_eq!(prepared.span, (7, 12));
|
|
|
|
let root = ws.prepare_rename(&key_cursor(2)).expect("root segment");
|
|
let RenameTarget::GraphProperty { path, .. } = &root.target else {
|
|
panic!("unexpected target {:?}", root.target);
|
|
};
|
|
assert_eq!(path.as_ref(), "result");
|
|
|
|
assert!(ws.inspect(&key_cursor(8)).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn date_input_of_child_decision_accepts_date_values_and_date_strings() {
|
|
let mut ws = Workspace::new();
|
|
let date_schema = |key: &str, format: &str| {
|
|
json!({
|
|
"type": "object",
|
|
"properties": { key: { "type": "string", "format": format } },
|
|
"required": [key]
|
|
})
|
|
};
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(date_schema("when", "date-time")),
|
|
vec![expression_node("calc", &[("year", "d(when).year()")])],
|
|
)),
|
|
);
|
|
let call = || node("call", "decisionNode", json!({ "key": "child" }));
|
|
ws.set_document(
|
|
"from_date_value",
|
|
document(linear_graph(
|
|
Some(date_schema("start", "date")),
|
|
vec![expression_node("prep", &[("when", "d(start)")]), call()],
|
|
)),
|
|
);
|
|
ws.set_document(
|
|
"from_date_string",
|
|
document(linear_graph(
|
|
Some(date_schema("when", "date-time")),
|
|
vec![call()],
|
|
)),
|
|
);
|
|
|
|
for parent in ["from_date_value", "from_date_string"] {
|
|
let errors: Vec<_> = ws
|
|
.diagnostics(parent)
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "{parent}: {errors:#?}");
|
|
}
|
|
|
|
let when = ws
|
|
.inputs(&ScopeRequest::for_policy("child"))
|
|
.into_iter()
|
|
.find(|p| p.path.as_ref() == "when")
|
|
.expect("child input 'when'");
|
|
let (inner, _) = when.resolved_type.unwrap_nullable();
|
|
assert!(
|
|
matches!(inner, VariableType::Date),
|
|
"got {}",
|
|
when.resolved_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_renames_handle_astral_characters() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let replaced = |edits: &[EngineEdit], id: &str| -> Value {
|
|
edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == id => Some(new_node.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| panic!("node {id} rewritten: {edits:?}"))
|
|
};
|
|
|
|
let mut ws = Workspace::new();
|
|
let function = node(
|
|
"fn",
|
|
"functionNode",
|
|
json!({ "source": "export const handler = async (input) => {\n const tag = '🚀 fast';\n return { score: input.age * 2 };\n};\n" }),
|
|
);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![function])),
|
|
);
|
|
let edits = ws.rename(
|
|
&RenameTarget::GraphProperty {
|
|
document: Arc::from("doc"),
|
|
path: Arc::from("age"),
|
|
},
|
|
"years",
|
|
);
|
|
let source = replaced(&edits, "fn")["content"]["source"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(source.contains("input.years * 2"), "{source}");
|
|
assert!(source.contains("'🚀 fast'"), "{source}");
|
|
|
|
let mut ws = Workspace::new();
|
|
let mut calc = expression_node("calc", &[("total", "age * 2")]);
|
|
calc["content"]["passThrough"] = json!(true);
|
|
let mut label = expression_node(
|
|
"label",
|
|
&[(
|
|
"text",
|
|
"'🚀' + string($nodes['calc'].total) + '🚀' + string($nodes.calc.total)",
|
|
)],
|
|
);
|
|
label["content"]["passThrough"] = json!(true);
|
|
ws.set_document(
|
|
"doc",
|
|
document(linear_graph(Some(person_schema()), vec![calc, label])),
|
|
);
|
|
let edits = ws.rename(
|
|
&RenameTarget::GraphNode {
|
|
document: Arc::from("doc"),
|
|
node_id: Arc::from("calc"),
|
|
},
|
|
"Calc2",
|
|
);
|
|
let rows = replaced(&edits, "label")["content"]["expressions"].clone();
|
|
assert_eq!(
|
|
rows[0]["value"],
|
|
json!("'🚀' + string($nodes['Calc2'].total) + '🚀' + string($nodes.Calc2.total)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn field_rename_reaches_imported_policies_reading_it() {
|
|
use zen_engine::policy::{ReferenceKind, RenameTarget};
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"main",
|
|
document(json!({
|
|
"imports": ["shared"],
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} }, "children": [] }
|
|
]
|
|
})),
|
|
);
|
|
ws.set_document(
|
|
"shared",
|
|
document(json!({
|
|
"blocks": [
|
|
{ "id": "m", "type": "match", "props": { "data": {
|
|
"key": "customer.tier",
|
|
"arms": [
|
|
{ "id": "a1", "condition": "customer.age > 50", "value": "\"gold\"" },
|
|
{ "id": "a2", "condition": "", "value": "\"silver\"" }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
|
|
let target = RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("age"),
|
|
};
|
|
let sites = ws.references(&target);
|
|
assert!(
|
|
sites
|
|
.iter()
|
|
.any(|s| s.policy_path.as_ref() == "shared" && s.kind == ReferenceKind::ExpressionRead),
|
|
"{sites:#?}"
|
|
);
|
|
assert!(
|
|
sites
|
|
.iter()
|
|
.any(|s| s.policy_path.as_ref() == "main" && s.kind == ReferenceKind::DataModel),
|
|
"{sites:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_rename_reaches_nodes_reads_and_child_decisions() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"customer": {
|
|
"type": "object",
|
|
"properties": { "age": { "type": "number" }, "name": { "type": "string" } },
|
|
"required": ["age", "name"]
|
|
}
|
|
},
|
|
"required": ["customer"]
|
|
});
|
|
let mut ws = Workspace::new();
|
|
ws.set_document(
|
|
"child",
|
|
document(linear_graph(
|
|
Some(schema.clone()),
|
|
vec![expression_node(
|
|
"verdict",
|
|
&[("adult", "customer.age >= 18")],
|
|
)],
|
|
)),
|
|
);
|
|
let mut graph = linear_graph(
|
|
Some(schema),
|
|
vec![
|
|
expression_node("calc", &[("isAdult", "customer.age >= 18")]),
|
|
expression_node("calc2", &[("next", "$nodes.in.customer.age + 1")]),
|
|
node("call", "decisionNode", json!({ "key": "child" })),
|
|
],
|
|
);
|
|
graph["nodes"][1]["content"]["passThrough"] = json!(true);
|
|
graph["nodes"][2]["content"]["passThrough"] = json!(true);
|
|
ws.set_document("parent", document(graph));
|
|
|
|
let edits = ws.rename(
|
|
&RenameTarget::GraphProperty {
|
|
document: Arc::from("parent"),
|
|
path: Arc::from("customer.age"),
|
|
},
|
|
"years",
|
|
);
|
|
let rendered: Vec<String> = edits
|
|
.iter()
|
|
.map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
document,
|
|
node_id,
|
|
new_node,
|
|
} => format!("{document}/{node_id}: {new_node}"),
|
|
other => format!("{other:?}"),
|
|
})
|
|
.collect();
|
|
let find = |doc: &str, id: &str| {
|
|
rendered
|
|
.iter()
|
|
.find(|r| r.starts_with(&format!("{doc}/{id}:")))
|
|
.unwrap_or_else(|| panic!("no edit for {doc}/{id}: {rendered:#?}"))
|
|
};
|
|
assert!(find("parent", "calc").contains("customer.years >= 18"));
|
|
assert!(find("parent", "calc2").contains("$nodes.in.customer.years + 1"));
|
|
assert!(find("parent", "in").contains("years"));
|
|
assert!(find("child", "verdict").contains("customer.years >= 18"));
|
|
assert!(find("child", "in").contains("years"));
|
|
}
|
|
|
|
#[test]
|
|
fn output_column_head_rename_renames_written_property() {
|
|
use zen_engine::policy::{EngineEdit, RenameTarget};
|
|
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"applicant": {
|
|
"type": "object",
|
|
"properties": { "score": { "type": "number" } },
|
|
"required": ["score"]
|
|
}
|
|
},
|
|
"required": ["applicant"]
|
|
});
|
|
let mut ws = Workspace::new();
|
|
let mut table = node(
|
|
"dt",
|
|
"decisionTableNode",
|
|
json!({
|
|
"hitPolicy": "first",
|
|
"inputField": "applicant",
|
|
"inputs": [{ "id": "c1", "name": "Score", "field": "score" }],
|
|
"outputs": [{ "id": "o1", "name": "Points", "field": "points" }],
|
|
"rules": [{ "_id": "r1", "c1": "> 10", "o1": "1" }, { "_id": "r2", "c1": "", "o1": "0" }]
|
|
}),
|
|
);
|
|
table["content"]["passThrough"] = json!(true);
|
|
let after = expression_node(
|
|
"after",
|
|
&[("double", "points * 2"), ("raw", "applicant.score")],
|
|
);
|
|
ws.set_document(
|
|
"g",
|
|
document(linear_graph(Some(schema), vec![table, after])),
|
|
);
|
|
|
|
let prepared = ws
|
|
.prepare_rename(&Cursor {
|
|
policy_path: Arc::from("g"),
|
|
block_id: Arc::from("dt"),
|
|
target: CursorTarget::DecisionTableHead {
|
|
col: Arc::from("o1"),
|
|
},
|
|
pos: 2,
|
|
})
|
|
.expect("output head is renamable");
|
|
assert_eq!(
|
|
prepared.target,
|
|
RenameTarget::GraphProperty {
|
|
document: Arc::from("g"),
|
|
path: Arc::from("points"),
|
|
}
|
|
);
|
|
|
|
let edits = ws.rename(&prepared.target, "rating");
|
|
let node_json = |id: &str| {
|
|
edits
|
|
.iter()
|
|
.find_map(|edit| match edit {
|
|
EngineEdit::ReplaceNode {
|
|
node_id, new_node, ..
|
|
} if node_id.as_ref() == id => Some(new_node.to_string()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| panic!("no edit for {id}: {edits:?}"))
|
|
};
|
|
let dt = node_json("dt");
|
|
assert!(dt.contains(r#""field":"rating""#), "{dt}");
|
|
assert!(
|
|
dt.contains(r#""field":"score""#),
|
|
"input column untouched: {dt}"
|
|
);
|
|
assert!(!dt.contains("points"), "{dt}");
|
|
let after = node_json("after");
|
|
assert!(after.contains("rating * 2"), "{after}");
|
|
assert!(after.contains("applicant.score"), "{after}");
|
|
}
|