#![allow(
clippy::unwrap_in_result,
reason = "unwraps are allowed anywhere in tests"
)]
#![allow(
clippy::indexing_slicing,
reason = "tests are allowed to index collections"
)]
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 rust_decimal_macros::dec;
use tracing::debug;
use crate::{
assert_approx_eq_tolerance, cdr, country, money,
price::{self, test::UnwrapReport as _},
tariff,
test::{self, ApproxEq},
ObjectType, ToHoursDecimal, Version,
};
#[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, path: &Path) {
const VERSION: Version = Version::V221;
test::setup();
let tariff_json = read_tariff_json(path).unwrap();
let tariff = {
let parse_report = tariff::parse_with_version(&tariff_json, VERSION).unwrap();
let tariff::ParseReport {
tariff,
unexpected_fields,
} = parse_report;
test::assert_no_unexpected_fields(ObjectType::Tariff, &unexpected_fields);
tariff
};
let test_run = serde_json::from_str::<TestRun>(test_run_json).unwrap();
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();
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();
let cdr::ParseReport {
cdr,
unexpected_fields,
} = parse_report;
debug!("{}", 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());
debug!("{:?}", report.total_time);
if let Some(total_cost) = report.total_cost.calculated {
assert_approx_eq_tolerance!(
total_cost,
expect.total_cost.value,
expect.total_cost.tolerance,
);
}
if let Some(total_energy) = report.total_energy.calculated {
assert_approx_eq_tolerance!(
total_energy,
expect.total_energy.value,
expect.total_energy.tolerance,
);
}
}
#[track_caller]
fn read_tariff_json(json_file_path: &Path) -> Option<String> {
const TARIFF_FILE_NAME: &str = "tariff.json";
let json_dir = json_file_path
.parent()
.expect("The given file should live in a dir");
debug!("Try to read tariff file: `{TARIFF_FILE_NAME}`");
let json = std::fs::read_to_string(json_dir.join(TARIFF_FILE_NAME))
.ok()
.map(|mut json| {
json_strip_comments::strip(&mut json).ok();
json
});
debug!("Successfully read tariff file: `{TARIFF_FILE_NAME}`");
json
}
#[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 {
total_cost: Tolerance<crate::Price>,
total_energy: Tolerance<crate::Kwh>,
}
#[derive(Debug, serde::Deserialize)]
struct Tolerance<T> {
value: T,
tolerance: Decimal,
}
impl ApproxEq for Tolerance<Decimal> {
type Tolerance = Decimal;
fn default_tolerance() -> Self::Tolerance {
dec!(0.1)
}
fn approx_eq(&self, other: &Self) -> bool {
self.approx_eq_tolerance(other, self.tolerance)
}
fn approx_eq_tolerance(&self, other: &Self, tolerance: Self::Tolerance) -> bool {
<Decimal as ApproxEq>::approx_eq_tolerance(&self.value, &other.value, tolerance)
}
}
impl ApproxEq for Tolerance<crate::Price> {
type Tolerance = Decimal;
fn default_tolerance() -> Self::Tolerance {
dec!(0.1)
}
fn approx_eq(&self, other: &Self) -> bool {
self.approx_eq_tolerance(other, self.tolerance)
}
fn approx_eq_tolerance(&self, other: &Self, tolerance: Self::Tolerance) -> bool {
<crate::Price as ApproxEq>::approx_eq_tolerance(&self.value, &other.value, tolerance)
}
}
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 ID_MAX_LEN: usize = 39;
const TOKEN_UID_LEN: usize = 36;
const TOKEN_CONTRACT_UID_LEN: usize = 36;
const LOCATION_ID_LEN: usize = 36;
const EVSE_UID_LEN: usize = 36;
const EVSE_ID_LEN: usize = 48;
const CONNECTOR_ID_LEN: usize = 36;
let super::PartialCdr {
cpo_country_code,
cpo_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 cpo_country_code = cpo_country_code.unwrap_or(DEFAULT_COUNTRY_CODE);
let country_code = cpo_country_code.into_alpha_2_str();
let party_id = party_id.as_deref().unwrap_or(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,
"party_id": party_id,
"id": id,
"start_date_time": start_date_time,
"end_date_time": end_date_time,
"cdr_token": {
"country_code": country_code,
"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": cpo_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": cpo_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"),
}
}
}