edifact-mapper 0.17.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! Gate: the generated samples satisfy their nested-list requirements.
//!
//! The lists `parent_field` rules write (`zuordnungen[]`, `mengen[]`,
//! `kontaktwege[]`, …) are validated per parent element: a kind of element the
//! AHB requires, the fields of each element, and the codes of every `werte[]`
//! entry. The samples under `fixtures/generated/` are rendered from the PID
//! schemas, so a finding here is either a requirement the builder gets wrong or
//! a sample that lacks what its guide asks for. Both are listed in
//! `tests/data/known_nested_list_findings.txt` — a ratchet: a new finding fails,
//! and so does a listed one that no longer occurs.
//!
//! Regenerate: `UPDATE_NESTED_LIST_FINDINGS=1 cargo test -p edifact-mapper --test nested_list_sample_gate`

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

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

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

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

fn baseline_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/known_nested_list_findings.txt")
}

/// The field of a finding about one of the entity's nested lists
/// (`zuordnungen[].werte[].code`). A flat list target on the entity itself
/// (`MengenumwerterDaten.werte[].text`) is not one.
fn nested_field<'e>(
    error: &'e PidValidationError,
    requirements: &PidRequirements,
) -> Option<(&'e str, &'e str, &'static str)> {
    let (entity, field, kind) = match error {
        PidValidationError::MissingField { entity, field, .. } => (entity, field, "missing"),
        PidValidationError::InvalidCode { entity, field, .. } => (entity, field, "invalid"),
        _ => return None,
    };
    let (list, _) = field.split_once('[')?;
    requirements
        .entities
        .iter()
        .filter(|e| &e.entity == entity)
        .any(|e| e.nested.iter().any(|n| n.field == list))
        .then_some((entity.as_str(), field.as_str(), kind))
}

/// `<fv> <variant> <pid> <validator> <kind> <Entity>.<field>`, deduplicated.
fn findings() -> BTreeSet<String> {
    let root = repo_root();
    let mapper = Mapper::from_data_dir(DataDir::path(root.join("dist")))
        .expect("the data bundles are a build artefact — run `bundle-data` first");
    let mut out = BTreeSet::new();
    let mut fvs: Vec<PathBuf> = std::fs::read_dir(root.join("fixtures/generated"))
        .into_iter()
        .flatten()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .collect();
    fvs.sort();
    for fv_dir in fvs {
        let fv = fv_dir.file_name().unwrap().to_string_lossy().to_uppercase();
        let mut msgs: Vec<PathBuf> = std::fs::read_dir(&fv_dir)
            .unwrap()
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.is_dir())
            .collect();
        msgs.sort();
        for msg_dir in msgs {
            let msg = msg_dir
                .file_name()
                .unwrap()
                .to_string_lossy()
                .to_uppercase();
            let variants: Vec<String> = if msg == "UTILMD" {
                vec![format!("{msg}_Strom"), format!("{msg}_Gas")]
            } else {
                vec![msg.clone()]
            };
            let mut files: Vec<PathBuf> = std::fs::read_dir(&msg_dir)
                .unwrap()
                .flatten()
                .map(|e| e.path())
                .filter(|p| p.extension().is_some_and(|e| e == "edi"))
                .collect();
            files.sort();
            for file in files {
                let pid = file.file_stem().unwrap().to_string_lossy().into_owned();
                let Ok(edifact) = std::fs::read_to_string(&file) else {
                    continue;
                };
                for variant in &variants {
                    let Ok(ic) = mapper.from_edifact::<Value, Value>(&edifact, &fv, variant, &pid)
                    else {
                        continue;
                    };
                    let ic: Dynamic = ic;
                    let requirements = mapper
                        .pid_requirements(&fv, variant, &pid)
                        .expect("requirements of a converted PID");
                    for message in &ic.nachrichten {
                        for tx in &message.transaktionen {
                            let mut stammdaten = tx["stammdaten"].clone();
                            mig_bo4e::model::restore_entity(
                                &mut stammdaten,
                                mig_bo4e::model::TX_METADATA_ENTITY,
                                tx.get("transaktionsdaten").unwrap_or(&Value::Null),
                            );
                            for (validator, result) in [
                                (
                                    "basic",
                                    mapper.validate_pid(&stammdaten, &fv, variant, &pid),
                                ),
                                (
                                    "conditional",
                                    mapper.validate_pid_with_conditions(
                                        &stammdaten,
                                        &fv,
                                        variant,
                                        &pid,
                                    ),
                                ),
                            ] {
                                for error in result.into_iter().flatten() {
                                    if let Some((entity, field, kind)) =
                                        nested_field(&error, &requirements)
                                    {
                                        out.insert(format!(
                                            "{fv} {variant} {pid} {validator} {kind} {entity}.{field}"
                                        ));
                                    }
                                }
                            }
                        }
                    }
                    break;
                }
            }
        }
    }
    out
}

#[test]
fn generated_samples_satisfy_their_nested_list_requirements() {
    let found = findings();

    if std::env::var("UPDATE_NESTED_LIST_FINDINGS").is_ok() {
        let body: String = found.iter().map(|l| format!("{l}\n")).collect();
        std::fs::write(baseline_path(), body).expect("write baseline");
        eprintln!("wrote {} nested-list finding(s)", found.len());
        return;
    }

    let baseline: BTreeSet<String> = std::fs::read_to_string(baseline_path())
        .unwrap_or_default()
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(str::to_owned)
        .collect();
    let new: Vec<&String> = found.difference(&baseline).collect();
    let fixed: Vec<&String> = baseline.difference(&found).collect();
    assert!(
        new.is_empty(),
        "{} new nested-list finding(s) on the generated samples:\n  {}",
        new.len(),
        new.iter()
            .map(|s| s.as_str())
            .collect::<Vec<_>>()
            .join("\n  ")
    );
    assert!(
        fixed.is_empty(),
        "{} listed finding(s) no longer occur; remove them:\n  {}",
        fixed.len(),
        fixed
            .iter()
            .map(|s| s.as_str())
            .collect::<Vec<_>>()
            .join("\n  ")
    );
}