automapper-validation 0.18.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Conditions are evaluated in the scope of the group instance their field
//! belongs to, at every nesting level.
//!
//! The validator used to scope only the top-level group (the SG4), so "in
//! dieser SG8" could not be told apart from "in der Nachricht": 55043's [420]
//! ("RFF+Z14 in dieser SG8") saw another meter's Smartmeter-Gateway and
//! demanded `konfigurationsId` of an OBIS group without one.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use automapper_validation::eval::{
    ConditionEvaluator, ConditionResult, EvaluationContext, GroupScope, MapExternalProvider,
    NoOpExternalProvider,
};
use automapper_validation::validator::validate::{AhbFieldRule, AhbWorkflow};
use automapper_validation::{build_validated_tree, EdifactValidator, ValidationLevel};
use mig_assembly::assembler::{
    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
};
use mig_assembly::navigator::AssembledTreeNavigator;
use mig_types::segment::OwnedSegment;

fn seg(tag: &str, mig: &str, elements: &[&[&str]]) -> AssembledSegment {
    AssembledSegment {
        tag: tag.to_string(),
        elements: elements
            .iter()
            .map(|e| e.iter().map(|c| c.to_string()).collect())
            .collect(),
        mig_number: Some(mig.to_string()),
        segment_number: None,
    }
}

fn instance(
    segments: Vec<AssembledSegment>,
    child_groups: Vec<AssembledGroup>,
) -> AssembledGroupInstance {
    AssembledGroupInstance {
        segments,
        child_groups,
        entry_mig_number: None,
        variant_mig_numbers: vec![],
        skipped_segments: vec![],
        skipped_positions: Vec::new(),
    }
}

fn group(id: &str, repetitions: Vec<AssembledGroupInstance>) -> AssembledGroup {
    AssembledGroup {
        group_id: id.to_string(),
        repetitions,
    }
}

/// SG4[0]: one meter SG8 (no SMGW).
/// SG4[1]: SG8[0] a meter without SMGW, SG8[1] a meter naming SMGW (RFF+Z14)
/// with an SG10 CCI.
fn tree() -> AssembledTree {
    let seq = || seg("SEQ", "001", &[&["Z03"]]);
    AssembledTree {
        segments: vec![],
        groups: vec![group(
            "SG4",
            vec![
                instance(
                    vec![seg("IDE", "000", &[&["24", "TX0"]])],
                    vec![group("SG8", vec![instance(vec![seq()], vec![])])],
                ),
                instance(
                    vec![seg("IDE", "000", &[&["24", "TX1"]])],
                    vec![group(
                        "SG8",
                        vec![
                            instance(vec![seq()], vec![]),
                            instance(
                                vec![seq(), seg("RFF", "002", &[&["Z14", "1ESG0000000018"]])],
                                vec![group(
                                    "SG10",
                                    vec![instance(
                                        vec![seg("CCI", "003", &[&[""], &[], &["E13"]])],
                                        vec![],
                                    )],
                                )],
                            ),
                        ],
                    )],
                ),
            ],
        )],
        post_group_start: 0,
        inter_group_segments: BTreeMap::new(),
    }
}

type Path = Vec<(String, usize)>;

/// Records the scope each condition is evaluated in; every condition holds.
#[derive(Default)]
struct RecordingEvaluator {
    seen: Arc<Mutex<Vec<(u32, Option<Path>)>>>,
}

impl ConditionEvaluator for RecordingEvaluator {
    fn evaluate(&self, condition: u32, ctx: &EvaluationContext) -> ConditionResult {
        let path = ctx
            .scope
            .map(|s| s.path.iter().map(|(id, i)| (id.to_string(), *i)).collect());
        self.seen.lock().unwrap().push((condition, path));
        ConditionResult::True
    }
    fn is_external(&self, _: u32) -> bool {
        false
    }
    fn message_type(&self) -> &str {
        "UTILMD"
    }
    fn format_version(&self) -> &str {
        "FV2604"
    }
}

fn rule(path: &str, status: &str, mig: &str) -> AhbFieldRule {
    AhbFieldRule {
        segment_path: path.to_string(),
        name: path.to_string(),
        ahb_status: status.to_string(),
        element_index: Some(0),
        component_index: Some(0),
        mig_number: Some(mig.to_string()),
        ..Default::default()
    }
}

fn p(steps: &[(&str, usize)]) -> Option<Path> {
    Some(steps.iter().map(|(id, i)| (id.to_string(), *i)).collect())
}

#[test]
fn each_field_is_evaluated_in_its_own_nested_instance() {
    let tree = tree();
    let workflow = AhbWorkflow {
        pruefidentifikator: "55043".to_string(),
        description: String::new(),
        communication_direction: None,
        fields: vec![
            rule("SG4/SG8/SEQ/1229", "Muss [1]", "001"),
            rule("SG4/SG8/SG10/CCI/7059", "Muss [2]", "003"),
        ],
        ub_definitions: BTreeMap::new(),
    };
    let validated = build_validated_tree(&workflow, &tree);
    let evaluator = RecordingEvaluator::default();
    let seen = Arc::clone(&evaluator.seen);
    let validator = EdifactValidator::new(evaluator);
    let navigator = AssembledTreeNavigator::new(&tree);
    validator.validate_tree(
        &validated,
        &[],
        &NoOpExternalProvider,
        ValidationLevel::Conditions,
        Some(&navigator),
    );

    let seen = seen.lock().unwrap().clone();
    let scopes_of = |condition: u32| -> Vec<Option<Path>> {
        seen.iter()
            .filter(|(c, _)| *c == condition)
            .map(|(_, path)| path.clone())
            .collect()
    };
    assert_eq!(
        scopes_of(1),
        [
            p(&[("SG4", 0), ("SG8", 0)]),
            p(&[("SG4", 1), ("SG8", 0)]),
            p(&[("SG4", 1), ("SG8", 1)]),
        ]
    );
    assert_eq!(scopes_of(2), [p(&[("SG4", 1), ("SG8", 1), ("SG10", 0)])]);
}

