use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, ContractId, CountryCode, Currency, DateTime, EvseId, Extensions, Number, OcpiString, PartyId,
PartyRef, Validate, Validator, ViolationCode,
};
use super::locations::{ConnectorFormat, ConnectorType, GeoLocation, PowerType};
use super::tariffs::Tariff;
use super::tokens::TokenType;
use super::types::Price;
pub const NON_CREDIT_ID_MAX_LEN: usize = 36;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Cdr {
pub country_code: CountryCode,
pub party_id: PartyId,
pub id: CiString<39>,
pub start_date_time: DateTime,
pub end_date_time: DateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<CiString<36>>,
pub cdr_token: CdrToken,
pub auth_method: AuthMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_reference: Option<CiString<36>>,
#[cfg(feature = "bookings")]
#[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_id: Option<CiString<36>>,
pub cdr_location: CdrLocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meter_id: Option<OcpiString<255>>,
pub currency: Currency,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tariffs: Vec<Tariff>,
pub charging_periods: Vec<ChargingPeriod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_data: Option<SignedData>,
pub total_cost: Price,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_fixed_cost: Option<Price>,
pub total_energy: Number,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_energy_cost: Option<Price>,
pub total_time: Number,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_time_cost: Option<Price>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_parking_time: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_parking_cost: Option<Price>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_reservation_cost: Option<Price>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remark: Option<OcpiString<255>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invoice_reference_id: Option<CiString<39>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit_reference_id: Option<CiString<39>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_charging_compensation: Option<bool>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Cdr {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
#[must_use]
pub fn is_credit(&self) -> bool {
self.credit.unwrap_or(false)
}
#[must_use]
pub fn total_charging_time(&self) -> Number {
self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
}
#[must_use]
pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
self.charging_periods
.iter()
.flat_map(|p| p.dimensions.iter())
.filter(|d| d.dimension_type == dimension)
.map(|d| d.volume)
.sum()
}
pub fn period_spans(&self) -> impl Iterator<Item = PeriodSpan<'_>> {
self.charging_periods.iter().enumerate().map(move |(i, period)| PeriodSpan {
start: period.start_date_time,
end: self.charging_periods.get(i + 1).map_or(self.end_date_time, |next| next.start_date_time),
period,
})
}
#[must_use]
pub fn delivery_latency_seconds(&self) -> Option<i64> {
if self.has_placeholder_timestamps() {
return None;
}
Some(self.last_updated.unix_timestamp() - self.end_date_time.unix_timestamp())
}
#[must_use]
pub fn has_placeholder_timestamps(&self) -> bool {
self.start_date_time.unix_timestamp() == 0 || self.end_date_time.unix_timestamp() == 0
}
}
impl Validate for Cdr {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
country_code,
party_id,
id,
start_date_time,
end_date_time,
session_id,
cdr_token,
auth_method,
authorization_reference,
cdr_location,
meter_id,
currency,
tariffs,
charging_periods,
signed_data,
total_cost,
total_fixed_cost,
total_energy,
total_energy_cost,
total_time,
total_time_cost,
total_parking_time,
total_parking_cost,
total_reservation_cost,
remark,
invoice_reference_id,
credit_reference_id,
last_updated,
);
if self.charging_periods.is_empty() {
v.report_at(
"charging_periods",
ViolationCode::EmptyRequiredList,
"a CDR has cardinality `+` charging_periods: at least one is required",
);
}
if !self.is_credit() && self.id.len() > NON_CREDIT_ID_MAX_LEN {
v.report_at(
"id",
ViolationCode::TooLong,
format!(
"a non-credit CDR id may be at most {NON_CREDIT_ID_MAX_LEN} characters; \
the extra length is reserved for credit CDRs"
),
);
}
if self.is_credit() && self.credit_reference_id.is_none() {
v.report_at(
"credit_reference_id",
ViolationCode::MissingConditional,
"is required to be set for a Credit CDR",
);
}
if !self.is_credit() && self.credit_reference_id.is_some() {
v.report_at(
"credit",
ViolationCode::Inconsistent,
"credit_reference_id is set, so `credit` should be true",
);
}
if !self.has_placeholder_timestamps() && self.end_date_time < self.start_date_time {
v.report_at(
"end_date_time",
ViolationCode::Inconsistent,
"a session cannot end before it starts",
);
}
let metered = self.dimension_total(CdrDimensionType::Energy);
if !self.charging_periods.is_empty()
&& self
.charging_periods
.iter()
.any(|p| p.dimensions.iter().any(|d| d.dimension_type == CdrDimensionType::Energy))
&& metered != self.total_energy
{
v.report_at(
"total_energy",
ViolationCode::Inconsistent,
format!(
"is {}, but the ENERGY dimensions of the charging periods add up to {metered}",
self.total_energy
),
);
}
validate_period_sequence(
&self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
self.start_date_time,
Some(self.end_date_time),
v,
);
if self.total_parking_time.is_some_and(|p| p > self.total_time) {
v.report_at(
"total_parking_time",
ViolationCode::Inconsistent,
"cannot exceed total_time, of which it is a part",
);
}
for (i, period) in self.charging_periods.iter().enumerate() {
for (j, dim) in period.dimensions.iter().enumerate() {
if dim.dimension_type.is_session_only() {
v.enter("charging_periods");
v.enter(&i.to_string());
v.enter("dimensions");
v.enter(&j.to_string());
v.report_at(
"type",
ViolationCode::Inconsistent,
format!(
"{} is marked \"Session Only\" and SHALL NOT appear in a CDR",
dim.dimension_type
),
);
v.leave();
v.leave();
v.leave();
v.leave();
}
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct CdrToken {
pub country_code: CountryCode,
pub party_id: PartyId,
pub uid: CiString<36>,
#[serde(rename = "type")]
pub token_type: TokenType,
pub contract_id: ContractId,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl CdrToken {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
}
impl Validate for CdrToken {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct CdrLocation {
pub id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<OcpiString<255>>,
pub address: OcpiString<45>,
pub city: OcpiString<45>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub postal_code: Option<OcpiString<10>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<OcpiString<20>>,
pub country: OcpiString<3>,
pub coordinates: GeoLocation,
pub evse_uid: CiString<36>,
pub evse_id: EvseId,
pub connector_id: CiString<36>,
pub connector_standard: ConnectorType,
pub connector_format: ConnectorFormat,
pub connector_power_type: PowerType,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl CdrLocation {
#[must_use]
pub fn is_reservation_only(&self) -> bool {
self.evse_uid.is_not_available()
|| self.evse_id.is_not_available()
|| self.connector_id.is_not_available()
}
}
impl Validate for CdrLocation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
id,
name,
address,
city,
postal_code,
state,
country,
coordinates,
evse_uid,
evse_id,
connector_id,
connector_standard,
connector_format,
connector_power_type,
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ChargingPeriod {
pub start_date_time: DateTime,
pub dimensions: Vec<CdrDimension>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tariff_id: Option<CiString<36>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl ChargingPeriod {
#[must_use]
pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PeriodSpan<'a> {
pub start: DateTime,
pub end: DateTime,
pub period: &'a ChargingPeriod,
}
impl PeriodSpan<'_> {
#[must_use]
pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
self.period.volume(dimension)
}
#[must_use]
pub fn duration_seconds(&self) -> i64 {
self.end.unix_timestamp() - self.start.unix_timestamp()
}
}
impl Validate for ChargingPeriod {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, start_date_time, dimensions, tariff_id);
if self.dimensions.is_empty() {
v.report_at(
"dimensions",
ViolationCode::EmptyRequiredList,
"a ChargingPeriod has cardinality `+` dimensions: at least one is required",
);
}
let mut seen: Vec<&CdrDimensionType> = Vec::new();
for d in &self.dimensions {
if seen.contains(&&d.dimension_type) {
v.report_at(
"dimensions",
ViolationCode::Inconsistent,
format!("the dimension {} appears more than once in one period", d.dimension_type),
);
}
seen.push(&d.dimension_type);
}
}
}
pub fn validate_period_sequence(
starts: &[DateTime],
session_start: DateTime,
session_end: Option<DateTime>,
v: &mut Validator,
) {
let mut previous: Option<DateTime> = None;
for (i, start) in starts.iter().copied().enumerate() {
let at = |v: &mut Validator, message: String| {
v.enter("charging_periods");
v.enter(&i.to_string());
v.report_at("start_date_time", ViolationCode::Inconsistent, message);
v.leave();
v.leave();
};
if let Some(previous) = previous
&& start <= previous
{
at(
v,
format!(
"is {start}, which is not after the previous period's {previous}; \
charging periods have to be in order for `step_size` and for a period's \
own duration to mean anything"
),
);
}
if start < session_start {
at(v, format!("is {start}, before the session started at {session_start}"));
}
if let Some(end) = session_end
&& start >= end
{
at(v, format!("is {start}, at or after the session ended at {end}"));
}
previous = Some(start);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CdrDimension {
#[serde(rename = "type")]
pub dimension_type: CdrDimensionType,
pub volume: Number,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl CdrDimension {
#[must_use]
pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
Self { dimension_type, volume, extensions: Extensions::new() }
}
}
impl Validate for CdrDimension {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, dimension_type as "type", volume);
if self.dimension_type == CdrDimensionType::StateOfCharge {
let pct = self.volume;
if pct < Number::ZERO || pct > Number::from(100u32) {
v.report_at(
"volume",
ViolationCode::OutOfRange,
"STATE_OF_CHARGE is a percentage: values allowed are 0 to 100",
);
}
}
if !self.dimension_type.may_be_negative() && self.volume.is_negative() {
v.report_at(
"volume",
ViolationCode::OutOfRange,
format!("{} cannot be negative", self.dimension_type),
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct SignedData {
pub encoding_method: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoding_method_version: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<OcpiString<512>>,
pub signed_values: Vec<SignedValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<OcpiString<512>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl SignedData {
#[must_use]
pub fn value_for(&self, nature: &str) -> Option<&SignedValue> {
self.signed_values.iter().find(|v| v.nature.eq_ignore_case(nature))
}
#[must_use]
pub fn start_value(&self) -> Option<&SignedValue> {
self.value_for("Start")
}
#[must_use]
pub fn end_value(&self) -> Option<&SignedValue> {
self.value_for("End")
}
}
impl Validate for SignedData {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, encoding_method, public_key, signed_values, url,);
if self.signed_values.is_empty() {
v.report_at(
"signed_values",
ViolationCode::EmptyRequiredList,
"SignedData has cardinality `+` signed_values: at least one is required",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SignedValue {
pub nature: CiString<32>,
pub plain_data: OcpiString<5000>,
pub signed_data: OcpiString<5000>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for SignedValue {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, nature, plain_data, signed_data);
}
}
ocpi_enum! {
pub enum AuthMethod {
AuthRequest = "AUTH_REQUEST",
Command = "COMMAND",
Whitelist = "WHITELIST",
}
}
ocpi_enum! {
pub enum CdrDimensionType {
Current = "CURRENT",
Energy = "ENERGY",
EnergyExport = "ENERGY_EXPORT",
EnergyImport = "ENERGY_IMPORT",
MaxCurrent = "MAX_CURRENT",
MinCurrent = "MIN_CURRENT",
MaxPower = "MAX_POWER",
MinPower = "MIN_POWER",
ParkingTime = "PARKING_TIME",
Power = "POWER",
ReservationTime = "RESERVATION_TIME",
ReservationExpires = "RESERVATION_EXPIRES",
ReservationOvertime = "RESERVATION_OVERTIME",
StateOfCharge = "STATE_OF_CHARGE",
Time = "TIME",
}
}
impl CdrDimensionType {
#[must_use]
pub const fn is_session_only(self) -> bool {
matches!(
self,
Self::Current | Self::EnergyExport | Self::EnergyImport | Self::Power | Self::StateOfCharge
)
}
#[must_use]
pub const fn may_be_negative(self) -> bool {
matches!(self, Self::Current | Self::Energy | Self::MinCurrent | Self::MinPower | Self::Power)
}
#[must_use]
pub const fn unit(self) -> &'static str {
match self {
Self::Current | Self::MaxCurrent | Self::MinCurrent => "A",
Self::Energy | Self::EnergyExport | Self::EnergyImport => "kWh",
Self::MaxPower | Self::MinPower | Self::Power => "kW",
Self::ParkingTime
| Self::ReservationTime
| Self::ReservationExpires
| Self::ReservationOvertime
| Self::Time => "h",
Self::StateOfCharge => "%",
}
}
}
#[cfg(test)]
mod cdr_helper_tests {
use super::*;
fn dt(s: &str) -> DateTime {
s.parse().expect("a valid timestamp")
}
fn period(start: &str, kwh: &str) -> ChargingPeriod {
ChargingPeriod::builder()
.start_date_time(dt(start))
.dimensions(vec![CdrDimension {
dimension_type: CdrDimensionType::Energy,
volume: kwh.parse().expect("a number"),
extensions: Extensions::new(),
}])
.build()
}
fn cdr_with(periods: Vec<ChargingPeriod>, end: &str, last_updated: &str) -> Cdr {
use crate::types::CiString;
let energy: Number = periods.iter().filter_map(|p| p.volume(CdrDimensionType::Energy)).sum();
Cdr::builder()
.country_code(CiString::new("NL").expect("valid"))
.party_id(CiString::new("TNM").expect("valid"))
.id(CiString::new("CDR1").expect("valid"))
.start_date_time(dt("2024-01-15T10:00:00Z"))
.end_date_time(dt(end))
.session_id(CiString::new("SESS1").expect("valid"))
.cdr_token(CdrToken {
country_code: CiString::new("DE").expect("valid"),
party_id: CiString::new("ABC").expect("valid"),
uid: CiString::new("012345678").expect("valid"),
token_type: TokenType::Rfid,
contract_id: CiString::new("DE8AACA2B3C4D5N").expect("valid"),
extensions: Extensions::new(),
})
.auth_method(AuthMethod::Whitelist)
.cdr_location(cdr_location())
.currency("EUR")
.charging_periods(periods)
.total_cost(crate::v2_3_0::types::Price::new("1.00".parse().expect("a number")))
.total_energy(energy)
.total_time("1".parse::<Number>().expect("a number"))
.last_updated(dt(last_updated))
.build()
}
fn cdr_location() -> CdrLocation {
use crate::types::CiString;
CdrLocation::builder()
.id(CiString::new("LOC1").expect("valid"))
.address("F.Rooseveltlaan 3A")
.city("Gent")
.country("BEL")
.coordinates(
crate::v2_3_0::locations::GeoLocation::new("3.729944", "51.047599")
.expect("valid coordinates"),
)
.evse_uid(CiString::new("3256").expect("valid"))
.evse_id(CiString::new("BE*BEC*E041503001").expect("valid"))
.connector_id(CiString::new("1").expect("valid"))
.connector_standard(crate::v2_3_0::locations::ConnectorType::Iec62196T2)
.connector_format(crate::v2_3_0::locations::ConnectorFormat::Socket)
.connector_power_type(crate::v2_3_0::locations::PowerType::Ac3Phase)
.build()
}
#[test]
fn a_period_span_runs_to_the_next_period_and_the_last_to_the_cdrs_end() {
let cdr = cdr_with(
vec![period("2024-01-15T10:00:00Z", "4.3"), period("2024-01-15T10:30:00Z", "1.1")],
"2024-01-15T11:00:00Z",
"2024-01-15T11:05:00Z",
);
let spans: Vec<_> = cdr.period_spans().collect();
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].end, dt("2024-01-15T10:30:00Z"), "the next period's start");
assert_eq!(spans[1].end, dt("2024-01-15T11:00:00Z"), "the CDR's end");
assert_eq!(spans[0].duration_seconds(), 1800);
assert_eq!(spans[1].duration_seconds(), 1800);
assert_eq!(spans[0].volume(CdrDimensionType::Energy).map(|v| v.to_string()), Some("4.3".into()));
assert!(spans[0].volume(CdrDimensionType::ParkingTime).is_none());
assert_eq!(spans[0].start, cdr.start_date_time);
assert_eq!(spans[0].end, spans[1].start);
assert_eq!(spans.last().expect("a span").end, cdr.end_date_time);
}
#[test]
fn a_single_period_spans_the_whole_session() {
let cdr = cdr_with(
vec![period("2024-01-15T10:00:00Z", "5.4")],
"2024-01-15T11:00:00Z",
"2024-01-15T11:00:00Z",
);
let spans: Vec<_> = cdr.period_spans().collect();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].duration_seconds(), 3600);
}
#[test]
fn delivery_latency_is_measured_from_last_updated_and_skips_placeholder_timestamps() {
let cdr = cdr_with(
vec![period("2024-01-15T10:00:00Z", "1")],
"2024-01-15T11:00:00Z",
"2024-01-17T09:00:00Z",
);
assert_eq!(cdr.delivery_latency_seconds(), Some(2 * 86_400 - 2 * 3600));
let mut placeholder = cdr.clone();
placeholder.start_date_time = dt("1970-01-01T00:00:00Z");
placeholder.end_date_time = dt("1970-01-01T00:00:00Z");
assert!(placeholder.has_placeholder_timestamps());
assert_eq!(placeholder.delivery_latency_seconds(), None);
let mut skewed = cdr;
skewed.last_updated = dt("2024-01-15T10:59:00Z");
assert_eq!(skewed.delivery_latency_seconds(), Some(-60));
}
#[test]
fn an_over_length_signed_blob_survives_a_round_trip_exactly() {
let blob = "O".repeat(6000);
let json = format!(r#"{{"nature":"End","plain_data":"{blob}","signed_data":"{blob}"}}"#);
let value: SignedValue = serde_json::from_str(&json).expect("decodes");
assert_eq!(value.signed_data.as_str(), blob, "not a byte moved");
assert_eq!(serde_json::to_string(&value).expect("encodes"), json, "and it goes back out the same");
assert_eq!(
value.validate().expect_err("the length is still reported").as_slice()[0].code,
crate::types::ViolationCode::TooLong,
);
}
#[test]
fn a_signed_data_url_may_run_past_the_length_of_an_ocpi_url() {
use crate::types::Validate;
let long = format!("https://e.com/{}", "a".repeat(300));
assert!(long.len() > 255 && long.len() <= 512);
let json = format!(
r#"{{"encoding_method":"OCMF","signed_values":[{{"nature":"End","plain_data":"p","signed_data":"s"}}],"url":"{long}"}}"#
);
let data: SignedData = serde_json::from_str(&json).expect("decodes");
assert_eq!(data.url.as_ref().expect("present").as_str(), long);
data.validate().expect("a 314-character signed-data URL is conformant");
}
#[test]
fn signed_values_are_reachable_by_nature() {
let value = |nature: &str| SignedValue {
nature: crate::types::CiString::new(nature).expect("valid"),
plain_data: crate::types::OcpiString::new_lenient("plain"),
signed_data: crate::types::OcpiString::new_lenient("signed"),
extensions: Extensions::new(),
};
let data = SignedData::builder()
.encoding_method(crate::types::CiString::<36>::new("OCMF").expect("valid"))
.signed_values(vec![value("Start"), value("End")])
.build();
assert!(data.start_value().is_some());
assert!(data.end_value().is_some());
assert!(data.value_for("end").is_some(), "natures compare case-insensitively");
assert!(data.value_for("Intermediate").is_none());
}
}
#[cfg(test)]
mod dimension_tests {
use super::*;
#[test]
fn the_bookings_branch_reservation_dimensions_decode() {
for (wire, expected, unit) in [
("RESERVATION_TIME", CdrDimensionType::ReservationTime, "h"),
("RESERVATION_EXPIRES", CdrDimensionType::ReservationExpires, "h"),
("RESERVATION_OVERTIME", CdrDimensionType::ReservationOvertime, "h"),
] {
let decoded: CdrDimensionType =
serde_json::from_str(&format!("\"{wire}\"")).unwrap_or_else(|e| panic!("{wire}: {e}"));
assert_eq!(decoded, expected);
assert_eq!(serde_json::to_string(&decoded).expect("serialises"), format!("\"{wire}\""));
assert_eq!(decoded.unit(), unit);
assert!(!decoded.is_session_only(), "{wire} has no Session-Only mark in the branch table");
}
}
}
#[cfg(test)]
mod period_sequence_tests {
use super::*;
use crate::types::Violation;
fn dt(s: &str) -> DateTime {
s.parse().expect("a valid timestamp")
}
fn check(starts: &[&str], start: &str, end: Option<&str>) -> Vec<Violation> {
let mut v = Validator::new();
validate_period_sequence(
&starts.iter().map(|s| dt(s)).collect::<Vec<_>>(),
dt(start),
end.map(dt),
&mut v,
);
v.finish().into_vec()
}
#[test]
fn a_well_formed_sequence_is_accepted() {
assert!(
check(
&["2024-01-15T10:00:00Z", "2024-01-15T10:30:00Z", "2024-01-15T11:00:00Z"],
"2024-01-15T10:00:00Z",
Some("2024-01-15T11:30:00Z"),
)
.is_empty()
);
}
#[test]
fn periods_out_of_order_are_reported_at_the_offending_index() {
let found = check(
&["2024-01-15T10:00:00Z", "2024-01-15T11:00:00Z", "2024-01-15T10:30:00Z"],
"2024-01-15T10:00:00Z",
Some("2024-01-15T12:00:00Z"),
);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].pointer, "/charging_periods/2/start_date_time");
assert_eq!(found[0].code, ViolationCode::Inconsistent);
}
#[test]
fn two_periods_at_the_same_instant_are_reported() {
let found = check(&["2024-01-15T10:00:00Z", "2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].pointer, "/charging_periods/1/start_date_time");
}
#[test]
fn a_period_outside_the_session_is_reported() {
let before = check(&["2024-01-15T09:00:00Z"], "2024-01-15T10:00:00Z", None);
assert_eq!(before.len(), 1);
assert!(before[0].message.contains("before the session started"), "{:?}", before[0]);
let after = check(&["2024-01-15T13:00:00Z"], "2024-01-15T10:00:00Z", Some("2024-01-15T12:00:00Z"));
assert_eq!(after.len(), 1);
assert!(after[0].message.contains("after the session ended"), "{:?}", after[0]);
}
#[test]
fn an_empty_or_single_period_list_has_nothing_to_disagree_with() {
assert!(check(&[], "2024-01-15T10:00:00Z", None).is_empty());
assert!(check(&["2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None).is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dim(t: CdrDimensionType, v: &str) -> CdrDimension {
CdrDimension::new(t, v.parse().unwrap())
}
#[test]
fn session_only_dimensions_are_rejected_in_a_cdr() {
let p = ChargingPeriod::builder()
.start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.dimensions(vec![dim(CdrDimensionType::StateOfCharge, "50")])
.build();
assert!(p.validate().is_ok(), "a Session may carry STATE_OF_CHARGE");
assert!(CdrDimensionType::StateOfCharge.is_session_only());
assert!(!CdrDimensionType::Energy.is_session_only());
}
#[test]
fn dimension_units_and_signs_follow_the_table() {
assert_eq!(CdrDimensionType::Energy.unit(), "kWh");
assert_eq!(CdrDimensionType::ParkingTime.unit(), "h");
assert!(CdrDimensionType::Power.may_be_negative(), "V2G power flows both ways");
assert!(!CdrDimensionType::ParkingTime.may_be_negative());
assert!(dim(CdrDimensionType::ParkingTime, "-1").validate().is_err());
assert!(dim(CdrDimensionType::Power, "-7.5").validate().is_ok());
assert!(dim(CdrDimensionType::StateOfCharge, "101").validate().is_err());
}
#[test]
fn a_period_cannot_measure_the_same_dimension_twice() {
let p = ChargingPeriod::builder()
.start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.dimensions(vec![dim(CdrDimensionType::Energy, "1"), dim(CdrDimensionType::Energy, "2")])
.build();
assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
}
#[test]
fn empty_dimensions_are_a_cardinality_violation() {
let p = ChargingPeriod::builder()
.start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.dimensions(vec![])
.build();
assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
}
}