ocpi-tariffs 0.53.0

OCPI tariff calculations
Documentation
//! The property that makes fixing a tariff safe: a fix must not change what a session costs.
//!
//! A case is a directory holding the tariff to fix, the CDR to price against it, and
//! `tariff_fixed.json`, the exact text the fix is expected to leave behind. The cases are split
//! by which property they demonstrate, because a `null` does not always mean the same thing:
//!
//! - `test_data/v221/fix/preserving` holds tariffs that price both before and after the fix,
//!   and have to price identically. This is the property the fix exists to keep.
//! - `test_data/v221/fix/repairing` holds tariffs that do not price at all until they are
//!   fixed. A `null` in `elements` is rejected when the tariff is lowered, so there is no
//!   price to preserve; the fix is what makes one exist.
//!
//! The reports are compared in full rather than only in the money total, because clearing a
//! dimension can turn a `Some(0.00)` total into `None` without changing what is owed. That
//! includes `tariff_reports`, which carries the warnings the tariff itself reported: a fix that
//! resolves one of those changes the report even when the money is identical, and this is meant
//! to notice.

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

use std::path::Path;

use crate::{
    cdr, json,
    price::{self, test::UnwrapReport as _},
    tariff, test, Version,
};

use super::{apply, edits};

const VERSION: Version = Version::V221;

/// The zone the fixture CDR's `NLD` location sits in.
const TIMEZONE: chrono_tz::Tz = chrono_tz::Tz::Europe__Amsterdam;

/// Fixing a tariff that already prices leaves the price of a session untouched.
#[test_each::file(
    glob = "ocpi-tariffs/test_data/v221/fix/preserving/*/tariff.json",
    name(segments = 2)
)]
fn fixing_a_tariff_does_not_change_what_a_session_costs(tariff_json: &str, path: &Path) {
    test::setup();

    let case = Case::at(path);
    let tariff_json_fixed = parse_validate_fix(tariff_json);

    case.assert_fixed_text(&tariff_json_fixed);

    // The same session, priced against the tariff as written and after fixing it.
    assert_eq!(
        priced(&case.cdr_json, tariff_json),
        priced(&case.cdr_json, &tariff_json_fixed),
        "fixing the tariff changed the report"
    );

    assert_no_more_fixes(&tariff_json_fixed);
}

/// A tariff that does not price at all is made priceable by fixing it.
#[test_each::file(
    glob = "ocpi-tariffs/test_data/v221/fix/repairing/*/tariff.json",
    name(segments = 2)
)]
fn fixing_a_tariff_makes_an_unpriceable_session_priceable(tariff_json: &str, path: &Path) {
    test::setup();

    let case = Case::at(path);
    let tariff_json_fixed = parse_validate_fix(tariff_json);

    case.assert_fixed_text(&tariff_json_fixed);

    assert!(
        prices(&case.cdr_json, tariff_json).is_err(),
        "the tariff was expected not to price before it was fixed"
    );
    assert!(
        prices(&case.cdr_json, &tariff_json_fixed).is_ok(),
        "the fixed tariff was expected to price"
    );

    assert_no_more_fixes(&tariff_json_fixed);
}

/// The files making up one case.
struct Case {
    cdr_json: String,
    expected: String,
}

impl Case {
    fn at(tariff_path: &Path) -> Self {
        let dir = tariff_path
            .parent()
            .expect("a tariff lives in a case directory");

        Self {
            cdr_json: read(&dir.join("cdr.json")),
            expected: read(&dir.join("tariff_fixed.json")),
        }
    }

    #[track_caller]
    fn assert_fixed_text(&self, fixed: &str) {
        assert_eq!(
            fixed, self.expected,
            "the fix did not leave the expected text"
        );
    }
}

/// Assert that the given tariff JSON needs no more fixes.
///
/// Fixing is idempotent, and every warning the fixed tariff still raises is one nothing
/// mechanical resolves. A fixed tariff is not required to be warning-free: removing the last
/// field of a `restrictions` object leaves it empty, and clearing that would take a second
/// pass over the document.
#[track_caller]
fn assert_no_more_fixes(tariff_fixed_json: &str) {
    assert_eq!(
        parse_validate_fix(tariff_fixed_json),
        tariff_fixed_json,
        "fixing is not idempotent"
    );
    assert_eq!(remaining_edits(tariff_fixed_json), Vec::new());
}

fn read(path: &Path) -> String {
    std::fs::read_to_string(path)
        .unwrap_or_else(|err| panic!("unable to read `{}`:\n{err}", path.display()))
}