#[test]
fn a_condition_reads_the_enclosing_instance_of_the_group_it_names() {
    let tree = tree();
    let navigator = AssembledTreeNavigator::new(&tree);
    let external = MapExternalProvider::new(Default::default());
    // The flat message, for lookups that fall back to message-wide.
    let flat = [OwnedSegment {
        id: "RFF".to_string(),
        elements: vec![vec!["Z14".to_string(), "1ESG0000000018".to_string()]],
        segment_number: 7,
    }];
    let ctx = EvaluationContext::with_navigator("55043", &external, &flat, &navigator);
    let rff_z14 = |ctx: &EvaluationContext, group: &str| {
        ctx.scoped_find_segments_in(group, "RFF")
            .iter()
            .any(|s| s.get_element(0) == "Z14")
    };

    // An SG10 field asking about "dieser SG8" sees its own SG8's RFF+Z14 ...
    let in_sg10 = [("SG4", 1), ("SG8", 1), ("SG10", 0)];
    assert!(rff_z14(
        &ctx.with_scope(GroupScope { path: &in_sg10 }),
        "SG8"
    ));
    // ... and a meter's SG8 without one does not see the other meter's.
    let other_meter = [("SG4", 1), ("SG8", 0)];
    assert!(!rff_z14(
        &ctx.with_scope(GroupScope { path: &other_meter }),
        "SG8"
    ));
    let other_transaction = [("SG4", 0), ("SG8", 0)];
    assert!(!rff_z14(
        &ctx.with_scope(GroupScope {
            path: &other_transaction
        }),
        "SG8"
    ));

    // The current instance includes its nested groups: the SG8 sees its SG10's CCI.
    let meter = [("SG4", 1), ("SG8", 1)];
    assert_eq!(
        ctx.with_scope(GroupScope { path: &meter })
            .scoped_find_segments("CCI")
            .len(),
        1
    );

    // No enclosing SG8 (a transaction-level field): the whole message, as unscoped.
    let transaction = [("SG4", 0)];
    assert!(rff_z14(
        &ctx.with_scope(GroupScope { path: &transaction }),
        "SG8"
    ));
}

#[test]
fn a_qualified_lookup_reads_the_enclosing_instance_too() {
    let tree = tree();
    let navigator = AssembledTreeNavigator::new(&tree);
    let external = MapExternalProvider::new(Default::default());
    let ctx = EvaluationContext::with_navigator("55043", &external, &[], &navigator);
    let z14 = |path: &[(&str, usize)]| {
        ctx.with_scope(GroupScope { path })
            .scoped_find_segments_in_with_qualifier("SG8", "RFF", 0, "Z14")
            .len()
    };
    assert_eq!(z14(&[("SG4", 1), ("SG8", 1), ("SG10", 0)]), 1);
    assert_eq!(z14(&[("SG4", 1), ("SG8", 0)]), 0);
}

/// The `this_group_*` helpers read the enclosing instance of the group their
/// path names ("in dieser SG8"), and fall back to their `any_group_*`
/// counterparts without a scope reaching it.
#[test]
fn this_group_helpers_read_the_enclosing_instance() {
    let tree = tree();
    let navigator = AssembledTreeNavigator::new(&tree);
    let external = MapExternalProvider::new(Default::default());
    let flat = [OwnedSegment {
        id: "RFF".to_string(),
        elements: vec![vec!["Z14".to_string(), "1ESG0000000018".to_string()]],
        segment_number: 7,
    }];
    let ctx = EvaluationContext::with_navigator("55043", &external, &flat, &navigator);
    let in_scope = |path: &[(&str, usize)]| {
        let scoped = ctx.with_scope(GroupScope { path });
        (
            scoped.this_group_has_qualifier("RFF", 0, "Z14", &["SG4", "SG8"]),
            scoped.this_group_has_any_qualifier("RFF", 0, &["Z14", "Z15"], &["SG8"]),
            scoped.this_group_has_qualified_value(
                "RFF",
                0,
                "Z14",
                0,
                1,
                &["1ESG0000000018"],
                &["SG8"],
            ),
            scoped.this_group_values("RFF", 0, 1, &["SG8"]),
        )
    };
    let with_smgw = in_scope(&[("SG4", 1), ("SG8", 1), ("SG10", 0)]);
    assert_eq!(
        (with_smgw.0, with_smgw.1, with_smgw.2),
        (
            ConditionResult::True,
            ConditionResult::True,
            ConditionResult::True
        )
    );
    assert_eq!(with_smgw.3, vec![(0, "1ESG0000000018".to_string())]);
    let without = in_scope(&[("SG4", 1), ("SG8", 0)]);
    assert_eq!(
        (without.0, without.1, without.2),
        (
            ConditionResult::False,
            ConditionResult::False,
            ConditionResult::False
        )
    );
    assert!(without.3.is_empty());
    // No SG8 in the scope: the `any_group_*` answer (here: the flat message).
    assert_eq!(
        ctx.with_scope(GroupScope {
            path: &[("SG4", 0)]
        })
        .this_group_has_qualifier("RFF", 0, "Z14", &["SG8"]),
        ctx.any_group_has_qualifier("RFF", 0, "Z14", &["SG8"])
    );
}