edifact-mapper 0.8.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! Regression tests for issue #104: `validate_pid` / `validate_pid_with_conditions`
//! on an entity fed by several AHB segment-group variants.
//!
//! PID 55042 maps `Geschaeftspartner` from four SG12 variants (NAD+Z03, Z05, Z07,
//! Z08) through a single TOML file. The validator used to
//! * keep only the first variant's codes (so `partnerrolle` allowed only Z03),
//! * apply every variant's required fields to every NAD element,
//! * skip code objects (`{code, meaning, enum}`) entirely, and
//! * compare `enum_map`-translated names against raw EDIFACT codes.
//!
//! Skips if the bundle / fixtures aren't present (both are build artifacts).

use std::path::PathBuf;

use edifact_mapper::{DataDir, Mapper};
use mig_bo4e::PidValidationError;
use serde_json::Value;

type Dynamic = mig_bo4e::model::Interchange<Value, Value>;

const VARIANT: &str = "UTILMD_Strom";
const PID: &str = "55042";

/// `nad.d3035` enum_map of `mappings/FV26*/UTILMD_Strom/pid_55042/geschaeftspartner.toml`.
const PARTNERROLLE: &[(&str, &str)] = &[
    ("Z03", "messlokationsadresse"),
    ("Z05", "ablesekarte"),
    ("Z07", "kundeMsb"),
    ("Z08", "korrespondenzKundeMsb"),
];

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("crate is two levels under the workspace root")
        .to_path_buf()
}

/// Parse the generated 55042 fixture and return (mapper, transaction stammdaten).
fn load(fv: &str) -> Option<(Mapper, Value)> {
    let root = repo_root();
    let fixture = root.join(format!(
        "fixtures/generated/{}/utilmd/{PID}.edi",
        fv.to_lowercase()
    ));
    if !root.join(format!("dist/edifact-data-{fv}.bin")).exists() || !fixture.exists() {
        eprintln!("skipping: regenerate the {fv} bundle + fixture first");
        return None;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(root.join("dist"))).expect("load bundle");
    let edifact = std::fs::read_to_string(&fixture).expect("read fixture");
    let ic: Dynamic = mapper
        .from_edifact(&edifact, fv, VARIANT, PID)
        .expect("convert fixture");
    // A transaction is `{stammdaten, transaktionsdaten}`; the entity map is the
    // `stammdaten` half, with the metadata entity merged back in for the reverse.
    let raw = ic.nachrichten[0].transaktionen[0].clone();
    let tx = match raw.get("stammdaten") {
        Some(st) => {
            let mut out = st.clone();
            mig_bo4e::model::restore_entity(
                &mut out,
                mig_bo4e::model::TX_METADATA_ENTITY,
                raw.get("transaktionsdaten").unwrap_or(&Value::Null),
            );
            out
        }
        None => raw,
    };
    let gp = tx
        .get("geschaeftspartner")
        .and_then(Value::as_array)
        .expect("55042 fixture has a Geschaeftspartner array");
    assert_eq!(gp.len(), 4, "one Geschaeftspartner per SG12 variant");
    Some((mapper, tx))
}

/// Rewrite every `geschaeftspartner[].partnerrolle` with `f`.
fn map_partnerrolle(tx: &Value, f: impl Fn(&Value) -> Value) -> Value {
    let mut tx = tx.clone();
    for gp in tx["geschaeftspartner"].as_array_mut().unwrap() {
        let new = f(&gp["partnerrolle"]);
        gp["partnerrolle"] = new;
    }
    tx
}

fn code_of(v: &Value) -> String {
    v.get("code")
        .and_then(Value::as_str)
        .or_else(|| v.as_str())
        .expect("partnerrolle is a code object or string")
        .to_string()
}

fn raw_code(name: &str) -> &'static str {
    PARTNERROLLE
        .iter()
        .find(|(_, n)| *n == name)
        .map(|(c, _)| *c)
        .unwrap_or_else(|| panic!("unexpected partnerrolle {name}"))
}

