1use std::slice::Iter;
2
3use chrono::{DateTime, Datelike};
4use serde::Serialize;
5
6use crate::{
7 Country, Language, LoadType, Money, Timezone, helpers,
8 hours::Hours,
9 months::{Month, Months},
10};
11
12#[derive(Debug, Clone, Copy, Serialize)]
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15pub enum Cost {
16 None,
17 Unverified,
19 Fixed(Money),
20 Fuses(&'static [(u16, Money)]),
21 FusesYearlyConsumption(&'static [(u16, Option<u32>, Money)]),
23 FuseRange(&'static [(u16, u16, Money)]),
24}
25
26impl Cost {
27 pub const fn fuses(values: &'static [(u16, Money)]) -> Self {
28 Self::Fuses(values)
29 }
30
31 pub const fn fuse_range(ranges: &'static [(u16, u16, Money)]) -> Self {
32 Self::FuseRange(ranges)
33 }
34
35 pub const fn fuses_with_yearly_consumption(
36 values: &'static [(u16, Option<u32>, Money)],
37 ) -> Self {
38 Self::FusesYearlyConsumption(values)
39 }
40
41 pub const fn fixed(int: i64, fract: u8) -> Self {
42 Self::Fixed(Money::new(int, fract))
43 }
44
45 pub const fn fixed_yearly(int: i64, fract: u8) -> Self {
46 Self::Fixed(Money::new(int, fract).divide_by(12))
47 }
48
49 pub const fn fixed_subunit(subunit: f64) -> Self {
50 Self::Fixed(Money::new_subunit(subunit))
51 }
52
53 pub const fn is_unverified(&self) -> bool {
54 matches!(self, Self::Unverified)
55 }
56
57 pub const fn divide_by(&self, by: i64) -> Self {
58 match self {
59 Self::None => Self::None,
60 Self::Unverified => Self::Unverified,
61 Self::Fixed(money) => Self::Fixed(money.divide_by(by)),
62 Self::Fuses(_) => panic!(".divide_by() is unsupported on Cost::Fuses"),
63 Self::FusesYearlyConsumption(_) => {
64 panic!(".divide_by() is unsupported on Cost::FuseRangeYearlyConsumption")
65 }
66 Self::FuseRange(_) => panic!(".divide_by() is unsupported on Cost::FuseRange"),
67 }
68 }
69
70 pub const fn cost_for(&self, fuse_size: u16, yearly_consumption: u32) -> Option<Money> {
71 match *self {
72 Cost::None => None,
73 Cost::Unverified => None,
74 Cost::Fixed(money) => Some(money),
75 Cost::Fuses(values) => {
76 let mut i = 0;
77 while i < values.len() {
78 let (fsize, money) = values[i];
79 if fuse_size == fsize {
80 return Some(money);
81 }
82 i += 1;
83 }
84 None
85 }
86 Cost::FusesYearlyConsumption(values) => {
87 let mut i = 0;
88 while i < values.len() {
89 let (fsize, max_consumption, money) = values[i];
90 if fsize == fuse_size {
91 if let Some(max_consumption) = max_consumption {
92 if yearly_consumption <= max_consumption {
93 return Some(money);
94 }
95 } else {
96 return Some(money);
97 }
98 }
99 i += 1;
100 }
101 None
102 }
103 Cost::FuseRange(ranges) => {
104 let mut i = 0;
105 while i < ranges.len() {
106 let (min, max, money) = ranges[i];
107 if fuse_size >= min && fuse_size <= max {
108 return Some(money);
109 }
110 i += 1;
111 }
112 None
113 }
114 }
115 }
116
117 pub const fn add_vat(&self, country: Country) -> Cost {
118 match self {
119 Cost::None => Cost::None,
120 Cost::Unverified => Cost::Unverified,
121 Cost::Fixed(money) => Cost::Fixed(money.add_vat(country)),
122 Cost::Fuses(_) => todo!(),
123 Cost::FusesYearlyConsumption(_) => todo!(),
124 Cost::FuseRange(_) => todo!(),
125 }
126 }
127
128 pub fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
129 match self {
130 Cost::FusesYearlyConsumption(items) => items
131 .iter()
132 .filter(|(fsize, _, _)| *fsize == fuse_size)
133 .any(|(_, yearly_consumption, _)| yearly_consumption.is_some()),
134 _ => false,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
142pub enum CostPeriodMatching {
143 First,
145 All,
147}
148
149#[derive(Debug, Clone, Copy, Serialize)]
150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
151pub struct CostPeriods {
152 match_method: CostPeriodMatching,
153 periods: &'static [CostPeriod],
154}
155
156impl CostPeriods {
157 pub const fn new_first(periods: &'static [CostPeriod]) -> Self {
158 Self {
159 match_method: CostPeriodMatching::First,
160 periods,
161 }
162 }
163
164 pub const fn new_all(periods: &'static [CostPeriod]) -> Self {
165 Self {
166 match_method: CostPeriodMatching::All,
167 periods,
168 }
169 }
170
171 pub const fn match_method(&self) -> CostPeriodMatching {
172 self.match_method
173 }
174
175 pub fn iter(&self) -> Iter<'_, CostPeriod> {
176 self.periods.iter()
177 }
178
179 pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
180 self.periods
181 .iter()
182 .any(|cp| cp.is_yearly_consumption_based(fuse_size))
183 }
184
185 pub fn matching_periods<Tz: chrono::TimeZone>(
186 &self,
187 timestamp: DateTime<Tz>,
188 ) -> Vec<&CostPeriod>
189 where
190 DateTime<Tz>: Copy,
191 {
192 let mut ret = vec![];
193 for period in self.periods {
194 if period.matches(timestamp) {
195 ret.push(period);
196 if self.match_method == CostPeriodMatching::First {
197 break;
198 }
199 }
200 }
201 ret
202 }
203}
204
205#[derive(Debug, Clone, Serialize)]
207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
208pub struct CostPeriodsSimple {
209 periods: Vec<CostPeriodSimple>,
210}
211
212impl CostPeriodsSimple {
213 pub(crate) fn new(
214 periods: CostPeriods,
215 fuse_size: u16,
216 yearly_consumption: u32,
217 language: Language,
218 ) -> Self {
219 Self {
220 periods: periods
221 .periods
222 .iter()
223 .flat_map(|period| {
224 CostPeriodSimple::new(period, fuse_size, yearly_consumption, language)
225 })
226 .collect(),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize)]
232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
233pub struct CostPeriod {
234 cost: Cost,
235 load: LoadType,
236 #[serde(serialize_with = "helpers::skip_nones")]
237 include: [Option<Include>; 2],
238 #[serde(serialize_with = "helpers::skip_nones")]
239 exclude: [Option<Exclude>; 2],
240 divide_kw_by: u8,
242}
243
244#[derive(Debug, Clone, Serialize)]
246#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
247pub(crate) struct CostPeriodSimple {
248 cost: Money,
249 load: LoadType,
250 include: Vec<Include>,
251 exclude: Vec<Exclude>,
252 divide_kw_by: u8,
254 info: String,
255}
256
257impl CostPeriodSimple {
258 fn new(
259 period: &CostPeriod,
260 fuse_size: u16,
261 yearly_consumption: u32,
262 language: Language,
263 ) -> Option<Self> {
264 let cost = period.cost().cost_for(fuse_size, yearly_consumption)?;
265 Some(
266 Self {
267 cost,
268 load: period.load,
269 include: period.include.into_iter().flatten().collect(),
270 exclude: period.exclude.into_iter().flatten().collect(),
271 divide_kw_by: period.divide_kw_by,
272 info: Default::default(),
273 }
274 .add_info(language),
275 )
276 }
277
278 fn add_info(mut self, language: Language) -> Self {
279 let mut infos = Vec::new();
280 for include in &self.include {
281 infos.push(include.translate(language));
282 }
283 for exclude in &self.exclude {
284 infos.push(exclude.translate(language).into());
285 }
286 self.info = infos.join(", ");
287 self
288 }
289}
290
291impl CostPeriod {
292 pub const fn builder() -> CostPeriodBuilder {
293 CostPeriodBuilder::new()
294 }
295
296 pub const fn cost(&self) -> Cost {
297 self.cost
298 }
299
300 pub const fn load(&self) -> LoadType {
301 self.load
302 }
303
304 pub const fn power_multiplier(&self) -> f64 {
305 1. / self.divide_kw_by as f64
306 }
307
308 pub fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool
309 where
310 DateTime<Tz>: Copy,
311 {
312 for include in self.include_period_types() {
313 if !include.matches(timestamp) {
314 return false;
315 }
316 }
317
318 for exclude in self.exclude_period_types() {
319 if exclude.matches(timestamp) {
320 return false;
321 }
322 }
323 true
324 }
325
326 fn include_period_types(&self) -> Vec<Include> {
327 self.include.iter().flatten().copied().collect()
328 }
329
330 fn exclude_period_types(&self) -> Vec<Exclude> {
331 self.exclude.iter().flatten().copied().collect()
332 }
333
334 fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
335 self.cost.is_yearly_consumption_based(fuse_size)
336 }
337}
338
339#[derive(Clone)]
340pub struct CostPeriodBuilder {
341 timezone: Option<Timezone>,
342 cost: Cost,
343 load: Option<LoadType>,
344 include: [Option<Include>; 2],
345 exclude: [Option<Exclude>; 2],
346 divide_kw_by: u8,
348}
349
350impl Default for CostPeriodBuilder {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356impl CostPeriodBuilder {
357 pub const fn new() -> Self {
358 let builder = Self {
359 timezone: None,
360 cost: Cost::None,
361 load: None,
362 include: [None; 2],
363 exclude: [None; 2],
364 divide_kw_by: 1,
365 };
366 builder.timezone(Timezone::Stockholm)
368 }
369
370 pub const fn build(self) -> CostPeriod {
371 CostPeriod {
372 cost: self.cost,
373 load: self.load.expect("`load` must be specified"),
374 include: self.include,
375 exclude: self.exclude,
376 divide_kw_by: self.divide_kw_by,
377 }
378 }
379
380 pub const fn cost(mut self, cost: Cost) -> Self {
381 self.cost = cost;
382 self
383 }
384
385 pub const fn load(mut self, load: LoadType) -> Self {
386 self.load = Some(load);
387 self
388 }
389
390 pub const fn fixed_cost(mut self, int: i64, fract: u8) -> Self {
391 self.cost = Cost::fixed(int, fract);
392 self
393 }
394
395 pub const fn fixed_cost_subunit(mut self, subunit: f64) -> Self {
396 self.cost = Cost::fixed_subunit(subunit);
397 self
398 }
399
400 pub const fn timezone(mut self, timezone: Timezone) -> Self {
401 self.timezone = Some(timezone);
402 self
403 }
404
405 const fn get_timezone(&self) -> Timezone {
406 self.timezone.expect("`timezone` must be specified")
407 }
408
409 pub const fn include(mut self, period_type: Include) -> Self {
410 let mut i = 0;
411 while i < self.include.len() {
412 if self.include[i].is_some() {
413 i += 1;
414 } else {
415 self.include[i] = Some(period_type);
416 return self;
417 }
418 }
419 panic!("Too many includes");
420 }
421
422 pub const fn months(self, from: Month, to: Month) -> Self {
423 let timezone = self.get_timezone();
424 self.include(Include::Months(Months::new(from, to, timezone)))
425 }
426
427 pub const fn month(self, month: Month) -> Self {
428 self.months(month, month)
429 }
430
431 pub const fn hours(self, from: u8, to_inclusive: u8) -> Self {
432 let timezone = self.get_timezone();
433 self.include(Include::Hours(Hours::new(from, to_inclusive, timezone)))
434 }
435
436 const fn exclude(mut self, period_type: Exclude) -> Self {
437 let mut i = 0;
438 while i < self.exclude.len() {
439 if self.exclude[i].is_some() {
440 i += 1;
441 } else {
442 self.exclude[i] = Some(period_type);
443 return self;
444 }
445 }
446 panic!("Too many excludes");
447 }
448
449 pub const fn exclude_holidays(self, country: Country) -> Self {
450 let tz = self.get_timezone();
451 self.exclude(Exclude::Holidays(country, tz))
452 }
453
454 pub const fn exclude_weekends(self) -> Self {
455 let tz = self.get_timezone();
456 self.exclude(Exclude::Weekends(tz))
457 }
458}
459
460#[derive(Debug, Clone, Copy, Serialize)]
461#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
462pub enum Include {
463 Months(Months),
464 Hours(Hours),
465}
466
467impl Include {
468 fn translate(&self, language: Language) -> String {
469 match self {
470 Include::Months(months) => months.translate(language),
471 Include::Hours(hours) => hours.translate(language),
472 }
473 }
474
475 fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool {
476 match self {
477 Include::Months(months) => months.matches(timestamp),
478 Include::Hours(hours) => hours.matches(timestamp),
479 }
480 }
481}
482
483#[derive(Debug, Clone, Copy, Serialize)]
484#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
485pub enum Exclude {
486 Weekends(Timezone),
487 Holidays(Country, Timezone),
488}
489
490impl Exclude {
491 pub(crate) fn translate(&self, language: Language) -> &'static str {
492 match language {
493 Language::En => match self {
494 Exclude::Weekends(_) => "Weekends",
495 Exclude::Holidays(country, _) => match country {
496 Country::SE => "Swedish holidays",
497 },
498 },
499 Language::Sv => match self {
500 Exclude::Weekends(_) => "Helg",
501 Exclude::Holidays(country, _) => match country {
502 Country::SE => "Svenska helgdagar",
503 },
504 },
505 }
506 }
507
508 fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool {
509 let tz_timestamp = timestamp.with_timezone(&self.tz());
510 match self {
511 Exclude::Weekends(_) => (6..=7).contains(&tz_timestamp.weekday().number_from_monday()),
512 Exclude::Holidays(country, _) => country.is_holiday(tz_timestamp.date_naive()),
513 }
514 }
515
516 const fn tz(&self) -> chrono_tz::Tz {
517 match self {
518 Exclude::Weekends(timezone) => timezone.to_tz(),
519 Exclude::Holidays(_, timezone) => timezone.to_tz(),
520 }
521 }
522}
523
524#[cfg(test)]
525mod tests {
526
527 use super::*;
528 use crate::money::Money;
529 use crate::months::Month::*;
530 use crate::{Stockholm, Utc};
531
532 #[test]
533 fn cost_for_none() {
534 const NONE_COST: Cost = Cost::None;
535 assert_eq!(NONE_COST.cost_for(16, 0), None);
536 assert_eq!(NONE_COST.cost_for(25, 5000), None);
537 }
538
539 #[test]
540 fn cost_for_unverified() {
541 const UNVERIFIED_COST: Cost = Cost::Unverified;
542 assert_eq!(UNVERIFIED_COST.cost_for(16, 0), None);
543 assert_eq!(UNVERIFIED_COST.cost_for(25, 5000), None);
544 }
545
546 #[test]
547 fn cost_for_fixed() {
548 const FIXED_COST: Cost = Cost::Fixed(Money::new(100, 50));
549 assert_eq!(FIXED_COST.cost_for(16, 0), Some(Money::new(100, 50)));
551 assert_eq!(FIXED_COST.cost_for(25, 5000), Some(Money::new(100, 50)));
552 assert_eq!(FIXED_COST.cost_for(63, 10000), Some(Money::new(100, 50)));
553 }
554
555 #[test]
556 fn cost_for_fuses_exact_match() {
557 const FUSES_COST: Cost = Cost::fuses(&[
558 (16, Money::new(50, 0)),
559 (25, Money::new(75, 0)),
560 (35, Money::new(100, 0)),
561 (50, Money::new(150, 0)),
562 ]);
563
564 assert_eq!(FUSES_COST.cost_for(16, 0), Some(Money::new(50, 0)));
566 assert_eq!(FUSES_COST.cost_for(25, 0), Some(Money::new(75, 0)));
567 assert_eq!(FUSES_COST.cost_for(35, 0), Some(Money::new(100, 0)));
568 assert_eq!(FUSES_COST.cost_for(50, 0), Some(Money::new(150, 0)));
569
570 assert_eq!(FUSES_COST.cost_for(25, 500000), Some(Money::new(75, 0)));
572 }
573
574 #[test]
575 fn cost_for_fuses_no_match() {
576 const FUSES_COST: Cost = Cost::fuses(&[(16, Money::new(50, 0)), (25, Money::new(75, 0))]);
577
578 assert_eq!(FUSES_COST.cost_for(20, 0), None);
580 assert_eq!(FUSES_COST.cost_for(63, 0), None);
581 }
582
583 #[test]
584 fn cost_for_fuses_yearly_consumption_with_limit() {
585 const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
586 (16, Some(5000), Money::new(50, 0)),
587 (16, None, Money::new(75, 0)),
588 (25, Some(10000), Money::new(100, 0)),
589 (25, None, Money::new(125, 0)),
590 ]);
591
592 assert_eq!(
594 FUSES_WITH_CONSUMPTION.cost_for(16, 3000),
595 Some(Money::new(50, 0))
596 );
597
598 assert_eq!(
600 FUSES_WITH_CONSUMPTION.cost_for(16, 5000),
601 Some(Money::new(50, 0))
602 );
603
604 assert_eq!(
606 FUSES_WITH_CONSUMPTION.cost_for(16, 6000),
607 Some(Money::new(75, 0))
608 );
609
610 assert_eq!(
612 FUSES_WITH_CONSUMPTION.cost_for(16, 20000),
613 Some(Money::new(75, 0))
614 );
615
616 assert_eq!(
618 FUSES_WITH_CONSUMPTION.cost_for(25, 10000),
619 Some(Money::new(100, 0))
620 );
621
622 assert_eq!(
624 FUSES_WITH_CONSUMPTION.cost_for(25, 15000),
625 Some(Money::new(125, 0))
626 );
627
628 assert_eq!(
630 FUSES_WITH_CONSUMPTION.cost_for(25, 5000),
631 Some(Money::new(100, 0))
632 );
633 }
634
635 #[test]
636 fn cost_for_fuses_yearly_consumption_no_limit() {
637 const FUSES_NO_LIMIT: Cost = Cost::fuses_with_yearly_consumption(&[
638 (16, None, Money::new(50, 0)),
639 (25, None, Money::new(75, 0)),
640 ]);
641
642 assert_eq!(FUSES_NO_LIMIT.cost_for(16, 0), Some(Money::new(50, 0)));
644 assert_eq!(FUSES_NO_LIMIT.cost_for(16, 1000), Some(Money::new(50, 0)));
645 assert_eq!(FUSES_NO_LIMIT.cost_for(16, 50000), Some(Money::new(50, 0)));
646 assert_eq!(FUSES_NO_LIMIT.cost_for(25, 100000), Some(Money::new(75, 0)));
647 }
648
649 #[test]
650 fn cost_for_fuses_yearly_consumption_no_fuse_match() {
651 const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
652 (16, Some(5000), Money::new(50, 0)),
653 (25, Some(10000), Money::new(100, 0)),
654 ]);
655
656 assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(35, 5000), None);
658 assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(50, 10000), None);
659 }
660
661 #[test]
662 fn cost_for_fuses_yearly_consumption_max_limit_no_fallback() {
663 const FUSES_ONLY_LIMITS: Cost = Cost::fuses_with_yearly_consumption(&[
664 (16, Some(5000), Money::new(50, 0)),
665 (25, Some(10000), Money::new(100, 0)),
666 ]);
667
668 assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 0), Some(Money::new(50, 0)));
670 assert_eq!(
671 FUSES_ONLY_LIMITS.cost_for(16, 3000),
672 Some(Money::new(50, 0))
673 );
674 assert_eq!(
675 FUSES_ONLY_LIMITS.cost_for(16, 4999),
676 Some(Money::new(50, 0))
677 );
678 assert_eq!(
679 FUSES_ONLY_LIMITS.cost_for(16, 5000),
680 Some(Money::new(50, 0))
681 );
682 assert_eq!(
683 FUSES_ONLY_LIMITS.cost_for(25, 9999),
684 Some(Money::new(100, 0))
685 );
686 assert_eq!(
687 FUSES_ONLY_LIMITS.cost_for(25, 10000),
688 Some(Money::new(100, 0))
689 );
690
691 assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 5001), None);
693 assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 10000), None);
694 assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 10001), None);
695 assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 20000), None);
696 }
697
698 #[test]
699 fn cost_for_fuse_range_within_range() {
700 const FUSE_BASED: Cost = Cost::fuse_range(&[
701 (16, 35, Money::new(54, 0)),
702 (35, u16::MAX, Money::new(108, 50)),
703 ]);
704
705 assert_eq!(FUSE_BASED.cost_for(10, 0), None);
707 assert_eq!(FUSE_BASED.cost_for(15, 0), None);
708
709 assert_eq!(FUSE_BASED.cost_for(16, 0), Some(Money::new(54, 0)));
711 assert_eq!(FUSE_BASED.cost_for(25, 0), Some(Money::new(54, 0)));
712 assert_eq!(FUSE_BASED.cost_for(35, 0), Some(Money::new(54, 0)));
713
714 assert_eq!(FUSE_BASED.cost_for(36, 0), Some(Money::new(108, 50)));
716 assert_eq!(FUSE_BASED.cost_for(50, 0), Some(Money::new(108, 50)));
717 assert_eq!(FUSE_BASED.cost_for(200, 0), Some(Money::new(108, 50)));
718 assert_eq!(FUSE_BASED.cost_for(u16::MAX, 0), Some(Money::new(108, 50)));
719 }
720
721 #[test]
722 fn cost_for_fuse_range_multiple_ranges() {
723 const MULTI_RANGE: Cost = Cost::fuse_range(&[
724 (1, 15, Money::new(20, 0)),
725 (16, 35, Money::new(50, 0)),
726 (36, 63, Money::new(100, 0)),
727 (64, u16::MAX, Money::new(200, 0)),
728 ]);
729
730 assert_eq!(MULTI_RANGE.cost_for(10, 0), Some(Money::new(20, 0)));
732 assert_eq!(MULTI_RANGE.cost_for(25, 0), Some(Money::new(50, 0)));
733 assert_eq!(MULTI_RANGE.cost_for(50, 0), Some(Money::new(100, 0)));
734 assert_eq!(MULTI_RANGE.cost_for(100, 0), Some(Money::new(200, 0)));
735
736 assert_eq!(MULTI_RANGE.cost_for(25, 10000), Some(Money::new(50, 0)));
738 }
739
740 #[test]
741 fn include_matches_hours() {
742 let include = Include::Hours(Hours::new(6, 22, Stockholm));
743 let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
744 let timestamp_no_match = Stockholm.dt(2025, 1, 15, 23, 0, 0);
745
746 assert!(include.matches(timestamp_match));
747 assert!(!include.matches(timestamp_no_match));
748 }
749
750 #[test]
751 fn include_matches_months() {
752 let include = Include::Months(Months::new(November, March, Stockholm));
753 let timestamp_match = Stockholm.dt(2025, 1, 15, 12, 0, 0);
754 let timestamp_no_match = Stockholm.dt(2025, 7, 15, 12, 0, 0);
755
756 assert!(include.matches(timestamp_match));
757 assert!(!include.matches(timestamp_no_match));
758 }
759
760 #[test]
761 fn exclude_matches_weekends_saturday() {
762 let exclude = Exclude::Weekends(Stockholm);
763 let timestamp = Stockholm.dt(2025, 1, 4, 12, 0, 0);
765 assert!(exclude.matches(timestamp));
766 }
767
768 #[test]
769 fn exclude_matches_weekends_sunday() {
770 let exclude = Exclude::Weekends(Stockholm);
771 let timestamp = Stockholm.dt(2025, 1, 5, 12, 0, 0);
773 assert!(exclude.matches(timestamp));
774 }
775
776 #[test]
777 fn exclude_does_not_match_weekday() {
778 let exclude = Exclude::Weekends(Stockholm);
779 let timestamp = Stockholm.dt(2025, 1, 6, 12, 0, 0);
781 assert!(!exclude.matches(timestamp));
782 }
783
784 #[test]
785 fn exclude_matches_swedish_new_year() {
786 let exclude = Exclude::Holidays(Country::SE, Stockholm);
787 let timestamp = Stockholm.dt(2025, 1, 1, 12, 0, 0);
789 assert!(exclude.matches(timestamp));
790 }
791
792 #[test]
793 fn exclude_does_not_match_non_holiday() {
794 let exclude = Exclude::Holidays(Country::SE, Stockholm);
795 let timestamp = Stockholm.dt(2025, 1, 2, 12, 0, 0);
797 assert!(!exclude.matches(timestamp));
798 }
799
800 #[test]
801 fn cost_period_matches_with_single_include() {
802 let period = CostPeriod::builder()
803 .load(LoadType::High)
804 .fixed_cost(10, 0)
805 .hours(6, 22)
806 .build();
807
808 let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
809 let timestamp_no_match = Stockholm.dt(2025, 1, 15, 23, 0, 0);
810
811 assert!(period.matches(timestamp_match));
812 assert!(!period.matches(timestamp_no_match));
813 }
814
815 #[test]
816 fn cost_period_matches_with_multiple_includes() {
817 let period = CostPeriod::builder()
818 .load(LoadType::High)
819 .fixed_cost(10, 0)
820 .hours(6, 22)
821 .months(November, March)
822 .build();
823
824 let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
826 let timestamp_wrong_hours = Stockholm.dt(2025, 1, 15, 23, 0, 0);
828 let timestamp_wrong_months = Stockholm.dt(2025, 7, 15, 14, 0, 0);
830
831 assert!(period.matches(timestamp_match));
832 assert!(!period.matches(timestamp_wrong_hours));
833 assert!(!period.matches(timestamp_wrong_months));
834 }
835
836 #[test]
837 fn cost_period_matches_with_exclude_weekends() {
838 let period = CostPeriod::builder()
839 .load(LoadType::High)
840 .fixed_cost(10, 0)
841 .hours(6, 22)
842 .exclude_weekends()
843 .build();
844
845 println!("Excludes: {:?}", period.exclude_period_types());
846 println!("Includes: {:?}", period.include_period_types());
847
848 let timestamp_weekday = Stockholm.dt(2025, 1, 6, 14, 0, 0);
850 let timestamp_saturday = Stockholm.dt(2025, 1, 4, 14, 0, 0);
852
853 assert!(period.matches(timestamp_weekday));
854 assert!(!period.matches(timestamp_saturday));
855 }
856
857 #[test]
858 fn cost_period_matches_with_exclude_holidays() {
859 let period = CostPeriod::builder()
860 .load(LoadType::High)
861 .fixed_cost(10, 0)
862 .hours(6, 22)
863 .exclude_holidays(Country::SE)
864 .build();
865
866 let timestamp_regular = Stockholm.dt(2025, 1, 2, 14, 0, 0);
868 let timestamp_holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
870
871 assert!(period.matches(timestamp_regular));
872 assert!(!period.matches(timestamp_holiday));
873 }
874
875 #[test]
876 fn cost_period_matches_complex_scenario() {
877 let period = CostPeriod::builder()
879 .load(LoadType::High)
880 .fixed_cost(10, 0)
881 .months(November, March)
882 .hours(6, 22)
883 .exclude_weekends()
884 .exclude_holidays(Country::SE)
885 .build();
886
887 let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
889
890 let timestamp_wrong_hours = Stockholm.dt(2025, 1, 15, 23, 0, 0);
892
893 let timestamp_weekend = Stockholm.dt(2025, 1, 4, 14, 0, 0);
895
896 let timestamp_holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
898
899 let timestamp_summer = Stockholm.dt(2025, 7, 15, 14, 0, 0);
901
902 assert!(period.matches(timestamp_match));
903 assert!(!period.matches(timestamp_wrong_hours));
904 assert!(!period.matches(timestamp_weekend));
905 assert!(!period.matches(timestamp_holiday));
906 assert!(!period.matches(timestamp_summer));
907 }
908
909 #[test]
910 fn cost_period_matches_base_load() {
911 let period = CostPeriod::builder()
913 .load(LoadType::Base)
914 .fixed_cost(5, 0)
915 .build();
916
917 let timestamp1 = Stockholm.dt(2025, 1, 1, 0, 0, 0);
919 let timestamp2 = Stockholm.dt(2025, 7, 15, 23, 59, 59);
920 let timestamp3 = Stockholm.dt(2025, 1, 4, 12, 0, 0);
921
922 assert!(period.matches(timestamp1));
923 assert!(period.matches(timestamp2));
924 assert!(period.matches(timestamp3));
925 }
926
927 #[test]
928 fn include_matches_hours_wraparound() {
929 let include = Include::Hours(Hours::new(22, 5, Stockholm));
931
932 let timestamp_evening = Stockholm.dt(2025, 1, 15, 22, 0, 0);
934 assert!(include.matches(timestamp_evening));
935
936 let timestamp_midnight = Stockholm.dt(2025, 1, 15, 0, 0, 0);
938 assert!(include.matches(timestamp_midnight));
939
940 let timestamp_morning = Stockholm.dt(2025, 1, 15, 5, 30, 0);
942 assert!(include.matches(timestamp_morning));
943
944 let timestamp_day = Stockholm.dt(2025, 1, 15, 14, 0, 0);
946 assert!(!include.matches(timestamp_day));
947
948 let timestamp_after = Stockholm.dt(2025, 1, 15, 6, 0, 0);
950 assert!(!include.matches(timestamp_after));
951
952 let timestamp_before = Stockholm.dt(2025, 1, 15, 21, 59, 59);
954 assert!(!include.matches(timestamp_before));
955 }
956
957 #[test]
958 fn include_matches_months_wraparound() {
959 let include = Include::Months(Months::new(November, March, Stockholm));
961
962 let timestamp_nov = Stockholm.dt(2025, 11, 15, 12, 0, 0);
964 assert!(include.matches(timestamp_nov));
965
966 let timestamp_dec = Stockholm.dt(2025, 12, 15, 12, 0, 0);
968 assert!(include.matches(timestamp_dec));
969
970 let timestamp_jan = Stockholm.dt(2025, 1, 15, 12, 0, 0);
972 assert!(include.matches(timestamp_jan));
973
974 let timestamp_mar = Stockholm.dt(2025, 3, 15, 12, 0, 0);
976 assert!(include.matches(timestamp_mar));
977
978 let timestamp_jul = Stockholm.dt(2025, 7, 15, 12, 0, 0);
980 assert!(!include.matches(timestamp_jul));
981
982 let timestamp_oct = Stockholm.dt(2025, 10, 31, 23, 59, 59);
984 assert!(!include.matches(timestamp_oct));
985
986 let timestamp_apr = Stockholm.dt(2025, 4, 1, 0, 0, 0);
988 assert!(!include.matches(timestamp_apr));
989 }
990
991 #[test]
992 fn cost_period_matches_hours_wraparound() {
993 let period = CostPeriod::builder()
995 .load(LoadType::Low)
996 .fixed_cost(5, 0)
997 .hours(22, 5)
998 .build();
999
1000 let timestamp_match_evening = Stockholm.dt(2025, 1, 15, 23, 0, 0);
1001 let timestamp_match_morning = Stockholm.dt(2025, 1, 15, 3, 0, 0);
1002 let timestamp_no_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
1003
1004 assert!(period.matches(timestamp_match_evening));
1005 assert!(period.matches(timestamp_match_morning));
1006 assert!(!period.matches(timestamp_no_match));
1007 }
1008
1009 #[test]
1010 fn cost_period_matches_with_both_excludes() {
1011 let period = CostPeriod::builder()
1012 .load(LoadType::High)
1013 .fixed_cost(10, 0)
1014 .hours(6, 22)
1015 .exclude_weekends()
1016 .exclude_holidays(Country::SE)
1017 .build();
1018
1019 let weekday = Stockholm.dt(2025, 1, 2, 14, 0, 0);
1021 assert!(period.matches(weekday));
1022
1023 let saturday = Stockholm.dt(2025, 1, 4, 14, 0, 0);
1025 assert!(!period.matches(saturday));
1026
1027 let holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
1029 assert!(!period.matches(holiday));
1030
1031 let wrong_hours = Stockholm.dt(2025, 1, 2, 23, 0, 0);
1033 assert!(!period.matches(wrong_hours));
1034 }
1035
1036 #[test]
1037 fn exclude_matches_friday_is_not_weekend() {
1038 let exclude = Exclude::Weekends(Stockholm);
1039 let friday = Stockholm.dt(2025, 1, 3, 12, 0, 0);
1041 assert!(!exclude.matches(friday));
1042 }
1043
1044 #[test]
1045 fn exclude_matches_monday_is_not_weekend() {
1046 let exclude = Exclude::Weekends(Stockholm);
1047 let monday = Stockholm.dt(2025, 1, 6, 12, 0, 0);
1049 assert!(!exclude.matches(monday));
1050 }
1051
1052 #[test]
1053 fn exclude_matches_holiday_midsummer() {
1054 let exclude = Exclude::Holidays(Country::SE, Stockholm);
1055 let midsummer = Stockholm.dt(2025, 6, 21, 12, 0, 0);
1057 assert!(exclude.matches(midsummer));
1058 }
1059
1060 #[test]
1061 fn cost_period_matches_month_and_hours() {
1062 let period = CostPeriod::builder()
1064 .load(LoadType::Low)
1065 .fixed_cost(5, 0)
1066 .month(June)
1067 .hours(22, 5)
1068 .build();
1069
1070 let match_june_night = Stockholm.dt(2025, 6, 15, 23, 0, 0);
1072 assert!(period.matches(match_june_night));
1073
1074 let june_day = Stockholm.dt(2025, 6, 15, 14, 0, 0);
1076 assert!(!period.matches(june_day));
1077
1078 let july_night = Stockholm.dt(2025, 7, 15, 23, 0, 0);
1080 assert!(!period.matches(july_night));
1081 }
1082
1083 #[test]
1084 fn cost_period_matches_months_and_hours_with_exclude() {
1085 let period = CostPeriod::builder()
1087 .load(LoadType::High)
1088 .fixed_cost(15, 0)
1089 .months(November, March)
1090 .hours(6, 22)
1091 .exclude_weekends()
1092 .exclude_holidays(Country::SE)
1093 .build();
1094
1095 let perfect = Stockholm.dt(2025, 1, 15, 10, 0, 0);
1097 assert!(period.matches(perfect));
1098
1099 let first_hour = Stockholm.dt(2025, 1, 15, 6, 0, 0);
1101 assert!(period.matches(first_hour));
1102
1103 let last_hour = Stockholm.dt(2025, 1, 15, 22, 59, 59);
1105 assert!(period.matches(last_hour));
1106
1107 let too_early = Stockholm.dt(2025, 1, 15, 5, 59, 59);
1109 assert!(!period.matches(too_early));
1110
1111 let too_late = Stockholm.dt(2025, 1, 15, 23, 0, 0);
1113 assert!(!period.matches(too_late));
1114
1115 let summer = Stockholm.dt(2025, 7, 15, 10, 0, 0);
1117 assert!(!period.matches(summer));
1118
1119 let weekend = Stockholm.dt(2025, 1, 4, 10, 0, 0);
1121 assert!(!period.matches(weekend));
1122 }
1123
1124 #[test]
1125 fn cost_period_matches_base_with_restrictions() {
1126 let period = CostPeriod::builder()
1128 .load(LoadType::Base)
1129 .fixed_cost(3, 0)
1130 .hours(0, 5)
1131 .build();
1132
1133 let match_night = Stockholm.dt(2025, 1, 15, 3, 0, 0);
1135 assert!(period.matches(match_night));
1136
1137 let no_match_day = Stockholm.dt(2025, 1, 15, 14, 0, 0);
1139 assert!(!period.matches(no_match_day));
1140 }
1141
1142 #[test]
1143 fn cost_period_matches_single_month() {
1144 let period = CostPeriod::builder()
1145 .load(LoadType::High)
1146 .fixed_cost(10, 0)
1147 .month(December)
1148 .build();
1149
1150 let dec_first = Stockholm.dt(2025, 12, 1, 0, 0, 0);
1152 assert!(period.matches(dec_first));
1153
1154 let dec_last = Stockholm.dt(2025, 12, 31, 23, 59, 59);
1156 assert!(period.matches(dec_last));
1157
1158 let nov = Stockholm.dt(2025, 11, 30, 12, 0, 0);
1160 assert!(!period.matches(nov));
1161
1162 let jan = Stockholm.dt(2025, 1, 1, 12, 0, 0);
1164 assert!(!period.matches(jan));
1165 }
1166
1167 #[test]
1168 fn cost_period_matches_all_hours() {
1169 let period = CostPeriod::builder()
1171 .load(LoadType::Low)
1172 .fixed_cost(5, 0)
1173 .hours(0, 23)
1174 .build();
1175
1176 let midnight = Stockholm.dt(2025, 1, 15, 0, 0, 0);
1177 let noon = Stockholm.dt(2025, 1, 15, 12, 0, 0);
1178 let almost_midnight = Stockholm.dt(2025, 1, 15, 23, 59, 59);
1179
1180 assert!(period.matches(midnight));
1181 assert!(period.matches(noon));
1182 assert!(period.matches(almost_midnight));
1183 }
1184
1185 #[test]
1186 fn cost_period_matches_edge_of_month_range() {
1187 let period = CostPeriod::builder()
1189 .load(LoadType::Low)
1190 .fixed_cost(5, 0)
1191 .months(May, September)
1192 .build();
1193
1194 let may_start = Stockholm.dt(2025, 5, 1, 0, 0, 0);
1196 assert!(period.matches(may_start));
1197
1198 let april_end = Stockholm.dt(2025, 4, 30, 23, 59, 59);
1200 assert!(!period.matches(april_end));
1201
1202 let sept_end = Stockholm.dt(2025, 9, 30, 23, 59, 59);
1204 assert!(period.matches(sept_end));
1205
1206 let oct_start = Stockholm.dt(2025, 10, 1, 0, 0, 0);
1208 assert!(!period.matches(oct_start));
1209 }
1210
1211 #[test]
1212 fn include_matches_month_boundary() {
1213 let include = Include::Months(Months::new(February, February, Stockholm));
1215
1216 let feb_start = Stockholm.dt(2025, 2, 1, 0, 0, 0);
1218 assert!(include.matches(feb_start));
1219
1220 let feb_end = Stockholm.dt(2025, 2, 28, 23, 59, 59);
1222 assert!(include.matches(feb_end));
1223
1224 let jan_end = Stockholm.dt(2025, 1, 31, 23, 59, 59);
1226 assert!(!include.matches(jan_end));
1227
1228 let mar_start = Stockholm.dt(2025, 3, 1, 0, 0, 0);
1230 assert!(!include.matches(mar_start));
1231 }
1232
1233 #[test]
1234 fn include_matches_hours_exact_boundaries() {
1235 let include = Include::Hours(Hours::new(6, 22, Stockholm));
1236
1237 let start = Stockholm.dt(2025, 1, 15, 6, 0, 0);
1239 assert!(include.matches(start));
1240
1241 let end = Stockholm.dt(2025, 1, 15, 22, 59, 59);
1243 assert!(include.matches(end));
1244
1245 let before = Stockholm.dt(2025, 1, 15, 5, 59, 59);
1247 assert!(!include.matches(before));
1248
1249 let after = Stockholm.dt(2025, 1, 15, 23, 0, 0);
1251 assert!(!include.matches(after));
1252 }
1253
1254 #[test]
1255 fn exclude_matches_weekends_with_utc_timestamps() {
1256 let exclude = Exclude::Weekends(Stockholm);
1257
1258 let saturday_utc = Utc.dt(2025, 1, 4, 11, 0, 0);
1261 assert!(exclude.matches(saturday_utc));
1262
1263 let sunday_utc = Utc.dt(2025, 1, 5, 11, 0, 0);
1266 assert!(exclude.matches(sunday_utc));
1267
1268 let monday_utc = Utc.dt(2025, 1, 6, 11, 0, 0);
1271 assert!(!exclude.matches(monday_utc));
1272 }
1273
1274 #[test]
1275 fn exclude_matches_weekends_timezone_boundary() {
1276 let exclude = Exclude::Weekends(Stockholm);
1277
1278 let friday_utc_saturday_stockholm = Utc.dt(2025, 1, 3, 23, 0, 0);
1282 assert!(
1283 exclude.matches(friday_utc_saturday_stockholm),
1284 "Should match because it's Saturday in Stockholm timezone"
1285 );
1286
1287 let sunday_utc_monday_stockholm = Utc.dt(2025, 1, 5, 23, 0, 0);
1291 assert!(
1292 !exclude.matches(sunday_utc_monday_stockholm),
1293 "Should not match because it's Monday in Stockholm timezone"
1294 );
1295
1296 let sunday_late_utc = Utc.dt(2025, 1, 5, 22, 59, 0);
1299 assert!(
1300 exclude.matches(sunday_late_utc),
1301 "Should match because it's still Sunday in Stockholm timezone"
1302 );
1303 }
1304
1305 #[test]
1306 fn exclude_matches_holidays_with_utc_timestamps() {
1307 let exclude = Exclude::Holidays(Country::SE, Stockholm);
1308
1309 let new_year_utc = Utc.dt(2025, 1, 1, 11, 0, 0);
1312 assert!(exclude.matches(new_year_utc));
1313
1314 let regular_day_utc = Utc.dt(2025, 1, 2, 11, 0, 0);
1317 assert!(!exclude.matches(regular_day_utc));
1318 }
1319
1320 #[test]
1321 fn exclude_matches_holidays_timezone_boundary() {
1322 let exclude = Exclude::Holidays(Country::SE, Stockholm);
1323
1324 let dec31_utc_jan1_stockholm = Utc.dt(2024, 12, 31, 23, 0, 0);
1328 assert!(
1329 exclude.matches(dec31_utc_jan1_stockholm),
1330 "Should match because it's New Year's Day in Stockholm timezone"
1331 );
1332
1333 let jan1_utc_jan2_stockholm = Utc.dt(2025, 1, 1, 23, 0, 0);
1337 assert!(
1338 !exclude.matches(jan1_utc_jan2_stockholm),
1339 "Should not match because it's January 2 in Stockholm timezone"
1340 );
1341 }
1342
1343 #[test]
1344 fn exclude_matches_weekends_summer_timezone() {
1345 let exclude = Exclude::Weekends(Stockholm);
1346
1347 let saturday_summer_utc = Utc.dt(2025, 6, 7, 10, 0, 0);
1350 assert!(exclude.matches(saturday_summer_utc));
1351
1352 let friday_utc_saturday_stockholm_summer = Utc.dt(2025, 6, 6, 22, 0, 0);
1355 assert!(
1356 exclude.matches(friday_utc_saturday_stockholm_summer),
1357 "Should match because it's Saturday in Stockholm timezone (CEST)"
1358 );
1359 }
1360}