1mod v2x;
2
3use std::{
4 cmp::{max, min},
5 fmt,
6 ops::Range,
7};
8
9use chrono::{DateTime, Datelike as _, NaiveDateTime, NaiveTime, TimeDelta, Utc};
10use rust_decimal::{prelude::ToPrimitive, Decimal};
11use rust_decimal_macros::dec;
12
13use crate::{
14 country, currency,
15 duration::ToHoursDecimal,
16 energy::{Ampere, Kw, Kwh},
17 into_caveat_all,
18 json::FromJson as _,
19 number::FromDecimal as _,
20 price,
21 tariff::{self, WarningKind},
22 warning::{self, GatherWarnings as _, IntoCaveat},
23 Price, Version, Versioned,
24};
25
26const MIN_CS_DURATION_SECS: i64 = 120;
28
29type DateTimeSpan = Range<DateTime<Utc>>;
30
31macro_rules! some_or_bail_msg {
33 ($opt:expr, $($tokens:tt)*) => {
34 match $opt {
35 Some(v) => v,
36 None => {
37 return Err(Error::Internal(
38 format!($($tokens)*).into(),
39 ));
40 }
41 }
42 }
43}
44
45#[derive(Debug)]
47pub struct Report {
48 pub tariff_id: String,
50
51 pub tariff_currency_code: currency::Code,
53
54 pub partial_cdr: PartialCdr,
61}
62
63#[derive(Debug)]
71pub struct PartialCdr {
72 pub country_code: Option<country::Code>,
74
75 pub party_id: Option<String>,
77
78 pub start_date_time: DateTime<Utc>,
80
81 pub end_date_time: DateTime<Utc>,
83
84 pub currency: currency::Code,
86
87 pub total_energy: Option<Kwh>,
89
90 pub total_time: Option<TimeDelta>,
92
93 pub total_parking_time: Option<TimeDelta>,
95
96 pub total_cost: Option<Price>,
98
99 pub total_energy_cost: Option<Price>,
101
102 pub total_fixed_cost: Option<Price>,
104
105 pub total_parking_cost: Option<Price>,
107
108 pub total_time_cost: Option<Price>,
110
111 pub charging_periods: Vec<ChargingPeriod>,
114}
115
116#[derive(Debug)]
120pub struct ChargingPeriod {
121 pub start_date_time: DateTime<Utc>,
124
125 pub dimensions: Vec<Dimension>,
127
128 pub tariff_id: Option<String>,
132}
133
134#[derive(Debug)]
138pub struct Dimension {
139 pub dimension_type: DimensionType,
140
141 pub volume: Decimal,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum DimensionType {
150 Energy,
152 MaxCurrent,
154 MinCurrent,
156 MaxPower,
158 MinPower,
160 ParkingTime,
162 ReservationTime,
164 Time,
166}
167
168into_caveat_all!(Report, Timeline);
169
170pub fn cdr_from_tariff(
172 tariff_elem: &tariff::Versioned<'_>,
173 config: Config,
174) -> Result<Caveat<Report>> {
175 let (metrics, timezone) = metrics(config)?;
183
184 let mut warnings = warning::Set::new();
185 let tariff = match tariff_elem.version() {
186 Version::V211 => {
187 let tariff = tariff::v211::Tariff::from_json(tariff_elem.as_element())?
188 .gather_warnings_into(&mut warnings);
189
190 tariff::v221::Tariff::from(tariff)
191 }
192 Version::V221 => tariff::v221::Tariff::from_json(tariff_elem.as_element())?
193 .gather_warnings_into(&mut warnings),
194 };
195
196 if !is_tariff_active(&metrics.start_date_time, &tariff) {
197 warnings.with_elem(WarningKind::NotActive, tariff_elem.as_element());
198 }
199
200 let timeline = timeline(timezone, &metrics, &tariff);
201 let mut charging_periods = charge_periods(&metrics, timeline);
202
203 let report = price::periods(
204 metrics.end_date_time,
205 timezone,
206 &tariff,
207 &mut charging_periods,
208 )?;
209
210 let price::PeriodsReport {
211 billable: _,
212 periods,
213 totals,
214 total_costs,
215 } = report;
216
217 let charging_periods = periods
218 .into_iter()
219 .map(|period| {
220 let price::PeriodReport {
221 start_date_time,
222 end_date_time: _,
223 dimensions,
224 } = period;
225 let time = dimensions
226 .duration_charging
227 .volume
228 .as_ref()
229 .map(|dt| Dimension {
230 dimension_type: DimensionType::Time,
231 volume: ToHoursDecimal::to_hours_dec(dt),
232 });
233 let parking_time = dimensions
234 .duration_parking
235 .volume
236 .as_ref()
237 .map(|dt| Dimension {
238 dimension_type: DimensionType::ParkingTime,
239 volume: ToHoursDecimal::to_hours_dec(dt),
240 });
241 let energy = dimensions.energy.volume.as_ref().map(|kwh| Dimension {
242 dimension_type: DimensionType::Energy,
243 volume: (*kwh).into(),
244 });
245 let dimensions = vec![energy, parking_time, time]
246 .into_iter()
247 .flatten()
248 .collect();
249
250 ChargingPeriod {
251 start_date_time,
252 dimensions,
253 tariff_id: Some(tariff.id.to_string()),
254 }
255 })
256 .collect();
257
258 let mut total_cost = total_costs.total();
259
260 if let Some(total_cost) = total_cost.as_mut() {
261 if let Some(min_price) = tariff.min_price {
262 if *total_cost < min_price {
263 *total_cost = min_price;
264 warnings.with_elem(WarningKind::TotalCostClampedToMin, tariff_elem.as_element());
265 }
266 }
267
268 if let Some(max_price) = tariff.max_price {
269 if *total_cost > max_price {
270 *total_cost = max_price;
271 warnings.with_elem(WarningKind::TotalCostClampedToMin, tariff_elem.as_element());
272 }
273 }
274 }
275
276 let report = Report {
277 tariff_id: tariff.id.to_string(),
278 tariff_currency_code: tariff.currency,
279 partial_cdr: PartialCdr {
280 country_code: tariff.country_code,
281 party_id: tariff.party_id.as_ref().map(ToString::to_string),
282 start_date_time: metrics.start_date_time,
283 end_date_time: metrics.end_date_time,
284 currency: tariff.currency,
285 total_energy: totals.energy,
286 total_time: totals.duration_charging,
287 total_parking_time: totals.duration_parking,
288 total_cost,
289 total_energy_cost: total_costs.energy,
290 total_fixed_cost: total_costs.fixed,
291 total_parking_cost: total_costs.duration_parking,
292 total_time_cost: total_costs.duration_charging,
293 charging_periods,
294 },
295 };
296
297 Ok(report.into_caveat(warnings))
298}
299
300fn timeline(
302 timezone: chrono_tz::Tz,
303 metrics: &Metrics,
304 tariff: &tariff::v221::Tariff<'_>,
305) -> Timeline {
306 let mut events = vec![];
307
308 let Metrics {
309 start_date_time: cdr_start,
310 end_date_time: cdr_end,
311 duration_charging,
312 duration_parking,
313 max_power_supply,
314 max_current_supply,
315
316 energy_supplied: _,
317 } = metrics;
318
319 events.push(Event {
320 duration_from_start: TimeDelta::seconds(0),
321 kind: EventKind::SessionStart,
322 });
323
324 events.push(Event {
325 duration_from_start: *duration_charging,
326 kind: EventKind::ChargingEnd,
327 });
328
329 if let Some(duration_parking) = duration_parking {
330 events.push(Event {
331 duration_from_start: *duration_parking,
332 kind: EventKind::ParkingEnd {
333 start: metrics.duration_charging,
334 },
335 });
336 }
337
338 let mut emit_current = false;
341
342 let mut emit_power = false;
345
346 for elem in &tariff.elements {
347 if let Some((time_restrictions, energy_restrictions)) = elem
348 .restrictions
349 .as_ref()
350 .map(tariff::v221::Restrictions::restrictions_by_category)
351 {
352 let mut time_events =
353 generate_time_events(timezone, *cdr_start..*cdr_end, time_restrictions);
354
355 let v2x::EnergyRestrictions {
356 min_kwh,
357 max_kwh,
358 min_current,
359 max_current,
360 min_power,
361 max_power,
362 } = energy_restrictions;
363
364 if !emit_current {
365 emit_current = (min_current..=max_current).contains(&Some(*max_current_supply));
370 }
371
372 if !emit_power {
373 emit_power = (min_power..=max_power).contains(&Some(*max_power_supply));
378 }
379
380 let mut energy_events = generate_energy_events(
381 metrics.duration_charging,
382 metrics.energy_supplied,
383 min_kwh,
384 max_kwh,
385 );
386
387 events.append(&mut time_events);
388 events.append(&mut energy_events);
389 }
390 }
391
392 Timeline {
393 events,
394 emit_current,
395 emit_power,
396 }
397}
398
399fn generate_time_events(
401 timezone: chrono_tz::Tz,
402 cdr_span: DateTimeSpan,
403 restrictions: v2x::TimeRestrictions,
404) -> Vec<Event> {
405 const MIDNIGHT: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0)
406 .expect("The hour, minute and second values are correct and hardcoded");
407 const ONE_DAY: TimeDelta = TimeDelta::days(1);
408
409 let v2x::TimeRestrictions {
410 start_time,
411 end_time,
412 start_date,
413 end_date,
414 min_duration,
415 max_duration,
416 weekdays,
417 } = restrictions;
418 let mut events = vec![];
419
420 let cdr_duration = cdr_span.end - cdr_span.start;
421
422 if let Some(min_duration) = min_duration.filter(|dt| &cdr_duration < dt) {
424 events.push(Event {
425 duration_from_start: min_duration,
426 kind: EventKind::MinDuration,
427 });
428 }
429
430 if let Some(max_duration) = max_duration.filter(|dt| &cdr_duration < dt) {
432 events.push(Event {
433 duration_from_start: max_duration,
434 kind: EventKind::MaxDuration,
435 });
436 }
437
438 let (start_date_time, end_date_time) =
448 if let (Some(start_time), Some(end_time)) = (start_time, end_time) {
449 if end_time < start_time {
450 (
451 start_date.map(|d| d.and_time(start_time)),
452 end_date.map(|d| d.and_time(end_time + ONE_DAY)),
453 )
454 } else {
455 (
456 start_date.map(|d| d.and_time(start_time)),
457 end_date.map(|d| d.and_time(end_time)),
458 )
459 }
460 } else {
461 (
462 start_date.map(|d| d.and_time(start_time.unwrap_or(MIDNIGHT))),
463 end_date.map(|d| d.and_time(end_time.unwrap_or(MIDNIGHT))),
464 )
465 };
466
467 let event_span = clamp_date_time_span(
470 start_date_time.and_then(|d| local_to_utc(timezone, d)),
471 end_date_time.and_then(|d| local_to_utc(timezone, d)),
472 cdr_span,
473 );
474
475 if let Some(start_time) = start_time {
476 let mut start_events =
477 gen_naive_time_events(&event_span, start_time, &weekdays, EventKind::StartTime);
478 events.append(&mut start_events);
479 }
480
481 if let Some(end_time) = end_time {
482 let mut end_events =
483 gen_naive_time_events(&event_span, end_time, &weekdays, EventKind::EndTime);
484 events.append(&mut end_events);
485 }
486
487 events
488}
489
490fn local_to_utc(timezone: chrono_tz::Tz, date_time: NaiveDateTime) -> Option<DateTime<Utc>> {
496 use chrono::offset::LocalResult;
497
498 let result = date_time.and_local_timezone(timezone);
499
500 let local_date_time = match result {
501 LocalResult::Single(d) => d,
502 LocalResult::Ambiguous(earliest, _latest) => earliest,
503 LocalResult::None => return None,
504 };
505
506 Some(local_date_time.to_utc())
507}
508
509fn gen_naive_time_events(
511 event_span: &Range<DateTime<Utc>>,
512 time: NaiveTime,
513 weekdays: &v2x::WeekdaySet,
514 kind: EventKind,
515) -> Vec<Event> {
516 let mut events = vec![];
517 let time_delta = time - event_span.start.time();
518 let cdr_duration = event_span.end - event_span.start;
519
520 let time_delta = if time_delta.num_seconds().is_negative() {
523 let time_delta = time + TimeDelta::days(1);
524 time_delta - event_span.start.time()
525 } else {
526 time_delta
527 };
528
529 if time_delta.num_seconds().is_negative() {
531 return vec![];
532 }
533
534 let remainder = cdr_duration - time_delta;
536
537 if remainder.num_seconds().is_positive() {
538 let duration_from_start = time_delta;
539 let date = event_span.start + duration_from_start;
540
541 if weekdays.contains(date.weekday()) {
542 events.push(Event {
544 duration_from_start: time_delta,
545 kind,
546 });
547 }
548
549 for day in 1..=remainder.num_days() {
550 let duration_from_start = time_delta + TimeDelta::days(day);
551 let date = event_span.start + duration_from_start;
552
553 if weekdays.contains(date.weekday()) {
554 events.push(Event {
555 duration_from_start,
556 kind,
557 });
558 }
559 }
560 }
561
562 events
563}
564
565fn generate_energy_events(
567 duration_charging: TimeDelta,
568 energy_supplied: Kwh,
569 min_kwh: Option<Kwh>,
570 max_kwh: Option<Kwh>,
571) -> Vec<Event> {
572 let mut events = vec![];
573
574 if let Some(duration_from_start) =
575 min_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
576 {
577 events.push(Event {
578 duration_from_start,
579 kind: EventKind::MinKwh,
580 });
581 }
582
583 if let Some(duration_from_start) =
584 max_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
585 {
586 events.push(Event {
587 duration_from_start,
588 kind: EventKind::MaxKwh,
589 });
590 }
591
592 events
593}
594
595fn energy_factor(power: Kwh, power_total: Kwh, duration_total: TimeDelta) -> Option<TimeDelta> {
596 use rust_decimal::prelude::ToPrimitive;
597
598 let power = Decimal::from(power);
601 let power_total = Decimal::from(power_total);
603 let factor = power_total / power;
605
606 if factor.is_sign_negative() || factor > dec!(1.0) {
607 return None;
608 }
609
610 let duration_from_start = factor * Decimal::from(duration_total.num_seconds());
611 duration_from_start.to_i64().map(TimeDelta::seconds)
612}
613
614fn charge_periods(metrics: &Metrics, timeline: Timeline) -> Vec<price::Period> {
616 enum ChargingPhase {
618 Charging,
619 Parking,
620 }
621
622 let Metrics {
623 start_date_time: cdr_start,
624 max_power_supply,
625 max_current_supply,
626
627 end_date_time: _,
628 duration_charging: _,
629 duration_parking: _,
630 energy_supplied: _,
631 } = metrics;
632
633 let Timeline {
634 mut events,
635 emit_current,
636 emit_power,
637 } = timeline;
638
639 events.sort_unstable_by_key(|e| e.duration_from_start);
640
641 let mut periods = vec![];
642 let emit_current = emit_current.then_some(*max_current_supply);
643 let emit_power = emit_power.then_some(*max_power_supply);
644 let mut charging_phase = ChargingPhase::Charging;
646
647 for items in events.windows(2) {
648 let [event, event_next] = items else {
649 unreachable!("The window size is 2");
650 };
651
652 let Event {
653 duration_from_start,
654 kind,
655 } = event;
656
657 if let EventKind::ChargingEnd = kind {
658 charging_phase = ChargingPhase::Parking;
659 }
660
661 let duration = event_next.duration_from_start - *duration_from_start;
662 let start_date_time = *cdr_start + *duration_from_start;
663
664 let consumed = if let ChargingPhase::Charging = charging_phase {
665 let energy = Decimal::from(*max_power_supply) * duration.to_hours_dec();
666 price::Consumed {
667 duration_charging: Some(duration),
668 duration_parking: None,
669 energy: Some(Kwh::from_decimal(energy)),
670 current_max: emit_current,
671 current_min: emit_current,
672 power_max: emit_power,
673 power_min: emit_power,
674 }
675 } else {
676 price::Consumed {
677 duration_charging: None,
678 duration_parking: Some(duration),
679 energy: None,
680 current_max: None,
681 current_min: None,
682 power_max: None,
683 power_min: None,
684 }
685 };
686
687 let period = price::Period {
688 start_date_time,
689 consumed,
690 };
691
692 periods.push(period);
693 }
694
695 periods
696}
697
698fn clamp_date_time_span(
704 min_date: Option<DateTime<Utc>>,
705 max_date: Option<DateTime<Utc>>,
706 span: DateTimeSpan,
707) -> DateTimeSpan {
708 let (min_date, max_date) = (min(min_date, max_date), max(min_date, max_date));
710
711 let start = min_date.filter(|d| &span.start < d).unwrap_or(span.start);
712 let end = max_date.filter(|d| &span.end > d).unwrap_or(span.end);
713
714 DateTimeSpan { start, end }
715}
716
717struct Timeline {
719 events: Vec<Event>,
721
722 emit_current: bool,
724
725 emit_power: bool,
727}
728
729#[derive(Debug)]
731struct Event {
732 duration_from_start: TimeDelta,
734
735 kind: EventKind,
737}
738
739#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
741enum EventKind {
742 SessionStart,
748
749 ChargingEnd,
754
755 ParkingEnd {
760 start: TimeDelta,
762 },
763
764 StartTime,
765
766 EndTime,
767
768 MinDuration,
773
774 MaxDuration,
779
780 MinKwh,
782
783 MaxKwh,
785}
786
787#[derive(Debug)]
789struct Metrics {
790 end_date_time: DateTime<Utc>,
792
793 start_date_time: DateTime<Utc>,
795
796 duration_charging: TimeDelta,
801
802 duration_parking: Option<TimeDelta>,
806
807 energy_supplied: Kwh,
809
810 max_current_supply: Ampere,
812
813 max_power_supply: Kw,
815}
816
817#[expect(
819 clippy::needless_pass_by_value,
820 reason = "Clippy is complaining that `Config` is not consumed by the function when it clearly is"
821)]
822fn metrics(config: Config) -> Result<(Metrics, chrono_tz::Tz)> {
823 const SECS_IN_HOUR: Decimal = dec!(3600);
824
825 let Config {
826 start_date_time,
827 end_date_time,
828 max_power_supply,
829 max_energy_battery,
830 max_current_supply,
831 timezone,
832 } = config;
833 let duration_session = end_date_time - start_date_time;
834
835 if duration_session.num_seconds().is_negative() {
837 return Err(Error::StartDateTimeIsAfterEndDateTime);
838 }
839
840 if duration_session.num_seconds() < MIN_CS_DURATION_SECS {
841 return Err(Error::DurationBelowMinimum);
842 }
843
844 let max_energy_battery = Decimal::from(max_energy_battery);
845 let duration_full_charge_hours = some_or_bail_msg!(
847 max_energy_battery.checked_div(Decimal::from(max_power_supply)),
848 "Unable to calculate changing time"
849 );
850
851 let charge_duration_hours =
853 Decimal::min(duration_full_charge_hours, duration_session.to_hours_dec());
854
855 let power_supplied_kwh = some_or_bail_msg!(
856 max_energy_battery.checked_div(charge_duration_hours),
857 "Unable to calculate the power supplied during the charging time"
858 );
859
860 let charging_duration_secs = some_or_bail_msg!(
862 charge_duration_hours.checked_mul(SECS_IN_HOUR),
863 "Unable to convert charging time from hours to seconds"
864 );
865
866 let charging_duration_secs = some_or_bail_msg!(
867 charging_duration_secs.to_i64(),
868 "Unable to convert charging duration Decimal to i64"
869 );
870 let duration_charging = TimeDelta::seconds(charging_duration_secs);
871
872 let duration_parking = some_or_bail_msg!(
873 duration_session.checked_sub(&duration_charging),
874 "Unable to calculate `idle_duration`"
875 );
876
877 let metrics = Metrics {
878 end_date_time,
879 start_date_time,
880 duration_charging,
881 duration_parking: Some(duration_parking).filter(|dt| dt.num_seconds().is_positive()),
882 energy_supplied: Kwh::from_decimal(power_supplied_kwh),
883 max_current_supply,
884 max_power_supply,
885 };
886 Ok((metrics, timezone))
887}
888
889fn is_tariff_active(cdr_start: &DateTime<Utc>, tariff: &tariff::v221::Tariff<'_>) -> bool {
890 match (tariff.start_date_time, tariff.end_date_time) {
891 (None, None) => true,
892 (None, Some(end)) => (..end).contains(cdr_start),
893 (Some(start), None) => (start..).contains(cdr_start),
894 (Some(start), Some(end)) => (start..end).contains(cdr_start),
895 }
896}
897
898pub type Caveat<T> = warning::Caveat<T, WarningKind>;
899pub type Result<T> = std::result::Result<T, Error>;
900
901#[derive(Debug)]
903pub enum Error {
904 DurationBelowMinimum,
906
907 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
909
910 Price(price::Error),
912
913 StartDateTimeIsAfterEndDateTime,
915
916 Tariff(warning::Set<WarningKind>),
919}
920
921impl std::error::Error for Error {
922 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
923 match self {
924 Self::DurationBelowMinimum
925 | Self::StartDateTimeIsAfterEndDateTime
926 | Self::Tariff(_) => None,
927 Self::Internal(err) => Some(&**err),
928 Self::Price(err) => Some(err),
929 }
930 }
931}
932
933impl fmt::Display for Error {
934 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
935 match self {
936 Self::DurationBelowMinimum => write!(
937 f,
938 "The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
939 ),
940 Self::Internal(err) => {
941 write!(f, "Internal: {err}")
942 }
943 Self::Price(err) => {
944 write!(f, "{err}")
945 }
946 Self::StartDateTimeIsAfterEndDateTime => {
947 write!(f, "The `start_date_time` is after the `end_date_time`")
948 }
949 Self::Tariff(warning) => {
950 write!(f, "Warnings: {warning:?}")
951 }
952 }
953 }
954}
955
956impl From<warning::Set<WarningKind>> for Error {
957 fn from(warnings: warning::Set<WarningKind>) -> Self {
958 Self::Tariff(warnings)
959 }
960}
961
962impl From<price::Error> for Error {
963 fn from(err: price::Error) -> Self {
964 Error::Price(err)
965 }
966}
967
968#[derive(Clone)]
970pub struct Config {
971 timezone: chrono_tz::Tz,
973
974 end_date_time: DateTime<Utc>,
976
977 max_current_supply: Ampere,
979
980 max_energy_battery: Kwh,
985
986 max_power_supply: Kw,
995
996 start_date_time: DateTime<Utc>,
998}
999
1000#[cfg(test)]
1001mod test {
1002 use std::str::FromStr as _;
1003
1004 use chrono::{DateTime, NaiveDateTime, Utc};
1005
1006 use super::DateTimeSpan;
1007
1008 #[track_caller]
1009 pub(super) fn date_time_span(
1010 date_start: &str,
1011 time_start: &str,
1012 date_end: &str,
1013 time_end: &str,
1014 ) -> DateTimeSpan {
1015 DateTimeSpan {
1016 start: datetime_utc(date_start, time_start),
1017 end: datetime_utc(date_end, time_end),
1018 }
1019 }
1020
1021 #[track_caller]
1022 pub(super) fn datetime_utc(date: &str, time: &str) -> DateTime<Utc> {
1023 let s = format!("{date} {time}+00:00");
1024 DateTime::<Utc>::from_str(&s).unwrap()
1025 }
1026
1027 #[track_caller]
1028 pub(super) fn datetime_naive(date: &str, time: &str) -> NaiveDateTime {
1029 let s = format!("{date}T{time}");
1030 NaiveDateTime::from_str(&s).unwrap()
1031 }
1032}
1033
1034#[cfg(test)]
1035mod test_local_to_utc {
1036 use super::{
1037 local_to_utc,
1038 test::{datetime_naive, datetime_utc},
1039 };
1040
1041 #[test]
1042 fn should_convert_from_utc_plus_one() {
1043 let date_time_utc = local_to_utc(
1044 chrono_tz::Tz::Europe__Amsterdam,
1045 datetime_naive("2025-12-18", "11:00:00"),
1046 )
1047 .unwrap();
1048
1049 assert_eq!(date_time_utc, datetime_utc("2025-12-18", "10:00:00"));
1050 }
1051
1052 #[test]
1053 fn should_choose_earliest_date_from_dst_end_fold() {
1054 let date_time_utc = local_to_utc(
1056 chrono_tz::Tz::Europe__Amsterdam,
1057 datetime_naive("2025-10-26", "02:59:59"),
1058 )
1059 .unwrap();
1060
1061 assert_eq!(date_time_utc, datetime_utc("2025-10-26", "00:59:59"));
1062 }
1063
1064 #[test]
1065 fn should_return_none_on_dst_begin_gap() {
1066 let date_time_utc = local_to_utc(
1068 chrono_tz::Tz::Europe__Amsterdam,
1069 datetime_naive("2025-03-30", "02:00:00"),
1070 );
1071
1072 assert_eq!(date_time_utc, None);
1073 }
1074}
1075
1076#[cfg(test)]
1077mod test_periods {
1078 use assert_matches::assert_matches;
1079 use chrono::TimeDelta;
1080 use rust_decimal_macros::dec;
1081
1082 use crate::{
1083 assert_approx_eq, country, currency,
1084 duration::ToHoursDecimal as _,
1085 generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1086 json::FromJson as _,
1087 price, tariff, Ampere, Kw, Kwh, Money, Price,
1088 };
1089
1090 use super::test;
1091
1092 const DATE: &str = "2025-11-10";
1093
1094 fn generate_config() -> generate::Config {
1095 generate::Config {
1096 timezone: chrono_tz::Europe::Amsterdam,
1097 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1098 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1099 max_power_supply: Kw::from(24),
1100 max_energy_battery: Kwh::from(80),
1101 max_current_supply: Ampere::from(4),
1102 }
1103 }
1104
1105 #[track_caller]
1106 fn periods(tariff_json: &str) -> Vec<price::Period> {
1107 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1108 let (metrics, _tz) = generate::metrics(generate_config()).unwrap();
1109 let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1110 .unwrap()
1111 .ignore_warnings();
1112 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1113 super::charge_periods(&metrics, timeline)
1114 }
1115
1116 #[test]
1117 fn should_generate_periods() {
1118 const TARIFF_JSON: &str = r#"{
1119 "country_code": "DE",
1120 "party_id": "ALL",
1121 "id": "1",
1122 "currency": "EUR",
1123 "type": "REGULAR",
1124 "elements": [
1125 {
1126 "price_components": [{
1127 "type": "ENERGY",
1128 "price": 0.50,
1129 "vat": 20.0,
1130 "step_size": 1
1131 }]
1132 }
1133 ],
1134 "last_updated": "2018-12-05T12:01:09Z"
1135}
1136"#;
1137
1138 let periods = periods(TARIFF_JSON);
1139 let [period] = periods
1140 .try_into()
1141 .expect("There are no restrictions so there should be one big period");
1142
1143 let price::Period {
1144 start_date_time,
1145 consumed,
1146 } = period;
1147
1148 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1149
1150 let price::Consumed {
1151 duration_charging,
1152 duration_parking,
1153 energy,
1154 current_max,
1155 current_min,
1156 power_max,
1157 power_min,
1158 } = consumed;
1159
1160 assert_eq!(
1161 duration_charging,
1162 Some(TimeDelta::minutes(10)),
1163 "The battery is charged for 10 mins and the plug is pulled"
1164 );
1165 assert_eq!(duration_parking, None, "The battery never fully charges");
1166 assert_approx_eq!(
1167 energy,
1168 Some(Kwh::from(4)),
1169 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1170 );
1171 assert_approx_eq!(
1172 current_max,
1173 None,
1174 "There is no `min_current` or `max_current` restriction defined"
1175 );
1176 assert_approx_eq!(
1177 current_min,
1178 None,
1179 "There is no `min_current` or `max_current` defined"
1180 );
1181 assert_approx_eq!(
1182 power_max,
1183 None,
1184 "There is no `min_power` or `max_power` defined"
1185 );
1186 assert_approx_eq!(
1187 power_min,
1188 None,
1189 "There is no `min_power` or `max_power` defined"
1190 );
1191 }
1192
1193 #[test]
1194 fn should_generate_power() {
1195 const TARIFF_JSON: &str = r#"{
1196 "country_code": "DE",
1197 "party_id": "ALL",
1198 "id": "1",
1199 "currency": "EUR",
1200 "type": "REGULAR",
1201 "elements": [
1202 {
1203 "price_components": [{
1204 "type": "ENERGY",
1205 "price": 0.60,
1206 "vat": 20.0,
1207 "step_size": 1
1208 }],
1209 "restrictions": {
1210 "max_power": 16.00
1211 }
1212 },
1213 {
1214 "price_components": [{
1215 "type": "ENERGY",
1216 "price": 0.70,
1217 "vat": 20.0,
1218 "step_size": 1
1219 }],
1220 "restrictions": {
1221 "max_power": 32.00
1222 }
1223 },
1224 {
1225 "price_components": [{
1226 "type": "ENERGY",
1227 "price": 0.50,
1228 "vat": 20.0,
1229 "step_size": 1
1230 }]
1231 }
1232 ],
1233 "last_updated": "2018-12-05T12:01:09Z"
1234}
1235"#;
1236
1237 let config = generate_config();
1238 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1239 let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1240 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1241 .unwrap()
1242 .ignore_warnings();
1243 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1244 let periods = super::charge_periods(&metrics, timeline);
1245
1246 let [ref period] = periods
1248 .try_into()
1249 .expect("There are no restrictions so there should be one big period");
1250
1251 let price::Period {
1252 start_date_time,
1253 consumed,
1254 } = period;
1255
1256 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1257
1258 let price::Consumed {
1259 duration_charging,
1260 duration_parking,
1261 energy,
1262 current_max,
1263 current_min,
1264 power_max,
1265 power_min,
1266 } = consumed;
1267
1268 assert_eq!(
1269 *duration_charging,
1270 Some(TimeDelta::minutes(10)),
1271 "The battery is charged for 10 mins and the plug is pulled"
1272 );
1273 assert_eq!(*duration_parking, None, "The battery never fully charges");
1274 assert_approx_eq!(
1275 energy,
1276 Some(Kwh::from(4)),
1277 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1278 );
1279 assert_approx_eq!(
1280 current_max,
1281 None,
1282 "There is no `min_current` or `max_current` restriction defined"
1283 );
1284 assert_approx_eq!(
1285 current_min,
1286 None,
1287 "There is no `min_current` or `max_current` defined"
1288 );
1289 assert_approx_eq!(
1290 power_max,
1291 Some(Kw::from(24)),
1292 "There is a `max_power` defined"
1293 );
1294 assert_approx_eq!(
1295 power_min,
1296 Some(Kw::from(24)),
1297 "There is a `max_power` defined"
1298 );
1299 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1300 let (report, warnings) = report.into_parts();
1301 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1302
1303 let PartialCdr {
1304 country_code,
1305 party_id,
1306 start_date_time,
1307 end_date_time,
1308 currency,
1309 total_energy,
1310 total_time,
1311 total_parking_time,
1312 total_cost,
1313 total_energy_cost,
1314 total_fixed_cost,
1315 total_parking_cost,
1316 total_time_cost,
1317 charging_periods,
1318 } = report.partial_cdr;
1319
1320 assert_eq!(country_code, Some(country::Code::De));
1321 assert_eq!(party_id.as_deref(), Some("ALL"));
1322 assert_eq!(currency, currency::Code::Eur);
1323 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1324 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1325
1326 assert_approx_eq!(
1327 total_cost,
1328 Some(Price {
1329 excl_vat: Money::from(2.80),
1330 incl_vat: Some(Money::from(3.36))
1331 }),
1332 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1333 );
1334 assert_eq!(
1335 total_time,
1336 Some(TimeDelta::minutes(10)),
1337 "The charging session is 10 min and is stopped before the battery is fully charged."
1338 );
1339 assert_eq!(
1340 total_parking_time, None,
1341 "There is no parking time since the battery never fully charged."
1342 );
1343 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1344 assert_approx_eq!(
1345 total_energy_cost,
1346 Some(Price {
1347 excl_vat: Money::from(2.80),
1348 incl_vat: Some(Money::from(3.36))
1349 }),
1350 "The cost per KwH is 70 cents and the VAT is 20%."
1351 );
1352 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1353 assert_eq!(
1354 total_parking_cost, None,
1355 "There is no parking cost as there is no parking time."
1356 );
1357 assert_eq!(
1358 total_time_cost, None,
1359 "There are no time costs defined in the tariff."
1360 );
1361
1362 let [period] = charging_periods
1363 .try_into()
1364 .expect("There should be one period.");
1365
1366 let ChargingPeriod {
1367 start_date_time,
1368 dimensions,
1369 tariff_id,
1370 } = period;
1371
1372 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1373 assert_eq!(tariff_id.as_deref(), Some("1"));
1374
1375 let [energy, time] = dimensions
1376 .try_into()
1377 .expect("There should be an energy dimension");
1378
1379 let Dimension {
1380 dimension_type,
1381 volume,
1382 } = energy;
1383
1384 assert_eq!(dimension_type, DimensionType::Energy);
1385 assert_approx_eq!(volume, dec!(4.0));
1386
1387 let Dimension {
1388 dimension_type,
1389 volume,
1390 } = time;
1391
1392 assert_eq!(dimension_type, DimensionType::Time);
1393 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1394 }
1395
1396 #[test]
1397 fn should_generate_current() {
1398 const TARIFF_JSON: &str = r#"{
1399 "country_code": "DE",
1400 "party_id": "ALL",
1401 "id": "1",
1402 "currency": "EUR",
1403 "type": "REGULAR",
1404 "elements": [
1405 {
1406 "price_components": [{
1407 "type": "ENERGY",
1408 "price": 0.60,
1409 "vat": 20.0,
1410 "step_size": 1
1411 }],
1412 "restrictions": {
1413 "max_current": 2
1414 }
1415 },
1416 {
1417 "price_components": [{
1418 "type": "ENERGY",
1419 "price": 0.70,
1420 "vat": 20.0,
1421 "step_size": 1
1422 }],
1423 "restrictions": {
1424 "max_current": 4
1425 }
1426 },
1427 {
1428 "price_components": [{
1429 "type": "ENERGY",
1430 "price": 0.50,
1431 "vat": 20.0,
1432 "step_size": 1
1433 }]
1434 }
1435 ],
1436 "last_updated": "2018-12-05T12:01:09Z"
1437}
1438"#;
1439
1440 let config = generate_config();
1441 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1442 let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1443 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1444 .unwrap()
1445 .ignore_warnings();
1446 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1447 let periods = super::charge_periods(&metrics, timeline);
1448
1449 let [ref period] = periods
1451 .try_into()
1452 .expect("There are no restrictions so there should be one big period");
1453
1454 let price::Period {
1455 start_date_time,
1456 consumed,
1457 } = period;
1458
1459 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1460
1461 let price::Consumed {
1462 duration_charging,
1463 duration_parking,
1464 current_max,
1465 current_min,
1466 energy,
1467 power_max,
1468 power_min,
1469 } = consumed;
1470
1471 assert_eq!(
1472 *duration_charging,
1473 Some(TimeDelta::minutes(10)),
1474 "The battery is charged for 10 mins and the plug is pulled"
1475 );
1476 assert_eq!(*duration_parking, None, "The battery never fully charges");
1477 assert_approx_eq!(
1478 energy,
1479 Some(Kwh::from(4)),
1480 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1481 );
1482 assert_approx_eq!(
1483 current_max,
1484 Some(Ampere::from(4)),
1485 "There is a `max_current` restriction defined"
1486 );
1487 assert_approx_eq!(
1488 current_min,
1489 Some(Ampere::from(4)),
1490 "There is a `max_current` restriction defined"
1491 );
1492 assert_approx_eq!(
1493 power_max,
1494 None,
1495 "There is no `min_power` or `max_power` defined"
1496 );
1497 assert_approx_eq!(
1498 power_min,
1499 None,
1500 "There is no `min_power` or `max_power` defined"
1501 );
1502 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1503 let (report, warnings) = report.into_parts();
1504 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1505
1506 let PartialCdr {
1507 country_code,
1508 party_id,
1509 start_date_time,
1510 end_date_time,
1511 currency,
1512 total_energy,
1513 total_time,
1514 total_parking_time,
1515 total_cost,
1516 total_energy_cost,
1517 total_fixed_cost,
1518 total_parking_cost,
1519 total_time_cost,
1520 charging_periods,
1521 } = report.partial_cdr;
1522
1523 assert_eq!(country_code, Some(country::Code::De));
1524 assert_eq!(party_id.as_deref(), Some("ALL"));
1525 assert_eq!(currency, currency::Code::Eur);
1526 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1527 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1528
1529 assert_approx_eq!(
1530 total_cost,
1531 Some(Price {
1532 excl_vat: Money::from(2.00),
1533 incl_vat: Some(Money::from(2.40))
1534 }),
1535 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1536 );
1537 assert_eq!(
1538 total_time,
1539 Some(TimeDelta::minutes(10)),
1540 "The charging session is 10 min and is stopped before the battery is fully charged."
1541 );
1542 assert_eq!(
1543 total_parking_time, None,
1544 "There is no parking time since the battery never fully charged."
1545 );
1546 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1547 assert_approx_eq!(
1548 total_energy_cost,
1549 Some(Price {
1550 excl_vat: Money::from(2.00),
1551 incl_vat: Some(Money::from(2.40))
1552 }),
1553 "The cost per KwH is 70 cents and the VAT is 20%."
1554 );
1555 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1556 assert_eq!(
1557 total_parking_cost, None,
1558 "There is no parking cost as there is no parking time."
1559 );
1560 assert_eq!(
1561 total_time_cost, None,
1562 "There are no time costs defined in the tariff."
1563 );
1564
1565 let [period] = charging_periods
1566 .try_into()
1567 .expect("There should be one period.");
1568
1569 let ChargingPeriod {
1570 start_date_time,
1571 dimensions,
1572 tariff_id,
1573 } = period;
1574
1575 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1576 assert_eq!(tariff_id.as_deref(), Some("1"));
1577
1578 let [energy, time] = dimensions
1579 .try_into()
1580 .expect("There should be an energy dimension");
1581
1582 let Dimension {
1583 dimension_type,
1584 volume,
1585 } = energy;
1586
1587 assert_eq!(dimension_type, DimensionType::Energy);
1588 assert_approx_eq!(volume, dec!(4.0));
1589
1590 let Dimension {
1591 dimension_type,
1592 volume,
1593 } = time;
1594
1595 assert_eq!(dimension_type, DimensionType::Time);
1596 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1597 }
1598}
1599
1600#[cfg(test)]
1601mod test_generate {
1602 use assert_matches::assert_matches;
1603
1604 use crate::{
1605 generate::{self},
1606 tariff,
1607 };
1608
1609 use super::test;
1610
1611 const DATE: &str = "2025-11-10";
1612
1613 #[test]
1614 fn should_warn_no_elements() {
1615 const TARIFF_JSON: &str = r#"{
1616 "country_code": "DE",
1617 "party_id": "ALL",
1618 "id": "1",
1619 "currency": "EUR",
1620 "type": "REGULAR",
1621 "elements": [],
1622 "last_updated": "2018-12-05T12:01:09Z"
1623}
1624"#;
1625
1626 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1627 let config = generate::Config {
1628 timezone: chrono_tz::Europe::Amsterdam,
1629 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1630 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1631 max_power_supply: 12.into(),
1632 max_energy_battery: 80.into(),
1633 max_current_supply: 2.into(),
1634 };
1635 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1636 let warnings = assert_matches!(err, generate::Error::Tariff(w) => w);
1637 assert_matches!(
1638 warnings.into_kind_vec().as_slice(),
1639 [generate::WarningKind::NoElements]
1640 );
1641 }
1642}
1643
1644#[cfg(test)]
1645mod test_generate_from_single_elem_tariff {
1646 use assert_matches::assert_matches;
1647 use chrono::TimeDelta;
1648
1649 use crate::{
1650 assert_approx_eq,
1651 generate::{self, PartialCdr},
1652 tariff, Kwh, Money, Price,
1653 };
1654
1655 use super::test;
1656
1657 const DATE: &str = "2025-11-10";
1658 const TARIFF_JSON: &str = r#"{
1659 "country_code": "DE",
1660 "party_id": "ALL",
1661 "id": "1",
1662 "currency": "EUR",
1663 "type": "REGULAR",
1664 "elements": [
1665 {
1666 "price_components": [{
1667 "type": "ENERGY",
1668 "price": 0.50,
1669 "vat": 20.0,
1670 "step_size": 1
1671 }]
1672 }
1673 ],
1674 "last_updated": "2018-12-05T12:01:09Z"
1675}
1676"#;
1677
1678 fn generate_config() -> generate::Config {
1679 generate::Config {
1680 timezone: chrono_tz::Europe::Amsterdam,
1681 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1682 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1683 max_power_supply: 12.into(),
1684 max_energy_battery: 80.into(),
1685 max_current_supply: 2.into(),
1686 }
1687 }
1688
1689 #[track_caller]
1690 fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1691 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1692 generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1693 }
1694
1695 #[test]
1696 fn should_warn_duration_below_min() {
1697 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1698 let config = generate::Config {
1699 timezone: chrono_tz::Europe::Amsterdam,
1700 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1701 end_date_time: test::datetime_utc(DATE, "15:03:12"),
1702 max_power_supply: 12.into(),
1703 max_energy_battery: 80.into(),
1704 max_current_supply: 2.into(),
1705 };
1706 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1707 assert_matches!(err, generate::Error::DurationBelowMinimum);
1708 }
1709
1710 #[test]
1711 fn should_warn_end_before_start() {
1712 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1713 let config = generate::Config {
1714 timezone: chrono_tz::Europe::Amsterdam,
1715 start_date_time: test::datetime_utc(DATE, "15:12:12"),
1716 end_date_time: test::datetime_utc(DATE, "15:02:12"),
1717 max_power_supply: 12.into(),
1718 max_energy_battery: 80.into(),
1719 max_current_supply: 2.into(),
1720 };
1721 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1722 assert_matches!(err, generate::Error::StartDateTimeIsAfterEndDateTime);
1723 }
1724
1725 #[test]
1726 fn should_generate_energy_for_ten_minutes() {
1727 let report = generate(TARIFF_JSON);
1728 let (report, warnings) = report.into_parts();
1729 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1730
1731 let PartialCdr {
1732 country_code: _,
1733 party_id: _,
1734 start_date_time: _,
1735 end_date_time: _,
1736 currency: _,
1737 total_energy,
1738 total_time,
1739 total_parking_time,
1740 total_cost,
1741 total_energy_cost,
1742 total_fixed_cost,
1743 total_parking_cost,
1744 total_time_cost,
1745 charging_periods: _,
1746 } = report.partial_cdr;
1747
1748 assert_approx_eq!(
1749 total_cost,
1750 Some(Price {
1751 excl_vat: Money::from(1),
1752 incl_vat: Some(Money::from(1.2))
1753 })
1754 );
1755 assert_eq!(
1756 total_time,
1757 Some(TimeDelta::minutes(10)),
1758 "The charging session is 10 min and is stopped before the battery is fully charged."
1759 );
1760 assert_eq!(
1761 total_parking_time, None,
1762 "There is no parking time since the battery never fully charged."
1763 );
1764 assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1765 assert_approx_eq!(
1766 total_energy_cost,
1767 Some(Price {
1768 excl_vat: Money::from(1),
1769 incl_vat: Some(Money::from(1.2))
1770 }),
1771 "The cost per KwH is 50 cents and the VAT is 20%."
1772 );
1773 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1774 assert_eq!(
1775 total_parking_cost, None,
1776 "There is no parking cost as there is no parking time."
1777 );
1778 assert_eq!(
1779 total_time_cost, None,
1780 "There are no time costs defined in the tariff."
1781 );
1782 }
1783}
1784
1785#[cfg(test)]
1786mod test_clamp_date_time_span {
1787 use super::{clamp_date_time_span, DateTimeSpan};
1788
1789 use super::test::{date_time_span, datetime_utc};
1790
1791 #[test]
1792 fn should_not_clamp_if_start_and_end_are_none() {
1793 let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1794
1795 let out_span = clamp_date_time_span(None, None, in_span.clone());
1796
1797 assert_eq!(in_span, out_span);
1798 }
1799
1800 #[test]
1801 fn should_not_clamp_if_start_and_end_are_contained() {
1802 let start = datetime_utc("2025-11-01", "12:02:00");
1803 let end = datetime_utc("2025-11-10", "14:00:00");
1804 let in_span = DateTimeSpan { start, end };
1805 let min_date = datetime_utc("2025-11-01", "11:00:00");
1806 let max_date = datetime_utc("2025-11-10", "15:00:00");
1807
1808 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1809
1810 assert_eq!(in_span, out_span);
1811 }
1812
1813 #[test]
1814 fn should_clamp_if_span_start_earlier() {
1815 let start = datetime_utc("2025-11-01", "12:02:00");
1816 let end = datetime_utc("2025-11-10", "14:00:00");
1817 let in_span = DateTimeSpan { start, end };
1818 let min_date = datetime_utc("2025-11-02", "00:00:00");
1819 let max_date = datetime_utc("2025-11-10", "23:00:00");
1820
1821 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1822
1823 assert_eq!(out_span.start, min_date);
1824 assert_eq!(out_span.end, end);
1825 }
1826
1827 #[test]
1828 fn should_clamp_if_end_later() {
1829 let start = datetime_utc("2025-11-01", "12:02:00");
1830 let end = datetime_utc("2025-11-10", "14:00:00");
1831 let in_span = DateTimeSpan { start, end };
1832 let min_date = datetime_utc("2025-11-01", "00:00:00");
1833 let max_date = datetime_utc("2025-11-09", "23:00:00");
1834
1835 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1836
1837 assert_eq!(out_span.start, start);
1838 assert_eq!(out_span.end, max_date);
1839 }
1840}
1841
1842#[cfg(test)]
1843mod test_gen_time_events {
1844 use assert_matches::assert_matches;
1845 use chrono::TimeDelta;
1846
1847 use super::{generate_time_events, v2x::TimeRestrictions};
1848
1849 use super::test::date_time_span;
1850
1851 #[test]
1852 fn should_emit_no_events_before_start_time() {
1853 let events = generate_time_events(
1855 chrono_tz::Tz::Europe__Amsterdam,
1856 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1857 TimeRestrictions {
1858 start_time: Some("15:00".parse().unwrap()),
1859 ..TimeRestrictions::default()
1860 },
1861 );
1862
1863 assert_matches!(events.as_slice(), []);
1864 }
1865
1866 #[test]
1867 fn should_emit_no_events_finishes_at_start_time_pricisely() {
1868 let events = generate_time_events(
1870 chrono_tz::Tz::Europe__Amsterdam,
1871 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1872 TimeRestrictions {
1873 start_time: Some("15:00".parse().unwrap()),
1874 ..TimeRestrictions::default()
1875 },
1876 );
1877
1878 assert_matches!(events.as_slice(), []);
1879 }
1880
1881 #[test]
1882 fn should_emit_one_event_precise_overlap_with_start_time() {
1883 let events = generate_time_events(
1885 chrono_tz::Tz::Europe__Amsterdam,
1886 date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1887 TimeRestrictions {
1888 start_time: Some("15:00".parse().unwrap()),
1889 ..TimeRestrictions::default()
1890 },
1891 );
1892
1893 let [event] = events.try_into().unwrap();
1894 assert_eq!(event.duration_from_start, TimeDelta::zero());
1895 }
1896
1897 #[test]
1898 fn should_emit_one_event_hour_before_start_time() {
1899 let events = generate_time_events(
1901 chrono_tz::Tz::Europe__Amsterdam,
1902 date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1903 TimeRestrictions {
1904 start_time: Some("15:00".parse().unwrap()),
1905 ..TimeRestrictions::default()
1906 },
1907 );
1908
1909 let [event] = events.try_into().unwrap();
1910 assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1911 }
1912
1913 #[test]
1914 fn should_emit_one_event_almost_full_day() {
1915 let events = generate_time_events(
1918 chrono_tz::Tz::Europe__Amsterdam,
1919 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1920 TimeRestrictions {
1921 start_time: Some("15:00".parse().unwrap()),
1922 ..TimeRestrictions::default()
1923 },
1924 );
1925
1926 let [event] = events.try_into().unwrap();
1927 assert_eq!(event.duration_from_start, TimeDelta::zero());
1928 }
1929
1930 #[test]
1931 fn should_emit_two_events_full_day_precisely() {
1932 let events = generate_time_events(
1933 chrono_tz::Tz::Europe__Amsterdam,
1934 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1935 TimeRestrictions {
1936 start_time: Some("15:00".parse().unwrap()),
1937 ..TimeRestrictions::default()
1938 },
1939 );
1940
1941 let [event_0, event_1] = events.try_into().unwrap();
1942 assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1943 assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1944 }
1945
1946 #[test]
1947 fn should_emit_two_events_full_day_with_hour_margin() {
1948 let events = generate_time_events(
1949 chrono_tz::Tz::Europe__Amsterdam,
1950 date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1951 TimeRestrictions {
1952 start_time: Some("15:00".parse().unwrap()),
1953 ..TimeRestrictions::default()
1954 },
1955 );
1956
1957 let [event_0, event_1] = events.try_into().unwrap();
1958 assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1959 assert_eq!(
1960 event_1.duration_from_start,
1961 TimeDelta::days(1) + TimeDelta::hours(1)
1962 );
1963 }
1964}