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 = 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, middle: Vec) -> 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 { 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 sparse_catch_all_row_covers_the_table() { let mut ws = Workspace::new(); let table = node( "dt", "decisionTableNode", json!({ "hitPolicy": "first", "inputs": [ { "id": "c1", "name": "Age", "field": "age" }, { "id": "c2", "name": "Name", "field": "name" } ], "outputs": [ { "id": "o1", "name": "Rate", "field": "rate" } ], "rules": [ { "_id": "r1", "c1": "> 18", "c2": "", "o1": "0.1" }, { "_id": "r2", "o1": "0.2" } ], "passThrough": true }), ); ws.set_document( "g", document(linear_graph(Some(person_schema()), vec![table])), ); let outputs = ws.outputs(&ScopeRequest::for_policy("g")); let rate = outputs .iter() .find(|o| o.path.as_ref() == "rate") .unwrap_or_else(|| panic!("{outputs:?}")); assert!( matches!(rate.resolved_type, VariableType::Number), "{:?}", rate.resolved_type ); } #[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 = 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 = 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 = 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 = 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 = cells .iter() .enumerate() .map(|(i, cell)| { let condition = if i + 1 == cells.len() { String::new() } else { i.to_string() }; json!({ "_id": format!("r{i}"), "c1": condition, "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::>() ); } #[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 = 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}"); }