mirror of
https://github.com/gorules/zen.git
synced 2026-10-08 16:02:30 +00:00
611 lines
19 KiB
Rust
611 lines
19 KiB
Rust
use serde::Deserialize;
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
use zen_engine::policy::{
|
|
Cursor, CursorTarget, EvaluateRequest, PolicyWorkspace, ReferenceKind, RenameTarget,
|
|
ScopeRequest,
|
|
};
|
|
use zen_expression::variable::Variable;
|
|
|
|
const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/policy/fixtures/");
|
|
|
|
fn build_workspace(policies: &[String]) -> PolicyWorkspace {
|
|
let mut ws = PolicyWorkspace::new();
|
|
for path in policies {
|
|
let raw = std::fs::read_to_string(format!("{FIXTURES_DIR}{path}"))
|
|
.unwrap_or_else(|e| panic!("cannot read fixture {path}: {e}"));
|
|
let doc = serde_json::from_str(&raw)
|
|
.unwrap_or_else(|e| panic!("cannot deserialize fixture {path}: {e}"));
|
|
ws.set_policy(path.as_str(), doc);
|
|
}
|
|
ws
|
|
}
|
|
|
|
fn toml_to_json(value: &toml::Value) -> serde_json::Value {
|
|
serde_json::to_value(value).expect("toml converts to json")
|
|
}
|
|
|
|
fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
|
|
match (a.as_f64(), b.as_f64()) {
|
|
(Some(x), Some(y)) => (x - y).abs() <= f64::EPSILON * x.abs().max(y.abs()).max(1.0),
|
|
_ => a == b,
|
|
}
|
|
}
|
|
|
|
fn assert_subset(expected: &serde_json::Value, actual: Option<&serde_json::Value>, ctx: &str) {
|
|
use serde_json::Value;
|
|
let Some(actual) = actual else {
|
|
panic!("{ctx}: expected {expected}, but key is absent");
|
|
};
|
|
match (expected, actual) {
|
|
(Value::Object(exp), Value::Object(act)) => {
|
|
for (key, val) in exp {
|
|
assert_subset(val, act.get(key), &format!("{ctx}.{key}"));
|
|
}
|
|
}
|
|
(Value::Array(exp), Value::Array(act)) => {
|
|
assert_eq!(
|
|
exp.len(),
|
|
act.len(),
|
|
"{ctx}: expected {} elements, got {}: {actual}",
|
|
exp.len(),
|
|
act.len()
|
|
);
|
|
for (idx, val) in exp.iter().enumerate() {
|
|
assert_subset(val, act.get(idx), &format!("{ctx}[{idx}]"));
|
|
}
|
|
}
|
|
(Value::Number(exp), Value::Number(act)) => {
|
|
assert!(
|
|
numbers_equal(exp, act),
|
|
"{ctx}: expected {expected}, got {actual}"
|
|
);
|
|
}
|
|
_ => assert_eq!(expected, actual, "{ctx}: expected {expected}, got {actual}"),
|
|
}
|
|
}
|
|
|
|
fn subset_matches(expected: &serde_json::Value, actual: Option<&serde_json::Value>) -> bool {
|
|
use serde_json::Value;
|
|
let Some(actual) = actual else { return false };
|
|
match (expected, actual) {
|
|
(Value::Object(exp), Value::Object(act)) => exp
|
|
.iter()
|
|
.all(|(key, val)| subset_matches(val, act.get(key))),
|
|
(Value::Array(exp), Value::Array(act)) => {
|
|
exp.len() == act.len() && exp.iter().zip(act).all(|(e, a)| subset_matches(e, Some(a)))
|
|
}
|
|
(Value::Number(exp), Value::Number(act)) => numbers_equal(exp, act),
|
|
_ => expected == actual,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EvaluationFile {
|
|
test: Vec<EvaluationCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EvaluationCase {
|
|
name: String,
|
|
policies: Vec<String>,
|
|
input: toml::Value,
|
|
output: toml::Value,
|
|
#[serde(default)]
|
|
nulls: Vec<String>,
|
|
trace: Option<TraceExpectation>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TraceExpectation {
|
|
blocks: BTreeMap<String, toml::Value>,
|
|
}
|
|
|
|
fn normalize_block_expectation(value: &toml::Value) -> serde_json::Value {
|
|
let mut json = toml_to_json(value);
|
|
if let Some(obj) = json.as_object_mut() {
|
|
if let Some(rows) = obj.remove("matched_rows") {
|
|
obj.insert("matchedRows".into(), rows);
|
|
}
|
|
}
|
|
json
|
|
}
|
|
|
|
fn run_evaluation(file_name: &str, toml_data: &str) {
|
|
let file: EvaluationFile =
|
|
toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}"));
|
|
|
|
for test in &file.test {
|
|
let ctx = format!("[{file_name}:{}]", test.name);
|
|
let ws = build_workspace(&test.policies);
|
|
let req = EvaluateRequest {
|
|
policy_path: Arc::from(test.policies[0].as_str()),
|
|
input: Variable::from(toml_to_json(&test.input)),
|
|
goals: Vec::new(),
|
|
trace: test.trace.is_some(),
|
|
};
|
|
let result = ws
|
|
.evaluate(&req)
|
|
.unwrap_or_else(|e| panic!("{ctx} evaluation failed: {e:?}"));
|
|
|
|
let output_json: serde_json::Value = result.output.into();
|
|
assert_subset(&toml_to_json(&test.output), Some(&output_json), &ctx);
|
|
for path in &test.nulls {
|
|
let pointer = format!("/{}", path.replace('.', "/"));
|
|
assert_eq!(
|
|
output_json.pointer(&pointer),
|
|
Some(&serde_json::Value::Null),
|
|
"{ctx} expected explicit null at '{path}'; got {output_json}"
|
|
);
|
|
}
|
|
|
|
let Some(expected_trace) = &test.trace else {
|
|
continue;
|
|
};
|
|
let trace = result
|
|
.trace
|
|
.unwrap_or_else(|| panic!("{ctx} expected trace but got none"));
|
|
for (block_id, expectation) in &expected_trace.blocks {
|
|
let expected = normalize_block_expectation(expectation);
|
|
let candidates: Vec<serde_json::Value> = trace
|
|
.executions
|
|
.iter()
|
|
.filter(|e| e.block_id.as_ref() == block_id.as_str())
|
|
.map(|e| serde_json::to_value(&e.trace).expect("trace serializes"))
|
|
.collect();
|
|
assert!(
|
|
!candidates.is_empty(),
|
|
"{ctx} block '{block_id}' not found in trace; executed: {:?}",
|
|
trace
|
|
.executions
|
|
.iter()
|
|
.map(|e| e.block_id.as_ref())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert!(
|
|
candidates
|
|
.iter()
|
|
.any(|actual| subset_matches(&expected, Some(actual))),
|
|
"{ctx} block '{block_id}': no execution matches {expected}\n got: {candidates:#?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RenameFile {
|
|
policies: Vec<String>,
|
|
test: Vec<RenameCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RenameCase {
|
|
name: String,
|
|
entity: String,
|
|
field: String,
|
|
new_name: String,
|
|
edits: Vec<EditExpectation>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EditExpectation {
|
|
policy: String,
|
|
block_id: String,
|
|
expression_id: Option<String>,
|
|
target_kind: Option<String>,
|
|
}
|
|
|
|
fn rename_target(entity: &str, field: &str) -> RenameTarget {
|
|
if field.is_empty() {
|
|
RenameTarget::Entity {
|
|
name: Arc::from(entity),
|
|
}
|
|
} else {
|
|
RenameTarget::Field {
|
|
entity: Arc::from(entity),
|
|
field: Arc::from(field),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_rename(file_name: &str, toml_data: &str) {
|
|
let file: RenameFile =
|
|
toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}"));
|
|
let ws = build_workspace(&file.policies);
|
|
|
|
for test in &file.test {
|
|
let ctx = format!("[{file_name}:{}]", test.name);
|
|
let target = rename_target(&test.entity, &test.field);
|
|
|
|
let sites = ws.references(&target);
|
|
let mut unmatched: Vec<usize> = (0..sites.len()).collect();
|
|
for expected in &test.edits {
|
|
let pos = unmatched.iter().position(|&i| {
|
|
let site = &sites[i];
|
|
if site.policy_path.as_ref() != expected.policy
|
|
|| site.block_id.as_ref() != expected.block_id
|
|
{
|
|
return false;
|
|
}
|
|
match (&expected.expression_id, &expected.target_kind) {
|
|
(Some(id), _) => {
|
|
site.expression_id.as_deref() == Some(id.as_str())
|
|
&& site.kind != ReferenceKind::DataModel
|
|
}
|
|
(None, Some(_)) => site.kind == ReferenceKind::DataModel,
|
|
(None, None) => {
|
|
site.expression_id.is_none() && site.kind != ReferenceKind::DataModel
|
|
}
|
|
}
|
|
});
|
|
match pos {
|
|
Some(idx_pos) => {
|
|
unmatched.remove(idx_pos);
|
|
}
|
|
None => panic!(
|
|
"{ctx} expected site {expected:?} not found.\n got: {:#?}",
|
|
sites
|
|
),
|
|
}
|
|
}
|
|
assert!(
|
|
unmatched.is_empty(),
|
|
"{ctx} unexpected extra sites: {:#?}",
|
|
unmatched.iter().map(|&i| &sites[i]).collect::<Vec<_>>()
|
|
);
|
|
|
|
let edits = ws.rename(&target, &test.new_name);
|
|
if test.edits.is_empty() {
|
|
assert!(edits.is_empty(), "{ctx} expected no edits, got {edits:?}");
|
|
} else {
|
|
let serialized = serde_json::to_string(&edits).expect("edits serialize");
|
|
assert!(
|
|
serialized.contains(&test.new_name),
|
|
"{ctx} rename edits must contain '{}': {serialized}",
|
|
test.new_name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PrepareRenameFile {
|
|
policies: Vec<String>,
|
|
test: Vec<PrepareRenameCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PrepareRenameCase {
|
|
name: String,
|
|
policy: String,
|
|
block_id: String,
|
|
expression_id: String,
|
|
pos: u32,
|
|
expected_entity: Option<String>,
|
|
expected_field: Option<String>,
|
|
}
|
|
|
|
fn run_prepare_rename(file_name: &str, toml_data: &str) {
|
|
let file: PrepareRenameFile =
|
|
toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}"));
|
|
let ws = build_workspace(&file.policies);
|
|
|
|
for test in &file.test {
|
|
let ctx = format!("[{file_name}:{}]", test.name);
|
|
let result = ws.prepare_rename(&Cursor {
|
|
policy_path: Arc::from(test.policy.as_str()),
|
|
block_id: Arc::from(test.block_id.as_str()),
|
|
pos: test.pos,
|
|
target: CursorTarget::Expression {
|
|
id: Arc::from(test.expression_id.as_str()),
|
|
},
|
|
});
|
|
|
|
match (&test.expected_entity, result) {
|
|
(None, None) => {}
|
|
(None, Some(r)) => panic!("{ctx} expected None, got {:?}", r.target),
|
|
(Some(_), None) => panic!("{ctx} expected Some, got None"),
|
|
(Some(entity), Some(r)) => {
|
|
let expected_field = test.expected_field.as_deref().unwrap_or("");
|
|
let expected = rename_target(entity, expected_field);
|
|
assert_eq!(r.target, expected, "{ctx} target mismatch");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CompletionsFile {
|
|
policies: Vec<String>,
|
|
test: Vec<CompletionsCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CompletionsCase {
|
|
name: String,
|
|
policy: String,
|
|
block_id: String,
|
|
expression_id: String,
|
|
pos: u32,
|
|
#[serde(default)]
|
|
head: bool,
|
|
row: Option<String>,
|
|
#[serde(default)]
|
|
includes: Vec<String>,
|
|
#[serde(default)]
|
|
excludes: Vec<String>,
|
|
}
|
|
|
|
fn run_completions(file_name: &str, toml_data: &str) {
|
|
let file: CompletionsFile =
|
|
toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}"));
|
|
let ws = build_workspace(&file.policies);
|
|
|
|
for test in &file.test {
|
|
let ctx = format!("[{file_name}:{}]", test.name);
|
|
let target = if test.head {
|
|
CursorTarget::DecisionTableHead {
|
|
col: Arc::from(test.expression_id.as_str()),
|
|
}
|
|
} else if let Some(row) = &test.row {
|
|
CursorTarget::DecisionTableCell {
|
|
row: Arc::from(row.as_str()),
|
|
col: Arc::from(test.expression_id.as_str()),
|
|
}
|
|
} else {
|
|
CursorTarget::Expression {
|
|
id: Arc::from(test.expression_id.as_str()),
|
|
}
|
|
};
|
|
let completions = ws.completions(&Cursor {
|
|
policy_path: Arc::from(test.policy.as_str()),
|
|
block_id: Arc::from(test.block_id.as_str()),
|
|
pos: test.pos,
|
|
target,
|
|
});
|
|
let labels: Vec<&str> = completions.iter().map(|c| c.label.as_ref()).collect();
|
|
|
|
for inc in &test.includes {
|
|
assert!(
|
|
labels.contains(&inc.as_str()),
|
|
"{ctx} expected completion '{inc}' missing.\n got: {labels:?}"
|
|
);
|
|
}
|
|
for exc in &test.excludes {
|
|
assert!(
|
|
!labels.contains(&exc.as_str()),
|
|
"{ctx} unexpected completion '{exc}'.\n got: {labels:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EntitiesFile {
|
|
policies: Vec<String>,
|
|
test: Vec<EntitiesCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EntitiesCase {
|
|
name: String,
|
|
policy: String,
|
|
entity_count: Option<usize>,
|
|
entity: Option<String>,
|
|
field: Option<String>,
|
|
field_kind: Option<toml::Value>,
|
|
instance_of: Option<toml::Value>,
|
|
#[serde(default)]
|
|
no_instance_of: bool,
|
|
property_kind: Option<String>,
|
|
source: Option<String>,
|
|
source_is_local: Option<bool>,
|
|
#[serde(default)]
|
|
no_duplicate_fields: bool,
|
|
#[serde(default)]
|
|
absent_fields: Vec<String>,
|
|
}
|
|
|
|
fn run_entities(file_name: &str, toml_data: &str) {
|
|
let file: EntitiesFile =
|
|
toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}"));
|
|
let ws = build_workspace(&file.policies);
|
|
|
|
for test in &file.test {
|
|
let ctx = format!("[{file_name}:{}]", test.name);
|
|
let entities = ws.entities(&ScopeRequest::for_policy(test.policy.as_str()));
|
|
|
|
if let Some(count) = test.entity_count {
|
|
assert_eq!(
|
|
entities.len(),
|
|
count,
|
|
"{ctx} entity count mismatch; got {:?}",
|
|
entities.iter().map(|e| e.name.as_ref()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
let Some(entity) = &test.entity else {
|
|
continue;
|
|
};
|
|
let entity_obj = entities
|
|
.iter()
|
|
.find(|e| e.name.as_ref() == entity.as_str())
|
|
.unwrap_or_else(|| panic!("{ctx} entity '{entity}' not found"));
|
|
|
|
if test.no_duplicate_fields {
|
|
let mut names: Vec<&str> = entity_obj.fields.iter().map(|f| f.name.as_ref()).collect();
|
|
let total = names.len();
|
|
names.sort();
|
|
names.dedup();
|
|
assert_eq!(names.len(), total, "{ctx} duplicate fields present");
|
|
}
|
|
for absent in &test.absent_fields {
|
|
assert!(
|
|
!entity_obj
|
|
.fields
|
|
.iter()
|
|
.any(|f| f.name.as_ref() == absent.as_str()),
|
|
"{ctx} field '{absent}' should be absent"
|
|
);
|
|
}
|
|
let Some(field) = &test.field else {
|
|
continue;
|
|
};
|
|
let field_obj = entity_obj
|
|
.fields
|
|
.iter()
|
|
.find(|f| f.name.as_ref() == field.as_str())
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"{ctx} field '{field}' not found; got {:?}",
|
|
entity_obj
|
|
.fields
|
|
.iter()
|
|
.map(|f| f.name.as_ref())
|
|
.collect::<Vec<_>>()
|
|
)
|
|
});
|
|
|
|
let origin_json = serde_json::to_value(&field_obj.origin).expect("origin serializes");
|
|
if let Some(kind) = &test.property_kind {
|
|
let actual = match origin_json["origin"].as_str() {
|
|
Some("schema") => "input",
|
|
Some("computed") => "computed",
|
|
other => panic!("{ctx} unexpected origin {other:?}"),
|
|
};
|
|
assert_eq!(actual, kind, "{ctx} property_kind mismatch: {origin_json}");
|
|
}
|
|
if let Some(expected_kind) = &test.field_kind {
|
|
assert_eq!(
|
|
origin_json["origin"].as_str(),
|
|
Some("schema"),
|
|
"{ctx} field_kind asserts require a schema-origin field: {origin_json}"
|
|
);
|
|
assert_subset(
|
|
&toml_to_json(expected_kind),
|
|
Some(&origin_json["fieldKind"]),
|
|
&format!("{ctx} field_kind"),
|
|
);
|
|
}
|
|
if let Some(expected) = &test.instance_of {
|
|
assert_eq!(
|
|
origin_json["origin"].as_str(),
|
|
Some("computed"),
|
|
"{ctx} instance_of asserts require a computed field: {origin_json}"
|
|
);
|
|
assert_subset(
|
|
&toml_to_json(expected),
|
|
Some(&origin_json["instanceOf"]),
|
|
&format!("{ctx} instance_of"),
|
|
);
|
|
}
|
|
if test.no_instance_of {
|
|
assert!(
|
|
origin_json.get("instanceOf").is_none(),
|
|
"{ctx} expected no instanceOf: {origin_json}"
|
|
);
|
|
}
|
|
|
|
let source_policy = match origin_json["origin"].as_str() {
|
|
Some("schema") => origin_json["source"].as_str(),
|
|
Some("computed") => origin_json["writtenBy"]["policyPath"].as_str(),
|
|
_ => None,
|
|
};
|
|
if let Some(expected) = &test.source {
|
|
assert_eq!(
|
|
source_policy,
|
|
Some(expected.as_str()),
|
|
"{ctx} source mismatch: {origin_json}"
|
|
);
|
|
}
|
|
if let Some(local) = test.source_is_local {
|
|
assert_eq!(
|
|
source_policy == Some(test.policy.as_str()),
|
|
local,
|
|
"{ctx} source_is_local mismatch: {origin_json}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn evaluation_toml_cases() {
|
|
run_evaluation(
|
|
"evaluation.toml",
|
|
include_str!("data/policy/evaluation.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_toml_cases() {
|
|
run_rename("rename.toml", include_str!("data/policy/rename.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn rename_multi_policy_toml_cases() {
|
|
run_rename(
|
|
"rename_multi_policy.toml",
|
|
include_str!("data/policy/rename_multi_policy.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_rename_toml_cases() {
|
|
run_prepare_rename(
|
|
"prepare_rename.toml",
|
|
include_str!("data/policy/prepare_rename.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_toml_cases() {
|
|
run_completions(
|
|
"completions.toml",
|
|
include_str!("data/policy/completions.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_scoping_toml_cases() {
|
|
run_completions(
|
|
"completions_scoping.toml",
|
|
include_str!("data/policy/completions_scoping.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_multi_entity_toml_cases() {
|
|
run_completions(
|
|
"completions_multi_entity.toml",
|
|
include_str!("data/policy/completions_multi_entity.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entities_toml_cases() {
|
|
run_entities("entities.toml", include_str!("data/policy/entities.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn entities_filter_toml_cases() {
|
|
run_entities(
|
|
"entities_filter.toml",
|
|
include_str!("data/policy/entities_filter.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entities_merge_toml_cases() {
|
|
run_entities(
|
|
"entities_merge.toml",
|
|
include_str!("data/policy/entities_merge.toml"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entities_merge_multi_policy_toml_cases() {
|
|
run_entities(
|
|
"entities_merge_multi_policy.toml",
|
|
include_str!("data/policy/entities_merge_multi_policy.toml"),
|
|
);
|
|
}
|