ocpi-tariffs 0.53.0

OCPI tariff calculations
Documentation
//! Tests for [`Fixable`] as `lint::tariff::Warning` implements it: which lint warnings a
//! mechanical edit resolves, and which are left for the tariff's author.
//!
//! Every fix here is about a `day_of_week` list, and all four of that list's warnings anchor to
//! the array itself, so one element can carry more than one of them at once.

#![allow(
    clippy::indexing_slicing,
    clippy::panic,
    clippy::unwrap_used,
    reason = "test code with known-structure parsed data"
)]

use crate::{json, lint, tariff, test, warning, Version, Warning as _};

use super::{apply, edits, Edit, Fixable as _, Json};

/// A tariff with one restriction, whose `day_of_week` is `{DAYS}`.
const TARIFF: &str = r#"{
  "country_code": "NL",
  "party_id": "TDR",
  "id": "TEST",
  "currency": "EUR",
  "elements": [
    {
      "restrictions": {
        "day_of_week": {DAYS}
      },
      "price_components": [
        {
          "type": "ENERGY",
          "price": 0.38,
          "step_size": 1
        }
      ]
    }
  ],
  "last_updated": "2020-01-01T00:00:00Z"
}"#;

/// A parsed document and the element of its `day_of_week` field, which the warnings anchor to.
fn day_of_week(days: &str) -> (json::Document<'_>, json::ElemId) {
    let doc = json::parse_object(days).unwrap();
    let id = doc.root().find_field("day_of_week").unwrap().element().id();

    (doc, id)
}

/// Lint `json` as a v2.2.1 tariff and return it with the warnings linting raised.
fn linted(json: &str) -> (tariff::Versioned<'_>, warning::Set<lint::tariff::Warning>) {
    let doc = json::parse_object(json).unwrap();
    let tariff = tariff::from_json(doc, Version::V221).ignore_warnings();
    let report = tariff::lint(&tariff);

    (tariff, report.warnings)
}

/// The ids of the warnings linting still raises for `json`.
fn lint_warning_ids(json: &str) -> Vec<String> {
    let (_tariff, warnings) = linted(json);

    warnings
        .iter()
        .flat_map(|group| group.warnings())
        .map(|warning| warning.id().as_str().to_owned())
        .collect()
}

/// All seven days match every day, which is what leaving the list out means.
#[test]
fn a_list_of_every_day_is_removed() {
    let days = r#"{"day_of_week": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]}"#;
    let (doc, id) = day_of_week(days);
    let element = doc.element(id).unwrap();

    assert_eq!(
        lint::tariff::Warning::ContainsEntireWeek.fix(element),
        Some(Edit::remove(id))
    );
}

#[test]
fn an_unsorted_list_is_put_in_spec_order() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["FRIDAY", "MONDAY"]}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(
        lint::tariff::Warning::DayOfWeekUnsorted.fix(element),
        Some(Edit::replace(id, Json::string_array(&["MONDAY", "FRIDAY"])))
    );
}

#[test]
fn a_repeating_list_keeps_one_of_each_day() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["MONDAY", "MONDAY", "TUESDAY"]}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(
        lint::tariff::Warning::DayOfWeekDuplicates.fix(element),
        Some(Edit::replace(
            id,
            Json::string_array(&["MONDAY", "TUESDAY"])
        ))
    );
}

/// Both warnings describe the same list, so both ask for the same normalized text.
#[test]
fn an_unsorted_repeating_list_gets_the_same_text_from_either_warning() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["FRIDAY", "MONDAY", "MONDAY"]}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(
        lint::tariff::Warning::DayOfWeekUnsorted.fix(element),
        lint::tariff::Warning::DayOfWeekDuplicates.fix(element)
    );
}

