#![allow(
clippy::unwrap_in_result,
reason = "unwraps are allowed anywhere in tests"
)]
#![allow(
clippy::indexing_slicing,
reason = "tests are allowed to index collections"
)]
#![allow(clippy::string_slice, reason = "tests are allowed to slice strings")]
use std::{fmt, path::Path, str::FromStr as _};
use chrono::{DateTime, Utc};
use num_traits::Zero as _;
use rand::RngExt as _;
use rust_decimal::Decimal;
use tracing::debug;
use crate::{
assert_approx_eq_tolerance, cdr, country, currency,
duration::Hms,
money,
price::{self, test::UnwrapReport as _, Total},
tariff,
test::{self, Expectation},
Kwh, ObjectType, ToHoursDecimal, Version,
};
use super::CpoId;
const TARIFF_FILE_NAME: &str = "tariff.json";
macro_rules! assert_req_total_field {
($expected:expr, $report:expr $(,)?) => {
let field_name = stringify!($report);
match $expected {
Expectation::Present(expected) => match expected {
test::ExpectValue::Some(expected) => {
let value = $report.calculated.unwrap_or_else(|| {
panic!("`{field_name}` is not calculated.");
});
assert_approx_eq_tolerance!(
value,
expected.value(),
expected.tolerance(),
"for `{field_name}`"
);
}
test::ExpectValue::Null => {
if let Some(v) = $report.calculated {
panic!("Expected `{field_name}` to not be calcutated; but it has value `{v}`.")
}
}
},
Expectation::Absent => {
panic!("The `{field_name}` field is required.")
}
}
};
}
macro_rules! assert_req_optional_field {
($expected:expr, $report:expr $(,)?) => {
let field_name = stringify!($report);
match $expected {
Expectation::Present(expected) => match expected {
test::ExpectValue::Some(expected) => {
let value = $report.unwrap_or_else(|| {
panic!("`{field_name}` is not calculated.");
});
assert_approx_eq_tolerance!(
value,
expected.value(),
expected.tolerance(),
"for `{field_name}`"
);
}
test::ExpectValue::Null => assert!(
$report.is_none(),
"Expected `{field_name}` to not be calcutated; but it is."
),
},
Expectation::Absent => {
panic!("The `{field_name}` field is required.")
}
}
};
}
#[test_each::file(
glob = "ocpi-tariffs/test_data/popular/*/test_run*.json",
name(segments = 2)
)]
fn should_price_cdr_generated_from_tariff(test_run_json: &str, test_run_file_path: &Path) {
const VERSION: Version = Version::V221;
test::setup();
let json_dir = test_run_file_path
.parent()
.expect("The given file should live in a dir");
let tariff_path = json_dir.join(TARIFF_FILE_NAME);
debug!("Try to read tariff file: `{TARIFF_FILE_NAME}`");
let tariff_json = test::read_file_content(&tariff_path).unwrap_or_else(|err| {
panic!("Unable to read `{TARIFF_FILE_NAME}` file:\n{err}");
});
let tariff = {
debug!("Successfully read tariff file: `{TARIFF_FILE_NAME}`");
let parse_report =
tariff::parse_with_version(&tariff_json, VERSION).unwrap_or_else(|err| {
panic!("Unable to parse the tariff:\n{err:#?}");
});
let tariff::ParseReport {
tariff,
unexpected_fields,
} = parse_report;
test::assert_no_unexpected_fields(ObjectType::Tariff, &unexpected_fields);
tariff
};
let mut test_run_json = test_run_json.to_string();
let test_run = {
json_strip_comments::strip(&mut test_run_json).unwrap_or_else(|err| {
panic!(
"Unable to strip comments from {}:\n{:#?}",
test_run_file_path.display(),
err
);
});
serde_json::from_str::<TestRun>(&test_run_json).unwrap_or_else(|err| {
panic!(
"Unable to parse {}:\n{:#?}",
test_run_file_path.display(),
err
);
})
};
let TestRun { input, expect } = test_run;
let config = super::Config::from(input);
let timezone = config.timezone;
let cdr_json = {
let report = super::cdr_from_tariff(&tariff, &config).unwrap_or_else(|err| {
panic!("Unable to generate a CDR:\n{err:#?}");
});
let (report, warnings) = report.into_parts();
assert!(
warnings.is_empty(),
"Generating the CDR from a tariff has warnings;\n{:?}",
warnings.path_id_map()
);
let cdr_json = partial_to_cdr(report.partial_cdr);
serde_json::to_string_pretty(&cdr_json).unwrap()
};
let tariff_source = price::TariffSource::Override(vec![tariff]);
let parse_report = cdr::parse_with_version(&cdr_json, VERSION).unwrap_or_else(|err| {
panic!("Unable to parse the generated CDR:\n{err:#?}");
});
let cdr::ParseReport {
cdr,
unexpected_fields,
} = parse_report;
debug!("The CDR generated is:\n{}", cdr.as_json_str());
test::assert_no_unexpected_fields(ObjectType::Cdr, &unexpected_fields);
let report = price::cdr(&cdr, tariff_source, timezone).unwrap_report(cdr.as_json_str());
let (report, warnings) = report.into_parts();
assert!(
warnings.is_empty(),
"Generating the CDR from a tariff has warnings;\n{:?}",
warnings.path_id_map()
);
debug!("The CDR's report is:\n{:#?}", report);
let price::Report {
periods: _,
tariff_used,
tariff_reports: _,
timezone: _,
billed_charging_time: _,
billed_energy: _,
billed_parking_time: _,
total_charging_time,
total_energy,
total_parking_time,
total_time: _,
total_cost,
total_energy_cost,
total_fixed_cost: _,
total_parking_cost,
total_reservation_cost: _,
total_time_cost: _,
} = report;
let Expect {
currency: expected_currency,
total_cost: expected_total_cost,
total_energy_cost: expected_total_energy_cost,
total_parking_cost: expected_total_parking_cost,
total_charging_time: expected_total_charging_time,
total_parking_time: expected_total_parking_time,
total_energy: expected_total_energy,
} = expect;
{
let expected_currency = match expected_currency {
Expectation::Present(value) => {
let code = value.expect_value();
currency::Code::from_alpha_3_str(&code)
}
Expectation::Absent => currency::Code::Eur,
};
assert_eq!(
tariff_used.currency,
expected_currency,
"Expected `{expected_currency}` currency for tariff test_run `{}`",
test_run_file_path.display()
);
}
assert_req_total_field!(expected_total_cost, total_cost);
assert_req_total_field!(expected_total_energy_cost, total_energy_cost);
assert_req_total_field!(expected_total_parking_cost, total_parking_cost);
assert_req_total_field!(expected_total_energy, total_energy);
{
let total_charging_time = total_charging_time.map(Hms);
assert_req_optional_field!(expected_total_charging_time, total_charging_time);
}
{
let Total { cdr, calculated } = total_parking_time;
let total_parking_time = Total {
cdr: cdr.map(Hms),
calculated: calculated.map(Hms),
};
assert_req_total_field!(expected_total_parking_time, total_parking_time);
}
}
#[derive(serde::Deserialize)]
struct TestRun {
input: Input,
expect: Expect,
}
#[derive(serde::Deserialize)]
struct Input {
timezone: String,
end_date_time: String,
max_current_supply_amp: Decimal,
requested_kwh: Decimal,
max_power_supply_kw: Decimal,
start_date_time: String,
}
#[derive(serde::Deserialize)]
struct Expect {
#[serde(default)]
currency: Expectation<String>,
#[serde(default)]
total_cost: Expectation<TolerancePrice>,
#[serde(default)]
total_energy_cost: Expectation<TolerancePrice>,
#[serde(default)]
total_parking_cost: Expectation<TolerancePrice>,
#[serde(default)]
total_charging_time: Expectation<ToleranceHms>,
#[serde(default)]
total_parking_time: Expectation<ToleranceHms>,
#[serde(default)]
total_energy: Expectation<ToleranceKwh>,
}
trait Tolerance<V, T> {
fn value(&self) -> V;
fn tolerance(&self) -> T;
}
#[derive(Debug, serde::Deserialize)]
struct TolerancePrice {
value: crate::Price,
tolerance: Decimal,
}
impl Tolerance<crate::Price, Decimal> for TolerancePrice {
fn value(&self) -> crate::Price {
self.value
}
fn tolerance(&self) -> Decimal {
self.tolerance
}
}
#[derive(Debug, serde::Deserialize)]
struct ToleranceKwh {
value: Decimal,
tolerance_kwh: Decimal,
}
impl Tolerance<Kwh, Decimal> for ToleranceKwh {
fn value(&self) -> Kwh {
self.value.into()
}
fn tolerance(&self) -> Decimal {
self.tolerance_kwh
}
}
#[derive(Debug, serde::Deserialize)]
struct ToleranceHms {
value: Hms,
tolerance_sec: u32,
}
impl Tolerance<Hms, i64> for ToleranceHms {
fn value(&self) -> Hms {
self.value
}
fn tolerance(&self) -> i64 {
self.tolerance_sec.into()
}
}
impl From<Input> for super::Config {
fn from(config: Input) -> Self {
let Input {
timezone,
end_date_time,
max_current_supply_amp,
requested_kwh,
max_power_supply_kw,
start_date_time,
} = config;
super::Config {
timezone: chrono_tz::Tz::from_str(&timezone).unwrap(),
end_date_time: DateTime::<Utc>::from_str(&end_date_time).unwrap(),
max_current_supply_amp,
requested_kwh,
max_power_supply_kw,
start_date_time: DateTime::<Utc>::from_str(&start_date_time).unwrap(),
}
}
}
#[expect(
clippy::similar_names,
reason = "evse_id and evse_uid are well known terms"
)]
fn partial_to_cdr(partial_cdr: super::PartialCdr) -> serde_json::Value {
const DEFAULT_COUNTRY_CODE: country::Code = country::Code::Nl;
const DEFAULT_PARTY_ID: &str = "ENE";
const CONNECTOR_ID_LEN: usize = 36;
const EVSE_ID_LEN: usize = 48;
const EVSE_UID_LEN: usize = 36;
const ID_MAX_LEN: usize = 39;
const LOCATION_ID_LEN: usize = 36;
const TOKEN_CONTRACT_UID_LEN: usize = 36;
const TOKEN_UID_LEN: usize = 36;
let super::PartialCdr {
currency_code,
party_id,
start_date_time,
end_date_time,
total_energy,
total_charging_duration,
total_parking_duration,
total_cost,
total_energy_cost,
total_fixed_cost,
total_parking_duration_cost,
total_charging_duration_cost,
charging_periods,
} = partial_cdr;
let (country_code, party_id): (country::Code, &str) = party_id
.as_ref()
.map(|CpoId { country_code, id }| (*country_code, id.as_str()))
.unwrap_or_else(|| (DEFAULT_COUNTRY_CODE, DEFAULT_PARTY_ID));
let id = random_alpha_num_string(ID_MAX_LEN);
let start_date_time = start_date_time.to_rfc3339();
let end_date_time = end_date_time.to_rfc3339();
let token_uid = random_alpha_num_string(TOKEN_UID_LEN);
let token_contract_id = random_alpha_num_string(TOKEN_CONTRACT_UID_LEN);
let location_id = random_alpha_num_string(LOCATION_ID_LEN);
let evse_uid = random_alpha_num_string(EVSE_UID_LEN);
let evse_id = random_alpha_num_string(EVSE_ID_LEN);
let connector_id = random_alpha_num_string(CONNECTOR_ID_LEN);
let total_energy = total_energy
.map(Decimal::from)
.unwrap_or_else(Decimal::zero);
let total_parking_time = total_parking_duration
.as_ref()
.map(ToHoursDecimal::to_hours_dec)
.unwrap_or_default();
let total_charging_time = total_charging_duration
.as_ref()
.map(ToHoursDecimal::to_hours_dec)
.unwrap_or_default();
let total_time = total_charging_time.checked_add(total_parking_time).unwrap();
let total_cost = total_cost.map(Price::from).unwrap_or_default();
let total_energy_cost = total_energy_cost.map(Price::from).unwrap_or_default();
let total_fixed_cost = total_fixed_cost.map(Price::from).unwrap_or_default();
let total_parking_duration_cost = total_parking_duration_cost
.map(Price::from)
.unwrap_or_default();
let total_charging_duration_cost = total_charging_duration_cost
.map(Price::from)
.unwrap_or_default();
let last_updated = Utc::now().to_rfc3339();
let charging_periods = charging_periods
.into_iter()
.map(ChargingPeriod::from)
.collect::<Vec<_>>();
serde_json::json!({
"country_code": country_code.into_alpha_2_str(),
"party_id": party_id,
"id": id,
"start_date_time": start_date_time,
"end_date_time": end_date_time,
"cdr_token": {
"country_code": country_code.into_alpha_2_str(),
"party_id": party_id,
"uid": token_uid,
"contract_id": token_contract_id,
"type": "RFID"
},
"auth_method": "whitelist",
"cdr_location": {
"id": location_id,
"address": "[address]",
"city": "[city]",
"country": country_code.into_alpha_3_str(),
"coordinates": {
"latitude": "50.770774",
"longitude": "-126.104965"
},
"evse_uid": evse_uid,
"evse_id": evse_id,
"connector_id": connector_id,
"connector_standard": "IEC_62196_T2",
"connector_format": "SOCKET",
"connector_power_type": "AC_1_PHASE"
},
"currency": currency_code.into_str(),
"total_energy": total_energy,
"total_time": total_time,
"total_parking_time": total_parking_time,
"total_cost": total_cost,
"total_energy_cost": total_energy_cost,
"total_fixed_cost": total_fixed_cost,
"total_parking_cost": total_parking_duration_cost,
"total_time_cost": total_charging_duration_cost,
"last_updated": last_updated,
"charging_periods": charging_periods
})
}
fn random_alpha_num_string(len: usize) -> String {
const ALPHA_NUM_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
0123456789";
const ALPHA_NUM_LEN: usize = ALPHA_NUM_CHARS.len();
let mut rng = rand::rng();
(0..len)
.map(|_| {
let idx = rng.random_range(0..ALPHA_NUM_LEN);
char::from(ALPHA_NUM_CHARS[idx])
})
.collect()
}
#[derive(serde::Serialize)]
struct ChargingPeriod {
start_date_time: String,
dimensions: Vec<Dimension>,
tariff_id: Option<String>,
}
impl From<super::ChargingPeriod> for ChargingPeriod {
fn from(value: super::ChargingPeriod) -> Self {
let super::ChargingPeriod {
start_date_time,
dimensions,
tariff_id,
} = value;
Self {
start_date_time: start_date_time.to_rfc3339(),
dimensions: dimensions.into_iter().map(Dimension::from).collect(),
tariff_id,
}
}
}
#[derive(serde::Serialize)]
struct Dimension {
#[serde(rename = "type")]
dimension_type: String,
volume: Decimal,
}
impl From<super::Dimension> for Dimension {
fn from(value: super::Dimension) -> Self {
let super::Dimension {
dimension_type,
volume,
} = value;
Self {
dimension_type: dimension_type.to_string(),
volume,
}
}
}
#[derive(serde::Serialize, Default)]
struct Price {
excl_vat: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
incl_vat: Option<f64>,
}
impl From<money::Price> for Price {
fn from(value: money::Price) -> Self {
use rust_decimal::prelude::ToPrimitive;
let money::Price { excl_vat, incl_vat } = value;
Self {
excl_vat: Decimal::from(excl_vat).to_f64().unwrap(),
incl_vat: incl_vat.map(|d| Decimal::from(d).to_f64().unwrap()),
}
}
}
impl fmt::Display for super::DimensionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
super::DimensionType::Energy => f.write_str("ENERGY"),
super::DimensionType::MaxCurrent => f.write_str("MAX_CURRENT"),
super::DimensionType::MinCurrent => f.write_str("MIN_CURRENT"),
super::DimensionType::MaxPower => f.write_str("MAX_POWER"),
super::DimensionType::MinPower => f.write_str("MIN_POWER"),
super::DimensionType::ParkingTime => f.write_str("PARKING_TIME"),
super::DimensionType::ReservationTime => f.write_str("RESERVATION_TIME"),
super::DimensionType::Time => f.write_str("TIME"),
}
}
}