use serde_json::json; use std::sync::Arc; use zen_engine::policy::{ EngineEdit, EvaluateRequest, PolicyWorkspace, RenameTarget, ScopeRequest, Severity, }; use zen_expression::variable::{Variable, VariableType}; fn workspace_with(doc: serde_json::Value) -> PolicyWorkspace { let mut ws = PolicyWorkspace::new(); ws.set_policy("p", serde_json::from_value(doc).unwrap()); ws } fn request(input: serde_json::Value, trace: bool) -> EvaluateRequest { EvaluateRequest { policy_path: Arc::from("p"), input: Variable::from(input), goals: Vec::new(), trace, } } fn evaluate_output(ws: &PolicyWorkspace, input: serde_json::Value) -> serde_json::Value { let result = ws.evaluate(&request(input, false)).expect("evaluate"); result.output.into() } fn output_type(ws: &PolicyWorkspace, path: &str) -> VariableType { ws.outputs(&ScopeRequest::for_policy("p")) .into_iter() .find(|o| o.path.as_ref() == path) .unwrap_or_else(|| panic!("output {path} not registered")) .resolved_type } fn error_messages(ws: &PolicyWorkspace) -> Vec { ws.diagnostics("p") .into_iter() .filter(|d| d.severity == Severity::Error) .map(|d| d.message.to_string()) .collect() } fn order_dm() -> serde_json::Value { json!({ "id": "dm", "type": "dataModel", "props": { "data": { "name": "order", "properties": [ { "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false }, { "id": "p2", "name": "region", "type": "string", "enum": ["US", "EU"], "array": false, "optional": false }, { "id": "p3", "name": "express", "type": "boolean", "array": false, "optional": false } ] } } }) } fn strict_table_doc() -> serde_json::Value { json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" }, { "id": "i2", "name": "", "field": "order.region" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.shippingCost" } ], "rules": [ { "i1": ">= 100", "i2": "\"US\"", "o1": "0" }, { "i1": ">= 100", "i2": "\"EU\"", "o1": "5" } ] } } } ] }) } #[test] fn no_match_emits_null_for_scalar_output() { let ws = workspace_with(strict_table_doc()); let output = evaluate_output(&ws, json!({ "order": { "amount": 50, "region": "US" } })); assert_eq!( output.pointer("/order/shippingCost"), Some(&serde_json::Value::Null), "no-match scalar column must write an explicit null; got {output:?}" ); } #[test] fn no_match_trace_has_no_matched_rows() { let ws = workspace_with(strict_table_doc()); let result = ws .evaluate(&request( json!({ "order": { "amount": 50, "region": "US" } }), true, )) .expect("evaluate"); let trace = result.trace.expect("trace"); let dt = trace .executions .iter() .find(|e| e.block_id.as_ref() == "dt") .expect("dt execution"); let trace_json = serde_json::to_value(&dt.trace).unwrap(); assert_eq!(trace_json["matchedRows"], json!([])); } #[test] fn uncovered_scalar_output_is_nullable() { let ws = workspace_with(strict_table_doc()); assert!(error_messages(&ws).is_empty()); assert!( matches!( output_type(&ws, "order.shippingCost"), VariableType::Nullable(_) ), "no catch-all and partial coverage must produce a nullable output" ); } #[test] fn catch_all_row_makes_scalar_output_non_nullable() { let mut doc = strict_table_doc(); doc["blocks"][1]["props"]["data"]["rules"] .as_array_mut() .unwrap() .push(json!({ "i1": "", "i2": "", "o1": "20" })); let ws = workspace_with(doc); assert!(error_messages(&ws).is_empty()); assert!( matches!(output_type(&ws, "order.shippingCost"), VariableType::Number), "catch-all row must prove coverage" ); } #[test] fn enum_union_across_rows_covers() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], "rules": [ { "i1": "\"US\"", "o1": "1" }, { "i1": "\"EU\"", "o1": "2" } ] } } } ] }); let ws = workspace_with(doc); assert!(error_messages(&ws).is_empty()); assert!(matches!( output_type(&ws, "order.zone"), VariableType::Number )); } #[test] fn partial_enum_union_stays_nullable() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], "rules": [ { "i1": "\"US\"", "o1": "1" } ] } } } ] }); let ws = workspace_with(doc); assert!(matches!( output_type(&ws, "order.zone"), VariableType::Nullable(_) )); } #[test] fn number_tiling_covers_and_gap_does_not() { let tiled = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.band" } ], "rules": [ { "i1": "< 100", "o1": "\"low\"" }, { "i1": "[100..500]", "o1": "\"mid\"" }, { "i1": "> 500", "o1": "\"high\"" } ] } } } ] }); let ws = workspace_with(tiled); assert!( !matches!(output_type(&ws, "order.band"), VariableType::Nullable(_)), "tiled number coverage must be non-nullable" ); let gapped = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.band" } ], "rules": [ { "i1": "< 100", "o1": "\"low\"" }, { "i1": "> 500", "o1": "\"high\"" } ] } } } ] }); let ws = workspace_with(gapped); assert!(matches!( output_type(&ws, "order.band"), VariableType::Nullable(_) )); } #[test] fn bool_both_values_cover() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.express" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.fee" } ], "rules": [ { "i1": "true", "o1": "10" }, { "i1": "false", "o1": "0" } ] } } } ] }); let ws = workspace_with(doc); assert!(matches!( output_type(&ws, "order.fee"), VariableType::Number )); } #[test] fn multi_column_rows_do_not_prove_coverage() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.region" }, { "id": "i2", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], "rules": [ { "i1": "\"US\"", "i2": "> 0", "o1": "1" }, { "i1": "\"EU\"", "i2": "> 0", "o1": "2" } ] } } } ] }); let ws = workspace_with(doc); assert!(matches!( output_type(&ws, "order.zone"), VariableType::Nullable(_) )); } #[test] fn optional_input_field_is_not_covered_without_catch_all() { let doc = json!({ "blocks": [ { "id": "dm", "type": "dataModel", "props": { "data": { "name": "order", "properties": [ { "id": "p2", "name": "region", "type": "string", "enum": ["US", "EU"], "array": false, "optional": true } ] } } }, { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], "rules": [ { "i1": "\"US\"", "o1": "1" }, { "i1": "\"EU\"", "o1": "2" } ] } } } ] }); let ws = workspace_with(doc); assert!(matches!( output_type(&ws, "order.zone"), VariableType::Nullable(_) )); } fn mixed_table_doc() -> serde_json::Value { json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.tags[]" }, { "id": "o2", "name": "", "field": "order.tier" } ], "rules": [ { "i1": "> 1000", "o1": "\"vip\"", "o2": "" }, { "i1": "> 100", "o1": "\"bulk\"", "o2": "\"gold\"" }, { "i1": "> 10", "o1": "", "o2": "\"silver\"" }, { "i1": "", "o1": "\"standard\"", "o2": "\"bronze\"" } ] } } } ] }) } #[test] fn mixed_table_collects_tags_and_first_matches_tier() { let ws = workspace_with(mixed_table_doc()); assert!(error_messages(&ws).is_empty(), "{:?}", error_messages(&ws)); let output = evaluate_output( &ws, json!({ "order": { "amount": 5000, "region": "US", "express": false } }), ); assert_eq!( output.pointer("/order/tags"), Some(&json!(["vip", "bulk", "standard"])) ); assert_eq!(output.pointer("/order/tier"), Some(&json!("gold"))); let output = evaluate_output( &ws, json!({ "order": { "amount": 50, "region": "US", "express": false } }), ); assert_eq!(output.pointer("/order/tags"), Some(&json!(["standard"]))); assert_eq!(output.pointer("/order/tier"), Some(&json!("silver"))); } #[test] fn scalar_falls_through_rows_with_empty_cell() { let ws = workspace_with(mixed_table_doc()); let output = evaluate_output( &ws, json!({ "order": { "amount": 5000, "region": "US", "express": false } }), ); assert_eq!( output.pointer("/order/tier"), Some(&json!("gold")), "row 0 matches with an empty tier cell; tier must fall through to row 1" ); } #[test] fn mixed_table_types_are_array_and_covered_scalar() { let ws = workspace_with(mixed_table_doc()); let tags = output_type(&ws, "order.tags"); assert!( matches!(&tags, VariableType::Array(inner) if !matches!(inner.as_ref(), VariableType::Nullable(_))), "collect column must type as a clean array, got {tags}" ); let tier = output_type(&ws, "order.tier"); assert!( !matches!(tier, VariableType::Nullable(_)), "catch-all row provides tier coverage, got {tier}" ); assert_eq!(format!("{tier}"), "\"gold\" | \"silver\" | \"bronze\""); } #[test] fn no_match_collect_emits_empty_array() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.tags[]" } ], "rules": [ { "i1": "> 1000", "o1": "\"vip\"" } ] } } } ] }); let ws = workspace_with(doc); let output = evaluate_output( &ws, json!({ "order": { "amount": 5, "region": "US", "express": false } }), ); assert_eq!(output.pointer("/order/tags"), Some(&json!([]))); } #[test] fn legacy_collect_hit_policy_marks_all_columns() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "hitPolicy": "collect", "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.tags" } ], "rules": [ { "i1": "> 10", "o1": "\"a\"" }, { "i1": "> 100", "o1": "\"b\"" } ] } } } ] }); let ws = workspace_with(doc); assert!(matches!( output_type(&ws, "order.tags"), VariableType::Array(_) )); let output = evaluate_output( &ws, json!({ "order": { "amount": 500, "region": "US", "express": false } }), ); assert_eq!(output.pointer("/order/tags"), Some(&json!(["a", "b"]))); } #[test] fn collect_marker_mid_path_errors() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.tags[].x" } ], "rules": [ { "i1": "", "o1": "\"a\"" } ] } } } ] }); let ws = workspace_with(doc); let errors = error_messages(&ws); assert!( errors.iter().any(|m| m.contains("[]")), "mid-path [] must raise InvalidWritePath, got {errors:?}" ); } #[test] fn bare_collect_marker_errors() { let doc = json!({ "blocks": [ order_dm(), { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "[]" } ], "rules": [ { "i1": "", "o1": "\"a\"" } ] } } } ] }); let ws = workspace_with(doc); let errors = error_messages(&ws); assert!( !errors.is_empty(), "bare [] field must raise InvalidWritePath" ); } #[test] fn rename_rewrites_collect_field_keeping_marker() { let ws = workspace_with(mixed_table_doc()); let edits = ws.rename( &RenameTarget::Field { entity: Arc::from("order"), field: Arc::from("tags"), }, "labels", ); let rewritten = edits .iter() .find_map(|e| match e { EngineEdit::ReplaceBlock { new_block, .. } => { Some(serde_json::to_string(new_block).unwrap()) } _ => None, }) .expect("rename must rewrite the table block"); assert!( rewritten.contains("order.labels[]"), "collect marker must survive rename, got {rewritten}" ); } #[test] fn unused_scalar_cells_stay_lazy() { let doc = json!({ "blocks": [ order_dm(), { "id": "e-gold", "type": "expression", "props": { "data": { "key": "order.goldLabel", "value": "\"gold\"" } } }, { "id": "e-silver", "type": "expression", "props": { "data": { "key": "order.silverLabel", "value": "\"silver\"" } } }, { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.tags[]" }, { "id": "o2", "name": "", "field": "order.tier" } ], "rules": [ { "i1": "> 100", "o1": "\"bulk\"", "o2": "order.goldLabel" }, { "i1": "", "o1": "\"standard\"", "o2": "order.silverLabel" } ] } } } ] }); let ws = workspace_with(doc); let result = ws .evaluate(&request( json!({ "order": { "amount": 500, "region": "US", "express": false } }), true, )) .expect("evaluate"); let executed: Vec = result .trace .expect("trace") .executions .iter() .map(|e| e.block_id.to_string()) .collect(); assert!( executed.contains(&"e-gold".to_string()), "resolving row's tier dependency must run: {executed:?}" ); assert!( !executed.contains(&"e-silver".to_string()), "non-resolving row's tier dependency must stay lazy: {executed:?}" ); let output: serde_json::Value = result.output.into(); assert_eq!(output.pointer("/order/tier"), Some(&json!("gold"))); assert_eq!( output.pointer("/order/tags"), Some(&json!(["bulk", "standard"])) ); } #[test] fn equality_index_matches_linear_semantics() { let doc = json!({ "blocks": [ { "id": "dm", "type": "dataModel", "props": { "data": { "name": "order", "properties": [ { "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false }, { "id": "p2", "name": "region", "type": "string", "array": false, "optional": false }, { "id": "p3", "name": "express", "type": "boolean", "array": false, "optional": false } ] } } }, { "id": "dt", "type": "decisionTable", "props": { "data": { "inputs": [ { "id": "i1", "name": "", "field": "order.region" }, { "id": "i2", "name": "", "field": "order.amount" }, { "id": "i3", "name": "", "field": "order.express" } ], "outputs": [ { "id": "o1", "name": "", "field": "order.bucket" } ], "rules": [ { "i1": "\"US\"", "i2": "1", "i3": "true", "o1": "\"r0\"" }, { "i1": "\"US\", \"EU\"", "i2": "2", "i3": "", "o1": "\"r1\"" }, { "i1": "in [\"EU\"]", "i2": "in [3, 4]", "i3": "false", "o1": "\"r2\"" }, { "i1": "(\"US\")", "i2": "[10..20]", "i3": "", "o1": "\"r3\"" }, { "i1": "", "i2": "100", "i3": "", "o1": "\"r4\"" }, { "i1": "\"AP\"", "i2": "", "i3": "", "o1": "\"r5\"" }, { "i1": "startsWith($, \"E\")", "i2": "777", "i3": "", "o1": "\"r6\"" }, { "i1": "\"US\"", "i2": "> 1000", "i3": "", "o1": "\"r7\"" }, { "i1": "\"EU\"", "i2": "2.50", "i3": "", "o1": "\"r8\"" }, { "i1": "", "i2": "", "i3": "", "o1": "\"r9\"" } ] } } } ] }); let ws = workspace_with(doc); let bucket_for = |region: &str, amount: serde_json::Value, express: bool| -> serde_json::Value { evaluate_output( &ws, json!({ "order": { "region": region, "amount": amount, "express": express } }), ) .pointer("/order/bucket") .cloned() .unwrap_or(serde_json::Value::Null) }; assert_eq!(bucket_for("US", json!(1), true), json!("r0")); assert_eq!(bucket_for("US", json!(1), false), json!("r9")); assert_eq!(bucket_for("EU", json!(2), false), json!("r1")); assert_eq!(bucket_for("EU", json!(3), false), json!("r2")); assert_eq!(bucket_for("EU", json!(4), true), json!("r9")); assert_eq!(bucket_for("US", json!(15), false), json!("r3")); assert_eq!(bucket_for("AP", json!(100), false), json!("r4")); assert_eq!(bucket_for("AP", json!(7), false), json!("r5")); assert_eq!(bucket_for("EU", json!(777), false), json!("r6")); assert_eq!(bucket_for("US", json!(5000), false), json!("r7")); assert_eq!(bucket_for("EU", json!(2.5), false), json!("r8")); assert_eq!(bucket_for("US", json!(999), false), json!("r9")); }