zen-engine 2.0.1

Business rules engine
Documentation
use serde_json::json;
use zen_engine::policy::{
    Cursor, CursorTarget, ExpressionKind, NlExpression, PolicyDocument, PolicyWorkspace,
};
use zen_expression::nl::{EditHint, NlTokenKind, OpSym, TypeTag};

fn workspace() -> PolicyWorkspace {
    let doc: PolicyDocument = 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": "p2", "name": "tier", "type": "string", "enum": ["gold", "silver", "bronze"], "array": false, "optional": false }
                    ]
                } },
                "children": []
            },
            {
                "id": "assert1",
                "type": "assertion",
                "props": { "data": {
                    "output": "customer.isAdult",
                    "conditions": [
                        { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
                    ]
                } },
                "children": []
            },
            {
                "id": "dt1",
                "type": "decisionTable",
                "props": { "data": {
                    "hitPolicy": "first",
                    "inputs": [
                        { "id": "in1", "name": "Age", "field": "customer.age" },
                        { "id": "in2", "name": "Tier", "field": "customer.tier" }
                    ],
                    "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
                    "rules": [ { "_id": "row1", "in1": "> 18", "in2": "'gold'", "out1": "'vip'" } ]
                } },
                "children": []
            }
        ]
    }))
    .expect("valid policy fixture");

    let mut ws = PolicyWorkspace::new();
    ws.set_policy("policy", doc);
    ws
}

fn find<'a>(results: &'a [NlExpression], pred: impl Fn(&NlExpression) -> bool) -> &'a NlExpression {
    results
        .iter()
        .find(|e| pred(e))
        .expect("expression present")
}

fn kinds(e: &NlExpression) -> Vec<NlTokenKind> {
    e.result.tokens.iter().map(|t| t.token.clone()).collect()
}

#[test]
fn assertion_condition_resolves_field_type() {
    let ws = workspace();
    let results = ws.nl("policy");

    let condition = find(
        &results,
        |e| matches!(&e.target, CursorTarget::Expression { id } if id.as_ref() == "c1"),
    );

    assert_eq!(condition.kind, ExpressionKind::Standard);
    assert_eq!(condition.block_id.as_ref(), "assert1");
    assert_eq!(
        kinds(condition),
        vec![
            NlTokenKind::Field {
                path: vec!["customer".into(), "age".into()],
                ty: TypeTag::Number,
            },
            NlTokenKind::Op {
                sym: OpSym::Gte,
                implied: false,
                between: false,
            },
            NlTokenKind::Number { value: "18".into() },
        ]
    );
}

#[test]
fn decision_table_input_cell_is_unary_with_resolved_subject() {
    let ws = workspace();
    let results = ws.nl("policy");

    let cell = find(&results, |e| {
        matches!(
            &e.target,
            CursorTarget::DecisionTableCell { row, col }
                if row.as_ref() == "row1" && col.as_ref() == "in1"
        )
    });

    assert_eq!(cell.kind, ExpressionKind::Unary);
    let k = kinds(cell);
    assert_eq!(
        k[0],
        NlTokenKind::Op {
            sym: OpSym::Gt,
            implied: true,
            between: false,
        }
    );
    assert_eq!(k[1], NlTokenKind::Number { value: "18".into() });
}

#[test]
fn decision_table_output_cell_is_standard() {
    let ws = workspace();
    let results = ws.nl("policy");

    let cell = find(&results, |e| {
        matches!(
            &e.target,
            CursorTarget::DecisionTableCell { row, col }
                if row.as_ref() == "row1" && col.as_ref() == "out1"
        )
    });

    assert_eq!(cell.kind, ExpressionKind::Standard);
    assert!(matches!(kinds(cell).as_slice(), [NlTokenKind::Str { .. }]));
}

fn cell_cursor(col: &str) -> Cursor {
    Cursor {
        policy_path: "policy".into(),
        block_id: "dt1".into(),
        pos: 0,
        target: CursorTarget::DecisionTableCell {
            row: "row1".into(),
            col: col.into(),
        },
    }
}

#[test]
fn nl_tokenize_resolves_unary_enum_subject() {
    let ws = workspace();
    let result = ws
        .nl_tokenize(&cell_cursor("in2"), "'gold'")
        .expect("cursor resolves");

    let str_tok = result
        .tokens
        .iter()
        .find(|t| matches!(t.token, NlTokenKind::Str { .. }))
        .expect("string token present");
    assert_eq!(str_tok.hint, Some(EditHint::Select { options: 0 }));
    let labels: Vec<&str> = result.enums[0].iter().map(|o| o.label.as_str()).collect();
    assert_eq!(labels, vec!["gold", "silver", "bronze"]);
    assert!(result.enums[0]
        .iter()
        .all(|o| o.source.as_deref() == Some(format!("\"{}\"", o.label).as_str())));
}

#[test]
fn nl_tokenize_projects_live_text_over_stored_cell() {
    let ws = workspace();
    let result = ws
        .nl_tokenize(&cell_cursor("in1"), "< 21")
        .expect("cursor resolves");

    let tokens: Vec<NlTokenKind> = result.tokens.iter().map(|t| t.token.clone()).collect();
    assert_eq!(
        tokens[0],
        NlTokenKind::Op {
            sym: OpSym::Lt,
            implied: true,
            between: false,
        }
    );
    assert_eq!(tokens[1], NlTokenKind::Number { value: "21".into() });
}

#[test]
fn nl_tokenize_standard_expression_uses_policy_scope() {
    let ws = workspace();
    let cursor = Cursor {
        policy_path: "policy".into(),
        block_id: "assert1".into(),
        pos: 0,
        target: CursorTarget::Expression { id: "c1".into() },
    };
    let result = ws
        .nl_tokenize(&cursor, "customer.age >= 21")
        .expect("cursor resolves");

    let tokens: Vec<NlTokenKind> = result.tokens.iter().map(|t| t.token.clone()).collect();
    assert_eq!(
        tokens[0],
        NlTokenKind::Field {
            path: vec!["customer".into(), "age".into()],
            ty: TypeTag::Number,
        }
    );
}

#[test]
fn decision_table_input_head_is_projected() {
    let ws = workspace();
    let results = ws.nl("policy");

    let head = find(
        &results,
        |e| matches!(&e.target, CursorTarget::DecisionTableHead { col } if col.as_ref() == "in1"),
    );

    assert_eq!(head.kind, ExpressionKind::Standard);
    assert_eq!(
        kinds(head),
        vec![NlTokenKind::Field {
            path: vec!["customer".into(), "age".into()],
            ty: TypeTag::Number,
        }]
    );
}