mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 16:02:18 +00:00
* feat: implement slots instead of natural language Replace the natural-language projection (`nl`) with engine-owned slot completion: for a cursor position the engine classifies the slot being edited (operand, operator, literal, member access, ...) and returns typed options, literal facts and the expected type, for policies and graphs. - zen-expression: new `slot` module (classification, literals, operators) and a lenient lexer mode for incomplete input; `nl` removed - Intellisense: typed diagnostic codes with arguments, richer inspect for hover, dedicated parser error variants (messages unchanged) - Engine: `Workspace::cursor_scope`, `slot`, `facts`; `rename_from` and `references_from`; editor spans in UTF-16 units; renames reach imported policies, `$nodes` reads, child graphs and output column heads; no `$root` completion in policies - Node bindings: `slot`, `facts`, `cursorScope`, `slotBatch` replace `nl`, `nlTokenize`, `nlEncodeString`, `nlTokenizeBatch` No change to expression evaluation: the VM, compiler, functions and the default lexer path are untouched, and parser error messages keep their text. * fix: harden slot classification, scopes and graph expectations - restore master execution order; slot scopes hide writes of the edited block and its dependents instead of relying on block ranking - token-start editing gated by slot state; operator, list and operand spans no longer splice into neighbouring tokens - interval-aware bracket pairing for half-open and reversed intervals - lenient lexer recovers from unknown characters (slots only) - graph expected type follows switch and pass-through nodes and merges compatible output schemas - sibling inference caps distinct values and analyses lazily - deterministic overload return type display * fix: order policy blocks by entity reads through closures and relationships - record reads for `#.field` in closures so entity fields read through lists become dependencies - link reads through derived lists (filter/map results) to the entity fields they carry and order the list after those fields - evaluate entity blocks on single relationships, and demand entity paths for plain reads through relationships - slot scopes keep the top-level variable when hiding written fields
5067 lines
171 KiB
Rust
5067 lines
171 KiB
Rust
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use zen_engine::policy::{
|
|
Cursor, CursorTarget, EngineEdit, EvaluateRequest, EvaluationError, PolicyWorkspace,
|
|
ScopeRequest, Severity,
|
|
};
|
|
|
|
fn rewritten_block_json(edits: &[EngineEdit], block_id: &str) -> Option<String> {
|
|
edits.iter().find_map(|e| match e {
|
|
EngineEdit::ReplaceBlock {
|
|
block_id: bid,
|
|
new_block,
|
|
..
|
|
} if bid.as_ref() == block_id => Some(serde_json::to_string(new_block).unwrap()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn any_rewritten_contains(edits: &[EngineEdit], needle: &str) -> bool {
|
|
edits.iter().any(|e| match e {
|
|
EngineEdit::ReplaceBlock { new_block, .. } => {
|
|
serde_json::to_string(new_block).unwrap().contains(needle)
|
|
}
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn any_touches_block(edits: &[EngineEdit], block_id: &str) -> bool {
|
|
edits.iter().any(|e| match e {
|
|
EngineEdit::ReplaceBlock { block_id: bid, .. }
|
|
| EngineEdit::DeleteBlock { block_id: bid, .. } => bid.as_ref() == block_id,
|
|
EngineEdit::InsertBlock { .. } | EngineEdit::ReplaceNode { .. } => false,
|
|
})
|
|
}
|
|
use zen_expression::variable::{Variable, VariableType};
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct DiagnosticsCase {
|
|
name: String,
|
|
#[serde(default)]
|
|
policies: Vec<String>,
|
|
#[serde(default)]
|
|
content: Option<String>,
|
|
#[serde(default)]
|
|
policy: Option<String>,
|
|
#[serde(default)]
|
|
no_errors: bool,
|
|
#[serde(default)]
|
|
error_codes: Vec<String>,
|
|
#[serde(default)]
|
|
warning_codes: Vec<String>,
|
|
#[serde(default)]
|
|
hint_codes: Vec<String>,
|
|
#[serde(default)]
|
|
error_count: Option<usize>,
|
|
#[serde(default)]
|
|
hint_count: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct DiagnosticsFile {
|
|
#[serde(rename = "test")]
|
|
tests: Vec<DiagnosticsCase>,
|
|
}
|
|
|
|
impl DiagnosticsCase {
|
|
fn policy_path(&self) -> &str {
|
|
self.policy
|
|
.as_deref()
|
|
.or_else(|| self.policies.first().map(String::as_str))
|
|
.unwrap_or("p")
|
|
}
|
|
|
|
fn run(&self, fixtures_dir: &Path) {
|
|
let mut ws = PolicyWorkspace::new();
|
|
|
|
if let Some(inline) = &self.content {
|
|
let doc: serde_json::Value = serde_json::from_str(inline)
|
|
.unwrap_or_else(|e| panic!("[{}] invalid inline policy JSON: {e}", self.name));
|
|
let path = self.policy_path();
|
|
ws.set_policy(path, serde_json::from_value(doc).unwrap());
|
|
} else {
|
|
for fixture in &self.policies {
|
|
let path = fixtures_dir.join(fixture);
|
|
let raw = fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("[{}] cannot read {:?}: {e}", self.name, path));
|
|
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {
|
|
panic!("[{}] invalid fixture JSON in {fixture}: {e}", self.name)
|
|
});
|
|
ws.set_policy(fixture.as_str(), serde_json::from_value(doc).unwrap());
|
|
}
|
|
}
|
|
|
|
let target = self.policy_path();
|
|
let diagnostics = ws.diagnostics(target);
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
let warnings: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Warning)
|
|
.collect();
|
|
let hints: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Hint)
|
|
.collect();
|
|
|
|
if self.no_errors {
|
|
assert!(
|
|
errors.is_empty(),
|
|
"[{}] expected no errors, got {errors:#?}",
|
|
self.name,
|
|
);
|
|
}
|
|
if let Some(expected) = self.error_count {
|
|
assert_eq!(
|
|
errors.len(),
|
|
expected,
|
|
"[{}] expected exactly {expected} error(s), got {}: {errors:#?}",
|
|
self.name,
|
|
errors.len(),
|
|
);
|
|
}
|
|
for code in &self.error_codes {
|
|
assert!(
|
|
errors.iter().any(|d| format!("{:?}", d.code) == *code),
|
|
"[{}] expected error `{code}` not found in {errors:#?}",
|
|
self.name,
|
|
);
|
|
}
|
|
for code in &self.warning_codes {
|
|
assert!(
|
|
warnings.iter().any(|d| format!("{:?}", d.code) == *code),
|
|
"[{}] expected warning `{code}` not found in {warnings:#?}",
|
|
self.name,
|
|
);
|
|
}
|
|
if let Some(expected) = self.hint_count {
|
|
assert_eq!(
|
|
hints.len(),
|
|
expected,
|
|
"[{}] expected exactly {expected} hint(s), got {}: {hints:#?}",
|
|
self.name,
|
|
hints.len(),
|
|
);
|
|
}
|
|
for code in &self.hint_codes {
|
|
assert!(
|
|
hints.iter().any(|d| format!("{:?}", d.code) == *code),
|
|
"[{}] expected hint `{code}` not found in {hints:#?}",
|
|
self.name,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_toml_cases() {
|
|
let toml_path =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/diagnostics.toml");
|
|
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/fixtures");
|
|
let raw =
|
|
fs::read_to_string(&toml_path).unwrap_or_else(|e| panic!("cannot read {toml_path:?}: {e}"));
|
|
let file: DiagnosticsFile =
|
|
toml::from_str(&raw).unwrap_or_else(|e| panic!("cannot parse {toml_path:?}: {e}"));
|
|
|
|
let mut failures: Vec<String> = Vec::new();
|
|
for case in &file.tests {
|
|
if let Err(err) =
|
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case.run(&fixtures_dir)))
|
|
{
|
|
let msg = err
|
|
.downcast_ref::<String>()
|
|
.cloned()
|
|
.or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()))
|
|
.unwrap_or_else(|| "<non-string panic>".to_string());
|
|
failures.push(format!("{}:\n{msg}", case.name));
|
|
}
|
|
}
|
|
assert!(
|
|
failures.is_empty(),
|
|
"{} diagnostics case(s) failed:\n\n{}",
|
|
failures.len(),
|
|
failures.join("\n\n"),
|
|
);
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct DependencyCase {
|
|
name: String,
|
|
#[serde(default)]
|
|
policies: Vec<String>,
|
|
#[serde(default)]
|
|
content: Option<String>,
|
|
#[serde(default)]
|
|
policy: Option<String>,
|
|
target: String,
|
|
#[serde(default)]
|
|
computed: Vec<String>,
|
|
#[serde(default)]
|
|
inputs: Vec<String>,
|
|
#[serde(default)]
|
|
unresolved: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct DependencyFile {
|
|
#[serde(rename = "test")]
|
|
tests: Vec<DependencyCase>,
|
|
}
|
|
|
|
impl DependencyCase {
|
|
fn collect(node: &zen_engine::policy::DependencyNode, out: &mut Vec<(String, bool, bool)>) {
|
|
out.push((
|
|
node.property.to_string(),
|
|
node.written_by.is_some(),
|
|
node.unresolved,
|
|
));
|
|
for d in &node.deps {
|
|
Self::collect(d, out);
|
|
}
|
|
}
|
|
|
|
fn run(&self, fixtures_dir: &Path) {
|
|
let mut ws = PolicyWorkspace::new();
|
|
if let Some(inline) = &self.content {
|
|
let doc: serde_json::Value = serde_json::from_str(inline)
|
|
.unwrap_or_else(|e| panic!("[{}] invalid inline policy JSON: {e}", self.name));
|
|
let path = self
|
|
.policy
|
|
.as_deref()
|
|
.or_else(|| self.policies.first().map(String::as_str))
|
|
.unwrap_or("p");
|
|
ws.set_policy(path, serde_json::from_value(doc).unwrap());
|
|
} else {
|
|
for fixture in &self.policies {
|
|
let path = fixtures_dir.join(fixture);
|
|
let raw = fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("[{}] cannot read {:?}: {e}", self.name, path));
|
|
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {
|
|
panic!("[{}] invalid fixture JSON in {fixture}: {e}", self.name)
|
|
});
|
|
ws.set_policy(fixture.as_str(), serde_json::from_value(doc).unwrap());
|
|
}
|
|
}
|
|
|
|
let mut nodes: Vec<(String, bool, bool)> = Vec::new();
|
|
Self::collect(
|
|
&ws.dependencies_scoped(&self.target, self.policy.as_deref()),
|
|
&mut nodes,
|
|
);
|
|
let paths: Vec<&str> = nodes.iter().map(|(p, _, _)| p.as_str()).collect();
|
|
let find = |path: &str| nodes.iter().find(|(p, _, _)| p == path);
|
|
|
|
for c in &self.computed {
|
|
let node = find(c)
|
|
.unwrap_or_else(|| panic!("[{}] expected `{c}` in tree; got {paths:?}", self.name));
|
|
assert!(
|
|
node.1,
|
|
"[{}] `{c}` expected computed (writer), got input/unresolved",
|
|
self.name
|
|
);
|
|
}
|
|
for i in &self.inputs {
|
|
let node = find(i)
|
|
.unwrap_or_else(|| panic!("[{}] expected `{i}` in tree; got {paths:?}", self.name));
|
|
assert!(
|
|
!node.1 && !node.2,
|
|
"[{}] `{i}` expected plain input, got writer={} unresolved={}",
|
|
self.name,
|
|
node.1,
|
|
node.2
|
|
);
|
|
}
|
|
for u in &self.unresolved {
|
|
let node = find(u)
|
|
.unwrap_or_else(|| panic!("[{}] expected `{u}` in tree; got {paths:?}", self.name));
|
|
assert!(
|
|
node.2,
|
|
"[{}] `{u}` expected unresolved, got writer={}",
|
|
self.name, node.1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_toml_cases() {
|
|
let toml_path =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/dependencies.toml");
|
|
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/fixtures");
|
|
let raw =
|
|
fs::read_to_string(&toml_path).unwrap_or_else(|e| panic!("cannot read {toml_path:?}: {e}"));
|
|
let file: DependencyFile =
|
|
toml::from_str(&raw).unwrap_or_else(|e| panic!("cannot parse {toml_path:?}: {e}"));
|
|
|
|
let mut failures: Vec<String> = Vec::new();
|
|
for case in &file.tests {
|
|
if let Err(err) =
|
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case.run(&fixtures_dir)))
|
|
{
|
|
let msg = err
|
|
.downcast_ref::<String>()
|
|
.cloned()
|
|
.or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()))
|
|
.unwrap_or_else(|| "<non-string panic>".to_string());
|
|
failures.push(format!("{}:\n{msg}", case.name));
|
|
}
|
|
}
|
|
assert!(
|
|
failures.is_empty(),
|
|
"{} dependency case(s) failed:\n\n{}",
|
|
failures.len(),
|
|
failures.join("\n\n"),
|
|
);
|
|
}
|
|
|
|
fn data_model_document() -> serde_json::Value {
|
|
json!({
|
|
"blocks": [
|
|
{
|
|
"id": "dm-heading",
|
|
"type": "heading",
|
|
"props": {},
|
|
"children": []
|
|
},
|
|
{
|
|
"id": "dm-customer",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "name", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "age", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p3", "name": "country", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p4", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false },
|
|
{ "id": "p5", "name": "creditReport", "type": "relationship", "target": "creditReport", "array": false, "optional": false },
|
|
{ "id": "p6", "name": "favoriteProduct", "type": "reference", "target": "product", "array": false, "optional": true }
|
|
]
|
|
})
|
|
}
|
|
},
|
|
{
|
|
"id": "dm-company",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p7", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p8", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
})
|
|
}
|
|
},
|
|
{
|
|
"id": "dm-credit-report",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": json!({
|
|
"name": "creditReport",
|
|
"properties": [
|
|
{ "id": "p11", "name": "score", "type": "number", "array": false, "optional": false }
|
|
]
|
|
})
|
|
}
|
|
},
|
|
{
|
|
"id": "dm-product",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": json!({
|
|
"name": "product",
|
|
"properties": [
|
|
{ "id": "p14", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p15", "name": "name", "type": "string", "array": false, "optional": false }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn basic_entities_and_inputs() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
let doc = data_model_document();
|
|
ws.set_policy("test", serde_json::from_value(doc).unwrap());
|
|
|
|
let diags = ws.diagnostics("test");
|
|
let errors: Vec<_> = diags
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {:?}", errors);
|
|
|
|
let entities = ws.entities(&ScopeRequest::for_policy("test"));
|
|
let names: Vec<_> = entities.iter().map(|e| e.name.to_string()).collect();
|
|
assert!(names.contains(&"customer".to_string()));
|
|
assert!(names.contains(&"company".to_string()));
|
|
assert!(names.contains(&"creditReport".to_string()));
|
|
assert!(names.contains(&"product".to_string()));
|
|
|
|
let customer = entities
|
|
.iter()
|
|
.find(|e| e.name.as_ref() == "customer")
|
|
.unwrap();
|
|
assert_eq!(customer.fields.len(), 6);
|
|
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("test"));
|
|
let paths: Vec<_> = inputs.iter().map(|p| p.path.to_string()).collect();
|
|
|
|
let fav = inputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "customer.favoriteProduct");
|
|
assert!(
|
|
fav.is_some(),
|
|
"customer.favoriteProduct missing; inputs={:?}",
|
|
paths
|
|
);
|
|
assert!(matches!(fav.unwrap().resolved_type, VariableType::String));
|
|
|
|
let companies = inputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "customer.companies");
|
|
assert!(companies.is_some());
|
|
assert!(matches!(
|
|
companies.unwrap().resolved_type,
|
|
VariableType::Array(_)
|
|
));
|
|
|
|
let product = inputs.iter().find(|p| p.path.as_ref() == "product");
|
|
assert!(product.is_some(), "product top-level ref target missing");
|
|
assert!(matches!(
|
|
product.unwrap().resolved_type,
|
|
VariableType::Array(_)
|
|
));
|
|
|
|
assert!(
|
|
!paths.iter().any(|p| p.starts_with("company.")),
|
|
"company.* should not be top-level"
|
|
);
|
|
assert!(
|
|
!paths.iter().any(|p| p == "company"),
|
|
"company should not appear as top-level"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn references_sorted_dataModel_then_reads_then_writes() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"policy",
|
|
serde_json::from_value(analysis_document()).unwrap(),
|
|
);
|
|
|
|
let refs = ws.references(&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("creditTier"),
|
|
});
|
|
assert!(!refs.is_empty(), "expected at least one reference site");
|
|
|
|
let order: Vec<u8> = refs.iter().map(|r| r.kind.display_order()).collect();
|
|
let mut sorted = order.clone();
|
|
sorted.sort();
|
|
assert_eq!(
|
|
order, sorted,
|
|
"references should be sorted by kind: dataModel → expressionRead → writeKey; got {refs:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_evaluate_accepts_array_input_for_array_element_reads() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"policy",
|
|
serde_json::from_value(analysis_document()).unwrap(),
|
|
);
|
|
|
|
let skeleton = ws.input_skeleton(&ScopeRequest {
|
|
policy_path: Arc::from("policy"),
|
|
goals: vec![Arc::from("customer.totalRevenue")],
|
|
});
|
|
let customer = skeleton
|
|
.as_object()
|
|
.and_then(|o| o.get("customer"))
|
|
.and_then(|v| v.as_object())
|
|
.expect("customer in skeleton");
|
|
assert!(
|
|
customer.contains_key("companies"),
|
|
"skeleton must include customer.companies for goal=totalRevenue; got {customer:#?}",
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("policy"),
|
|
input: Variable::from(skeleton),
|
|
goals: vec![Arc::from("customer.totalRevenue")],
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req);
|
|
assert!(
|
|
result.is_ok(),
|
|
"evaluate(goal=totalRevenue, input=skeleton) should succeed; got {:?}",
|
|
result.err(),
|
|
);
|
|
}
|
|
|
|
fn optional_input_document() -> serde_json::Value {
|
|
json!({
|
|
"blocks": [
|
|
{
|
|
"id": "dm",
|
|
"type": "dataModel",
|
|
"props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "nickname", "type": "string", "array": false, "optional": true }
|
|
]
|
|
}},
|
|
"children": []
|
|
},
|
|
{
|
|
"id": "assert-adult",
|
|
"type": "assertion",
|
|
"props": { "data": {
|
|
"output": "customer.adult",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}},
|
|
"children": []
|
|
},
|
|
{
|
|
"id": "assert-greeted",
|
|
"type": "assertion",
|
|
"props": { "data": {
|
|
"output": "customer.greeted",
|
|
"conditions": [
|
|
{ "id": "c2", "expression": "customer.nickname != null", "operator": "and", "depth": 0 }
|
|
]
|
|
}},
|
|
"children": []
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn optional_input_omission_does_not_flag_missing() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"policy",
|
|
serde_json::from_value(optional_input_document()).unwrap(),
|
|
);
|
|
|
|
let greeted = ws.evaluate(&EvaluateRequest {
|
|
policy_path: Arc::from("policy"),
|
|
input: Variable::from(json!({ "customer": {} })),
|
|
goals: vec![Arc::from("customer.greeted")],
|
|
trace: false,
|
|
});
|
|
assert!(
|
|
greeted.is_ok(),
|
|
"omitting optional input customer.nickname must not raise MissingRequiredInputs; got {:?}",
|
|
greeted.err(),
|
|
);
|
|
|
|
let adult = ws.evaluate(&EvaluateRequest {
|
|
policy_path: Arc::from("policy"),
|
|
input: Variable::from(json!({ "customer": {} })),
|
|
goals: vec![Arc::from("customer.adult")],
|
|
trace: false,
|
|
});
|
|
assert!(
|
|
matches!(adult, Err(EvaluationError::MissingRequiredInputs { .. })),
|
|
"omitting required input customer.age must raise MissingRequiredInputs; got {adult:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_distinguishes_per_write_in_multi_output_tree() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"policy",
|
|
serde_json::from_value(analysis_document()).unwrap(),
|
|
);
|
|
|
|
let total = ws.dependencies("customer.totalRevenue");
|
|
let dep_paths: Vec<String> = total.deps.iter().map(|d| d.property.to_string()).collect();
|
|
assert!(
|
|
dep_paths.iter().any(|p| p.contains("customer.companies")),
|
|
"totalRevenue should depend on customer.companies; got {dep_paths:?}",
|
|
);
|
|
assert!(
|
|
!dep_paths.iter().any(|p| p == "customer.creditReport.score"),
|
|
"totalRevenue must NOT depend on customer.creditReport.score (per-write granularity); got {dep_paths:?}",
|
|
);
|
|
|
|
let tier = ws.dependencies("customer.creditTier");
|
|
let tier_paths: Vec<String> = tier.deps.iter().map(|d| d.property.to_string()).collect();
|
|
assert!(
|
|
tier_paths
|
|
.iter()
|
|
.any(|p| p == "customer.creditReport.score"),
|
|
"creditTier should depend on customer.creditReport.score; got {tier_paths:?}",
|
|
);
|
|
|
|
let discount = ws.dependencies("customer.discount");
|
|
let discount_first: Vec<String> = discount
|
|
.deps
|
|
.iter()
|
|
.map(|d| d.property.to_string())
|
|
.collect();
|
|
assert!(
|
|
discount_first.iter().any(|p| p == "customer.creditTier"),
|
|
"discount should directly depend on creditTier; got {discount_first:?}",
|
|
);
|
|
assert!(
|
|
discount_first.iter().any(|p| p == "customer.totalRevenue"),
|
|
"discount should directly depend on totalRevenue; got {discount_first:?}",
|
|
);
|
|
let credit_node = discount
|
|
.deps
|
|
.iter()
|
|
.find(|d| d.property.as_ref() == "customer.creditTier")
|
|
.expect("creditTier child");
|
|
let credit_grandchildren: Vec<String> = credit_node
|
|
.deps
|
|
.iter()
|
|
.map(|d| d.property.to_string())
|
|
.collect();
|
|
assert!(
|
|
credit_grandchildren
|
|
.iter()
|
|
.any(|p| p == "customer.creditReport.score"),
|
|
"discount → creditTier → customer.creditReport.score should appear transitively; got {credit_grandchildren:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_skeleton_omits_back_references_and_terminates() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"test",
|
|
serde_json::from_value(data_model_document()).unwrap(),
|
|
);
|
|
|
|
let skeleton = ws.input_skeleton(&ScopeRequest::for_policy("test"));
|
|
|
|
let root = skeleton.as_object().expect("skeleton is an object");
|
|
let customer = root
|
|
.get("customer")
|
|
.and_then(|v| v.as_object())
|
|
.expect("customer top-level missing");
|
|
|
|
assert_eq!(customer.get("name"), Some(&serde_json::json!("")));
|
|
assert_eq!(customer.get("age"), Some(&serde_json::json!(0)));
|
|
|
|
assert_eq!(
|
|
customer.get("companies"),
|
|
Some(&serde_json::json!([{ "id": "", "revenue": 0 }])),
|
|
);
|
|
|
|
assert_eq!(
|
|
customer.get("favoriteProduct"),
|
|
Some(&serde_json::json!(""))
|
|
);
|
|
|
|
let credit = customer
|
|
.get("creditReport")
|
|
.and_then(|v| v.as_object())
|
|
.expect("creditReport defaults missing");
|
|
assert_eq!(credit.get("score"), Some(&serde_json::json!(0)));
|
|
assert!(
|
|
!credit.contains_key("customer"),
|
|
"creditReport must not carry a back-reference to customer in the skeleton; got {credit:#?}",
|
|
);
|
|
|
|
assert_eq!(
|
|
root.get("product"),
|
|
Some(&serde_json::json!([{ "id": "", "name": "" }])),
|
|
);
|
|
}
|
|
|
|
fn analysis_document() -> serde_json::Value {
|
|
let mut dm = data_model_document();
|
|
let blocks = dm["blocks"].as_array_mut().unwrap();
|
|
|
|
blocks.push(json!({
|
|
"id": "ds1",
|
|
"type": "expression",
|
|
"props": {
|
|
"data": json!({
|
|
"key": "customer.totalRevenue",
|
|
"value": "sum(map(customer.companies as c, c.revenue))"
|
|
})
|
|
}
|
|
}));
|
|
|
|
blocks.push(json!({
|
|
"id": "f1",
|
|
"type": "match",
|
|
"props": {
|
|
"data": json!({
|
|
"key": "customer.creditTier",
|
|
"arms": [
|
|
{ "id": "db1", "condition": "customer.creditReport.score >= 750", "value": "\"excellent\"" },
|
|
{ "id": "db2", "condition": "", "value": "\"good\"" }
|
|
]
|
|
})
|
|
}
|
|
}));
|
|
|
|
blocks.push(json!({
|
|
"id": "dt1",
|
|
"type": "decisionTable",
|
|
"props": {
|
|
"data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "i1", "name": "", "field": "customer.creditTier" },
|
|
{ "id": "i2", "name": "", "field": "customer.totalRevenue" }
|
|
],
|
|
"outputs": [
|
|
{ "id": "o1", "name": "", "field": "customer.discount" }
|
|
],
|
|
"rules": [
|
|
{ "i1": "\"excellent\"", "i2": ">= 500000", "o1": "0.2" },
|
|
{ "i1": "\"excellent\"", "i2": "", "o1": "0.15" },
|
|
{ "i1": "", "i2": "", "o1": "0.05" }
|
|
]
|
|
})
|
|
}
|
|
}));
|
|
|
|
dm
|
|
}
|
|
|
|
#[test]
|
|
fn analysis_no_errors_and_outputs_present() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
let doc = analysis_document();
|
|
ws.set_policy("policy", serde_json::from_value(doc).unwrap());
|
|
|
|
let diags = ws.diagnostics("policy");
|
|
let errors: Vec<_> = diags
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
for e in &errors {
|
|
eprintln!(" [{:?}] {} @ {:?}", e.code, e.message, e.location);
|
|
}
|
|
assert!(
|
|
errors.is_empty(),
|
|
"expected no errors, got {}",
|
|
errors.len()
|
|
);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("policy"));
|
|
let discount = outputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "customer.discount");
|
|
assert!(discount.is_some(), "customer.discount missing from outputs");
|
|
assert!(matches!(
|
|
discount.unwrap().resolved_type,
|
|
VariableType::Number
|
|
));
|
|
|
|
let credit_tier_refs = ws.references(&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("creditTier"),
|
|
});
|
|
assert!(
|
|
credit_tier_refs
|
|
.iter()
|
|
.any(|s| s.block_id.as_ref() == "dt1"),
|
|
"customer.creditTier should be referenced inside dt1; got {credit_tier_refs:#?}",
|
|
);
|
|
let total_revenue_refs = ws.references(&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("totalRevenue"),
|
|
});
|
|
assert!(
|
|
total_revenue_refs
|
|
.iter()
|
|
.any(|s| s.block_id.as_ref() == "dt1"),
|
|
"customer.totalRevenue should be referenced inside dt1; got {total_revenue_refs:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn columns_kind_shape_is_accepted() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{
|
|
"id": "dm",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "a", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
})
|
|
}
|
|
},
|
|
{
|
|
"id": "dt",
|
|
"type": "decisionTable",
|
|
"props": {
|
|
"data": json!({
|
|
"hitPolicy": "first",
|
|
"columns": [
|
|
{ "id": "i1", "field": "customer.age", "kind": "input" },
|
|
{ "id": "o1", "field": "customer.basePrice", "kind": "output" }
|
|
],
|
|
"rules": [
|
|
{ "i1": ">= 65", "o1": "80" },
|
|
{ "i1": ">= 18", "o1": "100" },
|
|
{ "i1": "", "o1": "50" }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("pricing", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("pricing")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {:?}", errors);
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("pricing"));
|
|
let base_price = outputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "customer.basePrice")
|
|
.expect("customer.basePrice should be registered");
|
|
assert!(matches!(base_price.resolved_type, VariableType::Number));
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("pricing"),
|
|
input: Variable::from(json!({ "customer": { "age": 35 } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate should succeed");
|
|
let serialized: serde_json::Value = result.output.into();
|
|
assert_eq!(
|
|
serialized
|
|
.get("customer")
|
|
.and_then(|c| c.get("basePrice"))
|
|
.and_then(|v| v.as_i64()),
|
|
Some(100),
|
|
"customer.basePrice should be 100 for age 35; got {:?}",
|
|
serialized
|
|
);
|
|
let trace = result.trace.expect("trace should be populated");
|
|
assert_eq!(trace.executions.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_input_cell_errors() {
|
|
let mut dm = data_model_document();
|
|
let blocks = dm["blocks"].as_array_mut().unwrap();
|
|
blocks.push(json!({
|
|
"id": "dt-bad",
|
|
"type": "decisionTable",
|
|
"props": {
|
|
"data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "customer.discount" } ],
|
|
"rules": [
|
|
{ "i1": "tru", "o1": "0.1" },
|
|
{ "i1": "", "o1": "0.05" }
|
|
]
|
|
})
|
|
}
|
|
}));
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("test", serde_json::from_value(dm).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("test")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(!errors.is_empty(), "expected errors for invalid cell 'tru'");
|
|
}
|
|
|
|
#[test]
|
|
fn multi_policy_with_import() {
|
|
let shared_schema = json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "a", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
});
|
|
|
|
let pricing = json!({
|
|
"blocks": [
|
|
{ "id": "dm-pricing", "type": "dataModel", "props": { "data": shared_schema.clone() } },
|
|
{
|
|
"id": "dt-pricing",
|
|
"type": "decisionTable",
|
|
"props": {
|
|
"data": json!({
|
|
"hitPolicy": "first",
|
|
"columns": [
|
|
{ "id": "i1", "field": "customer.age", "kind": "input" },
|
|
{ "id": "o1", "field": "customer.basePrice", "kind": "output" }
|
|
],
|
|
"rules": [
|
|
{ "i1": ">= 65", "o1": "80" },
|
|
{ "i1": ">= 18", "o1": "100" },
|
|
{ "i1": "", "o1": "50" }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
let order = json!({
|
|
"imports": ["pricing"],
|
|
"blocks": [
|
|
{ "id": "dm-order", "type": "dataModel", "props": { "data": shared_schema.clone() } },
|
|
{
|
|
"id": "a-order",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": json!({
|
|
"output": "customer.approved",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.basePrice > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("pricing", serde_json::from_value(pricing).unwrap());
|
|
ws.set_policy("order", serde_json::from_value(order).unwrap());
|
|
|
|
for path in ["pricing", "order"] {
|
|
let errors: Vec<_> = ws
|
|
.diagnostics(path)
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"[{path}] expected no errors, got {:?}",
|
|
errors
|
|
);
|
|
}
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("order"));
|
|
let bp = outputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "customer.basePrice")
|
|
.expect("customer.basePrice should be visible via import");
|
|
let writer = bp.written_by.as_ref().expect("writer present");
|
|
assert_eq!(writer.policy_path.as_ref(), "pricing");
|
|
assert_eq!(writer.block_id.as_ref(), "dt-pricing");
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("order"),
|
|
input: Variable::from(json!({ "customer": { "age": 35 } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let out: serde_json::Value = result.output.into();
|
|
assert_eq!(
|
|
out.get("customer")
|
|
.and_then(|c| c.get("basePrice"))
|
|
.and_then(|v| v.as_i64()),
|
|
Some(100)
|
|
);
|
|
assert_eq!(
|
|
out.get("customer")
|
|
.and_then(|c| c.get("approved"))
|
|
.and_then(|v| v.as_bool()),
|
|
Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_import_diagnostic_and_evaluate_error() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"main",
|
|
serde_json::from_value(json!({
|
|
"imports": ["does-not-exist"],
|
|
"blocks": [ expr("e1", "x", "1 + 1") ]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let errs = errors(&ws, "main");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("ImportNotFound")),
|
|
"expected ImportNotFound diagnostic, got {errs:?}"
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("main"),
|
|
input: Variable::from(json!({})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let err = ws
|
|
.evaluate(&req)
|
|
.expect_err("evaluate must fail when an import is missing");
|
|
assert!(
|
|
matches!(
|
|
&err,
|
|
EvaluationError::ImportNotFound { policy_path, import }
|
|
if policy_path.as_ref() == "main" && import.as_ref() == "does-not-exist"
|
|
),
|
|
"expected ImportNotFound, got {err:?}"
|
|
);
|
|
|
|
let err = ws
|
|
.enhance_trace(&req)
|
|
.expect_err("enhance_trace must fail when an import is missing");
|
|
assert!(matches!(&err, EvaluationError::ImportNotFound { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_transitive_import_fails_evaluate() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"mid",
|
|
serde_json::from_value(json!({
|
|
"imports": ["does-not-exist"],
|
|
"blocks": [ expr("e1", "y", "2") ]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
ws.set_policy(
|
|
"main",
|
|
serde_json::from_value(json!({
|
|
"imports": ["mid"],
|
|
"blocks": [ expr("e2", "x", "1") ]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("main"),
|
|
input: Variable::from(json!({})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let err = ws
|
|
.evaluate(&req)
|
|
.expect_err("evaluate must fail when a transitive import is missing");
|
|
assert!(
|
|
matches!(
|
|
&err,
|
|
EvaluationError::ImportNotFound { policy_path, import }
|
|
if policy_path.as_ref() == "mid" && import.as_ref() == "does-not-exist"
|
|
),
|
|
"expected ImportNotFound on 'mid', got {err:?}"
|
|
);
|
|
}
|
|
|
|
fn closure_doc() -> serde_json::Value {
|
|
json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.totalRevenue",
|
|
"value": "sum(map(customer.companies as c, c.revenue))"
|
|
}) }}
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn rename_follows_both_direct_refs_and_closure_aliases() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(closure_doc()).unwrap());
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Entity {
|
|
name: Arc::from("customer"),
|
|
},
|
|
"buyer",
|
|
);
|
|
assert!(
|
|
any_rewritten_contains(&edits, "buyer.companies"),
|
|
"entity rename should rewrite `customer.companies` → `buyer.companies` in the closure",
|
|
);
|
|
assert!(
|
|
any_rewritten_contains(&edits, "buyer.totalRevenue"),
|
|
"entity rename should rewrite the write key `customer.totalRevenue` → `buyer.totalRevenue`",
|
|
);
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("company"),
|
|
field: Arc::from("revenue"),
|
|
},
|
|
"income",
|
|
);
|
|
assert!(
|
|
any_touches_block(&edits, "dm-company"),
|
|
"DataModel declaration of company.revenue should be renamed",
|
|
);
|
|
let tree_json = rewritten_block_json(&edits, "tree")
|
|
.expect("rename should rewrite the tree block holding the closure body");
|
|
assert!(
|
|
tree_json.contains("c.income"),
|
|
"aliased access `c.revenue` should become `c.income`; got {tree_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_iteration_uses_owner_back_reference() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "company.isHighestRevenue",
|
|
"value": "max(map(company.customer.companies as c, c.revenue)) == company.revenue"
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}",);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"customer": {
|
|
"companies": [
|
|
{ "id": "c1", "revenue": 500 },
|
|
{ "id": "c2", "revenue": 1000 }
|
|
]
|
|
}
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate should succeed");
|
|
let serialized: serde_json::Value = result.output.into();
|
|
let companies = serialized
|
|
.get("customer")
|
|
.and_then(|c| c.get("companies"))
|
|
.and_then(|v| v.as_array())
|
|
.expect("customer.companies present");
|
|
|
|
let flags: Vec<bool> = companies
|
|
.iter()
|
|
.map(|c| {
|
|
c.get("isHighestRevenue")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
assert_eq!(flags, vec![false, true], "c2 has the highest revenue");
|
|
}
|
|
|
|
#[test]
|
|
fn completions_follow_cyclic_owner_back_reference_deeply() {
|
|
let expr = "company.customer.companies[0].customer.companies[0].";
|
|
let cursor_pos = expr.len() as u32;
|
|
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "company.tag", "value": expr
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let completions = ws.completions(&Cursor {
|
|
policy_path: Arc::from("p"),
|
|
block_id: Arc::from("tree"),
|
|
pos: cursor_pos,
|
|
target: CursorTarget::Expression {
|
|
id: Arc::from("s1"),
|
|
},
|
|
});
|
|
let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
|
|
assert!(
|
|
!labels.is_empty(),
|
|
"expected non-empty completions at deep back-ref"
|
|
);
|
|
for expected in ["id", "revenue", "customer"] {
|
|
assert!(
|
|
labels.iter().any(|l| *l == expected),
|
|
"expected completion '{expected}' in {labels:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rename_entity_from_decision_table_head() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm1", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dt1", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "customer.price" } ],
|
|
"rules": [ { "i1": ">= 18", "o1": "100" } ]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let cursor = Cursor {
|
|
policy_path: Arc::from("p"),
|
|
block_id: Arc::from("dt1"),
|
|
pos: 0,
|
|
target: CursorTarget::DecisionTableHead {
|
|
col: Arc::from("i1"),
|
|
},
|
|
};
|
|
|
|
let prep = ws.prepare_rename(&cursor).expect("prepare_rename");
|
|
let edits = ws.rename(&prep.target, "buyer");
|
|
assert!(!edits.is_empty(), "rename produced no edits");
|
|
}
|
|
|
|
#[test]
|
|
fn entities_includes_imported_policy_fields() {
|
|
let kyc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-cust", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "country", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p3", "name": "kycVerified", "type": "boolean", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let order = json!({
|
|
"imports": ["underwriting/kyc"],
|
|
"blocks": [
|
|
{ "id": "ord-a", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.orderApproved",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.kycVerified == true", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("underwriting/kyc", serde_json::from_value(kyc).unwrap());
|
|
ws.set_policy("risk/order", serde_json::from_value(order).unwrap());
|
|
|
|
let diagnostics = ws.diagnostics("risk/order");
|
|
let errors: Vec<_> = diagnostics
|
|
.iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"expected no errors compiling risk/order; got {errors:#?}"
|
|
);
|
|
|
|
let entities = ws.entities(&ScopeRequest::for_policy("risk/order"));
|
|
let customer = entities
|
|
.iter()
|
|
.find(|e| e.name.as_ref() == "customer")
|
|
.unwrap_or_else(|| panic!("customer entity missing from entities(); got {entities:#?}"));
|
|
|
|
let field_names: Vec<&str> = customer.fields.iter().map(|f| f.name.as_ref()).collect();
|
|
eprintln!("entities()['customer'] fields: {field_names:?}");
|
|
for required in ["age", "country", "kycVerified", "orderApproved"] {
|
|
assert!(
|
|
field_names.iter().any(|n| *n == required),
|
|
"expected field `{required}` in customer entity; got {field_names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn policy_merge_evaluates_correctly() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.merged",
|
|
"value": "merge([{a: 1, b: 2}, {b: 99, c: 3}])"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "id": "x" } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let out: serde_json::Value = result.output.into();
|
|
let merged = out
|
|
.get("customer")
|
|
.and_then(|c| c.get("merged"))
|
|
.expect("customer.merged missing");
|
|
assert_eq!(merged.get("a").and_then(|v| v.as_i64()), Some(1));
|
|
assert_eq!(merged.get("b").and_then(|v| v.as_i64()), Some(99));
|
|
assert_eq!(merged.get("c").and_then(|v| v.as_i64()), Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn rename_field_on_array_index_without_back_ref() {
|
|
let expr = "customer.companies[0].revenue";
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.topRevenue", "value": expr
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("company"),
|
|
field: Arc::from("revenue"),
|
|
},
|
|
"income",
|
|
);
|
|
|
|
let tree_json = rewritten_block_json(&edits, "tree")
|
|
.unwrap_or_else(|| panic!("rename should rewrite the tree block; got {edits:#?}"));
|
|
assert!(
|
|
tree_json.contains("customer.companies[0].income"),
|
|
"rename should rewrite trailing .revenue → .income; got {tree_json}",
|
|
);
|
|
assert!(
|
|
!tree_json.contains("customer.companies[0].revenue"),
|
|
"old `.revenue` should be gone; got {tree_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_field_through_indexed_back_reference_chain() {
|
|
let expr = "company.customer.companies[0].customer.creditTier";
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false },
|
|
{ "id": "p2", "name": "creditTier", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p3", "name": "id", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "company.hello", "value": expr
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}");
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("creditTier"),
|
|
},
|
|
"creditTiera",
|
|
);
|
|
|
|
assert!(
|
|
any_touches_block(&edits, "dm-customer"),
|
|
"rename should update customer.creditTier in dm-customer; got edits: {edits:#?}",
|
|
);
|
|
|
|
let tree_json = rewritten_block_json(&edits, "tree")
|
|
.unwrap_or_else(|| panic!("rename should rewrite the tree block; got edits: {edits:#?}"));
|
|
let expected = format!("company.customer.companies[0].customer.{}", "creditTiera");
|
|
assert!(
|
|
tree_json.contains(&expected),
|
|
"expected rewritten `{expected}` in tree block; got {tree_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn global_scalar_appears_at_json_top_level_and_evaluates() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "subtotal", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.taxed",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.subtotal * (1 + taxRate) > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}");
|
|
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("p"));
|
|
let tax = inputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "taxRate")
|
|
.expect("taxRate should be a top-level input");
|
|
assert!(matches!(tax.resolved_type, VariableType::Number));
|
|
|
|
let entities = ws.entities(&ScopeRequest::for_policy("p"));
|
|
assert!(
|
|
entities.iter().all(|e| e.name.as_ref() != "platform"),
|
|
"platform (global DM name) must not surface as an entity",
|
|
);
|
|
|
|
let skeleton = ws.input_skeleton(&ScopeRequest::for_policy("p"));
|
|
assert_eq!(
|
|
skeleton.as_object().and_then(|o| o.get("taxRate")),
|
|
Some(&serde_json::json!(0)),
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"taxRate": 0.2,
|
|
"customer": { "subtotal": 100 }
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate should succeed");
|
|
let out: serde_json::Value = result.output.into();
|
|
assert_eq!(
|
|
out.get("customer")
|
|
.and_then(|c| c.get("taxed"))
|
|
.and_then(|v| v.as_bool()),
|
|
Some(true),
|
|
"customer.taxed should compute from global taxRate; got {out:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn global_property_name_collides_with_entity_name() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-entity", "type": "dataModel", "props": { "data": json!({
|
|
"name": "tenant",
|
|
"properties": [
|
|
{ "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-global", "type": "dataModel", "props": { "data": json!({
|
|
"name": "settings",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "tenant", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "DataModelCollision"),
|
|
"expected DataModelCollision; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn block_writing_entity_and_global_is_mixed_scope() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "settings",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "lastSeenAge", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [],
|
|
"outputs": [
|
|
{ "id": "s1", "name": "", "field": "customer.adult" },
|
|
{ "id": "s2", "name": "", "field": "lastSeenAge" }
|
|
],
|
|
"rules": [
|
|
{ "_id": "r1", "s1": "customer.age >= 18", "s2": "customer.age" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "MixedScope"),
|
|
"expected MixedScope when block writes both entity and global; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn global_relationship_iterates_top_level_array() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-tenant", "type": "dataModel", "props": { "data": json!({
|
|
"name": "tenant",
|
|
"properties": [
|
|
{ "id": "p1", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "seats", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "tenant.large", "value": "tenant.seats >= 100"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}");
|
|
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("p"));
|
|
let tenants = inputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "tenants")
|
|
.expect("tenants should be a top-level array input");
|
|
assert!(matches!(tenants.resolved_type, VariableType::Array(_)));
|
|
assert!(
|
|
!inputs.iter().any(|p| p.path.as_ref() == "tenant"),
|
|
"tenant pool should not surface separately; got inputs {:?}",
|
|
inputs.iter().map(|i| i.path.as_ref()).collect::<Vec<_>>(),
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"tenants": [
|
|
{ "id": "t1", "seats": 50 },
|
|
{ "id": "t2", "seats": 250 }
|
|
]
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate should succeed");
|
|
let out: serde_json::Value = result.output.into();
|
|
let tenants_out = out
|
|
.get("tenants")
|
|
.and_then(|v| v.as_array())
|
|
.expect("tenants present");
|
|
let flags: Vec<bool> = tenants_out
|
|
.iter()
|
|
.map(|t| t.get("large").and_then(|v| v.as_bool()).unwrap_or(false))
|
|
.collect();
|
|
assert_eq!(flags, vec![false, true], "second tenant has >=100 seats");
|
|
}
|
|
|
|
#[test]
|
|
fn writing_to_a_global_input_is_rejected() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "taxRate",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "true", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "InputOverride"),
|
|
"expected InputOverride for write to global input; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_policy_global_collision_with_mismatched_shape() {
|
|
let policy_a = json!({
|
|
"blocks": [
|
|
{ "id": "dm-a", "type": "dataModel", "props": { "data": json!({
|
|
"name": "settings",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "region", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let policy_b = json!({
|
|
"imports": ["a"],
|
|
"blocks": [
|
|
{ "id": "dm-b", "type": "dataModel", "props": { "data": json!({
|
|
"name": "settings",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "region", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("a", serde_json::from_value(policy_a).unwrap());
|
|
ws.set_policy("b", serde_json::from_value(policy_b).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("b")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "DataModelCollision"),
|
|
"expected DataModelCollision for mismatched global shapes; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_global_scalar_rewrites_reads_writes_and_declaration() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "subtotal", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.taxed",
|
|
"value": "customer.subtotal * (1 + taxRate)"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Global {
|
|
name: Arc::from("taxRate"),
|
|
},
|
|
"vatRate",
|
|
);
|
|
|
|
assert!(
|
|
any_touches_block(&edits, "dm-globals"),
|
|
"rename must touch the global DM declaration; got {edits:#?}",
|
|
);
|
|
|
|
let tree_json = rewritten_block_json(&edits, "tree")
|
|
.unwrap_or_else(|| panic!("rename must rewrite the tree block; got {edits:#?}"));
|
|
assert!(
|
|
tree_json.contains("vatRate"),
|
|
"tree should contain the rewritten identifier; got {tree_json}",
|
|
);
|
|
assert!(
|
|
!tree_json.contains("taxRate"),
|
|
"old identifier should be gone from tree; got {tree_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_global_relationship_chains_into_target_entity() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-tenant", "type": "dataModel", "props": { "data": json!({
|
|
"name": "tenant",
|
|
"properties": [
|
|
{ "id": "p1", "name": "id", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "seats", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "tenant.large",
|
|
"value": "tenant.seats >= 100"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Entity {
|
|
name: Arc::from("tenant"),
|
|
},
|
|
"client",
|
|
);
|
|
|
|
let globals_json = rewritten_block_json(&edits, "dm-globals")
|
|
.unwrap_or_else(|| panic!("rename must rewrite the global DM; got {edits:#?}"));
|
|
assert!(
|
|
globals_json.contains("\"client\""),
|
|
"global Relationship target should be rewritten to `client`; got {globals_json}",
|
|
);
|
|
|
|
let tree_json = rewritten_block_json(&edits, "tree")
|
|
.unwrap_or_else(|| panic!("rename must rewrite the tree block; got {edits:#?}"));
|
|
assert!(
|
|
tree_json.contains("client.seats"),
|
|
"expression read should be rewritten; got {tree_json}",
|
|
);
|
|
assert!(
|
|
tree_json.contains("client.large"),
|
|
"write key should be rewritten; got {tree_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enhance_trace_populates_dt_input_pass_bitmask() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "country", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dt", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "collect",
|
|
"inputs": [
|
|
{ "id": "i1", "name": "", "field": "customer.age" },
|
|
{ "id": "i2", "name": "", "field": "customer.country" }
|
|
],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "customer.tier" } ],
|
|
"rules": [
|
|
{ "i1": ">= 18", "i2": "\"US\"", "o1": "\"adult-us\"" },
|
|
{ "i1": ">= 18", "i2": "\"CA\"", "o1": "\"adult-ca\"" },
|
|
{ "i1": "< 18", "i2": "", "o1": "\"minor\"" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "age": 25, "country": "US" } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.enhance_trace(&req).expect("enhance succeeded");
|
|
let trace = result.trace.expect("trace populated");
|
|
assert_eq!(trace.engine_version.as_ref(), env!("CARGO_PKG_VERSION"));
|
|
|
|
let dt_exec = trace
|
|
.executions
|
|
.iter()
|
|
.find(|e| e.block_id.as_ref() == "dt")
|
|
.expect("dt execution present");
|
|
|
|
let serde_json::Value::Object(trace_obj) = serde_json::to_value(&dt_exec.trace).unwrap() else {
|
|
panic!("dt trace not an object");
|
|
};
|
|
let extras = trace_obj
|
|
.get("extras")
|
|
.and_then(|v| v.as_object())
|
|
.expect("extras present under enhance_trace");
|
|
let input_pass_b64 = extras
|
|
.get("inputPass")
|
|
.and_then(|v| v.as_str())
|
|
.expect("inputPass present");
|
|
|
|
let bytes = base64_decode(input_pass_b64);
|
|
let cols = 2usize;
|
|
let bytes_per_row = cols.div_ceil(8);
|
|
let bit = |row: usize, col: usize| -> bool {
|
|
let byte = row * bytes_per_row + col / 8;
|
|
(bytes[byte] >> (col % 8)) & 1 == 1
|
|
};
|
|
assert!(bit(0, 0), "row 0 col 0 (age >= 18) should pass for age=25");
|
|
assert!(bit(0, 1), "row 0 col 1 (\"US\") should pass for US");
|
|
assert!(bit(1, 0), "row 1 col 0 (age >= 18) should pass for age=25");
|
|
assert!(!bit(1, 1), "row 1 col 1 (\"CA\") should fail for US");
|
|
assert!(!bit(2, 0), "row 2 col 0 (age < 18) should fail for age=25");
|
|
assert!(bit(2, 1), "row 2 col 1 (wildcard) passes");
|
|
}
|
|
|
|
fn base64_decode(s: &str) -> Vec<u8> {
|
|
use base64::Engine as _;
|
|
base64::engine::general_purpose::STANDARD.decode(s).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn enhance_trace_operand_values_are_flat_path_keyed() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "totalRevenue", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p3", "name": "threshold", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dt", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [
|
|
{ "id": "i1", "name": "", "field": "customer.totalRevenue" },
|
|
{ "id": "i2", "name": "", "field": "" }
|
|
],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "customer.discount" } ],
|
|
"rules": [
|
|
{ "_id": "r1", "i1": "$ > customer.revenue", "i2": "customer.threshold > customer.revenue", "o1": "0.1" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(
|
|
json!({ "customer": { "totalRevenue": 700000, "revenue": 500000, "threshold": 100 } }),
|
|
),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.enhance_trace(&req).expect("enhance succeeded");
|
|
let dt = result
|
|
.trace
|
|
.expect("trace populated")
|
|
.executions
|
|
.into_iter()
|
|
.find(|e| e.block_id.as_ref() == "dt")
|
|
.expect("dt execution present");
|
|
|
|
let ops: std::collections::BTreeMap<String, serde_json::Value> = dt
|
|
.operand_values
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.clone().into()))
|
|
.collect();
|
|
|
|
let expected: std::collections::BTreeMap<String, serde_json::Value> = [
|
|
("customer.totalRevenue".to_string(), json!(700000)),
|
|
("customer.revenue".to_string(), json!(500000)),
|
|
("customer.threshold".to_string(), json!(100)),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
assert_eq!(
|
|
ops, expected,
|
|
"operand values must be a flat property-path → value map (header read + in-cell reads, one entry per path, no expression-id buckets or spans)",
|
|
);
|
|
|
|
let json = serde_json::to_value(&dt).unwrap();
|
|
let serialized = serde_json::to_string(&json).unwrap();
|
|
assert!(
|
|
json["operandValues"]["customer.revenue"] == json!(500000),
|
|
"serialized operandValues must be keyed by property path; got {serialized}",
|
|
);
|
|
assert!(
|
|
!serialized.contains("\"span\""),
|
|
"operand values must no longer carry spans; got {serialized}",
|
|
);
|
|
assert!(
|
|
!serialized.contains("\"i1\"")
|
|
&& !serialized.contains("\"i2\"")
|
|
&& !serialized.contains("\"o1\""),
|
|
"operand values must not be keyed by expression/column id; got {serialized}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enhance_trace_operand_values_per_instance() {
|
|
let doc: serde_json::Value = serde_json::from_str(
|
|
&fs::read_to_string("tests/data/policy/fixtures/per_instance.json").unwrap(),
|
|
)
|
|
.unwrap();
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "name": "Alice", "companies": [
|
|
{ "name": "Acme", "revenue": 300000 },
|
|
{ "name": "Smol", "revenue": 50000 }
|
|
]}})),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.enhance_trace(&req).expect("enhance succeeded");
|
|
let execs: Vec<_> = result
|
|
.trace
|
|
.expect("trace populated")
|
|
.executions
|
|
.into_iter()
|
|
.filter(|e| e.block_id.as_ref() == "company-risk")
|
|
.collect();
|
|
assert_eq!(execs.len(), 2, "one execution per company instance");
|
|
|
|
let value_for = |instance: &str| -> serde_json::Value {
|
|
execs
|
|
.iter()
|
|
.find(|e| e.instance_path.as_deref() == Some(instance))
|
|
.and_then(|e| e.operand_values.get("company.revenue").cloned())
|
|
.map(Into::into)
|
|
.unwrap_or(serde_json::Value::Null)
|
|
};
|
|
assert_eq!(
|
|
value_for("customer.companies.0"),
|
|
json!(300000),
|
|
"operand value resolves against the per-instance scope",
|
|
);
|
|
assert_eq!(value_for("customer.companies.1"), json!(50000));
|
|
}
|
|
|
|
#[test]
|
|
fn enhance_trace_populates_match_arm_results() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "score", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "match", "props": { "data": json!({
|
|
"key": "customer.tier",
|
|
"arms": [
|
|
{ "id": "b1", "condition": "customer.score >= 750", "value": "\"gold\"" },
|
|
{ "id": "b2", "condition": "customer.score >= 500", "value": "\"silver\"" },
|
|
{ "id": "b3", "condition": "", "value": "\"bronze\"" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "score": 600 } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.enhance_trace(&req).expect("enhance succeeded");
|
|
let trace = result.trace.expect("trace populated");
|
|
|
|
let tree_exec = trace
|
|
.executions
|
|
.iter()
|
|
.find(|e| e.block_id.as_ref() == "tree")
|
|
.expect("match execution present");
|
|
let trace_obj = serde_json::to_value(&tree_exec.trace).unwrap();
|
|
let arms = trace_obj
|
|
.get("arms")
|
|
.and_then(|v| v.as_array())
|
|
.expect("arms present");
|
|
let result_for = |id: &str| {
|
|
arms.iter()
|
|
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
|
|
.and_then(|a| a.get("result").and_then(|v| v.as_bool()))
|
|
};
|
|
|
|
assert_eq!(
|
|
trace_obj.get("matchedArm").and_then(|v| v.as_str()),
|
|
Some("b2")
|
|
);
|
|
assert_eq!(result_for("b1"), Some(false));
|
|
assert_eq!(result_for("b2"), Some(true));
|
|
assert_eq!(
|
|
result_for("b3"),
|
|
Some(true),
|
|
"b3 (default arm) must be evaluated under extras so the UI can color it; got {arms:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_trace_omits_extras() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dt", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "customer.tier" } ],
|
|
"rules": [ { "i1": ">= 18", "o1": "\"adult\"" } ]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "age": 25 } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let trace = result.trace.expect("trace populated");
|
|
assert_eq!(trace.engine_version.as_ref(), env!("CARGO_PKG_VERSION"));
|
|
let dt_exec = trace
|
|
.executions
|
|
.iter()
|
|
.find(|e| e.block_id.as_ref() == "dt")
|
|
.unwrap();
|
|
let trace_json = serde_json::to_string(&dt_exec.trace).unwrap();
|
|
assert!(
|
|
!trace_json.contains("extras"),
|
|
"default trace should omit extras; got {trace_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn trace_block_execution_tags_imported_policy_only() {
|
|
let shared_schema = json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "a", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
});
|
|
let pricing = json!({
|
|
"blocks": [
|
|
{ "id": "dm-pricing", "type": "dataModel", "props": { "data": shared_schema.clone() } },
|
|
{
|
|
"id": "dt-pricing",
|
|
"type": "decisionTable",
|
|
"props": {
|
|
"data": json!({
|
|
"hitPolicy": "first",
|
|
"columns": [
|
|
{ "id": "i1", "field": "customer.age", "kind": "input" },
|
|
{ "id": "o1", "field": "customer.basePrice", "kind": "output" }
|
|
],
|
|
"rules": [
|
|
{ "i1": ">= 18", "o1": "100" },
|
|
{ "i1": "", "o1": "50" }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
});
|
|
let order = json!({
|
|
"imports": ["pricing"],
|
|
"blocks": [
|
|
{ "id": "dm-order", "type": "dataModel", "props": { "data": shared_schema.clone() } },
|
|
{
|
|
"id": "a-order",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": json!({
|
|
"output": "customer.approved",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.basePrice > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
})
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("pricing", serde_json::from_value(pricing).unwrap());
|
|
ws.set_policy("order", serde_json::from_value(order).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("order"),
|
|
input: Variable::from(json!({ "customer": { "age": 35 } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let trace = result.trace.expect("trace populated");
|
|
|
|
let pricing_exec = trace
|
|
.executions
|
|
.iter()
|
|
.find(|e| e.block_id.as_ref() == "dt-pricing")
|
|
.expect("dt-pricing execution present");
|
|
assert_eq!(
|
|
pricing_exec.policy_path.as_deref().map(|p| p.as_ref()),
|
|
Some("pricing"),
|
|
"imported block must carry its owning policy_path",
|
|
);
|
|
|
|
let order_exec = trace
|
|
.executions
|
|
.iter()
|
|
.find(|e| e.block_id.as_ref() == "a-order")
|
|
.expect("a-order execution present");
|
|
assert!(
|
|
order_exec.policy_path.is_none(),
|
|
"local block must omit policy_path (saves wire bytes); got {:?}",
|
|
order_exec.policy_path,
|
|
);
|
|
|
|
let order_json = serde_json::to_string(order_exec).unwrap();
|
|
assert!(
|
|
!order_json.contains("policyPath"),
|
|
"local execution must not serialize policyPath; got {order_json}",
|
|
);
|
|
let pricing_json = serde_json::to_string(pricing_exec).unwrap();
|
|
assert!(
|
|
pricing_json.contains("\"policyPath\":\"pricing\""),
|
|
"imported execution must serialize policyPath; got {pricing_json}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unreachable_read_of_iterated_entity_from_singleton_block() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.flagged",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "UnreachableEntityRead"),
|
|
"expected UnreachableEntityRead for direct iterated read; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unreachable_read_of_iterated_entity_from_global_block() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "globalFlag",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "UnreachableEntityRead"),
|
|
"expected UnreachableEntityRead from globals-writing block; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn iterated_read_via_closure_aggregation_is_reachable() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "tree", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.totalRevenue",
|
|
"value": "sum(map(customer.companies as c, c.revenue))"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"aggregation should be reachable; got {errors:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn iterated_block_reads_own_entity_fields() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "company.large",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "company.revenue >= 1000", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"iterated block reading its own entity must be reachable; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn closure_alias_sees_computed_field_on_iterated_entity() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-item", "type": "dataModel", "props": { "data": json!({
|
|
"name": "item",
|
|
"properties": [
|
|
{ "id": "p1", "name": "price", "type": "number", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "quantity", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-order", "type": "dataModel", "props": { "data": json!({
|
|
"name": "order",
|
|
"properties": [
|
|
{ "id": "p3", "name": "items", "type": "relationship", "target": "item", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "per-item", "type": "expression", "props": { "data": json!({
|
|
"key": "item.subtotal",
|
|
"value": "item.price * item.quantity"
|
|
}) }},
|
|
{ "id": "per-order", "type": "expression", "props": { "data": json!({
|
|
"key": "order.total",
|
|
"value": "sum(map(order.items as i, i.subtotal))"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"closure alias on iterated entity must see computed fields; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enum_field_type_checks_and_validates() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive", "pending"], "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.live",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.status == \"active\"", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"enum field should type-check; got {errors:#?}"
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "status": "active" } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let ok = ws.evaluate(&req).expect("valid enum value should evaluate");
|
|
let out: serde_json::Value = ok.output.into();
|
|
assert_eq!(
|
|
out.get("customer")
|
|
.and_then(|c| c.get("live"))
|
|
.and_then(|v| v.as_bool()),
|
|
Some(true)
|
|
);
|
|
|
|
let bad_req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "status": "unknown" } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let err = ws
|
|
.evaluate(&bad_req)
|
|
.expect_err("unknown enum value should be rejected");
|
|
let msg = format!("{err:?}");
|
|
assert!(
|
|
msg.contains("status") && msg.contains("active"),
|
|
"error should reference field + allowed values; got {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enum_with_duplicate_values_raises_diagnostic() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive", "active"], "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
let dup = errors
|
|
.iter()
|
|
.find(|d| format!("{:?}", d.code) == "DuplicateEnumValue")
|
|
.expect("expected DuplicateEnumValue diagnostic");
|
|
assert!(
|
|
dup.message.contains("'active'"),
|
|
"diagnostic should name the duplicate value; got {dup:?}",
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "status": "active" } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
ws.evaluate(&req)
|
|
.expect("dedup'd enum still validates the surviving values");
|
|
}
|
|
|
|
#[test]
|
|
fn enum_field_unknown_value_in_expression_is_type_mismatch() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive"], "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.live",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.status == \"unknown\"", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
!errors.is_empty(),
|
|
"comparing an enum field to a literal not in the enum should produce a type-check error",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn globals_query_returns_schema_and_computed_entries() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false },
|
|
{ "id": "g2", "name": "currency", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "isAdult",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}");
|
|
|
|
let globals = ws.globals(&ScopeRequest::for_policy("p"));
|
|
let names: Vec<&str> = globals.iter().map(|g| g.name.as_ref()).collect();
|
|
assert_eq!(names, vec!["currency", "isAdult", "taxRate"]);
|
|
|
|
let tax = globals
|
|
.iter()
|
|
.find(|g| g.name.as_ref() == "taxRate")
|
|
.unwrap();
|
|
assert!(matches!(tax.resolved_type, VariableType::Number));
|
|
assert!(matches!(
|
|
tax.origin,
|
|
zen_engine::policy::FieldOrigin::Schema { .. }
|
|
));
|
|
|
|
let is_adult = globals
|
|
.iter()
|
|
.find(|g| g.name.as_ref() == "isAdult")
|
|
.unwrap();
|
|
assert!(matches!(is_adult.resolved_type, VariableType::Bool));
|
|
assert!(matches!(
|
|
is_adult.origin,
|
|
zen_engine::policy::FieldOrigin::Computed { .. }
|
|
));
|
|
|
|
let entities = ws.entities(&ScopeRequest::for_policy("p"));
|
|
assert!(
|
|
entities.iter().all(|e| e.name.as_ref() != "platform"),
|
|
"platform (global DM name) must not appear in entities()",
|
|
);
|
|
assert!(
|
|
entities.iter().all(|e| e.name.as_ref() != "isAdult"),
|
|
"computed top-level path must not appear in entities()",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn global_data_model_name_can_be_empty() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"global DM with empty name should be valid; got {errors:#?}",
|
|
);
|
|
|
|
let inputs = ws.inputs(&ScopeRequest::for_policy("p"));
|
|
assert!(
|
|
inputs.iter().any(|p| p.path.as_ref() == "taxRate"),
|
|
"taxRate should still surface as a top-level input; got {inputs:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn assertion_writes_to_undeclared_global_top_level_path() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "isAdult",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "expected no errors, got {errors:#?}");
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("p"));
|
|
let is_adult = outputs
|
|
.iter()
|
|
.find(|p| p.path.as_ref() == "isAdult")
|
|
.expect("computed top-level path should surface in outputs()");
|
|
assert!(matches!(is_adult.resolved_type, VariableType::Bool));
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "age": 25 } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let out: serde_json::Value = result.output.into();
|
|
assert_eq!(
|
|
out.get("isAdult").and_then(|v| v.as_bool()),
|
|
Some(true),
|
|
"isAdult must land at the JSON root; got {out:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn global_relationship_to_unknown_entity_raises_diagnostic() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|d| format!("{:?}", d.code) == "UnknownDataModelTarget"),
|
|
"expected UnknownDataModelTarget from a global with no declared entity; got {errors:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_validation_rejects_wrong_type_for_global() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-globals", "type": "dataModel", "props": { "data": json!({
|
|
"name": "platform",
|
|
"scope": "global",
|
|
"properties": [
|
|
{ "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"taxRate": "not a number",
|
|
"customer": { "id": "c1" }
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let err = ws
|
|
.evaluate(&req)
|
|
.expect_err("validator should reject string for numeric global");
|
|
assert!(
|
|
format!("{err:?}").contains("taxRate"),
|
|
"error should reference taxRate; got {err:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn expression_diagnostics_distinguish_key_from_value() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "s1", "type": "expression", "props": { "data": { "key": "customer.bad name", "value": "customer.age" } } },
|
|
{ "id": "s2", "type": "expression", "props": { "data": { "key": "customer.result", "value": "nonexistentVar + 1" } } }
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let diags = ws.diagnostics("p");
|
|
|
|
let key_diag = diags
|
|
.iter()
|
|
.find(|d| format!("{:?}", d.code) == "InvalidWritePath")
|
|
.expect("expected an InvalidWritePath diagnostic on the statement key");
|
|
assert_eq!(key_diag.location.expression_id.as_deref(), Some("s1"));
|
|
assert!(
|
|
matches!(
|
|
&key_diag.location.target,
|
|
Some(CursorTarget::ExpressionKey { .. })
|
|
),
|
|
"key diagnostic must target the expression key, got {:?}",
|
|
key_diag.location.target
|
|
);
|
|
|
|
let value_diag = diags
|
|
.iter()
|
|
.find(|d| {
|
|
d.location.expression_id.as_deref() == Some("s2")
|
|
&& format!("{:?}", d.code) == "UndefinedVariable"
|
|
})
|
|
.expect("expected an UndefinedVariable diagnostic on the statement value");
|
|
assert!(
|
|
value_diag.location.target.is_none(),
|
|
"value diagnostic must carry no target (defaults to the value expression), got {:?}",
|
|
value_diag.location.target
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undefined_member_emits_single_diagnostic_not_duplicate() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "tree", "type": "expression", "props": { "data": { "key": "customer.result", "value": "customer.agee + 1" } } }
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let undefined: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| format!("{:?}", d.code) == "UndefinedVariable")
|
|
.collect();
|
|
assert_eq!(
|
|
undefined.len(),
|
|
1,
|
|
"a single undefined member must not be reported twice, got {undefined:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_root_read_is_not_an_unknown_property() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "e1", "type": "expression", "props": { "data": { "key": "customer.rootKeys", "value": "len(keys($root))" } } }
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"bare $root must not be flagged: {errors:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undefined_bare_identifier_still_reported_by_read_check() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "tree", "type": "expression", "props": { "data": { "key": "customer.result", "value": "nonexistentVar + 1" } } }
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
|
|
let undefined: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| format!("{:?}", d.code) == "UndefinedVariable")
|
|
.collect();
|
|
assert_eq!(
|
|
undefined.len(),
|
|
1,
|
|
"a bare undefined identifier the type-checker can't see must still be flagged, got {undefined:#?}"
|
|
);
|
|
assert!(undefined[0].message.contains("nonexistentVar"));
|
|
}
|
|
|
|
#[test]
|
|
fn input_override_on_expression_key_carries_key_target() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "s1", "type": "expression", "props": { "data": { "key": "customer.age", "value": "5" } } }
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
let d = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.find(|d| format!("{:?}", d.code) == "InputOverride")
|
|
.expect("expected InputOverride");
|
|
assert!(
|
|
matches!(&d.location.target, Some(CursorTarget::ExpressionKey { .. })),
|
|
"InputOverride must target the expression key, got {:?}",
|
|
d.location.target
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn incompatible_key_type_carries_key_target_and_span() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"p",
|
|
serde_json::from_value(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}}},
|
|
{ "id": "tree", "type": "match", "props": { "data": { "key": "customer.tag", "arms": [
|
|
{ "id": "b1", "condition": "customer.age > 18", "value": "\"adult\"" },
|
|
{ "id": "b2", "condition": "", "value": "1" }
|
|
]}}}
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
let d = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.find(|d| format!("{:?}", d.code) == "TypeMismatch")
|
|
.expect("expected TypeMismatch on incompatible key types");
|
|
assert!(
|
|
matches!(&d.location.target, Some(CursorTarget::MatchTarget)),
|
|
"type-mismatch must target the match key, got {:?}",
|
|
d.location.target
|
|
);
|
|
assert_eq!(
|
|
d.location.span,
|
|
Some((0, "customer.tag".chars().count() as u32)),
|
|
"type-mismatch must span the key field"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_policies_do_not_cross_flag_writes() {
|
|
let mk = || {
|
|
json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [ { "id": "p1", "name": "income", "type": "number", "array": false, "optional": false } ]
|
|
})}},
|
|
{ "id": "a", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.score",
|
|
"conditions": [ { "id": "c1", "expression": "customer.income > 0", "operator": "and", "depth": 0 } ]
|
|
})}}
|
|
]
|
|
})
|
|
};
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("alpha", serde_json::from_value(mk()).unwrap());
|
|
ws.set_policy("beta", serde_json::from_value(mk()).unwrap());
|
|
|
|
for path in ["alpha", "beta"] {
|
|
let errors: Vec<_> = ws
|
|
.diagnostics(path)
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"[{path}] unrelated policy writing the same path must not be flagged; got {errors:#?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_writer_is_reported_on_the_importing_policy() {
|
|
let writer = |block: &str, imports: &[&str]| {
|
|
json!({
|
|
"imports": imports,
|
|
"blocks": [
|
|
{ "id": block, "type": "expression", "props": { "data": json!({
|
|
"key": "decision", "value": "\"x\""
|
|
})}}
|
|
]
|
|
})
|
|
};
|
|
let duplicates = |ws: &PolicyWorkspace, path: &str| -> Vec<Option<String>> {
|
|
ws.diagnostics(path)
|
|
.into_iter()
|
|
.filter(|d| d.code == zen_engine::policy::DiagnosticCode::DuplicateWriter)
|
|
.map(|d| d.location.block_id.map(|b| b.to_string()))
|
|
.collect()
|
|
};
|
|
|
|
for (importer, imported, entry) in [
|
|
("airline/rules/apu", "airline/test", "airline/entry"),
|
|
("zz/importer", "aa/imported", "mm/entry"),
|
|
] {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
imported,
|
|
serde_json::from_value(writer("imported-block", &[])).unwrap(),
|
|
);
|
|
ws.set_policy(
|
|
importer,
|
|
serde_json::from_value(writer("importer-block", &[imported])).unwrap(),
|
|
);
|
|
ws.set_policy(
|
|
entry,
|
|
serde_json::from_value(json!({ "imports": [importer], "blocks": [] })).unwrap(),
|
|
);
|
|
|
|
assert_eq!(
|
|
duplicates(&ws, importer),
|
|
vec![Some("importer-block".to_string())],
|
|
"[{importer}] the importing policy owns the conflict on its own block",
|
|
);
|
|
assert!(
|
|
duplicates(&ws, imported).is_empty(),
|
|
"[{imported}] the imported policy cannot see its importer's write",
|
|
);
|
|
assert!(
|
|
duplicates(&ws, entry).is_empty(),
|
|
"[{entry}] a policy importing the offender must not repeat its error",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn disjoint_nested_writes_across_blocks_merge_at_runtime() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "globals", "scope": "global",
|
|
"properties": [ { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } ]
|
|
})}},
|
|
{ "id": "current", "type": "expression", "props": { "data": json!({
|
|
"key": "portfolio.byBucket.CURRENT.balance", "value": "principal * 0.7"
|
|
})}},
|
|
{ "id": "late", "type": "expression", "props": { "data": json!({
|
|
"key": "portfolio.byBucket.LATE.balance", "value": "principal * 0.3"
|
|
})}}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"disjoint assembly must be clean; got {errors:#?}"
|
|
);
|
|
|
|
let result = ws
|
|
.evaluate(&EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "principal": 1000 })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
})
|
|
.expect("evaluate should succeed");
|
|
let out: serde_json::Value = result.output.into();
|
|
let bucket = out
|
|
.get("portfolio")
|
|
.and_then(|p| p.get("byBucket"))
|
|
.and_then(|b| b.as_object())
|
|
.expect("portfolio.byBucket assembled");
|
|
assert_eq!(
|
|
bucket
|
|
.get("CURRENT")
|
|
.and_then(|c| c.get("balance"))
|
|
.and_then(|v| v.as_f64()),
|
|
Some(700.0),
|
|
"both blocks' disjoint nested writes must survive the merge; got {out}",
|
|
);
|
|
assert_eq!(
|
|
bucket
|
|
.get("LATE")
|
|
.and_then(|c| c.get("balance"))
|
|
.and_then(|v| v.as_f64()),
|
|
Some(300.0),
|
|
"both blocks' disjoint nested writes must survive the merge; got {out}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reading_assembled_object_is_self_reference_not_cycle() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "globals", "scope": "global",
|
|
"properties": [ { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } ]
|
|
})}},
|
|
{ "id": "s1", "type": "expression", "props": { "data": json!({ "key": "totals.base", "value": "principal" })}},
|
|
{ "id": "s2", "type": "expression", "props": { "data": json!({ "key": "totals.count", "value": "len(keys(totals))" })}}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let codes: Vec<String> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| format!("{:?}", d.code))
|
|
.collect();
|
|
assert!(
|
|
codes.iter().any(|c| c == "SelfReferencingWrite"),
|
|
"reading the object being assembled should surface as SelfReferencingWrite; got {codes:?}",
|
|
);
|
|
assert!(
|
|
!codes.iter().any(|c| c == "CyclicDependency"),
|
|
"it must not surface as a cryptic CyclicDependency; got {codes:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_policies_do_not_leak_computed_global_types() {
|
|
let writer = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"scope": "global", "name": "g",
|
|
"properties": [ { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } ]
|
|
}) }},
|
|
{ "id": "t", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i1", "name": "", "field": "score" } ],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "tier" } ],
|
|
"rules": [
|
|
{ "_id": "r1", "i1": ">= 100", "o1": "\"gold\"" },
|
|
{ "_id": "r2", "i1": ">= 50", "o1": "\"silver\"" },
|
|
{ "_id": "r3", "i1": "", "o1": "\"bronze\"" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
let reader = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"scope": "global", "name": "g",
|
|
"properties": [ { "id": "p1", "name": "tier", "type": "string", "array": false, "optional": false } ]
|
|
}) }},
|
|
{ "id": "t", "type": "decisionTable", "props": { "data": json!({
|
|
"hitPolicy": "first",
|
|
"inputs": [ { "id": "i1", "name": "", "field": "tier" } ],
|
|
"outputs": [ { "id": "o1", "name": "", "field": "discount" } ],
|
|
"rules": [
|
|
{ "_id": "r1", "i1": "\"platinum\"", "o1": "0.2" },
|
|
{ "_id": "r2", "i1": "\"gold\"", "o1": "0.1" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("tier-writer", serde_json::from_value(writer).unwrap());
|
|
ws.set_policy("tier-reader", serde_json::from_value(reader).unwrap());
|
|
|
|
let diags: Vec<_> = ws
|
|
.diagnostics("tier-reader")
|
|
.into_iter()
|
|
.filter(|d| d.severity != Severity::Hint)
|
|
.collect();
|
|
assert!(
|
|
diags.is_empty(),
|
|
"tier-reader declares its own global tier:string and never imports tier-writer; \
|
|
tier-writer's computed gold|silver|bronze enum must not narrow it. Got: {:?}",
|
|
diags
|
|
.iter()
|
|
.map(|d| (&d.code, &d.message))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn assertion_nested_or_of_and_group() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "pa", "name": "a", "type": "boolean", "array": false, "optional": false },
|
|
{ "id": "pb", "name": "b", "type": "boolean", "array": false, "optional": false },
|
|
{ "id": "pc", "name": "c", "type": "boolean", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.result",
|
|
"conditions": [
|
|
{ "id": "ca", "expression": "customer.a", "operator": "or", "depth": 0 },
|
|
{ "id": "cb", "expression": "customer.b", "operator": "and", "depth": 1 },
|
|
{ "id": "cc", "expression": "customer.c", "operator": "and", "depth": 1 }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let eval = |a: bool, b: bool, c: bool| -> bool {
|
|
let result = ws
|
|
.evaluate(&EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "a": a, "b": b, "c": c } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
})
|
|
.expect("evaluate succeeded");
|
|
result
|
|
.output
|
|
.dot("customer.result")
|
|
.and_then(|v| v.as_bool())
|
|
.expect("customer.result is a bool")
|
|
};
|
|
|
|
assert!(eval(true, false, false), "true OR (false AND false) = true");
|
|
assert!(eval(false, true, true), "false OR (true AND true) = true");
|
|
assert!(
|
|
!eval(false, true, false),
|
|
"false OR (true AND false) = false"
|
|
);
|
|
assert!(
|
|
!eval(false, false, false),
|
|
"false OR (false AND false) = false"
|
|
);
|
|
assert!(eval(true, true, true), "true OR (true AND true) = true");
|
|
}
|
|
|
|
fn expr(id: &str, key: &str, value: &str) -> serde_json::Value {
|
|
json!({ "id": id, "type": "expression", "props": { "data": { "key": key, "value": value } } })
|
|
}
|
|
|
|
fn match_block(id: &str, key: &str, arms: serde_json::Value) -> serde_json::Value {
|
|
json!({ "id": id, "type": "match", "props": { "data": { "key": key, "arms": arms } } })
|
|
}
|
|
|
|
fn arm(id: &str, condition: &str, value: &str) -> serde_json::Value {
|
|
json!({ "id": id, "condition": condition, "value": value })
|
|
}
|
|
|
|
fn customer_dm() -> serde_json::Value {
|
|
json!({
|
|
"id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "a", "name": "age", "type": "number" },
|
|
{ "id": "i", "name": "income", "type": "number" }
|
|
]
|
|
}}
|
|
})
|
|
}
|
|
|
|
fn errors(ws: &PolicyWorkspace, path: &str) -> Vec<String> {
|
|
ws.diagnostics(path)
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.map(|d| format!("{:?}: {}", d.code, d.message))
|
|
.collect()
|
|
}
|
|
|
|
fn set(ws: &mut PolicyWorkspace, path: &str, blocks: serde_json::Value) {
|
|
ws.set_policy(
|
|
path,
|
|
serde_json::from_value(json!({ "blocks": blocks })).unwrap(),
|
|
);
|
|
}
|
|
|
|
fn eval(ws: &PolicyWorkspace, path: &str, input: serde_json::Value) -> serde_json::Value {
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from(path),
|
|
input: Variable::from(input),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
ws.evaluate(&req).expect("evaluate ok").output.into()
|
|
}
|
|
|
|
#[test]
|
|
fn expression_block_writes_and_types() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("e1", "customer.bonus", "customer.income * 0.1")
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("p"));
|
|
let bonus = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "customer.bonus")
|
|
.expect("bonus output");
|
|
assert_eq!(format!("{}", bonus.resolved_type), "number");
|
|
|
|
let out = eval(
|
|
&ws,
|
|
"p",
|
|
json!({ "customer": { "age": 40, "income": 1000 } }),
|
|
);
|
|
assert_eq!(out["customer"]["bonus"].as_f64(), Some(100.0));
|
|
}
|
|
|
|
#[test]
|
|
fn separate_expression_blocks_order_independently_no_false_cycle() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("b", "customer.b", "customer.a + 1"),
|
|
expr("c", "customer.c", "customer.income + 5"),
|
|
expr("a", "customer.a", "customer.c + 1"),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let out = eval(&ws, "p", json!({ "customer": { "age": 1, "income": 10 } }));
|
|
assert_eq!(out["customer"]["c"].as_f64(), Some(15.0));
|
|
assert_eq!(out["customer"]["a"].as_f64(), Some(16.0));
|
|
assert_eq!(out["customer"]["b"].as_f64(), Some(17.0));
|
|
}
|
|
|
|
#[test]
|
|
fn genuine_cycle_is_reported() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("a", "customer.a", "customer.b + 1"),
|
|
expr("b", "customer.b", "customer.a + 1"),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("Cyclic")),
|
|
"expected cycle, got {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_writer_across_expression_blocks() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("e1", "customer.tier", "\"a\""),
|
|
expr("e2", "customer.tier", "\"b\""),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("DuplicateWriter")),
|
|
"{errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_with_default_is_total_and_merges_types() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.age >= 65", "\"senior\""),
|
|
arm("a2", "customer.age >= 18", "\"adult\""),
|
|
arm("a3", "", "\"minor\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("p"));
|
|
let tier = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "customer.tier")
|
|
.expect("tier output");
|
|
let ty = format!("{}", tier.resolved_type);
|
|
assert!(
|
|
ty.contains("senior") && ty.contains("adult") && ty.contains("minor"),
|
|
"{ty}"
|
|
);
|
|
assert!(!ty.contains('?'), "total match must not be nullable: {ty}");
|
|
|
|
assert_eq!(
|
|
eval(&ws, "p", json!({ "customer": { "age": 70 } }))["customer"]["tier"],
|
|
json!("senior")
|
|
);
|
|
assert_eq!(
|
|
eval(&ws, "p", json!({ "customer": { "age": 30 } }))["customer"]["tier"],
|
|
json!("adult")
|
|
);
|
|
assert_eq!(
|
|
eval(&ws, "p", json!({ "customer": { "age": 5 } }))["customer"]["tier"],
|
|
json!("minor")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_without_default_errors_and_returns_null_at_runtime() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([arm("a1", "customer.age >= 65", "\"senior\""),])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"non-exhaustive match must require a default arm: {errs:?}"
|
|
);
|
|
|
|
let out = eval(&ws, "p", json!({ "customer": { "age": 5 } }));
|
|
assert_eq!(out["customer"]["tier"], json!(null));
|
|
}
|
|
|
|
#[test]
|
|
fn match_non_boolean_condition_errors() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([arm("a1", "customer.age", "\"x\""), arm("a2", "", "\"y\""),])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter()
|
|
.any(|e| e.contains("TypeMismatch") && e.contains("boolean")),
|
|
"{errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_feeds_downstream_expression() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("e", "customer.discounted", "customer.rate < 0.1"),
|
|
match_block(
|
|
"m",
|
|
"customer.rate",
|
|
json!([
|
|
arm("a1", "customer.age >= 65", "0.05"),
|
|
arm("a2", "", "0.2"),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let out = eval(&ws, "p", json!({ "customer": { "age": 70 } }));
|
|
assert_eq!(out["customer"]["rate"].as_f64(), Some(0.05));
|
|
assert_eq!(out["customer"]["discounted"], json!(true));
|
|
}
|
|
|
|
#[test]
|
|
fn cross_block_deep_read_of_whole_object_write() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr(
|
|
"e1",
|
|
"customer.profile",
|
|
"{ age: customer.age, vip: customer.income > 1000 }"
|
|
),
|
|
expr("e2", "customer.adult", "customer.profile.age >= 18"),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let out = eval(
|
|
&ws,
|
|
"p",
|
|
json!({ "customer": { "age": 25, "income": 500 } }),
|
|
);
|
|
assert_eq!(out["customer"]["adult"], json!(true));
|
|
}
|
|
|
|
#[test]
|
|
fn cross_block_condition_sharing_via_intermediate() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr(
|
|
"d",
|
|
"customer.isPremium",
|
|
"customer.age >= 65 and customer.income > 1000"
|
|
),
|
|
match_block(
|
|
"m1",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.isPremium", "\"gold\""),
|
|
arm("a2", "", "\"std\"")
|
|
])
|
|
),
|
|
match_block(
|
|
"m2",
|
|
"customer.discount",
|
|
json!([arm("b1", "customer.isPremium", "0.2"), arm("b2", "", "0.0")])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let out = eval(
|
|
&ws,
|
|
"p",
|
|
json!({ "customer": { "age": 70, "income": 5000 } }),
|
|
);
|
|
assert_eq!(out["customer"]["tier"], json!("gold"));
|
|
assert_eq!(out["customer"]["discount"].as_f64(), Some(0.2));
|
|
}
|
|
|
|
fn disc_dm() -> serde_json::Value {
|
|
json!({
|
|
"id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate", "other"] },
|
|
{ "id": "ac", "name": "active", "type": "boolean" },
|
|
{ "id": "a", "name": "age", "type": "number" },
|
|
{ "id": "r", "name": "region", "type": "string" }
|
|
]
|
|
}}
|
|
})
|
|
}
|
|
|
|
fn tier_type(ws: &PolicyWorkspace) -> String {
|
|
let outputs = ws.outputs(&ScopeRequest::for_policy("p"));
|
|
let tier = outputs
|
|
.iter()
|
|
.find(|o| o.path.as_ref() == "customer.tier")
|
|
.expect("tier output");
|
|
format!("{}", tier.resolved_type)
|
|
}
|
|
|
|
#[test]
|
|
fn match_enum_full_cover_is_total_without_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.segment == \"retail\"", "\"gold\""),
|
|
arm("a2", "customer.segment == \"corporate\"", "\"silver\""),
|
|
arm("a3", "customer.segment == \"other\"", "\"bronze\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let ty = tier_type(&ws);
|
|
assert!(!ty.contains('?'), "full enum cover must be total: {ty}");
|
|
assert_eq!(
|
|
eval(&ws, "p", json!({ "customer": { "segment": "corporate" } }))["customer"]["tier"],
|
|
json!("silver")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_enum_partial_cover_errors_missing_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.segment == \"retail\"", "\"gold\""),
|
|
arm("a2", "customer.segment == \"corporate\"", "\"silver\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"partial enum cover must require a default arm: {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_in_set_covers_enum() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([arm(
|
|
"a1",
|
|
"customer.segment in [\"retail\", \"corporate\", \"other\"]",
|
|
"\"any\""
|
|
),])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
assert!(
|
|
!tier_type(&ws).contains('?'),
|
|
"in-set covering the enum must be total"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_bool_both_arms_is_total() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.active == true", "\"on\""),
|
|
arm("a2", "customer.active == false", "\"off\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
assert!(
|
|
!tier_type(&ws).contains('?'),
|
|
"bool true+false must be total"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_bool_one_arm_errors_missing_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([arm("a1", "customer.active == true", "\"on\""),])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"single bool arm must require a default arm: {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_number_tiling_no_gap_is_total() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.age < 18", "\"minor\""),
|
|
arm("a2", "customer.age >= 18", "\"adult\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
assert!(
|
|
!tier_type(&ws).contains('?'),
|
|
"gap-free number tiling must be total"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_number_tiling_with_gap_errors_missing_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.age < 18", "\"minor\""),
|
|
arm("a2", "customer.age > 18", "\"adult\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"number tiling with a gap must require a default arm: {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_guard_does_not_count_errors_missing_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm(
|
|
"a1",
|
|
"customer.segment == \"retail\" and customer.region == \"EU\"",
|
|
"\"gold\""
|
|
),
|
|
arm("a2", "customer.segment == \"corporate\"", "\"silver\""),
|
|
arm("a3", "customer.segment == \"other\"", "\"bronze\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"a guarded arm is not a pure discriminant test, so a default arm is required: {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_different_property_errors_missing_default() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm("a1", "customer.segment == \"retail\"", "\"gold\""),
|
|
arm("a2", "customer.active == false", "\"silver\""),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
let errs = errors(&ws, "p");
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("MissingDefaultBranch")),
|
|
"arms on different properties have no single discriminant, so a default arm is required: {errs:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lazy_prunes_dead_arm_intermediate() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
expr("retail", "customer.retailValue", "customer.age * 2"),
|
|
expr("corporate", "customer.corporateValue", "customer.age * 3"),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm(
|
|
"a1",
|
|
"customer.segment == \"retail\"",
|
|
"customer.retailValue"
|
|
),
|
|
arm(
|
|
"a2",
|
|
"customer.segment == \"corporate\"",
|
|
"customer.corporateValue"
|
|
),
|
|
arm("a3", "", "0"),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let out = eval(
|
|
&ws,
|
|
"p",
|
|
json!({ "customer": { "segment": "corporate", "age": 10 } }),
|
|
);
|
|
assert_eq!(out["customer"]["tier"].as_f64(), Some(30.0));
|
|
assert_eq!(out["customer"]["corporateValue"].as_f64(), Some(30.0));
|
|
assert!(
|
|
out["customer"].get("retailValue").is_none(),
|
|
"dead-arm intermediate must be pruned; got {out}"
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "segment": "corporate", "age": 10 } })),
|
|
goals: vec![Arc::from("customer.retailValue")],
|
|
trace: false,
|
|
};
|
|
let goal_out: serde_json::Value = ws.evaluate(&req).expect("goal eval ok").output.into();
|
|
assert_eq!(
|
|
goal_out["customer"]["retailValue"].as_f64(),
|
|
Some(20.0),
|
|
"a property promoted to a goal is forced even when only a dead arm reads it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lazy_equals_eager_for_branch_free_policy() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
disc_dm(),
|
|
expr("a", "customer.doubled", "customer.age * 2"),
|
|
expr("b", "customer.tier", "customer.doubled + 1"),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let out = eval(
|
|
&ws,
|
|
"p",
|
|
json!({ "customer": { "segment": "retail", "age": 5 } }),
|
|
);
|
|
assert_eq!(out["customer"]["doubled"].as_f64(), Some(10.0));
|
|
assert_eq!(out["customer"]["tier"].as_f64(), Some(11.0));
|
|
}
|
|
|
|
#[test]
|
|
fn lazy_iterated_match_picks_per_instance_arm() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "region", "type": "string", "enum": ["us", "eu"], "array": false, "optional": false },
|
|
{ "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "us", "type": "expression", "props": { "data": json!({ "key": "company.usBonus", "value": "company.revenue * 2" }) }},
|
|
{ "id": "eu", "type": "expression", "props": { "data": json!({ "key": "company.euBonus", "value": "company.revenue * 3" }) }},
|
|
{ "id": "m", "type": "match", "props": { "data": json!({
|
|
"key": "company.bonus",
|
|
"arms": [
|
|
{ "id": "a1", "condition": "company.region == \"us\"", "value": "company.usBonus" },
|
|
{ "id": "a2", "condition": "company.region == \"eu\"", "value": "company.euBonus" },
|
|
{ "id": "a3", "condition": "", "value": "0" }
|
|
]
|
|
}) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
let errs: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errs.is_empty(), "expected no errors, got {errs:#?}");
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"customer": { "companies": [
|
|
{ "region": "us", "revenue": 100 },
|
|
{ "region": "eu", "revenue": 100 }
|
|
] }
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let serialized: serde_json::Value = ws.evaluate(&req).expect("evaluate ok").output.into();
|
|
let companies = serialized
|
|
.pointer("/customer/companies")
|
|
.and_then(|v| v.as_array())
|
|
.expect("companies present");
|
|
assert_eq!(
|
|
companies[0]["bonus"].as_f64(),
|
|
Some(200.0),
|
|
"us company resolves the us arm"
|
|
);
|
|
assert_eq!(
|
|
companies[1]["bonus"].as_f64(),
|
|
Some(300.0),
|
|
"eu company resolves the eu arm"
|
|
);
|
|
}
|
|
|
|
fn group_paths(group: &serde_json::Value, key: &str) -> Vec<String> {
|
|
group[key]
|
|
.as_array()
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|p| p["path"].as_str().map(String::from))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn conditional_schema_clean_enum_discriminant_union() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate"] },
|
|
{ "id": "sd", "name": "retailData", "type": "number" },
|
|
{ "id": "ad", "name": "corporateData", "type": "number" }
|
|
]
|
|
}}}),
|
|
expr("scalc", "customer.retailCalc", "customer.retailData * 2"),
|
|
expr(
|
|
"acalc",
|
|
"customer.corporateCalc",
|
|
"customer.corporateData * 2"
|
|
),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm(
|
|
"a1",
|
|
"customer.segment == \"retail\"",
|
|
"customer.retailCalc"
|
|
),
|
|
arm(
|
|
"a2",
|
|
"customer.segment == \"corporate\"",
|
|
"customer.corporateCalc"
|
|
),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let schema =
|
|
serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap();
|
|
assert_eq!(schema["kind"], "union");
|
|
assert_eq!(schema["union"]["property"], "customer.segment");
|
|
|
|
let common_inputs = group_paths(&schema["common"], "inputs");
|
|
let common_outputs = group_paths(&schema["common"], "outputs");
|
|
assert!(
|
|
common_inputs.contains(&"customer.segment".to_string()),
|
|
"{common_inputs:?}"
|
|
);
|
|
assert!(
|
|
common_outputs.contains(&"customer.tier".to_string()),
|
|
"{common_outputs:?}"
|
|
);
|
|
|
|
let variants = schema["union"]["variants"].as_array().unwrap();
|
|
let retail = variants
|
|
.iter()
|
|
.find(|v| v["value"] == "retail")
|
|
.expect("retail variant");
|
|
let sag_inputs = group_paths(&retail["group"], "inputs");
|
|
let sag_outputs = group_paths(&retail["group"], "outputs");
|
|
assert!(
|
|
sag_inputs.contains(&"customer.retailData".to_string()),
|
|
"{sag_inputs:?}"
|
|
);
|
|
assert!(
|
|
!sag_inputs.contains(&"customer.corporateData".to_string()),
|
|
"retail variant must not carry the corporate input: {sag_inputs:?}"
|
|
);
|
|
assert!(
|
|
sag_outputs.contains(&"customer.retailCalc".to_string()),
|
|
"{sag_outputs:?}"
|
|
);
|
|
|
|
let corporate = variants
|
|
.iter()
|
|
.find(|v| v["value"] == "corporate")
|
|
.expect("corporate variant");
|
|
assert!(
|
|
group_paths(&corporate["group"], "inputs").contains(&"customer.corporateData".to_string())
|
|
);
|
|
|
|
let all_inputs: std::collections::HashSet<String> = ws
|
|
.inputs(&ScopeRequest::for_policy("p"))
|
|
.iter()
|
|
.map(|i| i.path.to_string())
|
|
.collect();
|
|
let mut partitioned: std::collections::HashSet<String> = common_inputs.into_iter().collect();
|
|
for v in variants {
|
|
partitioned.extend(group_paths(&v["group"], "inputs"));
|
|
}
|
|
assert_eq!(
|
|
partitioned, all_inputs,
|
|
"common + variants must cover the flat input superset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conditional_schema_compound_condition_is_flat_with_guard() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate"] },
|
|
{ "id": "r", "name": "region", "type": "string" },
|
|
{ "id": "sd", "name": "retailData", "type": "number" }
|
|
]
|
|
}}}),
|
|
expr("scalc", "customer.retailCalc", "customer.retailData * 2"),
|
|
match_block(
|
|
"m",
|
|
"customer.tier",
|
|
json!([
|
|
arm(
|
|
"a1",
|
|
"customer.segment == \"retail\" and customer.region == \"EU\"",
|
|
"customer.retailCalc"
|
|
),
|
|
arm("a2", "", "0"),
|
|
])
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let schema =
|
|
serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap();
|
|
assert_eq!(
|
|
schema["kind"], "flat",
|
|
"compound guard has no single clean discriminant"
|
|
);
|
|
|
|
let cond_inputs = schema["conditional"]["inputs"].as_array().unwrap();
|
|
let retail = cond_inputs
|
|
.iter()
|
|
.find(|p| p["path"] == "customer.retailData")
|
|
.expect("retailData is conditional");
|
|
assert!(
|
|
retail["requiredWhen"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("region"),
|
|
"guard should carry the compound condition: {retail}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conditional_schema_branch_free_is_flat_with_empty_conditional() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
customer_dm(),
|
|
expr("a", "customer.doubled", "customer.age * 2"),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
let schema =
|
|
serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap();
|
|
assert_eq!(schema["kind"], "flat");
|
|
assert!(schema["conditional"]["inputs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
assert!(schema["conditional"]["outputs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn global_aggregation_of_fanout_field() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "fee", "type": "expression", "props": { "data": json!({ "key": "company.fee", "value": "company.revenue * 2" }) }},
|
|
{ "id": "total", "type": "expression", "props": { "data": json!({ "key": "totalFee", "value": "sum(map(customer.companies as c, c.fee))" }) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
let errs: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errs.is_empty(), "expected no errors, got {errs:#?}");
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(
|
|
json!({ "customer": { "companies": [ { "revenue": 10 }, { "revenue": 20 } ] } }),
|
|
),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let out: serde_json::Value = ws.evaluate(&req).expect("evaluate ok").output.into();
|
|
assert_eq!(
|
|
out["totalFee"].as_f64(),
|
|
Some(60.0),
|
|
"global aggregation must demand the per-entity fan-out write; got {out}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn trace_on_error_carries_partial_trace() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"p",
|
|
json!([
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "a", "name": "age", "type": "number" },
|
|
{ "id": "n", "name": "name", "type": "string" }
|
|
]
|
|
}}}),
|
|
expr("ok", "okValue", "42"),
|
|
expr(
|
|
"bad",
|
|
"customer.bad",
|
|
"okValue > 0 ? sum([customer.age, customer.name]) : 0"
|
|
),
|
|
]),
|
|
);
|
|
assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p"));
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "age": 10, "name": "Alice" } })),
|
|
goals: Vec::new(),
|
|
trace: true,
|
|
};
|
|
match ws
|
|
.evaluate(&req)
|
|
.expect_err("summing a non-numeric array must fail at runtime")
|
|
{
|
|
EvaluationError::ExpressionFailed {
|
|
block_id,
|
|
partial_trace,
|
|
..
|
|
} => {
|
|
assert_eq!(block_id.as_ref(), "bad", "error names the failing block");
|
|
let trace = partial_trace.expect("partial trace attached on error");
|
|
assert!(
|
|
trace.executions.iter().any(|e| e.block_id.as_ref() == "ok"),
|
|
"partial trace must include blocks that ran before the failure; got {:?}",
|
|
trace
|
|
.executions
|
|
.iter()
|
|
.map(|e| e.block_id.clone())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
other => panic!("expected ExpressionFailed, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn goal_validation_resolves_fanout_entity_input() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-app", "type": "dataModel", "props": { "data": json!({
|
|
"name": "application",
|
|
"properties": [
|
|
{ "id": "d", "name": "drivers", "type": "relationship", "target": "driver", "array": true, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-driver", "type": "dataModel", "props": { "data": json!({
|
|
"name": "driver",
|
|
"properties": [
|
|
{ "id": "dob", "name": "dob", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "age", "type": "expression", "props": { "data": json!({ "key": "driver.age", "value": "2025 - driver.dob" }) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
let errs: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(errs.is_empty(), "expected no errors, got {errs:#?}");
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "application": { "drivers": [ { "dob": 1990 } ] } })),
|
|
goals: vec![Arc::from("driver.age")],
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req);
|
|
assert!(
|
|
result.is_ok(),
|
|
"fan-out entity field input must satisfy the goal's required input; got {result:?}"
|
|
);
|
|
let out: serde_json::Value = result.unwrap().output.into();
|
|
assert_eq!(
|
|
out.pointer("/application/drivers/0/age")
|
|
.and_then(|v| v.as_f64()),
|
|
Some(35.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_component_duplicate_writer_detected() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
set(
|
|
&mut ws,
|
|
"a",
|
|
json!([customer_dm(), expr("e", "customer.tier", "\"gold\"")]),
|
|
);
|
|
set(
|
|
&mut ws,
|
|
"b",
|
|
json!([customer_dm(), expr("e", "customer.tier", "\"silver\"")]),
|
|
);
|
|
|
|
let conflicts = ws.cross_component_write_conflicts();
|
|
let tier = conflicts
|
|
.iter()
|
|
.find(|c| c.path.as_ref() == "customer.tier")
|
|
.expect("cross-component conflict on customer.tier");
|
|
assert!(
|
|
tier.policies.iter().any(|p| p.as_ref() == "a"),
|
|
"{:?}",
|
|
tier.policies
|
|
);
|
|
assert!(
|
|
tier.policies.iter().any(|p| p.as_ref() == "b"),
|
|
"{:?}",
|
|
tier.policies
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_component_no_conflict_when_imported() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"base",
|
|
serde_json::from_value(
|
|
json!({ "blocks": [ customer_dm(), expr("e", "customer.x", "1") ] }),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
ws.set_policy("mid", serde_json::from_value(json!({ "imports": ["base"], "blocks": [ expr("e2", "customer.y", "customer.x + 1") ] })).unwrap());
|
|
assert!(
|
|
ws.cross_component_write_conflicts().is_empty(),
|
|
"imported policies share a component, no conflict"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn component_members_lists_merge_set() {
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy(
|
|
"base",
|
|
serde_json::from_value(
|
|
json!({ "blocks": [ customer_dm(), expr("e", "customer.x", "1") ] }),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
ws.set_policy("mid", serde_json::from_value(json!({ "imports": ["base"], "blocks": [ expr("e2", "customer.y", "customer.x + 1") ] })).unwrap());
|
|
ws.set_policy(
|
|
"solo",
|
|
serde_json::from_value(json!({ "blocks": [ customer_dm() ] })).unwrap(),
|
|
);
|
|
|
|
let members: Vec<String> = ws
|
|
.component_members("mid")
|
|
.iter()
|
|
.map(|m| m.to_string())
|
|
.collect();
|
|
assert_eq!(
|
|
members,
|
|
vec!["base".to_string(), "mid".to_string()],
|
|
"mid merges with base"
|
|
);
|
|
assert_eq!(
|
|
ws.component_members("solo")
|
|
.iter()
|
|
.map(|m| m.to_string())
|
|
.collect::<Vec<_>>(),
|
|
vec!["solo".to_string()],
|
|
"unrelated policy is its own component"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reference_pool_field_read_terminates_and_evaluates() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-order", "type": "dataModel", "props": { "data": json!({
|
|
"name": "order",
|
|
"properties": [
|
|
{ "id": "p1", "name": "productId", "type": "reference", "target": "product" },
|
|
{ "id": "p2", "name": "qty", "type": "number" }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-product", "type": "dataModel", "props": { "data": json!({
|
|
"name": "product",
|
|
"properties": [
|
|
{ "id": "p3", "name": "id", "type": "string" },
|
|
{ "id": "p4", "name": "price", "type": "number" }
|
|
]
|
|
}) }},
|
|
{ "id": "e-total", "type": "expression", "props": { "data": json!({
|
|
"key": "order.total",
|
|
"value": "(product[0].price ?? 0) * order.qty"
|
|
}) }},
|
|
{ "id": "e-premium", "type": "expression", "props": { "data": json!({
|
|
"key": "product.premium",
|
|
"value": "product.price > 100"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let _ = ws.all_diagnostics();
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({
|
|
"order": { "productId": "p1", "qty": 2 },
|
|
"product": [ { "id": "p1", "price": 10 }, { "id": "p2", "price": 200 } ]
|
|
})),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let output: serde_json::Value = result.output.into();
|
|
assert_eq!(output.pointer("/order/total"), Some(&json!(20)));
|
|
assert_eq!(output.pointer("/product/0/premium"), Some(&json!(false)));
|
|
assert_eq!(output.pointer("/product/1/premium"), Some(&json!(true)));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_relationship_chain_writes_are_flagged() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": json!({
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "branches", "type": "relationship", "target": "branch", "array": true }
|
|
]
|
|
}) }},
|
|
{ "id": "dm-branch", "type": "dataModel", "props": { "data": json!({
|
|
"name": "branch",
|
|
"properties": [
|
|
{ "id": "p3", "name": "headcount", "type": "number" }
|
|
]
|
|
}) }},
|
|
{ "id": "e-score", "type": "expression", "props": { "data": json!({
|
|
"key": "branch.score",
|
|
"value": "branch.headcount * 2"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let nested: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| {
|
|
d.code == zen_engine::policy::DiagnosticCode::UnsupportedNestedIteration
|
|
&& d.severity == Severity::Error
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
nested.len(),
|
|
1,
|
|
"writes behind a two-level relationship chain must be rejected, got {nested:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_match_arm_value_types_as_nullable() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "flag", "type": "boolean" }
|
|
]
|
|
}) }},
|
|
{ "id": "m", "type": "match", "props": { "data": json!({
|
|
"key": "customer.tier",
|
|
"arms": [
|
|
{ "id": "a1", "condition": "customer.flag", "value": "1" },
|
|
{ "id": "a2", "condition": "", "value": "" }
|
|
]
|
|
}) }},
|
|
{ "id": "e-next", "type": "expression", "props": { "data": json!({
|
|
"key": "customer.next",
|
|
"value": "customer.tier + 1"
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
!errors.is_empty(),
|
|
"customer.tier can be null at runtime, so `customer.tier + 1` must be a type error",
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "flag": false } })),
|
|
goals: vec![Arc::from("customer.tier")],
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let output: serde_json::Value = result.output.into();
|
|
assert_eq!(output.pointer("/customer/tier"), Some(&json!(null)));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_write_paths_are_not_registered_as_writers() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number" }
|
|
]
|
|
}) }},
|
|
{ "id": "e-bad", "type": "expression", "props": { "data": json!({
|
|
"key": "total amount",
|
|
"value": "customer.age * 2"
|
|
}) }},
|
|
{ "id": "m-bad", "type": "match", "props": { "data": json!({
|
|
"key": "a..b",
|
|
"arms": [ { "id": "a1", "condition": "", "value": "1" } ]
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let invalid_paths = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| {
|
|
d.code == zen_engine::policy::DiagnosticCode::InvalidWritePath
|
|
&& d.severity == Severity::Error
|
|
})
|
|
.count();
|
|
assert_eq!(invalid_paths, 2);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "age": 30 } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("evaluate succeeded");
|
|
let output: serde_json::Value = result.output.into();
|
|
assert!(
|
|
output.get("total amount").is_none(),
|
|
"malformed expression key must not be written: {output:#?}",
|
|
);
|
|
assert!(
|
|
output.get("a").is_none(),
|
|
"malformed match key must not be written: {output:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_field_rewrites_nested_write_keys() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "base", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "e-total", "type": "expression", "props": { "data": {
|
|
"key": "customer.scores.total",
|
|
"value": "customer.base * 2"
|
|
} } },
|
|
{ "id": "e-flat", "type": "expression", "props": { "data": {
|
|
"key": "customer.scoresTotal",
|
|
"value": "customer.base * 3"
|
|
} } },
|
|
{ "id": "e-read", "type": "expression", "props": { "data": {
|
|
"key": "customer.final",
|
|
"value": "customer.scores.total + 1"
|
|
} } }
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let edits = ws.rename(
|
|
&zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("scores"),
|
|
},
|
|
"metrics",
|
|
);
|
|
let total_json = rewritten_block_json(&edits, "e-total")
|
|
.unwrap_or_else(|| panic!("rename must rewrite the nested write key; got {edits:#?}"));
|
|
assert!(
|
|
total_json.contains("customer.metrics.total"),
|
|
"write key `customer.scores.total` must become `customer.metrics.total`; got {total_json}",
|
|
);
|
|
let read_json = rewritten_block_json(&edits, "e-read")
|
|
.unwrap_or_else(|| panic!("rename must rewrite the reader; got {edits:#?}"));
|
|
assert!(
|
|
read_json.contains("customer.metrics.total"),
|
|
"read of `customer.scores.total` must follow the write key; got {read_json}",
|
|
);
|
|
assert!(
|
|
!any_touches_block(&edits, "e-flat"),
|
|
"write key `customer.scoresTotal` must not be treated as a prefix match; got {edits:#?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_resolves_nested_field_target_across_components() {
|
|
let decoy = json!({
|
|
"blocks": [
|
|
{ "id": "dm-other", "type": "dataModel", "props": { "data": {
|
|
"name": "other",
|
|
"properties": [
|
|
{ "id": "p1", "name": "x", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "e-decoy", "type": "expression", "props": { "data": {
|
|
"key": "other.y",
|
|
"value": "other.x * 2"
|
|
} } }
|
|
]
|
|
});
|
|
let writer = json!({
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p2", "name": "base", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "e-out", "type": "expression", "props": { "data": {
|
|
"key": "customer.out",
|
|
"value": "{ sub: customer.base * 2 }"
|
|
} } }
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("a-decoy", serde_json::from_value(decoy).unwrap());
|
|
ws.set_policy("z-writer", serde_json::from_value(writer).unwrap());
|
|
|
|
let node = ws.dependencies("customer.out.sub");
|
|
let written_by = node
|
|
.written_by
|
|
.as_ref()
|
|
.unwrap_or_else(|| panic!("customer.out.sub must resolve to its writer; got {node:#?}"));
|
|
assert_eq!(written_by.policy_path.as_ref(), "z-writer");
|
|
assert_eq!(written_by.block_id.as_ref(), "e-out");
|
|
let dep_paths: Vec<String> = node.deps.iter().map(|d| d.property.to_string()).collect();
|
|
assert!(
|
|
dep_paths.iter().any(|p| p == "customer.base"),
|
|
"nested target must decompose into the value expression's reads; got {dep_paths:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn null_typed_computed_field_reads_are_not_unknown_properties() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "base", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "e-null", "type": "expression", "props": { "data": {
|
|
"key": "customer.flag",
|
|
"value": "null"
|
|
} } },
|
|
{ "id": "e-read", "type": "expression", "props": { "data": {
|
|
"key": "customer.out",
|
|
"value": "customer.flag ?? 'none'"
|
|
} } }
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let errors: Vec<_> = ws
|
|
.diagnostics("p")
|
|
.into_iter()
|
|
.filter(|d| d.severity == Severity::Error)
|
|
.collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"reading a null-typed computed field must not raise errors: {errors:#?}",
|
|
);
|
|
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "base": 1 } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
};
|
|
let result = ws.evaluate(&req).expect("policy must evaluate");
|
|
let output: serde_json::Value = result.output.into();
|
|
assert_eq!(output.pointer("/customer/out"), Some(&json!("none")));
|
|
}
|
|
|
|
fn bool_assertion_eval(conditions: serde_json::Value) -> impl Fn(bool, bool, bool) -> bool {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "pa", "name": "a", "type": "boolean", "array": false, "optional": false },
|
|
{ "id": "pb", "name": "b", "type": "boolean", "array": false, "optional": false },
|
|
{ "id": "pc", "name": "c", "type": "boolean", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "assert", "type": "assertion", "props": { "data": json!({
|
|
"output": "customer.result",
|
|
"conditions": conditions
|
|
}) }}
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
move |a: bool, b: bool, c: bool| -> bool {
|
|
let result = ws
|
|
.evaluate(&EvaluateRequest {
|
|
policy_path: Arc::from("p"),
|
|
input: Variable::from(json!({ "customer": { "a": a, "b": b, "c": c } })),
|
|
goals: Vec::new(),
|
|
trace: false,
|
|
})
|
|
.expect("evaluate succeeded");
|
|
result
|
|
.output
|
|
.dot("customer.result")
|
|
.and_then(|v| v.as_bool())
|
|
.expect("customer.result is a bool")
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn assertion_group_then_sibling_keeps_or_operator() {
|
|
let eval = bool_assertion_eval(json!([
|
|
{ "id": "ca", "expression": "customer.a", "operator": "and", "depth": 1 },
|
|
{ "id": "cb", "expression": "customer.b", "operator": "or", "depth": 1 },
|
|
{ "id": "cc", "expression": "customer.c", "operator": "and", "depth": 0 }
|
|
]));
|
|
|
|
assert!(eval(false, false, true), "(false AND false) OR true = true");
|
|
assert!(eval(true, true, false), "(true AND true) OR false = true");
|
|
assert!(
|
|
!eval(false, true, false),
|
|
"(false AND true) OR false = false"
|
|
);
|
|
assert!(eval(true, true, true), "(true AND true) OR true = true");
|
|
}
|
|
|
|
#[test]
|
|
fn assertion_group_then_sibling_keeps_and_operator() {
|
|
let eval = bool_assertion_eval(json!([
|
|
{ "id": "ca", "expression": "customer.a", "operator": "or", "depth": 1 },
|
|
{ "id": "cb", "expression": "customer.b", "operator": "and", "depth": 1 },
|
|
{ "id": "cc", "expression": "customer.c", "operator": "and", "depth": 0 }
|
|
]));
|
|
|
|
assert!(
|
|
!eval(true, false, false),
|
|
"(true OR false) AND false = false"
|
|
);
|
|
assert!(eval(true, false, true), "(true OR false) AND true = true");
|
|
assert!(
|
|
!eval(false, false, true),
|
|
"(false OR false) AND true = false"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn assertion_with_only_empty_conditions_still_writes_false() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "base", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "assert", "type": "assertion", "props": { "data": {
|
|
"output": "customer.ok",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "", "operator": "and", "depth": 0 }
|
|
]
|
|
} } },
|
|
{ "id": "e-read", "type": "expression", "props": { "data": {
|
|
"key": "customer.okText",
|
|
"value": "customer.ok ? 'yes' : 'no'"
|
|
} } }
|
|
]
|
|
});
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let output = eval(&ws, "p", json!({ "customer": { "base": 1 } }));
|
|
assert_eq!(
|
|
output.pointer("/customer/ok"),
|
|
Some(&json!(false)),
|
|
"an assertion with no effective conditions must still write false: {output:#?}",
|
|
);
|
|
assert_eq!(output.pointer("/customer/okText"), Some(&json!("no")));
|
|
}
|
|
|
|
#[test]
|
|
fn completions_offered_for_empty_and_trailing_space_sources() {
|
|
let doc = json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": json!({
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}) }},
|
|
{ "id": "empty", "type": "expression", "props": { "data": json!({ "key": "total", "value": "" }) }},
|
|
{ "id": "partial", "type": "expression", "props": { "data": json!({ "key": "fee", "value": "100 + " }) }}
|
|
]
|
|
});
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let labels_at = |block_id: &str, pos: u32| -> Vec<String> {
|
|
ws.completions(&Cursor {
|
|
policy_path: Arc::from("p"),
|
|
block_id: Arc::from(block_id),
|
|
pos,
|
|
target: CursorTarget::Expression { id: Arc::from("x") },
|
|
})
|
|
.into_iter()
|
|
.map(|c| c.label)
|
|
.collect()
|
|
};
|
|
|
|
let empty = labels_at("empty", 0);
|
|
assert!(
|
|
empty.iter().any(|l| l == "customer"),
|
|
"empty expression should offer scope completions: {empty:?}"
|
|
);
|
|
|
|
let partial = labels_at("partial", 7);
|
|
assert!(
|
|
partial.iter().any(|l| l == "customer"),
|
|
"cursor past trimmed source should offer scope completions: {partial:?}"
|
|
);
|
|
assert!(
|
|
!empty.iter().chain(&partial).any(|l| l == "$root"),
|
|
"policies have no $root: {empty:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_ignore_declarations_in_unrelated_policies() {
|
|
let global_model = |props: serde_json::Value| {
|
|
json!({ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "inputs", "scope": "global", "properties": props
|
|
} } })
|
|
};
|
|
let b = json!({
|
|
"blocks": [
|
|
global_model(json!([
|
|
{ "id": "p1", "name": "score", "type": "number", "array": false, "optional": false }
|
|
])),
|
|
{ "id": "e", "type": "expression", "props": { "data": { "key": "status", "value": "" } } }
|
|
]
|
|
});
|
|
let a = json!({
|
|
"blocks": [
|
|
global_model(json!([
|
|
{ "id": "p1", "name": "status", "type": "string", "array": false, "optional": false }
|
|
]))
|
|
]
|
|
});
|
|
|
|
let labels = |ws: &PolicyWorkspace| -> Vec<String> {
|
|
ws.completions(&Cursor {
|
|
policy_path: Arc::from("b.json"),
|
|
block_id: Arc::from("e"),
|
|
pos: 0,
|
|
target: CursorTarget::Expression { id: Arc::from("e") },
|
|
})
|
|
.into_iter()
|
|
.map(|c| c.label)
|
|
.collect()
|
|
};
|
|
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("b.json", serde_json::from_value(b).unwrap());
|
|
let alone = labels(&ws);
|
|
assert!(alone.iter().any(|l| l == "score"), "{alone:?}");
|
|
|
|
ws.set_policy("a.json", serde_json::from_value(a).unwrap());
|
|
let with_unrelated = labels(&ws);
|
|
assert!(
|
|
with_unrelated.iter().any(|l| l == "score"),
|
|
"{with_unrelated:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_rename_on_collect_output_head() {
|
|
let doc = json!({ "blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
] } } },
|
|
{ "id": "dt", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [{ "id": "i1", "name": "", "field": "customer.age" }],
|
|
"outputs": [{ "id": "o1", "name": "", "field": "customer.tags[]" }],
|
|
"rules": [{ "_id": "r1", "i1": "> 1", "o1": "\"a\"" }]
|
|
} } }
|
|
] });
|
|
let mut ws = PolicyWorkspace::new();
|
|
ws.set_policy("p", serde_json::from_value(doc).unwrap());
|
|
|
|
let prepared = ws
|
|
.prepare_rename(&Cursor {
|
|
policy_path: Arc::from("p"),
|
|
block_id: Arc::from("dt"),
|
|
pos: 11,
|
|
target: CursorTarget::DecisionTableHead {
|
|
col: Arc::from("o1"),
|
|
},
|
|
})
|
|
.expect("collect head is renamable");
|
|
assert_eq!(
|
|
prepared.target,
|
|
zen_engine::policy::RenameTarget::Field {
|
|
entity: Arc::from("customer"),
|
|
field: Arc::from("tags"),
|
|
}
|
|
);
|
|
assert_eq!(prepared.span, (9, 13));
|
|
}
|