edifact-mapper 0.30.2

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! The transaction view reads a message one transaction at a time and gives
//! it back unchanged.
//!
//! Requested by mako-twin: ORDERS 17009 keeps its Lieferort (NAD+DP / LOC+172)
//! and dates at message level, ORDRSP 19015 / 19016 have no transaction at all
//! (their AHB lists no Positionsteil). Every generated sample of the message
//! types they read goes through `transaction_views` and back with an identical
//! `Nachricht` and identical rendering.

use std::path::PathBuf;

use edifact_mapper::{DataDir, Mapper, TransactionView};
use serde_json::{json, Value};

const TYPES: [&str; 7] = [
    "orders", "ordrsp", "reqote", "quotes", "mscons", "utilmd", "iftsta",
];

fn root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn mapper(fv: &str) -> Option<Mapper> {
    if !root().join(format!("dist/edifact-data-{fv}.bin")).exists() {
        eprintln!("skipping {fv}: build the bundle first");
        return None;
    }
    Some(Mapper::from_data_dir(DataDir::path(root().join("dist")).eager(&[fv])).expect("bundle"))
}

#[test]
fn every_sample_reads_as_views_and_back() {
    for fv in ["FV2504", "FV2510", "FV2604", "FV2610"] {
        let Some(m) = mapper(fv) else {
            continue;
        };
        let mut checked = 0;
        for kind in TYPES {
            let dir = root().join(format!("fixtures/generated/{}/{kind}", fv.to_lowercase()));
            let Ok(entries) = std::fs::read_dir(&dir) else {
                continue;
            };
            let mut files: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
            files.sort();
            for file in files {
                let stem = file.file_stem().unwrap().to_string_lossy().to_string();
                let pid = stem.split("__").next().unwrap().to_string();
                let edi = std::fs::read_to_string(&file).unwrap();
                let variants: Vec<String> = if kind == "utilmd" {
                    vec!["UTILMD_Strom".into(), "UTILMD_Gas".into()]
                } else {
                    vec![kind.to_uppercase()]
                };
                let Some((variant, interchange)) = variants.iter().find_map(|v| {
                    m.from_edifact::<Value, Value>(&edi, fv, v, &pid)
                        .ok()
                        .map(|i| (v.clone(), i))
                }) else {
                    continue;
                };
                for nachricht in &interchange.nachrichten {
                    let views = m
                        .transaction_views(nachricht)
                        .unwrap_or_else(|e| panic!("{fv} {stem}: {e}"));
                    assert_eq!(
                        views.len(),
                        nachricht.transaktionen.len().max(1),
                        "{fv} {stem}"
                    );
                    assert_eq!(
                        views.iter().any(|v| v.synthetic),
                        nachricht.transaktionen.is_empty(),
                        "{fv} {stem}"
                    );
                    let back = m
                        .from_transaction_views(
                            &views,
                            &nachricht.nachrichtendaten,
                            fv,
                            &variant,
                            &pid,
                        )
                        .unwrap_or_else(|e| panic!("{fv} {stem}: {e}"));
                    assert_eq!(
                        serde_json::to_value(&back).unwrap(),
                        serde_json::to_value(nachricht).unwrap(),
                        "{fv} {stem}: the views give the message back"
                    );
                    assert_eq!(
                        m.to_edifact_nachricht(&back, fv, &variant, &pid).unwrap(),
                        m.to_edifact_nachricht(nachricht, fv, &variant, &pid)
                            .unwrap(),
                        "{fv} {stem}: rendered alike"
                    );
                    checked += 1;
                }
            }
        }
        assert!(checked > 100, "{fv}: only {checked} messages");
    }
}

/// ORDERS 17009's view carries the Lieferort; ORDRSP 19015's single view is
/// synthetic and has no `transaktionsdaten`.
#[test]
fn message_level_content_is_in_every_view() {
    let Some(m) = mapper("FV2610") else {
        return;
    };
    let read = |kind: &str, pid: &str| {
        let edi = std::fs::read_to_string(
            root().join(format!("fixtures/generated/fv2610/{kind}/{pid}.edi")),
        )
        .unwrap();
        m.from_edifact::<Value, Value>(&edi, "FV2610", &kind.to_uppercase(), pid)
            .unwrap()
            .nachrichten
            .remove(0)
    };

    let orders = read("orders", "17009");
    let views = m.transaction_views(&orders).unwrap();
    let view = &views[0].value;
    assert!(!views[0].synthetic);
    let dp = view["stammdaten"]["marktteilnehmer"]
        .as_array()
        .unwrap()
        .iter()
        .find(|p| p["meldepunktId"].is_string());
    assert!(dp.is_some(), "{view:#}");
    assert!(view["stammdaten"]["position"].is_object(), "{view:#}");
    assert!(view["nachricht"]["auftragsdatum"].is_string(), "{view:#}");
    assert!(view["nachricht"].get("unhReferenz").is_none(), "{view:#}");

    let ordrsp = read("ordrsp", "19015");
    let views = m.transaction_views(&ordrsp).unwrap();
    assert_eq!(views.len(), 1);
    assert!(views[0].synthetic);
    assert!(views[0].value["transaktionsdaten"].is_null());
    assert!(views[0].value["stammdaten"]["antwortkategorie"].is_object());
    assert!(views[0].value["stammdaten"]["referenz"].is_array());
}

/// The inverse refuses what does not make one message.
#[test]
fn inconsistent_views_are_refused() {
    let Some(m) = mapper("FV2610") else {
        return;
    };
    let edi =
        std::fs::read_to_string(root().join("fixtures/generated/fv2610/ordrsp/19015.edi")).unwrap();
    let nachricht = m
        .from_edifact::<Value, Value>(&edi, "FV2610", "ORDRSP", "19015")
        .unwrap()
        .nachrichten
        .remove(0);
    let views = m.transaction_views(&nachricht).unwrap();
    let kopf = &nachricht.nachrichtendaten;
    let back = |views: &[TransactionView]| {
        m.from_transaction_views(views, kopf, "FV2610", "ORDRSP", "19015")
            .unwrap_err()
            .to_string()
    };

    // Two copies of the message-level content that differ.
    let mut other = views[0].clone();
    other.synthetic = false;
    other.value["stammdaten"]["antwortkategorie"]["antwortCode"] = json!("ANDERS");
    let mut two = views.clone();
    two[0].synthetic = false;
    two.push(other);
    assert!(back(&two).contains("antwortkategorie"), "{}", back(&two));

    // A synthetic view holding a transaction-level entity.
    let mut synthetic = views.clone();
    synthetic[0].value["stammdaten"]["position"] = json!({"positionsnummer": "1"});
    assert!(
        back(&synthetic).contains("position"),
        "{}",
        back(&synthetic)
    );

    assert!(back(&[]).contains("no view"));
}