ocpi-tariffs 0.49.0

OCPI tariff calculations
Documentation
use super::{walk, BuilderKind, Cardinality, Field, Object, Scalar, Schema, Warning};
use crate::json::{self, parse};

static NAME_OBJ: Object = Object {
    fields: &[
        Field::required("first", Scalar::String),
        Field::required("last", Scalar::String),
    ],
    kind: BuilderKind::Ignore,
};
static NAME_SCHEMA: Schema = Schema::Object(&NAME_OBJ);
static PERSON_OBJ: Object = Object {
    fields: &[
        Field::optional("age", Scalar::Number),
        Field::required_object("name", &NAME_OBJ),
    ],
    kind: BuilderKind::Ignore,
};
static PERSON_SCHEMA: Schema = Schema::Object(&PERSON_OBJ);
static NAMES_ARRAY_SCHEMA: Schema = Schema::Array {
    item: &NAME_SCHEMA,
    cardinality: Cardinality::ZeroOrMore,
};
static NAMES_ARRAY_ONE_OR_MORE: Schema = Schema::Array {
    item: &NAME_SCHEMA,
    cardinality: Cardinality::OneOrMore,
};

#[test]
fn empty_object_reports_missing_required() {
    let doc = parse("{}".into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$"],
        vec![&Warning::MissingField { name: "name" }]
    );
}

#[test]
fn unexpected_fields_reported() {
    let doc = parse(r#"{"nane": {}, "extra": 1}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    let by_path = warnings.path_map();
    assert_eq!(warnings.len_warnings(), 3);
    assert_eq!(by_path["$.nane"], vec![&Warning::UnexpectedField]);
    assert_eq!(by_path["$.extra"], vec![&Warning::UnexpectedField]);
    assert_eq!(by_path["$"], vec![&Warning::MissingField { name: "name" }]);
}

#[test]
fn valid_object_is_clean() {
    let doc = parse(r#"{"name": {"first": "John", "last": "Doe"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn optional_field_present_does_not_warn() {
    let doc = parse(r#"{"age": 30, "name": {"first": "J", "last": "D"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn nested_unexpected_reported_with_path() {
    let doc = parse(r#"{"name": {"first": "J", "last": "D", "middle": "X"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.name.middle"],
        vec![&Warning::UnexpectedField]
    );
}

#[test]
fn nested_missing_required_reported_with_path() {
    let doc = parse(r#"{"name": {"first": "J"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.name"],
        vec![&Warning::MissingField { name: "last" }]
    );
}

#[test]
fn array_items_each_validated() {
    let doc =
        parse(r#"[{"first": "A", "last": "B"}, {"first": "C", "typo": "D"}]"#.into()).unwrap();
    let warnings = walk(&doc, &NAMES_ARRAY_SCHEMA).into_warnings();
    let by_path = warnings.path_map();
    assert_eq!(warnings.len_warnings(), 2);
    assert_eq!(by_path["$[1].typo"], vec![&Warning::UnexpectedField]);
    assert_eq!(
        by_path["$[1]"],
        vec![&Warning::MissingField { name: "last" }]
    );
}

#[test]
fn any_imposes_no_kind_constraint() {
    // `Scalar::Any` constrains nothing, so arbitrary non-null content is clean.
    // (Nested `null`s are still reported - see `null_inside_any_subtree_is_reported`.)
    let doc = parse(r#"{"anything": [1, 2, {"nested": true}]}"#.into()).unwrap();
    let warnings = walk(&doc, &Schema::Scalar(Scalar::Any)).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn non_object_against_object_schema_reported() {
    // An array where an object is expected is a shape violation.
    let doc = parse("[1, 2, 3]".into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::Object,
            actual: json::ValueKind::Array,
        }]
    );
}

#[test]
fn scalar_where_object_expected_reported() {
    // `name` is declared as an object; a string value is a shape violation. The
    // object schema is not entered, so its required fields are not chased.
    let doc = parse(r#"{"name": "John Doe"}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.name"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::Object,
            actual: json::ValueKind::String,
        }]
    );
}

#[test]
fn non_array_against_array_schema_reported() {
    // An object where an array is expected is a shape violation.
    let doc = parse(r#"{"first": "J", "last": "D"}"#.into()).unwrap();
    let warnings = walk(&doc, &NAMES_ARRAY_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::Array,
            actual: json::ValueKind::Object,
        }]
    );
}

#[test]
fn scalar_type_mismatch_reported() {
    // `age` is declared `Scalar::Number`; a boolean value is a type mismatch.
    let doc = parse(r#"{"age": true, "name": {"first": "J", "last": "D"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.age"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::Number,
            actual: json::ValueKind::Bool,
        }]
    );
}

