ocpi-tariffs 0.51.0

OCPI tariff calculations
Documentation
//! Tests for lowering a `schema::Str` into the `mod string` types via `FromSchema`.
//!
//! A `schema::Str` is only produced by the builder, so each test drives a real `v2.1.1`
//! tariff through `build_tariff` and lowers its `id` field (a `Str` leaf). The leaf does
//! not carry the domain length bound, so each test lowers it at whatever const length it
//! wants to exercise.

#![allow(
    clippy::indexing_slicing,
    reason = "unwraps and indexing are allowed anywhere in tests"
)]

use std::assert_matches;

use super::{Base, CiExactLen, CiMaxLen, Warning};
use crate::{
    json,
    schema::{self, v211, Integrity},
    warning, FromSchema as _,
};

/// A minimal, fully valid `v2.1.1` tariff. `id` is the `Str` leaf the tests lower.
const VALID: &str = r#"{
    "currency": "EUR",
    "elements": [
        {"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY"}]}
    ],
    "id": "ID",
    "last_updated": "2024-01-01T00:00:00Z"
}"#;

/// Build a `v2.1.1` tariff, dropping the schema-level warnings (the tests assert on the
/// warnings produced by lowering the `id` leaf, not by the schema walk).
fn build_tariff<'buf>(doc: &json::Document<'buf>) -> v211::Tariff<'buf> {
    v211::build_tariff(doc).ignore_warnings()
}

/// Borrow the built `id` `Str` leaf out of a tariff.
fn id<'a, 'buf>(tariff: &'a v211::Tariff<'buf>) -> &'a schema::Str<'buf> {
    let Integrity::Ok(id) = &tariff.id else {
        panic!("the id should be built: {:?}", tariff.id);
    };
    id
}

/// Collect every warning across all paths into a flat list.
fn all_warnings(warnings: &warning::Set<Warning>) -> Vec<&Warning> {
    warnings.path_map().into_values().flatten().collect()
}

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

    let (value, warnings) = CiMaxLen::<36>::from_schema(id(&tariff))
        .unwrap()
        .into_parts();

    assert_eq!(&*value, "ID");
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn max_len_exceeded_warns() {
    let src = VALID.replace(r#""id": "ID""#, r#""id": "hello""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (value, warnings) = CiMaxLen::<3>::from_schema(id(&tariff))
        .unwrap()
        .into_parts();

    assert_eq!(&*value, "hello");
    assert_matches!(
        all_warnings(&warnings)[..],
        [Warning::InvalidLengthMax { length: 3 }]
    );
}

#[test]
fn exact_len_match_lowers_cleanly() {
    let src = VALID.replace(r#""id": "ID""#, r#""id": "abc""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (value, warnings) = CiExactLen::<3>::from_schema(id(&tariff))
        .unwrap()
        .into_parts();

    assert_eq!(&*value, "abc");
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn exact_len_mismatch_warns() {
    let src = VALID.replace(r#""id": "ID""#, r#""id": "hello""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (value, warnings) = CiExactLen::<3>::from_schema(id(&tariff))
        .unwrap()
        .into_parts();

    assert_eq!(&*value, "hello");
    assert_matches!(
        all_warnings(&warnings)[..],
        [Warning::InvalidLengthExact { length: 3 }]
    );
}

#[test]
fn escape_sequence_warns() {
    // A `\t` escape: a `CiString` should not need escapes, so the presence of one is
    // flagged. (This mirrors `FromJson`, which likewise reports only the escape here.)
    let src = VALID.replace(r#""id": "ID""#, r#""id": "a\tb""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (_value, warnings) = Base::from_schema(id(&tariff)).unwrap().into_parts();

    assert_matches!(all_warnings(&warnings)[..], [Warning::ContainsEscapeCodes]);
}

#[test]
fn literal_control_character_warns() {
    // A literal TAB byte (not an escape) inside the string is non-printable ASCII.
    let src = VALID.replace(r#""id": "ID""#, "\"id\": \"a\tb\"");
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (_value, warnings) = Base::from_schema(id(&tariff)).unwrap().into_parts();

    assert_matches!(
        all_warnings(&warnings)[..],
        [Warning::ContainsNonPrintableASCII]
    );
}

#[test]
fn escape_decoding_to_non_printable_warns_both() {
    // `\u0020` is an escape (flagged) that decodes to a space, which is non-printable
    // ASCII (also flagged). This exercises the on-the-fly decode in the printability
    // scan: the raw bytes look printable, only the decoded char is not.
    let src = VALID.replace(r#""id": "ID""#, r#""id": "a\u0020b""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let tariff = build_tariff(&doc);

    let (_value, warnings) = Base::from_schema(id(&tariff)).unwrap().into_parts();

    assert_matches!(
        all_warnings(&warnings)[..],
        [
            Warning::ContainsEscapeCodes,
            Warning::ContainsNonPrintableASCII
        ]
    );
}