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_kw: max_power_supply,
829 max_energy_battery_kwh: max_energy_battery,
830 max_current_supply_amp: 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: Ampere::from_decimal(max_current_supply),
884 max_power_supply: Kw::from_decimal(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 pub timezone: chrono_tz::Tz,
973
974 pub end_date_time: DateTime<Utc>,
976
977 pub max_current_supply_amp: Decimal,
979
980 pub max_energy_battery_kwh: Decimal,
985
986 pub max_power_supply_kw: Decimal,
995
996 pub 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::Decimal;
1081 use rust_decimal_macros::dec;
1082
1083 use crate::{
1084 assert_approx_eq, country, currency,
1085 duration::ToHoursDecimal as _,
1086 generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1087 json::FromJson as _,
1088 price, tariff, Ampere, Kw, Kwh, Money, Price,
1089 };
1090
1091 use super::test;
1092
1093 const DATE: &str = "2025-11-10";
1094
1095 fn generate_config() -> generate::Config {
1096 generate::Config {
1097 timezone: chrono_tz::Europe::Amsterdam,
1098 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1099 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1100 max_power_supply_kw: Decimal::from(24),
1101 max_energy_battery_kwh: Decimal::from(80),
1102 max_current_supply_amp: Decimal::from(4),
1103 }
1104 }
1105
1106 #[track_caller]
1107 fn periods(tariff_json: &str) -> Vec<price::Period> {
1108 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1109 let (metrics, _tz) = generate::metrics(generate_config()).unwrap();
1110 let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1111 .unwrap()
1112 .ignore_warnings();
1113 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1114 super::charge_periods(&metrics, timeline)
1115 }
1116
1117 #[test]
1118 fn should_generate_periods() {
1119 const TARIFF_JSON: &str = r#"{
1120 "country_code": "DE",
1121 "party_id": "ALL",
1122 "id": "1",
1123 "currency": "EUR",
1124 "type": "REGULAR",
1125 "elements": [
1126 {
1127 "price_components": [{
1128 "type": "ENERGY",
1129 "price": 0.50,
1130 "vat": 20.0,
1131 "step_size": 1
1132 }]
1133 }
1134 ],
1135 "last_updated": "2018-12-05T12:01:09Z"
1136}
1137"#;
1138
1139 let periods = periods(TARIFF_JSON);
1140 let [period] = periods
1141 .try_into()
1142 .expect("There are no restrictions so there should be one big period");
1143
1144 let price::Period {
1145 start_date_time,
1146 consumed,
1147 } = period;
1148
1149 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1150
1151 let price::Consumed {
1152 duration_charging,
1153 duration_parking,
1154 energy,
1155 current_max,
1156 current_min,
1157 power_max,
1158 power_min,
1159 } = consumed;
1160
1161 assert_eq!(
1162 duration_charging,
1163 Some(TimeDelta::minutes(10)),
1164 "The battery is charged for 10 mins and the plug is pulled"
1165 );
1166 assert_eq!(duration_parking, None, "The battery never fully charges");
1167 assert_approx_eq!(
1168 energy,
1169 Some(Kwh::from(4)),
1170 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1171 );
1172 assert_approx_eq!(
1173 current_max,
1174 None,
1175 "There is no `min_current` or `max_current` restriction defined"
1176 );
1177 assert_approx_eq!(
1178 current_min,
1179 None,
1180 "There is no `min_current` or `max_current` defined"
1181 );
1182 assert_approx_eq!(
1183 power_max,
1184 None,
1185 "There is no `min_power` or `max_power` defined"
1186 );
1187 assert_approx_eq!(
1188 power_min,
1189 None,
1190 "There is no `min_power` or `max_power` defined"
1191 );
1192 }
1193
1194 #[test]
1195 fn should_generate_power() {
1196 const TARIFF_JSON: &str = r#"{
1197 "country_code": "DE",
1198 "party_id": "ALL",
1199 "id": "1",
1200 "currency": "EUR",
1201 "type": "REGULAR",
1202 "elements": [
1203 {
1204 "price_components": [{
1205 "type": "ENERGY",
1206 "price": 0.60,
1207 "vat": 20.0,
1208 "step_size": 1
1209 }],
1210 "restrictions": {
1211 "max_power": 16.00
1212 }
1213 },
1214 {
1215 "price_components": [{
1216 "type": "ENERGY",
1217 "price": 0.70,
1218 "vat": 20.0,
1219 "step_size": 1
1220 }],
1221 "restrictions": {
1222 "max_power": 32.00
1223 }
1224 },
1225 {
1226 "price_components": [{
1227 "type": "ENERGY",
1228 "price": 0.50,
1229 "vat": 20.0,
1230 "step_size": 1
1231 }]
1232 }
1233 ],
1234 "last_updated": "2018-12-05T12:01:09Z"
1235}
1236"#;
1237
1238 let config = generate_config();
1239 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1240 let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1241 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1242 .unwrap()
1243 .ignore_warnings();
1244 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1245 let periods = super::charge_periods(&metrics, timeline);
1246
1247 let [ref period] = periods
1249 .try_into()
1250 .expect("There are no restrictions so there should be one big period");
1251
1252 let price::Period {
1253 start_date_time,
1254 consumed,
1255 } = period;
1256
1257 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1258
1259 let price::Consumed {
1260 duration_charging,
1261 duration_parking,
1262 energy,
1263 current_max,
1264 current_min,
1265 power_max,
1266 power_min,
1267 } = consumed;
1268
1269 assert_eq!(
1270 *duration_charging,
1271 Some(TimeDelta::minutes(10)),
1272 "The battery is charged for 10 mins and the plug is pulled"
1273 );
1274 assert_eq!(*duration_parking, None, "The battery never fully charges");
1275 assert_approx_eq!(
1276 energy,
1277 Some(Kwh::from(4)),
1278 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1279 );
1280 assert_approx_eq!(
1281 current_max,
1282 None,
1283 "There is no `min_current` or `max_current` restriction defined"
1284 );
1285 assert_approx_eq!(
1286 current_min,
1287 None,
1288 "There is no `min_current` or `max_current` defined"
1289 );
1290 assert_approx_eq!(
1291 power_max,
1292 Some(Kw::from(24)),
1293 "There is a `max_power` defined"
1294 );
1295 assert_approx_eq!(
1296 power_min,
1297 Some(Kw::from(24)),
1298 "There is a `max_power` defined"
1299 );
1300 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1301 let (report, warnings) = report.into_parts();
1302 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1303
1304 let PartialCdr {
1305 country_code,
1306 party_id,
1307 start_date_time,
1308 end_date_time,
1309 currency,
1310 total_energy,
1311 total_time,
1312 total_parking_time,
1313 total_cost,
1314 total_energy_cost,
1315 total_fixed_cost,
1316 total_parking_cost,
1317 total_time_cost,
1318 charging_periods,
1319 } = report.partial_cdr;
1320
1321 assert_eq!(country_code, Some(country::Code::De));
1322 assert_eq!(party_id.as_deref(), Some("ALL"));
1323 assert_eq!(currency, currency::Code::Eur);
1324 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1325 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1326
1327 assert_approx_eq!(
1328 total_cost,
1329 Some(Price {
1330 excl_vat: Money::from(2.80),
1331 incl_vat: Some(Money::from(3.36))
1332 }),
1333 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1334 );
1335 assert_eq!(
1336 total_time,
1337 Some(TimeDelta::minutes(10)),
1338 "The charging session is 10 min and is stopped before the battery is fully charged."
1339 );
1340 assert_eq!(
1341 total_parking_time, None,
1342 "There is no parking time since the battery never fully charged."
1343 );
1344 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1345 assert_approx_eq!(
1346 total_energy_cost,
1347 Some(Price {
1348 excl_vat: Money::from(2.80),
1349 incl_vat: Some(Money::from(3.36))
1350 }),
1351 "The cost per KwH is 70 cents and the VAT is 20%."
1352 );
1353 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1354 assert_eq!(
1355 total_parking_cost, None,
1356 "There is no parking cost as there is no parking time."
1357 );
1358 assert_eq!(
1359 total_time_cost, None,
1360 "There are no time costs defined in the tariff."
1361 );
1362
1363 let [period] = charging_periods
1364 .try_into()
1365 .expect("There should be one period.");
1366
1367 let ChargingPeriod {
1368 start_date_time,
1369 dimensions,
1370 tariff_id,
1371 } = period;
1372
1373 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1374 assert_eq!(tariff_id.as_deref(), Some("1"));
1375
1376 let [energy, time] = dimensions
1377 .try_into()
1378 .expect("There should be an energy dimension");
1379
1380 let Dimension {
1381 dimension_type,
1382 volume,
1383 } = energy;
1384
1385 assert_eq!(dimension_type, DimensionType::Energy);
1386 assert_approx_eq!(volume, dec!(4.0));
1387
1388 let Dimension {
1389 dimension_type,
1390 volume,
1391 } = time;
1392
1393 assert_eq!(dimension_type, DimensionType::Time);
1394 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1395 }
1396
1397 #[test]
1398 fn should_generate_current() {
1399 const TARIFF_JSON: &str = r#"{
1400 "country_code": "DE",
1401 "party_id": "ALL",
1402 "id": "1",
1403 "currency": "EUR",
1404 "type": "REGULAR",
1405 "elements": [
1406 {
1407 "price_components": [{
1408 "type": "ENERGY",
1409 "price": 0.60,
1410 "vat": 20.0,
1411 "step_size": 1
1412 }],
1413 "restrictions": {
1414 "max_current": 2
1415 }
1416 },
1417 {
1418 "price_components": [{
1419 "type": "ENERGY",
1420 "price": 0.70,
1421 "vat": 20.0,
1422 "step_size": 1
1423 }],
1424 "restrictions": {
1425 "max_current": 4
1426 }
1427 },
1428 {
1429 "price_components": [{
1430 "type": "ENERGY",
1431 "price": 0.50,
1432 "vat": 20.0,
1433 "step_size": 1
1434 }]
1435 }
1436 ],
1437 "last_updated": "2018-12-05T12:01:09Z"
1438}
1439"#;
1440
1441 let config = generate_config();
1442 let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1443 let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1444 let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1445 .unwrap()
1446 .ignore_warnings();
1447 let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1448 let periods = super::charge_periods(&metrics, timeline);
1449
1450 let [ref period] = periods
1452 .try_into()
1453 .expect("There are no restrictions so there should be one big period");
1454
1455 let price::Period {
1456 start_date_time,
1457 consumed,
1458 } = period;
1459
1460 assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1461
1462 let price::Consumed {
1463 duration_charging,
1464 duration_parking,
1465 current_max,
1466 current_min,
1467 energy,
1468 power_max,
1469 power_min,
1470 } = consumed;
1471
1472 assert_eq!(
1473 *duration_charging,
1474 Some(TimeDelta::minutes(10)),
1475 "The battery is charged for 10 mins and the plug is pulled"
1476 );
1477 assert_eq!(*duration_parking, None, "The battery never fully charges");
1478 assert_approx_eq!(
1479 energy,
1480 Some(Kwh::from(4)),
1481 "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1482 );
1483 assert_approx_eq!(
1484 current_max,
1485 Some(Ampere::from(4)),
1486 "There is a `max_current` restriction defined"
1487 );
1488 assert_approx_eq!(
1489 current_min,
1490 Some(Ampere::from(4)),
1491 "There is a `max_current` restriction defined"
1492 );
1493 assert_approx_eq!(
1494 power_max,
1495 None,
1496 "There is no `min_power` or `max_power` defined"
1497 );
1498 assert_approx_eq!(
1499 power_min,
1500 None,
1501 "There is no `min_power` or `max_power` defined"
1502 );
1503 let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1504 let (report, warnings) = report.into_parts();
1505 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1506
1507 let PartialCdr {
1508 country_code,
1509 party_id,
1510 start_date_time,
1511 end_date_time,
1512 currency,
1513 total_energy,
1514 total_time,
1515 total_parking_time,
1516 total_cost,
1517 total_energy_cost,
1518 total_fixed_cost,
1519 total_parking_cost,
1520 total_time_cost,
1521 charging_periods,
1522 } = report.partial_cdr;
1523
1524 assert_eq!(country_code, Some(country::Code::De));
1525 assert_eq!(party_id.as_deref(), Some("ALL"));
1526 assert_eq!(currency, currency::Code::Eur);
1527 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1528 assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1529
1530 assert_approx_eq!(
1531 total_cost,
1532 Some(Price {
1533 excl_vat: Money::from(2.00),
1534 incl_vat: Some(Money::from(2.40))
1535 }),
1536 "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1537 );
1538 assert_eq!(
1539 total_time,
1540 Some(TimeDelta::minutes(10)),
1541 "The charging session is 10 min and is stopped before the battery is fully charged."
1542 );
1543 assert_eq!(
1544 total_parking_time, None,
1545 "There is no parking time since the battery never fully charged."
1546 );
1547 assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1548 assert_approx_eq!(
1549 total_energy_cost,
1550 Some(Price {
1551 excl_vat: Money::from(2.00),
1552 incl_vat: Some(Money::from(2.40))
1553 }),
1554 "The cost per KwH is 70 cents and the VAT is 20%."
1555 );
1556 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1557 assert_eq!(
1558 total_parking_cost, None,
1559 "There is no parking cost as there is no parking time."
1560 );
1561 assert_eq!(
1562 total_time_cost, None,
1563 "There are no time costs defined in the tariff."
1564 );
1565
1566 let [period] = charging_periods
1567 .try_into()
1568 .expect("There should be one period.");
1569
1570 let ChargingPeriod {
1571 start_date_time,
1572 dimensions,
1573 tariff_id,
1574 } = period;
1575
1576 assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1577 assert_eq!(tariff_id.as_deref(), Some("1"));
1578
1579 let [energy, time] = dimensions
1580 .try_into()
1581 .expect("There should be an energy dimension");
1582
1583 let Dimension {
1584 dimension_type,
1585 volume,
1586 } = energy;
1587
1588 assert_eq!(dimension_type, DimensionType::Energy);
1589 assert_approx_eq!(volume, dec!(4.0));
1590
1591 let Dimension {
1592 dimension_type,
1593 volume,
1594 } = time;
1595
1596 assert_eq!(dimension_type, DimensionType::Time);
1597 assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1598 }
1599}
1600
1601#[cfg(test)]
1602mod test_generate {
1603 use assert_matches::assert_matches;
1604
1605 use crate::{
1606 generate::{self},
1607 tariff,
1608 };
1609
1610 use super::test;
1611
1612 const DATE: &str = "2025-11-10";
1613
1614 #[test]
1615 fn should_warn_no_elements() {
1616 const TARIFF_JSON: &str = r#"{
1617 "country_code": "DE",
1618 "party_id": "ALL",
1619 "id": "1",
1620 "currency": "EUR",
1621 "type": "REGULAR",
1622 "elements": [],
1623 "last_updated": "2018-12-05T12:01:09Z"
1624}
1625"#;
1626
1627 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1628 let config = generate::Config {
1629 timezone: chrono_tz::Europe::Amsterdam,
1630 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1631 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1632 max_power_supply_kw: 12.into(),
1633 max_energy_battery_kwh: 80.into(),
1634 max_current_supply_amp: 2.into(),
1635 };
1636 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1637 let warnings = assert_matches!(err, generate::Error::Tariff(w) => w);
1638 assert_matches!(
1639 warnings.into_kind_vec().as_slice(),
1640 [generate::WarningKind::NoElements]
1641 );
1642 }
1643}
1644
1645#[cfg(test)]
1646mod test_generate_from_single_elem_tariff {
1647 use assert_matches::assert_matches;
1648 use chrono::TimeDelta;
1649
1650 use crate::{
1651 assert_approx_eq,
1652 generate::{self, PartialCdr},
1653 tariff, Kwh, Money, Price,
1654 };
1655
1656 use super::test;
1657
1658 const DATE: &str = "2025-11-10";
1659 const TARIFF_JSON: &str = r#"{
1660 "country_code": "DE",
1661 "party_id": "ALL",
1662 "id": "1",
1663 "currency": "EUR",
1664 "type": "REGULAR",
1665 "elements": [
1666 {
1667 "price_components": [{
1668 "type": "ENERGY",
1669 "price": 0.50,
1670 "vat": 20.0,
1671 "step_size": 1
1672 }]
1673 }
1674 ],
1675 "last_updated": "2018-12-05T12:01:09Z"
1676}
1677"#;
1678
1679 fn generate_config() -> generate::Config {
1680 generate::Config {
1681 timezone: chrono_tz::Europe::Amsterdam,
1682 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1683 end_date_time: test::datetime_utc(DATE, "15:12:12"),
1684 max_power_supply_kw: 12.into(),
1685 max_energy_battery_kwh: 80.into(),
1686 max_current_supply_amp: 2.into(),
1687 }
1688 }
1689
1690 #[track_caller]
1691 fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1692 let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1693 generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1694 }
1695
1696 #[test]
1697 fn should_warn_duration_below_min() {
1698 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1699 let config = generate::Config {
1700 timezone: chrono_tz::Europe::Amsterdam,
1701 start_date_time: test::datetime_utc(DATE, "15:02:12"),
1702 end_date_time: test::datetime_utc(DATE, "15:03:12"),
1703 max_power_supply_kw: 12.into(),
1704 max_energy_battery_kwh: 80.into(),
1705 max_current_supply_amp: 2.into(),
1706 };
1707 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1708 assert_matches!(err, generate::Error::DurationBelowMinimum);
1709 }
1710
1711 #[test]
1712 fn should_warn_end_before_start() {
1713 let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1714 let config = generate::Config {
1715 timezone: chrono_tz::Europe::Amsterdam,
1716 start_date_time: test::datetime_utc(DATE, "15:12:12"),
1717 end_date_time: test::datetime_utc(DATE, "15:02:12"),
1718 max_power_supply_kw: 12.into(),
1719 max_energy_battery_kwh: 80.into(),
1720 max_current_supply_amp: 2.into(),
1721 };
1722 let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1723 assert_matches!(err, generate::Error::StartDateTimeIsAfterEndDateTime);
1724 }
1725
1726 #[test]
1727 fn should_generate_energy_for_ten_minutes() {
1728 let report = generate(TARIFF_JSON);
1729 let (report, warnings) = report.into_parts();
1730 assert_matches!(warnings.into_kind_vec().as_slice(), []);
1731
1732 let PartialCdr {
1733 country_code: _,
1734 party_id: _,
1735 start_date_time: _,
1736 end_date_time: _,
1737 currency: _,
1738 total_energy,
1739 total_time,
1740 total_parking_time,
1741 total_cost,
1742 total_energy_cost,
1743 total_fixed_cost,
1744 total_parking_cost,
1745 total_time_cost,
1746 charging_periods: _,
1747 } = report.partial_cdr;
1748
1749 assert_approx_eq!(
1750 total_cost,
1751 Some(Price {
1752 excl_vat: Money::from(1),
1753 incl_vat: Some(Money::from(1.2))
1754 })
1755 );
1756 assert_eq!(
1757 total_time,
1758 Some(TimeDelta::minutes(10)),
1759 "The charging session is 10 min and is stopped before the battery is fully charged."
1760 );
1761 assert_eq!(
1762 total_parking_time, None,
1763 "There is no parking time since the battery never fully charged."
1764 );
1765 assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1766 assert_approx_eq!(
1767 total_energy_cost,
1768 Some(Price {
1769 excl_vat: Money::from(1),
1770 incl_vat: Some(Money::from(1.2))
1771 }),
1772 "The cost per KwH is 50 cents and the VAT is 20%."
1773 );
1774 assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1775 assert_eq!(
1776 total_parking_cost, None,
1777 "There is no parking cost as there is no parking time."
1778 );
1779 assert_eq!(
1780 total_time_cost, None,
1781 "There are no time costs defined in the tariff."
1782 );
1783 }
1784}
1785
1786#[cfg(test)]
1787mod test_clamp_date_time_span {
1788 use super::{clamp_date_time_span, DateTimeSpan};
1789
1790 use super::test::{date_time_span, datetime_utc};
1791
1792 #[test]
1793 fn should_not_clamp_if_start_and_end_are_none() {
1794 let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1795
1796 let out_span = clamp_date_time_span(None, None, in_span.clone());
1797
1798 assert_eq!(in_span, out_span);
1799 }
1800
1801 #[test]
1802 fn should_not_clamp_if_start_and_end_are_contained() {
1803 let start = datetime_utc("2025-11-01", "12:02:00");
1804 let end = datetime_utc("2025-11-10", "14:00:00");
1805 let in_span = DateTimeSpan { start, end };
1806 let min_date = datetime_utc("2025-11-01", "11:00:00");
1807 let max_date = datetime_utc("2025-11-10", "15:00:00");
1808
1809 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1810
1811 assert_eq!(in_span, out_span);
1812 }
1813
1814 #[test]
1815 fn should_clamp_if_span_start_earlier() {
1816 let start = datetime_utc("2025-11-01", "12:02:00");
1817 let end = datetime_utc("2025-11-10", "14:00:00");
1818 let in_span = DateTimeSpan { start, end };
1819 let min_date = datetime_utc("2025-11-02", "00:00:00");
1820 let max_date = datetime_utc("2025-11-10", "23:00:00");
1821
1822 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1823
1824 assert_eq!(out_span.start, min_date);
1825 assert_eq!(out_span.end, end);
1826 }
1827
1828 #[test]
1829 fn should_clamp_if_end_later() {
1830 let start = datetime_utc("2025-11-01", "12:02:00");
1831 let end = datetime_utc("2025-11-10", "14:00:00");
1832 let in_span = DateTimeSpan { start, end };
1833 let min_date = datetime_utc("2025-11-01", "00:00:00");
1834 let max_date = datetime_utc("2025-11-09", "23:00:00");
1835
1836 let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1837
1838 assert_eq!(out_span.start, start);
1839 assert_eq!(out_span.end, max_date);
1840 }
1841}
1842
1843#[cfg(test)]
1844mod test_gen_time_events {
1845 use assert_matches::assert_matches;
1846 use chrono::TimeDelta;
1847
1848 use super::{generate_time_events, v2x::TimeRestrictions};
1849
1850 use super::test::date_time_span;
1851
1852 #[test]
1853 fn should_emit_no_events_before_start_time() {
1854 let events = generate_time_events(
1856 chrono_tz::Tz::Europe__Amsterdam,
1857 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1858 TimeRestrictions {
1859 start_time: Some("15:00".parse().unwrap()),
1860 ..TimeRestrictions::default()
1861 },
1862 );
1863
1864 assert_matches!(events.as_slice(), []);
1865 }
1866
1867 #[test]
1868 fn should_emit_no_events_finishes_at_start_time_pricisely() {
1869 let events = generate_time_events(
1871 chrono_tz::Tz::Europe__Amsterdam,
1872 date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1873 TimeRestrictions {
1874 start_time: Some("15:00".parse().unwrap()),
1875 ..TimeRestrictions::default()
1876 },
1877 );
1878
1879 assert_matches!(events.as_slice(), []);
1880 }
1881
1882 #[test]
1883 fn should_emit_one_event_precise_overlap_with_start_time() {
1884 let events = generate_time_events(
1886 chrono_tz::Tz::Europe__Amsterdam,
1887 date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1888 TimeRestrictions {
1889 start_time: Some("15:00".parse().unwrap()),
1890 ..TimeRestrictions::default()
1891 },
1892 );
1893
1894 let [event] = events.try_into().unwrap();
1895 assert_eq!(event.duration_from_start, TimeDelta::zero());
1896 }
1897
1898 #[test]
1899 fn should_emit_one_event_hour_before_start_time() {
1900 let events = generate_time_events(
1902 chrono_tz::Tz::Europe__Amsterdam,
1903 date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1904 TimeRestrictions {
1905 start_time: Some("15:00".parse().unwrap()),
1906 ..TimeRestrictions::default()
1907 },
1908 );
1909
1910 let [event] = events.try_into().unwrap();
1911 assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1912 }
1913
1914 #[test]
1915 fn should_emit_one_event_almost_full_day() {
1916 let events = generate_time_events(
1919 chrono_tz::Tz::Europe__Amsterdam,
1920 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1921 TimeRestrictions {
1922 start_time: Some("15:00".parse().unwrap()),
1923 ..TimeRestrictions::default()
1924 },
1925 );
1926
1927 let [event] = events.try_into().unwrap();
1928 assert_eq!(event.duration_from_start, TimeDelta::zero());
1929 }
1930
1931 #[test]
1932 fn should_emit_two_events_full_day_precisely() {
1933 let events = generate_time_events(
1934 chrono_tz::Tz::Europe__Amsterdam,
1935 date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1936 TimeRestrictions {
1937 start_time: Some("15:00".parse().unwrap()),
1938 ..TimeRestrictions::default()
1939 },
1940 );
1941
1942 let [event_0, event_1] = events.try_into().unwrap();
1943 assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1944 assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1945 }
1946
1947 #[test]
1948 fn should_emit_two_events_full_day_with_hour_margin() {
1949 let events = generate_time_events(
1950 chrono_tz::Tz::Europe__Amsterdam,
1951 date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1952 TimeRestrictions {
1953 start_time: Some("15:00".parse().unwrap()),
1954 ..TimeRestrictions::default()
1955 },
1956 );
1957
1958 let [event_0, event_1] = events.try_into().unwrap();
1959 assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1960 assert_eq!(
1961 event_1.duration_from_start,
1962 TimeDelta::days(1) + TimeDelta::hours(1)
1963 );
1964 }
1965}