#[test]
fn number_field_accepts_string_encoding() {
    // OCPI numbers may be serialized as quoted strings; that form is accepted silently
    // (no type mismatch and no warning). A linter may choose to flag it later.
    let doc = parse(r#"{"age": "30", "name": {"first": "J", "last": "D"}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn empty_one_or_more_array_reported() {
    // A `+` array must hold at least one element; an empty array is a violation.
    let doc = parse("[]".into()).unwrap();
    let warnings = walk(&doc, &NAMES_ARRAY_ONE_OR_MORE).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$"],
        vec![&Warning::Cardinality {
            expected: Cardinality::OneOrMore,
            len: 0,
        }]
    );
}

#[test]
fn empty_zero_or_more_array_is_clean() {
    // A `*` array may be empty.
    let doc = parse("[]".into()).unwrap();
    let warnings = walk(&doc, &NAMES_ARRAY_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn non_empty_one_or_more_array_is_clean() {
    let doc = parse(r#"[{"first": "A", "last": "B"}]"#.into()).unwrap();
    let warnings = walk(&doc, &NAMES_ARRAY_ONE_OR_MORE).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn null_inside_any_subtree_is_reported() {
    // `Scalar::Any` imposes no kind constraint, but the subtree is still walked,
    // so a nested `null` is reported at full depth.
    static OPAQUE_OBJ: Object = Object {
        fields: &[Field::optional("blob", Scalar::Any)],
        kind: BuilderKind::Ignore,
    };
    static OPAQUE_SCHEMA: Schema = Schema::Object(&OPAQUE_OBJ);
    let doc = parse(r#"{"blob": {"nested": null}}"#.into()).unwrap();
    let warnings = walk(&doc, &OPAQUE_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.blob.nested"],
        vec![&Warning::NullField]
    );
}

#[test]
fn invalid_type_subtree_is_not_walked() {
    // `name` expects an object but is given an array; the shape mismatch is
    // reported and the array is NOT walked, so the nested `null` is not seen
    // (the single warning is the mismatch).
    let doc = parse(r#"{"name": [null]}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.name"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::Object,
            actual: json::ValueKind::Array,
        }]
    );
}

#[test]
fn unexpected_field_subtree_is_not_walked() {
    // An unexpected key is reported, but its subtree is NOT walked, so the nested
    // `null` is not reported (the single warning is the unexpected field).
    let doc =
        parse(r#"{"name": {"first": "J", "last": "D"}, "typo": {"x": null}}"#.into()).unwrap();
    let warnings = walk(&doc, &PERSON_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.typo"],
        vec![&Warning::UnexpectedField]
    );
}

const COLOR_VALUES: &[&str] = &["RED", "GREEN", "BLUE"];
static PAINT_OBJ: Object = Object {
    fields: &[Field::required("color", Scalar::Enum(COLOR_VALUES))],
    kind: BuilderKind::Ignore,
};
static PAINT_SCHEMA: Schema = Schema::Object(&PAINT_OBJ);

#[test]
fn enum_permitted_value_is_clean() {
    let doc = parse(r#"{"color": "GREEN"}"#.into()).unwrap();
    let warnings = walk(&doc, &PAINT_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn enum_value_matched_case_insensitively() {
    // A differently-cased spelling matches the uppercase variant; the schema
    // does not flag the casing (the decode layer raises that separately).
    let doc = parse(r#"{"color": "green"}"#.into()).unwrap();
    let warnings = walk(&doc, &PAINT_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn enum_value_matched_escape_aware_and_case_insensitively() {
    // The value is compared escape-aware and case-insensitively: the JSON
    // string `green` decodes to "green", which matches "GREEN".
    let doc = parse(r#"{"color": "\u0067reen"}"#.into()).unwrap();
    let warnings = walk(&doc, &PAINT_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn enum_unknown_value_reported() {
    let doc = parse(r#"{"color": "PURPLE"}"#.into()).unwrap();
    let warnings = walk(&doc, &PAINT_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.color"],
        vec![&Warning::FieldInvalidValue {
            expected: COLOR_VALUES,
            actual: "PURPLE".to_owned(),
        }]
    );
}

#[test]
fn enum_non_string_reported_as_type_mismatch() {
    // A non-string value fails the kind check first; the value-membership check
    // is not reached, so only a single type mismatch is reported.
    let doc = parse(r#"{"color": 7}"#.into()).unwrap();
    let warnings = walk(&doc, &PAINT_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.color"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::String,
            actual: json::ValueKind::Number,
        }]
    );
}

static LABEL_OBJ: Object = Object {
    fields: &[Field::required("code", Scalar::StringMax(3))],
    kind: BuilderKind::Ignore,
};
static LABEL_SCHEMA: Schema = Schema::Object(&LABEL_OBJ);

#[test]
fn string_within_max_is_clean() {
    let doc = parse(r#"{"code": "AB"}"#.into()).unwrap();
    let warnings = walk(&doc, &LABEL_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn string_at_max_is_clean() {
    // A value exactly at the limit is permitted (the bound is inclusive).
    let doc = parse(r#"{"code": "ABC"}"#.into()).unwrap();
    let warnings = walk(&doc, &LABEL_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn string_over_max_reported() {
    let doc = parse(r#"{"code": "ABCD"}"#.into()).unwrap();
    let warnings = walk(&doc, &LABEL_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.code"],
        vec![&Warning::StringTooLong { max: 3, len: 4 }]
    );
}

#[test]
fn string_max_non_string_reported_as_type_mismatch() {
    // A non-string value fails the kind check first; the length check is not
    // reached, so only a single type mismatch is reported.
    let doc = parse(r#"{"code": 7}"#.into()).unwrap();
    let warnings = walk(&doc, &LABEL_SCHEMA).into_warnings();
    assert_eq!(warnings.len_warnings(), 1);
    assert_eq!(
        warnings.path_map()["$.code"],
        vec![&Warning::TypeMismatch {
            expected: json::ValueKind::String,
            actual: json::ValueKind::Number,
        }]
    );
}

#[test]
fn string_max_counts_decoded_characters() {
    // The JSON value spells three escaped characters (raw form is 18 bytes) that
    // decode to "AAA" - three characters, within the limit of three. The length
    // is measured on the decoded form, so this is clean.
    let doc = parse(r#"{"code": "\u0041\u0041\u0041"}"#.into()).unwrap();
    let warnings = walk(&doc, &LABEL_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}

#[test]
fn scalar_any_is_not_type_checked() {
    // A field declared `Scalar::Any` accepts any JSON kind, including an object.
    static OPAQUE_OBJ: Object = Object {
        fields: &[Field::optional("blob", Scalar::Any)],
        kind: BuilderKind::Ignore,
    };
    static OPAQUE_SCHEMA: Schema = Schema::Object(&OPAQUE_OBJ);
    let doc = parse(r#"{"blob": {"nested": 1}}"#.into()).unwrap();
    let warnings = walk(&doc, &OPAQUE_SCHEMA).into_warnings();
    assert!(warnings.is_empty());
}