automapper-validation 0.5.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Per-instance parent-group evaluation (Path C) — failing regression test.
//!
//! Today's `validate_tree` evaluates `parent_group_ahb_status` once
//! message-wide, so a condition that's True for one SG8 instance and
//! False for another fires on both. After B5 restructures the validator
//! to pass a per-instance `GroupScope`, conditions that use
//! `ctx.scoped_find_segments` evaluate per instance — this test pins
//! the post-B5 behavior.
//!
//! Drive the **matched-rules path**: each SG8 instance has a CCI child
//! group whose CCI segment carries the qualifier but not the code value.
//! `build_validated_tree` creates one `AhbNode` per SG8 instance — both
//! with `value: None`. B5's per-instance restructure populates
//! `instance_index` on errors emitted from those nodes.
//!
//! Pre-B5: fails because 2 AHB001 errors are emitted (one per SG8) with
//! `instance_index: None`.
//! Post-B5: passes — only SG8[0] errors, with `instance_index: Some(0)`.

use automapper_validation::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
use automapper_validation::eval::{
    ConditionEvaluator, ConditionResult, EvaluationContext, NoOpExternalProvider,
};
use automapper_validation::validator::validate::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
use automapper_validation::{
    build_validated_tree, EdifactValidator, ValidationIssue, ValidationLevel,
};
use mig_assembly::assembler::{
    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
};
use mig_assembly::navigator::AssembledTreeNavigator;
use mig_types::segment::OwnedSegment;
use std::collections::BTreeMap;

/// Narrate an issue the way the EDIFACT view would, for assertions that used
/// to read `.message` before Task 5 removed it.
fn narrate(issue: &ValidationIssue) -> String {
    TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
}

/// Minimal evaluator with one synthetic condition [999]:
/// "True if there's a PIA+Z02 with code 1-02-0-001 *in scope*."
///
/// Uses `ctx.scoped_find_segments` so the answer narrows to the current
/// group instance when `ctx.scope` is set, and falls back to message-wide
/// when it isn't.
struct PerInstanceTestEvaluator;

impl ConditionEvaluator for PerInstanceTestEvaluator {
    fn message_type(&self) -> &str {
        "TEST"
    }
    fn format_version(&self) -> &str {
        "FV9999"
    }
    fn evaluate(&self, id: u32, ctx: &EvaluationContext) -> ConditionResult {
        if id == 999 {
            let any_match = ctx.scoped_find_segments("PIA").iter().any(|s| {
                let is_z02 = s
                    .elements
                    .first()
                    .and_then(|e| e.first())
                    .is_some_and(|v| v == "Z02");
                let is_target = s
                    .elements
                    .get(1)
                    .and_then(|e| e.first())
                    .is_some_and(|v| v == "1-02-0-001");
                is_z02 && is_target
            });
            return ConditionResult::from(any_match);
        }
        ConditionResult::Unknown
    }
    fn is_external(&self, _id: u32) -> bool {
        false
    }
    fn is_known(&self, id: u32) -> bool {
        id == 999
    }
}

fn assembled_pia(qual: &str, code: &str, seg_mig: &str) -> AssembledSegment {
    AssembledSegment {
        tag: "PIA".to_string(),
        elements: vec![vec![qual.to_string()], vec![code.to_string()]],
        mig_number: Some(seg_mig.to_string()),
        segment_number: None,
    }
}

fn flat_pia(code: &str, number: u32) -> OwnedSegment {
    OwnedSegment {
        id: "PIA".to_string(),
        segment_number: number,
        elements: vec![vec!["Z02".to_string()], vec![code.to_string()]],
    }
}

/// Build an SG10 child group containing a CCI segment with the qualifier
/// present but the C240 code value absent.
///
/// The CCI structure: `CCI+<qualifier>+<C240 with d7037 missing>`. We
/// model element 0 as the qualifier element and element 1 (C240) as
/// an empty element. With element 1 empty, `extract_value` for
/// component 0 returns `None` → the resulting `AhbNode.value` is `None`.
fn cci_child_group() -> AssembledGroup {
    AssembledGroup {
        group_id: "SG10".to_string(),
        repetitions: vec![AssembledGroupInstance {
            segments: vec![AssembledSegment {
                tag: "CCI".to_string(),
                // element 0: qualifier; element 1 (C240): empty component.
                elements: vec![vec!["Z88".to_string()], vec![String::new()]],
                mig_number: Some("0060".to_string()),
                segment_number: None,
            }],
            child_groups: vec![],
            entry_mig_number: Some("0060".to_string()),
            variant_mig_numbers: vec!["0060".to_string()],
            skipped_segments: vec![],
            skipped_positions: Vec::new(),
        }],
    }
}