/// Parse, validate and lint `tariff_json`. Apply fixes for both the schema and lint warnings.
/// Return a fixed tariff JSON.
///
/// Both sets go into one `apply`, because the two can land on the same element: a schema fix
/// and a lint fix of one field have to be resolved against each other rather than in turn.
fn parse_validate_fix(tariff_json: &str) -> String {
    let doc = json::parse_object(tariff_json).unwrap();
    let (tariff, schema_warnings) = tariff::from_json(doc, VERSION).into_parts();
    let doc = tariff.as_doc();
    let lint_warnings = tariff::lint(&tariff).warnings;

    let mut proposed = edits(doc, &schema_warnings).unwrap();
    proposed.extend(edits(doc, &lint_warnings).unwrap());

    apply(doc, &proposed).unwrap()
}

/// The edits still proposed for `tariff_json`, from the schema walk and from linting alike.
fn remaining_edits(tariff_json: &str) -> Vec<super::Edit> {
    let doc = json::parse_object(tariff_json).unwrap();
    let (tariff, schema_warnings) = tariff::from_json(doc, VERSION).into_parts();
    let doc = tariff.as_doc();
    let lint_warnings = tariff::lint(&tariff).warnings;

    let mut proposed = edits(doc, &schema_warnings).unwrap();
    proposed.extend(edits(doc, &lint_warnings).unwrap());

    proposed
}

/// Price `cdr_json` against `tariff_json`, reporting whether it could be priced at all.
fn prices(cdr_json: &str, tariff_json: &str) -> Result<(), String> {
    let doc = json::parse_object(tariff_json).unwrap();
    let tariff = tariff::from_json(doc, VERSION).ignore_warnings();

    let doc = json::parse_object(cdr_json).unwrap();
    let cdr = cdr::from_json(doc, VERSION).ignore_warnings();

    match price::cdr(&cdr, price::TariffSource::Override(vec![tariff]), TIMEZONE) {
        Ok(_report) => Ok(()),
        Err(errors) => Err(format!("{:?}", errors.into_parts().0)),
    }
}

/// Price `cdr_json` against `tariff_json` and render the whole report for comparison.
fn priced(cdr_json: &str, tariff_json: &str) -> String {
    let doc = json::parse_object(tariff_json).unwrap();
    let tariff = tariff::from_json(doc, VERSION).ignore_warnings();

    let doc = json::parse_object(cdr_json).unwrap();
    let cdr = cdr::from_json(doc, VERSION).ignore_warnings();

    // Both sides are priced in the same zone, which is all the comparison needs.
    let report = price::cdr(&cdr, price::TariffSource::Override(vec![tariff]), TIMEZONE)
        .unwrap_report(cdr.as_json_str());

    rendered(&report.ignore_warnings())
}

/// A whole [`price::Report`] rendered for comparison.
///
/// `Report` has no `PartialEq`, and giving it one would mean deriving it for the nine warning
/// enums `tariff_reports` reaches, so the fields are compared as text instead.
///
/// The text is built from the fields bound here rather than from `Report`'s own `Debug`, which
/// is written by hand. A field missing from that impl would be left out of the comparison
/// silently; a field missing here does not compile.
fn rendered(report: &price::Report) -> String {
    let price::Report {
        periods,
        tariff_used,
        tariff_reports,
        timezone,
        billed_charging_time,
        billed_energy,
        billed_idle_time,
        total_charging_time,
        total_energy,
        total_idle_time,
        total_time,
        total_cost,
        total_energy_cost,
        total_fixed_cost,
        total_idle_cost,
        total_charging_time_cost,
    } = report;

    format!(
        "periods: {periods:#?}\n\
         tariff_used: {tariff_used:#?}\n\
         tariff_reports: {tariff_reports:#?}\n\
         timezone: {timezone:#?}\n\
         billed_charging_time: {billed_charging_time:#?}\n\
         billed_energy: {billed_energy:#?}\n\
         billed_idle_time: {billed_idle_time:#?}\n\
         total_charging_time: {total_charging_time:#?}\n\
         total_energy: {total_energy:#?}\n\
         total_idle_time: {total_idle_time:#?}\n\
         total_time: {total_time:#?}\n\
         total_cost: {total_cost:#?}\n\
         total_energy_cost: {total_energy_cost:#?}\n\
         total_fixed_cost: {total_fixed_cost:#?}\n\
         total_idle_cost: {total_idle_cost:#?}\n\
         total_charging_time_cost: {total_charging_time_cost:#?}\n"
    )
}