fn validate_both(mapper: &Mapper, fv: &str, tx: &Value) -> Vec<(&'static str, PidValidationError)> {
    let mut out = Vec::new();
    for e in mapper.validate_pid(tx, fv, VARIANT, PID).unwrap() {
        out.push(("validate_pid", e));
    }
    for e in mapper
        .validate_pid_with_conditions(tx, fv, VARIANT, PID)
        .unwrap()
    {
        out.push(("validate_pid_with_conditions", e));
    }
    out
}

/// Findings on Geschaeftspartner that a valid 55042 message must not produce:
/// any `InvalidCode`, or a `MissingField` reported as an error.
fn geschaeftspartner_false_positives(errors: &[(&'static str, PidValidationError)]) -> Vec<String> {
    errors
        .iter()
        .filter(|(_, e)| match e {
            PidValidationError::InvalidCode { entity, .. } => entity == "Geschaeftspartner",
            PidValidationError::MissingField { entity, .. } => {
                entity == "Geschaeftspartner" && e.is_error()
            }
            _ => false,
        })
        .map(|(which, e)| format!("[{which}] {e}"))
        .collect()
}

fn assert_valid_forms(fv: &str) {
    let Some((mapper, tx)) = load(fv) else {
        return;
    };

    let forms = [
        ("parser output (code objects)", tx.clone()),
        (
            "partnerrolle as mapped-name strings",
            map_partnerrolle(&tx, |v| Value::String(code_of(v))),
        ),
        (
            "partnerrolle as raw EDIFACT code strings",
            map_partnerrolle(&tx, |v| Value::String(raw_code(&code_of(v)).to_string())),
        ),
        (
            "partnerrolle as raw EDIFACT code objects",
            map_partnerrolle(
                &tx,
                |v| serde_json::json!({ "code": raw_code(&code_of(v)) }),
            ),
        ),
    ];

    for (label, json) in forms {
        let errors = validate_both(&mapper, fv, &json);
        let bad = geschaeftspartner_false_positives(&errors);
        assert!(
            bad.is_empty(),
            "{fv} {label}: valid 55042 Geschaeftspartner reported as invalid:\n{}",
            bad.join("\n")
        );
    }
}

fn assert_invalid_code_reported(fv: &str) {
    let Some((mapper, tx)) = load(fv) else {
        return;
    };

    let forms = [
        ("plain string", Value::String("bogus".to_string())),
        ("raw-looking string", Value::String("Z99".to_string())),
        ("code object", serde_json::json!({ "code": "Z99" })),
    ];

    for (label, bad_value) in forms {
        // Only the Z07 element gets the invalid qualifier.
        let json = map_partnerrolle(&tx, |v| {
            if code_of(v) == "kundeMsb" {
                bad_value.clone()
            } else {
                v.clone()
            }
        });
        let errors = validate_both(&mapper, fv, &json);
        for which in ["validate_pid", "validate_pid_with_conditions"] {
            let invalid: Vec<_> = errors
                .iter()
                .filter(|(w, e)| {
                    *w == which
                        && matches!(e, PidValidationError::InvalidCode { entity, field, .. }
                            if entity == "Geschaeftspartner" && field == "partnerrolle")
                })
                .collect();
            assert_eq!(
                invalid.len(),
                1,
                "{fv} {which} {label}: expected exactly one InvalidCode for the bogus \
                 partnerrolle, got: {errors:#?}"
            );
            let PidValidationError::InvalidCode { valid_values, .. } = &invalid[0].1 else {
                unreachable!()
            };
            for (code, _) in PARTNERROLLE {
                assert!(
                    valid_values.iter().any(|(c, _)| c == code),
                    "{fv} {which}: valid_values must list every SG12 variant's code, \
                     missing {code}: {valid_values:?}"
                );
            }
        }
    }
}

#[test]
fn fv2604_55042_geschaeftspartner_valid_forms_pass() {
    assert_valid_forms("FV2604");
}

#[test]
fn fv2610_55042_geschaeftspartner_valid_forms_pass() {
    assert_valid_forms("FV2610");
}

#[test]
fn fv2604_55042_geschaeftspartner_invalid_code_reported() {
    assert_invalid_code_reported("FV2604");
}

#[test]
fn fv2610_55042_geschaeftspartner_invalid_code_reported() {
    assert_invalid_code_reported("FV2610");
}