/// Build a tree with two SG8 instances, each with its own PIA and a
/// CCI child group whose code value is absent.
fn make_test_tree() -> AssembledTree {
    AssembledTree {
        segments: vec![],
        groups: vec![AssembledGroup {
            group_id: "SG8".to_string(),
            repetitions: vec![
                // Instance 0: PIA+Z02+1-02-0-001 — triggers [999]=True in scope
                AssembledGroupInstance {
                    segments: vec![assembled_pia("Z02", "1-02-0-001", "0050")],
                    child_groups: vec![cci_child_group()],
                    entry_mig_number: Some("0050".to_string()),
                    variant_mig_numbers: vec!["0050".to_string(), "0060".to_string()],
                    skipped_segments: vec![],
                    skipped_positions: Vec::new(),
                },
                // Instance 1: PIA+Z02+1-10-1 — [999] False in scope
                AssembledGroupInstance {
                    segments: vec![assembled_pia("Z02", "1-10-1", "0050")],
                    child_groups: vec![cci_child_group()],
                    entry_mig_number: Some("0050".to_string()),
                    variant_mig_numbers: vec!["0050".to_string(), "0060".to_string()],
                    skipped_segments: vec![],
                    skipped_positions: Vec::new(),
                },
            ],
        }],
        post_group_start: 0,
        inter_group_segments: BTreeMap::new(),
    }
}

fn make_workflow() -> AhbWorkflow {
    AhbWorkflow {
        pruefidentifikator: "TEST".to_string(),
        description: "Test workflow".to_string(),
        communication_direction: None,
        fields: vec![
            // CCI code value under SG8/SG10, mandatory under [999].
            // Qualifier element is present in the segment but the code
            // value component is empty — matched path, value: None.
            AhbFieldRule {
                segment_path: "SG8/SG10/CCI/C240/7037".to_string(),
                name: "Merkmalcode".to_string(),
                ahb_status: "Muss [999]".to_string(),
                codes: vec![AhbCodeRule {
                    value: "Z01".to_string(),
                    description: "Test code".to_string(),
                    ahb_status: "X".to_string(),
                }],
                parent_group_ahb_status: Some("Muss".to_string()),
                segment_ahb_status: None,
                element_index: Some(1),
                component_index: Some(0),
                mig_number: Some("0060".to_string()),
            },
        ],
        ub_definitions: BTreeMap::new(),
    }
}

#[test]
fn missing_field_only_errors_on_instance_where_parent_condition_fires() {
    let tree = make_test_tree();
    let workflow = make_workflow();
    // Flat segments for message-wide fallback (both PIAs visible).
    let flat_segments = vec![flat_pia("1-02-0-001", 1), flat_pia("1-10-1", 2)];

    let validated_tree = build_validated_tree(&workflow, &tree);

    let evaluator = PerInstanceTestEvaluator;
    let validator = EdifactValidator::new(evaluator);
    let external = NoOpExternalProvider;

    let navigator = AssembledTreeNavigator::new(&tree);
    let report = validator.validate_tree(
        &validated_tree,
        &flat_segments,
        &external,
        ValidationLevel::Full,
        Some(&navigator),
    );

    let ahb001: Vec<_> = report
        .issues
        .iter()
        .filter(|i| i.code() == "AHB001")
        .collect();

    assert_eq!(
        ahb001.len(),
        1,
        "Expected exactly 1 AHB001 (on SG8[0]); got {}:\n{}",
        ahb001.len(),
        ahb001
            .iter()
            .map(|i| format!(
                "  - {} (instance={:?}): {}",
                i.field_path.as_deref().unwrap_or(""),
                i.instance_index,
                narrate(i),
            ))
            .collect::<Vec<_>>()
            .join("\n"),
    );
    assert_eq!(
        ahb001[0].instance_index,
        Some(0),
        "The AHB001 should be tagged with instance_index=Some(0); \
         SG8[0] is the instance whose PIA triggers [999]=True."
    );
}