ocpi-tariffs 0.49.0

OCPI tariff calculations
Documentation
//! Tests for the `v2.2.1` tariff intermediate-representation (IR) builder.

use std::assert_matches;

use super::build_tariff;
use crate::{
    json,
    schema::{Integrity, Number, Warning},
    warning,
};

/// Return true if any warning in the set satisfies `pred`.
fn any_warning(warnings: &warning::Set<Warning>, pred: impl Fn(&Warning) -> bool) -> bool {
    warnings
        .iter()
        .any(|group| group.to_parts().1.into_iter().any(&pred))
}

/// A minimal, fully valid `v2.2.1` tariff with one energy price component.
const VALID: &str = r#"{
    "country_code": "NL",
    "currency": "EUR",
    "elements": [
        {"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY"}]}
    ],
    "id": "T1",
    "last_updated": "2024-01-01T00:00:00Z",
    "party_id": "CPO"
}"#;

#[test]
fn valid_tariff_builds_without_warnings() {
    let doc = json::parse(VALID.into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    assert!(warnings.is_empty(), "{:?}", warnings.path_id_map());

    let Integrity::Ok(currency) = &tariff.currency else {
        panic!("currency should be built: {:?}", tariff.currency);
    };
    assert_eq!(
        currency.element().to_raw_str().unwrap().as_unescaped_str(),
        "EUR"
    );

    let Integrity::Ok(elements) = &tariff.elements else {
        panic!("elements should be built: {:?}", tariff.elements);
    };
    assert_eq!(elements.len(), 1);

    let Integrity::Ok(element) = &elements[0] else {
        panic!("the element should be built");
    };
    let Integrity::Ok(components) = &element.price_components else {
        panic!("price_components should be built");
    };
    assert_eq!(components.len(), 1);

    let Integrity::Ok(component) = &components[0] else {
        panic!("the price component should be built");
    };
    let Integrity::Ok(dimension_type) = &component.dimension_type else {
        panic!("the dimension type should be built");
    };
    assert_eq!(dimension_type.canonical(), "ENERGY");
}

#[test]
fn id_over_max_length_is_flagged() {
    // `id` is `CiString(36)`; a 37-character id exceeds the bound but is still built.
    let src = VALID.replace(r#""id": "T1""#, &format!(r#""id": "{}""#, "x".repeat(37)));
    let doc = json::parse(src.as_str().into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    assert_matches!(tariff.id, Integrity::Ok(_));
    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::StringTooLong { max: 36, len: 37 }
    )));
}

#[test]
fn empty_elements_array_is_flagged() {
    let src = VALID.replace(
        r#"[
        {"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY"}]}
    ]"#,
        "[]",
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    // The array is present but empty: built as an empty `Vec`, with a cardinality warning.
    let Integrity::Ok(elements) = &tariff.elements else {
        panic!(
            "an empty elements array still builds: {:?}",
            tariff.elements
        );
    };
    assert!(elements.is_empty());
    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::Cardinality { .. }
    )));
}

#[test]
fn string_encoded_number_is_accepted_silently() {
    // `price` is a `number`, here encoded as a JSON string. OCPI permits this, so the
    // value is accepted with no warning (a linter may choose to flag it later).
    let src = VALID.replace(r#""price": 0.25"#, r#""price": "0.25""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    assert!(warnings.is_empty(), "{:?}", warnings.path_id_map());

    let Integrity::Ok(elements) = &tariff.elements else {
        panic!("elements should be built: {:?}", tariff.elements);
    };
    let Integrity::Ok(element) = &elements[0] else {
        panic!("the element should be built");
    };
    let Integrity::Ok(components) = &element.price_components else {
        panic!("price_components should be built");
    };
    let Integrity::Ok(component) = &components[0] else {
        panic!("the price component should be built");
    };
    // The string-encoded form is still recognized as a number and built.
    assert_matches!(component.price, Integrity::Ok(Number::StringEncoded(_)));
}

#[test]
fn unknown_dimension_type_is_err_not_dropped() {
    let src = VALID.replace(r#""type": "ENERGY""#, r#""type": "FOO""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    // The component is kept; its unrecognized dimension type is `Err`.
    let Integrity::Ok(elements) = &tariff.elements else {
        panic!("elements should be built");
    };
    let Integrity::Ok(element) = &elements[0] else {
        panic!("the element should be built");
    };
    let Integrity::Ok(components) = &element.price_components else {
        panic!("price_components should be built");
    };
    let Integrity::Ok(component) = &components[0] else {
        panic!("the price component should be built");
    };
    assert_matches!(component.dimension_type, Integrity::Err);
    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::FieldInvalidValue { .. }
    )));
}

#[test]
fn missing_required_field_is_missing_not_fatal() {
    let src = VALID.replace(r#""currency": "EUR","#, "");
    let doc = json::parse(src.as_str().into()).unwrap();
    let (tariff, warnings) = build_tariff(&doc).into_parts();

    // Building is infallible: the absent required field is `Missing`, plus a warning.
    assert_matches!(tariff.currency, Integrity::Missing);
    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::MissingField { name: "currency" }
    )));
}

#[test]
fn building_is_total_on_degenerate_input() {
    // An empty object and a non-object root must not panic; they build an all-`Missing`
    // tariff (the non-object root yields the `empty` fallback).
    for src in ["{}", "[]", "\"not an object\""] {
        let doc = json::parse(src.into()).unwrap();
        let (tariff, _warnings) = build_tariff(&doc).into_parts();
        assert_matches!(tariff.currency, Integrity::Missing);
        assert_matches!(tariff.elements, Integrity::Missing);
    }
}