/// An empty list matches no day and removing it would match every day, so neither keeps the
/// meaning the author wrote.
#[test]
fn an_empty_list_is_left_alone() {
    let (doc, id) = day_of_week(r#"{"day_of_week": []}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(lint::tariff::Warning::DayOfWeekEmpty.fix(element), None);
}

/// The list of `an_unsorted_list_is_put_in_spec_order` lower-cased and changed in no other way,
/// so the case is the only thing between this warning being fixed and being left alone.
///
/// The order stays unsorted because the schema accepts a day in any case: this list really does
/// carry `DayOfWeekUnsorted`, and it is the fixer's exact match on the names that declines it,
/// rather than rewriting a list it has only half understood. The case is the schema walk's to
/// report and resolve, after which the list is one this warning does fix.
#[test]
fn a_list_naming_days_in_lower_case_is_left_alone_by_the_sort() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["friday", "monday"]}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(lint::tariff::Warning::DayOfWeekUnsorted.fix(element), None);
}

#[test]
fn a_list_holding_something_other_than_a_day_is_left_alone() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["FRIDAY", 3]}"#);
    let element = doc.element(id).unwrap();

    assert_eq!(lint::tariff::Warning::DayOfWeekUnsorted.fix(element), None);
}

#[test]
fn a_warning_needing_a_human_decision_proposes_nothing() {
    let (doc, id) = day_of_week(r#"{"day_of_week": ["FRIDAY", "MONDAY"]}"#);
    let element = doc.element(id).unwrap();

    let unfixable = [
        lint::tariff::Warning::ContainsEntireDay,
        lint::tariff::Warning::CpoCountryCodeShouldBeAlpha2,
        lint::tariff::Warning::EndTimeIsNearEndOfDay,
        lint::tariff::Warning::MaxZeroNeverMatch,
        lint::tariff::Warning::MinPriceIsGreaterThanMax,
        lint::tariff::Warning::NeverValid,
        lint::tariff::Warning::StartDateTimeIsAfterEndDateTime,
    ];

    for warning in unfixable {
        assert_eq!(warning.fix(element), None, "for `{warning:?}`");
    }
}

/// The whole round trip, over a list carrying both warnings at once: lint the tariff, propose
/// the edits, apply them. The two proposals agree, so `apply` takes them as one edit.
#[test]
fn a_tariff_has_its_day_of_week_list_normalized() {
    test::setup();

    let source = TARIFF.replace("{DAYS}", r#"["FRIDAY", "MONDAY", "MONDAY"]"#);
    let (tariff, warnings) = linted(&source);
    let doc = tariff.as_doc();
    let proposed = edits(doc, &warnings).unwrap();
    let fixed = apply(doc, &proposed).unwrap();

    assert_eq!(fixed, TARIFF.replace("{DAYS}", r#"["MONDAY", "FRIDAY"]"#));
    assert_eq!(lint_warning_ids(&fixed), Vec::<String>::new());
}

/// The list is this element's only restriction, so removing it leaves the object empty. That
/// is reported rather than fixed: clearing it would take a second pass over the document.
#[test]
fn a_tariff_loses_a_day_of_week_list_naming_every_day() {
    test::setup();

    let days = r#"["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]"#;
    let source = TARIFF.replace("{DAYS}", days);
    let (tariff, warnings) = linted(&source);
    let doc = tariff.as_doc();
    let proposed = edits(doc, &warnings).unwrap();
    let fixed = apply(doc, &proposed).unwrap();

    assert!(!fixed.contains("day_of_week"), "got:\n{fixed}");
    assert_eq!(
        lint_warning_ids(&fixed),
        vec!["restrictions_empty".to_owned()]
    );
}

/// A list naming every day out of order carries the removal and the sort at once, and the
/// removal wins because the bytes go away either way. The emptied object is left behind, as
/// in the test above.
#[test]
fn removing_a_list_beats_sorting_it() {
    test::setup();

    let days = r#"["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"]"#;
    let source = TARIFF.replace("{DAYS}", days);
    let (tariff, warnings) = linted(&source);
    let doc = tariff.as_doc();
    let proposed = edits(doc, &warnings).unwrap();
    let fixed = apply(doc, &proposed).unwrap();

    assert!(!fixed.contains("day_of_week"), "got:\n{fixed}");
    assert_eq!(
        lint_warning_ids(&fixed),
        vec!["restrictions_empty".to_owned()]
    );
}