1use bon::Builder;
12use serde::{Deserialize, Serialize};
13
14use crate::ocpi_enum;
15use crate::types::validate_fields;
16use crate::types::{
17 CiString, ContractId, CountryCode, Currency, DateTime, EvseId, Extensions, Number, OcpiString, PartyId,
18 PartyRef, Validate, Validator, ViolationCode,
19};
20
21use super::locations::{ConnectorFormat, ConnectorType, GeoLocation, PowerType};
22use super::tariffs::Tariff;
23use super::tokens::TokenType;
24use super::types::Price;
25
26pub const NON_CREDIT_ID_MAX_LEN: usize = 36;
32
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[builder(on(_, into))]
39pub struct Cdr {
40 pub country_code: CountryCode,
42 pub party_id: PartyId,
44 pub id: CiString<39>,
46 pub start_date_time: DateTime,
48 pub end_date_time: DateTime,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub session_id: Option<CiString<36>>,
56 pub cdr_token: CdrToken,
58 pub auth_method: AuthMethod,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub authorization_reference: Option<CiString<36>>,
63 #[cfg(feature = "bookings")]
71 #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub booking_id: Option<CiString<36>>,
74 pub cdr_location: CdrLocation,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub meter_id: Option<OcpiString<255>>,
79 pub currency: Currency,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 #[builder(default)]
84 pub tariffs: Vec<Tariff>,
85 pub charging_periods: Vec<ChargingPeriod>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub signed_data: Option<SignedData>,
90 pub total_cost: Price,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub total_fixed_cost: Option<Price>,
95 pub total_energy: Number,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub total_energy_cost: Option<Price>,
100 pub total_time: Number,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub total_time_cost: Option<Price>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub total_parking_time: Option<Number>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub total_parking_cost: Option<Price>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub total_reservation_cost: Option<Price>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub remark: Option<OcpiString<255>>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub invoice_reference_id: Option<CiString<39>>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub credit: Option<bool>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub credit_reference_id: Option<CiString<39>>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub home_charging_compensation: Option<bool>,
129 pub last_updated: DateTime,
131 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
133 #[builder(default)]
134 pub extensions: Extensions,
135}
136
137impl Cdr {
138 #[must_use]
140 pub fn owner_party(&self) -> PartyRef {
141 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
142 }
143
144 #[must_use]
146 pub fn is_credit(&self) -> bool {
147 self.credit.unwrap_or(false)
148 }
149
150 #[must_use]
155 pub fn total_charging_time(&self) -> Number {
156 self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
157 }
158
159 #[must_use]
161 pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
162 self.charging_periods
163 .iter()
164 .flat_map(|p| p.dimensions.iter())
165 .filter(|d| d.dimension_type == dimension)
166 .map(|d| d.volume)
167 .sum()
168 }
169
170 pub fn period_spans(&self) -> impl Iterator<Item = PeriodSpan<'_>> {
212 self.charging_periods.iter().enumerate().map(move |(i, period)| PeriodSpan {
213 start: period.start_date_time,
214 end: self.charging_periods.get(i + 1).map_or(self.end_date_time, |next| next.start_date_time),
215 period,
216 })
217 }
218
219 #[must_use]
236 pub fn delivery_latency_seconds(&self) -> Option<i64> {
237 if self.has_placeholder_timestamps() {
238 return None;
239 }
240 Some(self.last_updated.unix_timestamp() - self.end_date_time.unix_timestamp())
241 }
242
243 #[must_use]
249 pub fn has_placeholder_timestamps(&self) -> bool {
250 self.start_date_time.unix_timestamp() == 0 || self.end_date_time.unix_timestamp() == 0
251 }
252}
253
254impl Validate for Cdr {
255 fn validate_in(&self, v: &mut Validator) {
256 validate_fields!(
257 self,
258 v,
259 country_code,
260 party_id,
261 id,
262 start_date_time,
263 end_date_time,
264 session_id,
265 cdr_token,
266 auth_method,
267 authorization_reference,
268 cdr_location,
269 meter_id,
270 currency,
271 tariffs,
272 charging_periods,
273 signed_data,
274 total_cost,
275 total_fixed_cost,
276 total_energy,
277 total_energy_cost,
278 total_time,
279 total_time_cost,
280 total_parking_time,
281 total_parking_cost,
282 total_reservation_cost,
283 remark,
284 invoice_reference_id,
285 credit_reference_id,
286 last_updated,
287 );
288
289 if self.charging_periods.is_empty() {
290 v.report_at(
291 "charging_periods",
292 ViolationCode::EmptyRequiredList,
293 "a CDR has cardinality `+` charging_periods: at least one is required",
294 );
295 }
296
297 if !self.is_credit() && self.id.len() > NON_CREDIT_ID_MAX_LEN {
299 v.report_at(
300 "id",
301 ViolationCode::TooLong,
302 format!(
303 "a non-credit CDR id may be at most {NON_CREDIT_ID_MAX_LEN} characters; \
304 the extra length is reserved for credit CDRs"
305 ),
306 );
307 }
308
309 if self.is_credit() && self.credit_reference_id.is_none() {
312 v.report_at(
313 "credit_reference_id",
314 ViolationCode::MissingConditional,
315 "is required to be set for a Credit CDR",
316 );
317 }
318 if !self.is_credit() && self.credit_reference_id.is_some() {
319 v.report_at(
320 "credit",
321 ViolationCode::Inconsistent,
322 "credit_reference_id is set, so `credit` should be true",
323 );
324 }
325
326 if !self.has_placeholder_timestamps() && self.end_date_time < self.start_date_time {
327 v.report_at(
328 "end_date_time",
329 ViolationCode::Inconsistent,
330 "a session cannot end before it starts",
331 );
332 }
333
334 let metered = self.dimension_total(CdrDimensionType::Energy);
336 if !self.charging_periods.is_empty()
337 && self
338 .charging_periods
339 .iter()
340 .any(|p| p.dimensions.iter().any(|d| d.dimension_type == CdrDimensionType::Energy))
341 && metered != self.total_energy
342 {
343 v.report_at(
344 "total_energy",
345 ViolationCode::Inconsistent,
346 format!(
347 "is {}, but the ENERGY dimensions of the charging periods add up to {metered}",
348 self.total_energy
349 ),
350 );
351 }
352
353 validate_period_sequence(
354 &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
355 self.start_date_time,
356 Some(self.end_date_time),
357 v,
358 );
359
360 if self.total_parking_time.is_some_and(|p| p > self.total_time) {
361 v.report_at(
362 "total_parking_time",
363 ViolationCode::Inconsistent,
364 "cannot exceed total_time, of which it is a part",
365 );
366 }
367
368 for (i, period) in self.charging_periods.iter().enumerate() {
370 for (j, dim) in period.dimensions.iter().enumerate() {
371 if dim.dimension_type.is_session_only() {
372 v.enter("charging_periods");
373 v.enter(&i.to_string());
374 v.enter("dimensions");
375 v.enter(&j.to_string());
376 v.report_at(
377 "type",
378 ViolationCode::Inconsistent,
379 format!(
380 "{} is marked \"Session Only\" and SHALL NOT appear in a CDR",
381 dim.dimension_type
382 ),
383 );
384 v.leave();
385 v.leave();
386 v.leave();
387 v.leave();
388 }
389 }
390 }
391 }
392}
393
394#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399#[builder(on(_, into))]
400pub struct CdrToken {
401 pub country_code: CountryCode,
403 pub party_id: PartyId,
405 pub uid: CiString<36>,
407 #[serde(rename = "type")]
409 pub token_type: TokenType,
410 pub contract_id: ContractId,
412 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
414 #[builder(default)]
415 pub extensions: Extensions,
416}
417
418impl CdrToken {
419 #[must_use]
421 pub fn owner_party(&self) -> PartyRef {
422 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
423 }
424}
425
426impl Validate for CdrToken {
427 fn validate_in(&self, v: &mut Validator) {
428 validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
429 }
430}
431
432#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437#[builder(on(_, into))]
438pub struct CdrLocation {
439 pub id: CiString<36>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub name: Option<OcpiString<255>>,
444 pub address: OcpiString<45>,
446 pub city: OcpiString<45>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub postal_code: Option<OcpiString<10>>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub state: Option<OcpiString<20>>,
454 pub country: OcpiString<3>,
456 pub coordinates: GeoLocation,
458 pub evse_uid: CiString<36>,
460 pub evse_id: EvseId,
462 pub connector_id: CiString<36>,
464 pub connector_standard: ConnectorType,
466 pub connector_format: ConnectorFormat,
468 pub connector_power_type: PowerType,
470 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
472 #[builder(default)]
473 pub extensions: Extensions,
474}
475
476impl CdrLocation {
477 #[must_use]
483 pub fn is_reservation_only(&self) -> bool {
484 self.evse_uid.is_not_available()
485 || self.evse_id.is_not_available()
486 || self.connector_id.is_not_available()
487 }
488}
489
490impl Validate for CdrLocation {
491 fn validate_in(&self, v: &mut Validator) {
492 validate_fields!(
493 self,
494 v,
495 id,
496 name,
497 address,
498 city,
499 postal_code,
500 state,
501 country,
502 coordinates,
503 evse_uid,
504 evse_id,
505 connector_id,
506 connector_standard,
507 connector_format,
508 connector_power_type,
509 );
510 }
511}
512
513#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521#[builder(on(_, into))]
522pub struct ChargingPeriod {
523 pub start_date_time: DateTime,
525 pub dimensions: Vec<CdrDimension>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub tariff_id: Option<CiString<36>>,
530 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
532 #[builder(default)]
533 pub extensions: Extensions,
534}
535
536impl ChargingPeriod {
537 #[must_use]
539 pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
540 self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
541 }
542}
543
544#[derive(Clone, Copy, Debug, PartialEq)]
548pub struct PeriodSpan<'a> {
549 pub start: DateTime,
551 pub end: DateTime,
553 pub period: &'a ChargingPeriod,
555}
556
557impl PeriodSpan<'_> {
558 #[must_use]
560 pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
561 self.period.volume(dimension)
562 }
563
564 #[must_use]
566 pub fn duration_seconds(&self) -> i64 {
567 self.end.unix_timestamp() - self.start.unix_timestamp()
568 }
569}
570
571impl Validate for ChargingPeriod {
572 fn validate_in(&self, v: &mut Validator) {
573 validate_fields!(self, v, start_date_time, dimensions, tariff_id);
574 if self.dimensions.is_empty() {
575 v.report_at(
576 "dimensions",
577 ViolationCode::EmptyRequiredList,
578 "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
579 );
580 }
581 let mut seen: Vec<&CdrDimensionType> = Vec::new();
582 for d in &self.dimensions {
583 if seen.contains(&&d.dimension_type) {
584 v.report_at(
585 "dimensions",
586 ViolationCode::Inconsistent,
587 format!("the dimension {} appears more than once in one period", d.dimension_type),
588 );
589 }
590 seen.push(&d.dimension_type);
591 }
592 }
593}
594
595pub fn validate_period_sequence(
614 starts: &[DateTime],
615 session_start: DateTime,
616 session_end: Option<DateTime>,
617 v: &mut Validator,
618) {
619 let mut previous: Option<DateTime> = None;
620 for (i, start) in starts.iter().copied().enumerate() {
621 let at = |v: &mut Validator, message: String| {
622 v.enter("charging_periods");
623 v.enter(&i.to_string());
624 v.report_at("start_date_time", ViolationCode::Inconsistent, message);
625 v.leave();
626 v.leave();
627 };
628 if let Some(previous) = previous
629 && start <= previous
630 {
631 at(
632 v,
633 format!(
634 "is {start}, which is not after the previous period's {previous}; \
635 charging periods have to be in order for `step_size` and for a period's \
636 own duration to mean anything"
637 ),
638 );
639 }
640 if start < session_start {
641 at(v, format!("is {start}, before the session started at {session_start}"));
642 }
643 if let Some(end) = session_end
644 && start >= end
645 {
646 at(v, format!("is {start}, at or after the session ended at {end}"));
647 }
648 previous = Some(start);
649 }
650}
651
652#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
656#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
657pub struct CdrDimension {
658 #[serde(rename = "type")]
660 pub dimension_type: CdrDimensionType,
661 pub volume: Number,
663 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
665 pub extensions: Extensions,
666}
667
668impl CdrDimension {
669 #[must_use]
671 pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
672 Self { dimension_type, volume, extensions: Extensions::new() }
673 }
674}
675
676impl Validate for CdrDimension {
677 fn validate_in(&self, v: &mut Validator) {
678 validate_fields!(self, v, dimension_type as "type", volume);
679 if self.dimension_type == CdrDimensionType::StateOfCharge {
680 let pct = self.volume;
681 if pct < Number::ZERO || pct > Number::from(100u32) {
682 v.report_at(
683 "volume",
684 ViolationCode::OutOfRange,
685 "STATE_OF_CHARGE is a percentage: values allowed are 0 to 100",
686 );
687 }
688 }
689 if !self.dimension_type.may_be_negative() && self.volume.is_negative() {
690 v.report_at(
691 "volume",
692 ViolationCode::OutOfRange,
693 format!("{} cannot be negative", self.dimension_type),
694 );
695 }
696 }
697}
698
699#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704#[builder(on(_, into))]
705pub struct SignedData {
706 pub encoding_method: CiString<36>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub encoding_method_version: Option<i32>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub public_key: Option<OcpiString<512>>,
717 pub signed_values: Vec<SignedValue>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub url: Option<OcpiString<512>>,
728 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
730 #[builder(default)]
731 pub extensions: Extensions,
732}
733
734impl SignedData {
735 #[must_use]
743 pub fn value_for(&self, nature: &str) -> Option<&SignedValue> {
744 self.signed_values.iter().find(|v| v.nature.eq_ignore_case(nature))
745 }
746
747 #[must_use]
749 pub fn start_value(&self) -> Option<&SignedValue> {
750 self.value_for("Start")
751 }
752
753 #[must_use]
755 pub fn end_value(&self) -> Option<&SignedValue> {
756 self.value_for("End")
757 }
758}
759
760impl Validate for SignedData {
761 fn validate_in(&self, v: &mut Validator) {
762 validate_fields!(self, v, encoding_method, public_key, signed_values, url,);
763 if self.signed_values.is_empty() {
764 v.report_at(
765 "signed_values",
766 ViolationCode::EmptyRequiredList,
767 "SignedData has cardinality `+` signed_values: at least one is required",
768 );
769 }
770 }
771}
772
773#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
778pub struct SignedValue {
779 pub nature: CiString<32>,
784 pub plain_data: OcpiString<5000>,
788 pub signed_data: OcpiString<5000>,
797 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
799 pub extensions: Extensions,
800}
801
802impl Validate for SignedValue {
803 fn validate_in(&self, v: &mut Validator) {
804 validate_fields!(self, v, nature, plain_data, signed_data);
805 }
806}
807
808ocpi_enum! {
809 pub enum AuthMethod {
813 AuthRequest = "AUTH_REQUEST",
815 Command = "COMMAND",
817 Whitelist = "WHITELIST",
819 }
820}
821
822ocpi_enum! {
823 pub enum CdrDimensionType {
830 Current = "CURRENT",
832 Energy = "ENERGY",
834 EnergyExport = "ENERGY_EXPORT",
836 EnergyImport = "ENERGY_IMPORT",
838 MaxCurrent = "MAX_CURRENT",
840 MinCurrent = "MIN_CURRENT",
842 MaxPower = "MAX_POWER",
844 MinPower = "MIN_POWER",
846 ParkingTime = "PARKING_TIME",
851 Power = "POWER",
853 ReservationTime = "RESERVATION_TIME",
855 ReservationExpires = "RESERVATION_EXPIRES",
863 ReservationOvertime = "RESERVATION_OVERTIME",
869 StateOfCharge = "STATE_OF_CHARGE",
871 Time = "TIME",
873 }
874}
875
876impl CdrDimensionType {
877 #[must_use]
884 pub const fn is_session_only(self) -> bool {
885 matches!(
886 self,
887 Self::Current | Self::EnergyExport | Self::EnergyImport | Self::Power | Self::StateOfCharge
888 )
889 }
890
891 #[must_use]
896 pub const fn may_be_negative(self) -> bool {
897 matches!(self, Self::Current | Self::Energy | Self::MinCurrent | Self::MinPower | Self::Power)
898 }
899
900 #[must_use]
902 pub const fn unit(self) -> &'static str {
903 match self {
904 Self::Current | Self::MaxCurrent | Self::MinCurrent => "A",
905 Self::Energy | Self::EnergyExport | Self::EnergyImport => "kWh",
906 Self::MaxPower | Self::MinPower | Self::Power => "kW",
907 Self::ParkingTime
908 | Self::ReservationTime
909 | Self::ReservationExpires
910 | Self::ReservationOvertime
911 | Self::Time => "h",
912 Self::StateOfCharge => "%",
913 }
914 }
915}
916
917#[cfg(test)]
918mod cdr_helper_tests {
919 use super::*;
920
921 fn dt(s: &str) -> DateTime {
922 s.parse().expect("a valid timestamp")
923 }
924
925 fn period(start: &str, kwh: &str) -> ChargingPeriod {
926 ChargingPeriod::builder()
927 .start_date_time(dt(start))
928 .dimensions(vec![CdrDimension {
929 dimension_type: CdrDimensionType::Energy,
930 volume: kwh.parse().expect("a number"),
931 extensions: Extensions::new(),
932 }])
933 .build()
934 }
935
936 fn cdr_with(periods: Vec<ChargingPeriod>, end: &str, last_updated: &str) -> Cdr {
938 use crate::types::CiString;
939 let energy: Number = periods.iter().filter_map(|p| p.volume(CdrDimensionType::Energy)).sum();
940 Cdr::builder()
941 .country_code(CiString::new("NL").expect("valid"))
942 .party_id(CiString::new("TNM").expect("valid"))
943 .id(CiString::new("CDR1").expect("valid"))
944 .start_date_time(dt("2024-01-15T10:00:00Z"))
945 .end_date_time(dt(end))
946 .session_id(CiString::new("SESS1").expect("valid"))
947 .cdr_token(CdrToken {
948 country_code: CiString::new("DE").expect("valid"),
949 party_id: CiString::new("ABC").expect("valid"),
950 uid: CiString::new("012345678").expect("valid"),
951 token_type: TokenType::Rfid,
952 contract_id: CiString::new("DE8AACA2B3C4D5N").expect("valid"),
953 extensions: Extensions::new(),
954 })
955 .auth_method(AuthMethod::Whitelist)
956 .cdr_location(cdr_location())
957 .currency("EUR")
958 .charging_periods(periods)
959 .total_cost(crate::v2_3_0::types::Price::new("1.00".parse().expect("a number")))
960 .total_energy(energy)
961 .total_time("1".parse::<Number>().expect("a number"))
962 .last_updated(dt(last_updated))
963 .build()
964 }
965
966 fn cdr_location() -> CdrLocation {
967 use crate::types::CiString;
968 CdrLocation::builder()
969 .id(CiString::new("LOC1").expect("valid"))
970 .address("F.Rooseveltlaan 3A")
971 .city("Gent")
972 .country("BEL")
973 .coordinates(
974 crate::v2_3_0::locations::GeoLocation::new("3.729944", "51.047599")
975 .expect("valid coordinates"),
976 )
977 .evse_uid(CiString::new("3256").expect("valid"))
978 .evse_id(CiString::new("BE*BEC*E041503001").expect("valid"))
979 .connector_id(CiString::new("1").expect("valid"))
980 .connector_standard(crate::v2_3_0::locations::ConnectorType::Iec62196T2)
981 .connector_format(crate::v2_3_0::locations::ConnectorFormat::Socket)
982 .connector_power_type(crate::v2_3_0::locations::PowerType::Ac3Phase)
983 .build()
984 }
985
986 #[test]
988 fn a_period_span_runs_to_the_next_period_and_the_last_to_the_cdrs_end() {
989 let cdr = cdr_with(
990 vec![period("2024-01-15T10:00:00Z", "4.3"), period("2024-01-15T10:30:00Z", "1.1")],
991 "2024-01-15T11:00:00Z",
992 "2024-01-15T11:05:00Z",
993 );
994 let spans: Vec<_> = cdr.period_spans().collect();
995 assert_eq!(spans.len(), 2);
996 assert_eq!(spans[0].end, dt("2024-01-15T10:30:00Z"), "the next period's start");
997 assert_eq!(spans[1].end, dt("2024-01-15T11:00:00Z"), "the CDR's end");
998 assert_eq!(spans[0].duration_seconds(), 1800);
999 assert_eq!(spans[1].duration_seconds(), 1800);
1000 assert_eq!(spans[0].volume(CdrDimensionType::Energy).map(|v| v.to_string()), Some("4.3".into()));
1001 assert!(spans[0].volume(CdrDimensionType::ParkingTime).is_none());
1002
1003 assert_eq!(spans[0].start, cdr.start_date_time);
1005 assert_eq!(spans[0].end, spans[1].start);
1006 assert_eq!(spans.last().expect("a span").end, cdr.end_date_time);
1007 }
1008
1009 #[test]
1010 fn a_single_period_spans_the_whole_session() {
1011 let cdr = cdr_with(
1012 vec![period("2024-01-15T10:00:00Z", "5.4")],
1013 "2024-01-15T11:00:00Z",
1014 "2024-01-15T11:00:00Z",
1015 );
1016 let spans: Vec<_> = cdr.period_spans().collect();
1017 assert_eq!(spans.len(), 1);
1018 assert_eq!(spans[0].duration_seconds(), 3600);
1019 }
1020
1021 #[test]
1024 fn delivery_latency_is_measured_from_last_updated_and_skips_placeholder_timestamps() {
1025 let cdr = cdr_with(
1026 vec![period("2024-01-15T10:00:00Z", "1")],
1027 "2024-01-15T11:00:00Z",
1028 "2024-01-17T09:00:00Z",
1029 );
1030 assert_eq!(cdr.delivery_latency_seconds(), Some(2 * 86_400 - 2 * 3600));
1031
1032 let mut placeholder = cdr.clone();
1035 placeholder.start_date_time = dt("1970-01-01T00:00:00Z");
1036 placeholder.end_date_time = dt("1970-01-01T00:00:00Z");
1037 assert!(placeholder.has_placeholder_timestamps());
1038 assert_eq!(placeholder.delivery_latency_seconds(), None);
1039
1040 let mut skewed = cdr;
1042 skewed.last_updated = dt("2024-01-15T10:59:00Z");
1043 assert_eq!(skewed.delivery_latency_seconds(), Some(-60));
1044 }
1045
1046 #[test]
1048 fn an_over_length_signed_blob_survives_a_round_trip_exactly() {
1049 let blob = "O".repeat(6000);
1051 let json = format!(r#"{{"nature":"End","plain_data":"{blob}","signed_data":"{blob}"}}"#);
1052 let value: SignedValue = serde_json::from_str(&json).expect("decodes");
1053 assert_eq!(value.signed_data.as_str(), blob, "not a byte moved");
1054 assert_eq!(serde_json::to_string(&value).expect("encodes"), json, "and it goes back out the same");
1055 assert_eq!(
1056 value.validate().expect_err("the length is still reported").as_slice()[0].code,
1057 crate::types::ViolationCode::TooLong,
1058 );
1059 }
1060
1061 #[test]
1067 fn a_signed_data_url_may_run_past_the_length_of_an_ocpi_url() {
1068 use crate::types::Validate;
1069 let long = format!("https://e.com/{}", "a".repeat(300));
1070 assert!(long.len() > 255 && long.len() <= 512);
1071 let json = format!(
1072 r#"{{"encoding_method":"OCMF","signed_values":[{{"nature":"End","plain_data":"p","signed_data":"s"}}],"url":"{long}"}}"#
1073 );
1074 let data: SignedData = serde_json::from_str(&json).expect("decodes");
1075 assert_eq!(data.url.as_ref().expect("present").as_str(), long);
1076 data.validate().expect("a 314-character signed-data URL is conformant");
1077 }
1078
1079 #[test]
1080 fn signed_values_are_reachable_by_nature() {
1081 let value = |nature: &str| SignedValue {
1082 nature: crate::types::CiString::new(nature).expect("valid"),
1083 plain_data: crate::types::OcpiString::new_lenient("plain"),
1084 signed_data: crate::types::OcpiString::new_lenient("signed"),
1085 extensions: Extensions::new(),
1086 };
1087 let data = SignedData::builder()
1088 .encoding_method(crate::types::CiString::<36>::new("OCMF").expect("valid"))
1089 .signed_values(vec![value("Start"), value("End")])
1090 .build();
1091 assert!(data.start_value().is_some());
1092 assert!(data.end_value().is_some());
1093 assert!(data.value_for("end").is_some(), "natures compare case-insensitively");
1095 assert!(data.value_for("Intermediate").is_none());
1096 }
1097}
1098
1099#[cfg(test)]
1100mod dimension_tests {
1101 use super::*;
1102
1103 #[test]
1107 fn the_bookings_branch_reservation_dimensions_decode() {
1108 for (wire, expected, unit) in [
1109 ("RESERVATION_TIME", CdrDimensionType::ReservationTime, "h"),
1110 ("RESERVATION_EXPIRES", CdrDimensionType::ReservationExpires, "h"),
1111 ("RESERVATION_OVERTIME", CdrDimensionType::ReservationOvertime, "h"),
1112 ] {
1113 let decoded: CdrDimensionType =
1114 serde_json::from_str(&format!("\"{wire}\"")).unwrap_or_else(|e| panic!("{wire}: {e}"));
1115 assert_eq!(decoded, expected);
1116 assert_eq!(serde_json::to_string(&decoded).expect("serialises"), format!("\"{wire}\""));
1117 assert_eq!(decoded.unit(), unit);
1118 assert!(!decoded.is_session_only(), "{wire} has no Session-Only mark in the branch table");
1119 }
1120 }
1121}
1122
1123#[cfg(test)]
1124mod period_sequence_tests {
1125 use super::*;
1126 use crate::types::Violation;
1127
1128 fn dt(s: &str) -> DateTime {
1129 s.parse().expect("a valid timestamp")
1130 }
1131
1132 fn check(starts: &[&str], start: &str, end: Option<&str>) -> Vec<Violation> {
1133 let mut v = Validator::new();
1134 validate_period_sequence(
1135 &starts.iter().map(|s| dt(s)).collect::<Vec<_>>(),
1136 dt(start),
1137 end.map(dt),
1138 &mut v,
1139 );
1140 v.finish().into_vec()
1141 }
1142
1143 #[test]
1144 fn a_well_formed_sequence_is_accepted() {
1145 assert!(
1146 check(
1147 &["2024-01-15T10:00:00Z", "2024-01-15T10:30:00Z", "2024-01-15T11:00:00Z"],
1148 "2024-01-15T10:00:00Z",
1149 Some("2024-01-15T11:30:00Z"),
1150 )
1151 .is_empty()
1152 );
1153 }
1154
1155 #[test]
1156 fn periods_out_of_order_are_reported_at_the_offending_index() {
1157 let found = check(
1159 &["2024-01-15T10:00:00Z", "2024-01-15T11:00:00Z", "2024-01-15T10:30:00Z"],
1160 "2024-01-15T10:00:00Z",
1161 Some("2024-01-15T12:00:00Z"),
1162 );
1163 assert_eq!(found.len(), 1, "{found:?}");
1164 assert_eq!(found[0].pointer, "/charging_periods/2/start_date_time");
1165 assert_eq!(found[0].code, ViolationCode::Inconsistent);
1166 }
1167
1168 #[test]
1169 fn two_periods_at_the_same_instant_are_reported() {
1170 let found = check(&["2024-01-15T10:00:00Z", "2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None);
1172 assert_eq!(found.len(), 1, "{found:?}");
1173 assert_eq!(found[0].pointer, "/charging_periods/1/start_date_time");
1174 }
1175
1176 #[test]
1177 fn a_period_outside_the_session_is_reported() {
1178 let before = check(&["2024-01-15T09:00:00Z"], "2024-01-15T10:00:00Z", None);
1179 assert_eq!(before.len(), 1);
1180 assert!(before[0].message.contains("before the session started"), "{:?}", before[0]);
1181
1182 let after = check(&["2024-01-15T13:00:00Z"], "2024-01-15T10:00:00Z", Some("2024-01-15T12:00:00Z"));
1183 assert_eq!(after.len(), 1);
1184 assert!(after[0].message.contains("after the session ended"), "{:?}", after[0]);
1185 }
1186
1187 #[test]
1188 fn an_empty_or_single_period_list_has_nothing_to_disagree_with() {
1189 assert!(check(&[], "2024-01-15T10:00:00Z", None).is_empty());
1190 assert!(check(&["2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None).is_empty());
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 fn dim(t: CdrDimensionType, v: &str) -> CdrDimension {
1199 CdrDimension::new(t, v.parse().unwrap())
1200 }
1201
1202 #[test]
1203 fn session_only_dimensions_are_rejected_in_a_cdr() {
1204 let p = ChargingPeriod::builder()
1205 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1206 .dimensions(vec![dim(CdrDimensionType::StateOfCharge, "50")])
1207 .build();
1208 assert!(p.validate().is_ok(), "a Session may carry STATE_OF_CHARGE");
1209 assert!(CdrDimensionType::StateOfCharge.is_session_only());
1210 assert!(!CdrDimensionType::Energy.is_session_only());
1211 }
1212
1213 #[test]
1214 fn dimension_units_and_signs_follow_the_table() {
1215 assert_eq!(CdrDimensionType::Energy.unit(), "kWh");
1216 assert_eq!(CdrDimensionType::ParkingTime.unit(), "h");
1217 assert!(CdrDimensionType::Power.may_be_negative(), "V2G power flows both ways");
1218 assert!(!CdrDimensionType::ParkingTime.may_be_negative());
1219 assert!(dim(CdrDimensionType::ParkingTime, "-1").validate().is_err());
1220 assert!(dim(CdrDimensionType::Power, "-7.5").validate().is_ok());
1221 assert!(dim(CdrDimensionType::StateOfCharge, "101").validate().is_err());
1222 }
1223
1224 #[test]
1225 fn a_period_cannot_measure_the_same_dimension_twice() {
1226 let p = ChargingPeriod::builder()
1227 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1228 .dimensions(vec![dim(CdrDimensionType::Energy, "1"), dim(CdrDimensionType::Energy, "2")])
1229 .build();
1230 assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
1231 }
1232
1233 #[test]
1234 fn empty_dimensions_are_a_cardinality_violation() {
1235 let p = ChargingPeriod::builder()
1236 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1237 .dimensions(vec![])
1238 .build();
1239 assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
1240 }
1241}