ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
#![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
#![allow(clippy::panic, reason = "tests are allowed panic")]

use std::{
    collections::{BTreeMap, BTreeSet},
    path::Path,
};

use super::Report;
use crate::{
    json, tariff,
    test::{self, Expectation},
    warning, Version,
};

/// Each `test*.json` file in the `test_data/v221/lint` directories results in a test run.
#[test_each::file(
    glob = "ocpi-tariffs/test_data/v221/lint/*/test*.json",
    name(segments = 2)
)]
fn should_lint_v221_tariff(test_json: &str, path: &Path) {
    test::setup();
    run_lint(test_json, path, Version::V221);
}

/// Each `test*.json` file in the `test_data/v211/lint` directories results in a test run.
#[test_each::file(
    glob = "ocpi-tariffs/test_data/v211/lint/*/test*.json",
    name(segments = 2)
)]
fn should_lint_v211_tariff(test_json: &str, path: &Path) {
    test::setup();
    run_lint(test_json, path, Version::V211);
}

/// A `test*.json` fixture: an input tariff and what linting it should report.
#[derive(serde::Deserialize)]
struct TestRun<'buf> {
    /// The input tariff for this test run.
    #[serde(borrow)]
    tariff: &'buf serde_json::value::RawValue,

    /// The expectation for the report produced from the tariff.
    expect: Expect,
}

/// Expectations for the result of calling `tariff::lint`.
#[derive(serde::Deserialize)]
struct Expect {
    #[serde(default)]
    unexpected_fields: Expectation<Vec<json::test::PathGlob>>,

    /// What the linter itself should report, keyed by `json::Element` path.
    #[serde(default)]
    warnings: Expectation<test::WarningMap>,

    /// What validating against the OCPI schema should report, keyed by `json::Element` path.
    #[serde(default)]
    schema: Expectation<test::WarningMap>,

    /// Warnings this fixture exists to pin down, but which no lint raises yet.
    ///
    /// The linter is being rebuilt on the schema IR one group of lints at a time (see
    /// `docs/lint-catalogue.md`). An entry here records what this fixture is for without
    /// asserting it, and the test fails the moment the warning does start being raised -
    /// that is the signal to move the entry into `warnings`.
    #[serde(default)]
    pending: Expectation<BTreeMap<String, Vec<String>>>,
}

#[track_caller]
pub fn run_lint(test_json: &str, test_file_path: &Path, expected_version: Version) {
    let mut test_json = test_json.to_owned();
    let test_run = {
        json_strip_comments::strip(&mut test_json).unwrap_or_else(|err| {
            panic!(
                "Unable to strip comments from {}:\n{:#?}",
                test_file_path.display(),
                err
            );
        });
        serde_json::from_str::<TestRun<'_>>(&test_json).unwrap_or_else(|err| {
            panic!("Unable to parse {}:\n{:#?}", test_file_path.display(), err);
        })
    };

    let TestRun { tariff, expect } = test_run;
    let expect_file_name = test_file_path.display().to_string();
    let Expect {
        unexpected_fields: expect_unexpected_fields,
        warnings: expect_warnings,
        schema: expect_schema,
        pending: expect_pending,
    } = expect;

    let (tariff, schema_warnings) = {
        let tariff_json = tariff.get();
        let doc = json::parse_object(tariff_json).unwrap_or_else(|err| {
            panic!("Unable to parse the tariff:\n{err:#?}");
        });
        let (tariff, warnings) = tariff::from_json(doc, expected_version).into_parts();
        let mut unexpected_fields = warnings.unexpected_fields();

        test::expect_unexpected_fields(
            &expect_file_name,
            &mut unexpected_fields,
            expect_unexpected_fields,
        );

        (tariff, warnings)
    };

    let Report { warnings } = super::lint(&tariff);

    assert_not_yet_raised(&expect_file_name, &warnings, expect_pending);

    // If there are warnings reported and there is no `expect` file
    // then panic printing the fields of the expect JSON object that would silence these warnings.
    // These can be copied into the `test*.json` file.
    warning::test::assert_warnings(
        &format!("`warnings` in the `{expect_file_name}` file"),
        &warnings,
        expect_warnings,
    );
    warning::test::assert_warnings(
        &format!("`schema` in the `{expect_file_name}` file"),
        &schema_warnings,
        expect_schema,
    );
}

/// Assert that nothing listed under `pending` is being raised yet.
///
/// When a lint is reintroduced its fixture starts passing this check and failing it at the
/// same time: the warning appears in `warnings`, so the entry has to move out of `pending`.
#[track_caller]
fn assert_not_yet_raised<W>(
    expect_file_name: &str,
    warnings: &warning::Set<W>,
    pending: Expectation<BTreeMap<String, Vec<String>>>,
) where
    W: crate::Warning,
{
    let Expectation::Present(pending) = pending else {
        return;
    };
    let Some(pending) = pending.into_option() else {
        return;
    };

    let raised: BTreeMap<&str, BTreeSet<String>> = warnings
        .iter()
        .map(|group| {
            let (element, warnings) = group.to_parts();
            let ids = warnings
                .iter()
                .map(|w| w.id().as_str().to_owned())
                .collect();
            (element.path.as_str(), ids)
        })
        .collect();

    let mut now_raised = vec![];

    for (path, ids) in &pending {
        let Some(raised) = raised.get(path.as_str()) else {
            continue;
        };

        for id in ids {
            if raised.contains(id) {
                now_raised.push(format!("{path}: {id}"));
            }
        }
    }

    assert!(
        now_raised.is_empty(),
        "The `{expect_file_name}` file lists these warnings as `pending`, but they are now \
         being raised. Move them from `pending` into `warnings`:\n{now_raised:#?}"
    );
}