grid_tariffs/
costs.rs

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// TODO: Make CostBuilder!
13#[derive(Debug, Clone, Copy, Serialize)]
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15pub enum Cost {
16    None,
17    /// Cost has not been verified
18    Unverified,
19    Fixed(Money),
20    Fuses(&'static [(u16, Money)]),
21    /// Fuse size combined with a yearly energy consumption limit
22    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)]
140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
141pub enum CostPeriodMatching {
142    First,
143    All,
144}
145
146#[derive(Debug, Clone, Copy, Serialize)]
147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
148pub struct CostPeriods {
149    match_method: CostPeriodMatching,
150    periods: &'static [CostPeriod],
151}
152
153impl CostPeriods {
154    pub const fn new_first(periods: &'static [CostPeriod]) -> Self {
155        Self {
156            match_method: CostPeriodMatching::First,
157            periods,
158        }
159    }
160
161    pub const fn new_all(periods: &'static [CostPeriod]) -> Self {
162        Self {
163            match_method: CostPeriodMatching::All,
164            periods,
165        }
166    }
167
168    pub fn match_method(&self) -> CostPeriodMatching {
169        self.match_method
170    }
171
172    pub fn iter(&self) -> Iter<'_, CostPeriod> {
173        self.periods.iter()
174    }
175
176    pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
177        self.periods
178            .iter()
179            .any(|cp| cp.is_yearly_consumption_based(fuse_size))
180    }
181
182    pub fn matching_periods<Tz: chrono::TimeZone>(
183        &self,
184        timestamp: DateTime<Tz>,
185    ) -> Vec<&CostPeriod>
186    where
187        DateTime<Tz>: Copy,
188    {
189        let mut ret = vec![];
190        for period in self.periods {
191            if period.matches(timestamp) {
192                ret.push(period);
193                if self.match_method == CostPeriodMatching::First {
194                    break;
195                }
196            }
197        }
198        ret
199    }
200}
201
202/// Like CostPeriods, but with costs being simple Money objects
203#[derive(Debug, Clone, Serialize)]
204#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
205pub struct CostPeriodsSimple {
206    periods: Vec<CostPeriodSimple>,
207}
208
209impl CostPeriodsSimple {
210    pub(crate) fn new(
211        periods: CostPeriods,
212        fuse_size: u16,
213        yearly_consumption: u32,
214        language: Language,
215    ) -> Self {
216        Self {
217            periods: periods
218                .periods
219                .iter()
220                .flat_map(|period| {
221                    CostPeriodSimple::new(period, fuse_size, yearly_consumption, language)
222                })
223                .collect(),
224        }
225    }
226}
227
228#[derive(Debug, Clone, Serialize)]
229#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
230pub struct CostPeriod {
231    cost: Cost,
232    load: LoadType,
233    #[serde(serialize_with = "helpers::skip_nones")]
234    include: [Option<Include>; 2],
235    #[serde(serialize_with = "helpers::skip_nones")]
236    exclude: [Option<Exclude>; 2],
237    /// Divide kw by this amount during this period
238    divide_kw_by: u8,
239}
240
241/// Like CostPeriod, but with cost being a simple Money object
242#[derive(Debug, Clone, Serialize)]
243#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
244pub(crate) struct CostPeriodSimple {
245    cost: Money,
246    load: LoadType,
247    include: Vec<Include>,
248    exclude: Vec<Exclude>,
249    /// Divide kw by this amount during this period
250    divide_kw_by: u8,
251    info: String,
252}
253
254impl CostPeriodSimple {
255    fn new(
256        period: &CostPeriod,
257        fuse_size: u16,
258        yearly_consumption: u32,
259        language: Language,
260    ) -> Option<Self> {
261        let cost = period.cost().cost_for(fuse_size, yearly_consumption)?;
262        Some(
263            Self {
264                cost,
265                load: period.load,
266                include: period.include.into_iter().flatten().collect(),
267                exclude: period.exclude.into_iter().flatten().collect(),
268                divide_kw_by: period.divide_kw_by,
269                info: Default::default(),
270            }
271            .add_info(language),
272        )
273    }
274
275    fn add_info(mut self, language: Language) -> Self {
276        let mut infos = Vec::new();
277        for include in &self.include {
278            infos.push(include.translate(language));
279        }
280        for exclude in &self.exclude {
281            infos.push(exclude.translate(language).into());
282        }
283        self.info = infos.join(", ");
284        self
285    }
286}
287
288impl CostPeriod {
289    pub const fn builder() -> CostPeriodBuilder {
290        CostPeriodBuilder::new()
291    }
292
293    pub const fn cost(&self) -> Cost {
294        self.cost
295    }
296
297    pub const fn load(&self) -> LoadType {
298        self.load
299    }
300
301    pub const fn power_multiplier(&self) -> f64 {
302        1. / self.divide_kw_by as f64
303    }
304
305    pub fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool
306    where
307        DateTime<Tz>: Copy,
308    {
309        for include in self.include_period_types() {
310            if !include.matches(timestamp) {
311                return false;
312            }
313        }
314
315        for exclude in self.exclude_period_types() {
316            if exclude.matches(timestamp) {
317                return false;
318            }
319        }
320        true
321    }
322
323    fn include_period_types(&self) -> Vec<Include> {
324        self.include.iter().flatten().copied().collect()
325    }
326
327    fn exclude_period_types(&self) -> Vec<Exclude> {
328        self.exclude.iter().flatten().copied().collect()
329    }
330
331    fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
332        self.cost.is_yearly_consumption_based(fuse_size)
333    }
334}
335
336#[derive(Clone)]
337pub struct CostPeriodBuilder {
338    timezone: Option<Timezone>,
339    cost: Cost,
340    load: Option<LoadType>,
341    include: [Option<Include>; 2],
342    exclude: [Option<Exclude>; 2],
343    /// Divide kw by this amount during this period
344    divide_kw_by: u8,
345}
346
347impl CostPeriodBuilder {
348    pub const fn new() -> Self {
349        let builder = Self {
350            timezone: None,
351            cost: Cost::None,
352            load: None,
353            include: [None; 2],
354            exclude: [None; 2],
355            divide_kw_by: 1,
356        };
357        // TODO: Don't hardcode this!
358        builder.timezone(Timezone::Stockholm)
359    }
360
361    pub const fn build(self) -> CostPeriod {
362        CostPeriod {
363            cost: self.cost,
364            load: self.load.expect("`load` must be specified"),
365            include: self.include,
366            exclude: self.exclude,
367            divide_kw_by: self.divide_kw_by,
368        }
369    }
370
371    pub const fn cost(mut self, cost: Cost) -> Self {
372        self.cost = cost;
373        self
374    }
375
376    pub const fn load(mut self, load: LoadType) -> Self {
377        self.load = Some(load);
378        self
379    }
380
381    pub const fn fixed_cost(mut self, int: i64, fract: u8) -> Self {
382        self.cost = Cost::fixed(int, fract);
383        self
384    }
385
386    pub const fn fixed_cost_subunit(mut self, subunit: f64) -> Self {
387        self.cost = Cost::fixed_subunit(subunit);
388        self
389    }
390
391    pub const fn timezone(mut self, timezone: Timezone) -> Self {
392        self.timezone = Some(timezone);
393        self
394    }
395
396    const fn get_timezone(&self) -> Timezone {
397        self.timezone.expect("`timezone` must be specified")
398    }
399
400    pub const fn include(mut self, period_type: Include) -> Self {
401        let mut i = 0;
402        while i < self.include.len() {
403            if self.include[i].is_some() {
404                i += 1;
405            } else {
406                self.include[i] = Some(period_type);
407                return self;
408            }
409        }
410        panic!("Too many includes");
411    }
412
413    pub const fn months(self, from: Month, to: Month) -> Self {
414        let timezone = self.get_timezone();
415        self.include(Include::Months(Months::new(from, to, timezone)))
416    }
417
418    pub const fn month(self, month: Month) -> Self {
419        self.months(month, month)
420    }
421
422    pub const fn hours(self, from: u8, to_inclusive: u8) -> Self {
423        let timezone = self.get_timezone();
424        self.include(Include::Hours(Hours::new(from, to_inclusive, timezone)))
425    }
426
427    const fn exclude(mut self, period_type: Exclude) -> Self {
428        let mut i = 0;
429        while i < self.exclude.len() {
430            if self.exclude[i].is_some() {
431                i += 1;
432            } else {
433                self.exclude[i] = Some(period_type);
434                return self;
435            }
436        }
437        panic!("Too many excludes");
438    }
439
440    pub const fn exclude_holidays(self, country: Country) -> Self {
441        let tz = self.get_timezone();
442        self.exclude(Exclude::Holidays(country, tz))
443    }
444
445    pub const fn exclude_weekends(self) -> Self {
446        let tz = self.get_timezone();
447        self.exclude(Exclude::Weekends(tz))
448    }
449}
450
451#[derive(Debug, Clone, Copy, Serialize)]
452#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
453pub enum Include {
454    Months(Months),
455    Hours(Hours),
456}
457
458impl Include {
459    fn translate(&self, language: Language) -> String {
460        match self {
461            Include::Months(months) => months.translate(language),
462            Include::Hours(hours) => hours.translate(language),
463        }
464    }
465
466    fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool {
467        match self {
468            Include::Months(months) => months.matches(timestamp),
469            Include::Hours(hours) => hours.matches(timestamp),
470        }
471    }
472}
473
474#[derive(Debug, Clone, Copy, Serialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub enum Exclude {
477    Weekends(Timezone),
478    Holidays(Country, Timezone),
479}
480
481impl Exclude {
482    pub(crate) fn translate(&self, language: Language) -> &'static str {
483        match language {
484            Language::En => match self {
485                Exclude::Weekends(_) => "Weekends",
486                Exclude::Holidays(country, _) => match country {
487                    Country::SE => "Swedish holidays",
488                },
489            },
490            Language::Sv => match self {
491                Exclude::Weekends(_) => "Helg",
492                Exclude::Holidays(country, _) => match country {
493                    Country::SE => "Svenska helgdagar",
494                },
495            },
496        }
497    }
498
499    fn matches<Tz: chrono::TimeZone>(&self, timestamp: DateTime<Tz>) -> bool {
500        let tz_timestamp = timestamp.with_timezone(&self.tz());
501        match self {
502            Exclude::Weekends(_) => (6..=7).contains(&tz_timestamp.weekday().number_from_monday()),
503            Exclude::Holidays(country, _) => country.is_holiday(tz_timestamp.date_naive()),
504        }
505    }
506
507    const fn tz(&self) -> chrono_tz::Tz {
508        match self {
509            Exclude::Weekends(timezone) => timezone.to_tz(),
510            Exclude::Holidays(_, timezone) => timezone.to_tz(),
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517
518    use super::*;
519    use crate::money::Money;
520    use crate::months::Month::*;
521    use crate::{Stockholm, Utc};
522
523    #[test]
524    fn cost_for_none() {
525        const NONE_COST: Cost = Cost::None;
526        assert_eq!(NONE_COST.cost_for(16, 0), None);
527        assert_eq!(NONE_COST.cost_for(25, 5000), None);
528    }
529
530    #[test]
531    fn cost_for_unverified() {
532        const UNVERIFIED_COST: Cost = Cost::Unverified;
533        assert_eq!(UNVERIFIED_COST.cost_for(16, 0), None);
534        assert_eq!(UNVERIFIED_COST.cost_for(25, 5000), None);
535    }
536
537    #[test]
538    fn cost_for_fixed() {
539        const FIXED_COST: Cost = Cost::Fixed(Money::new(100, 50));
540        // Fixed cost should return the same value regardless of fuse size or consumption
541        assert_eq!(FIXED_COST.cost_for(16, 0), Some(Money::new(100, 50)));
542        assert_eq!(FIXED_COST.cost_for(25, 5000), Some(Money::new(100, 50)));
543        assert_eq!(FIXED_COST.cost_for(63, 10000), Some(Money::new(100, 50)));
544    }
545
546    #[test]
547    fn cost_for_fuses_exact_match() {
548        const FUSES_COST: Cost = Cost::fuses(&[
549            (16, Money::new(50, 0)),
550            (25, Money::new(75, 0)),
551            (35, Money::new(100, 0)),
552            (50, Money::new(150, 0)),
553        ]);
554
555        // Test exact matches
556        assert_eq!(FUSES_COST.cost_for(16, 0), Some(Money::new(50, 0)));
557        assert_eq!(FUSES_COST.cost_for(25, 0), Some(Money::new(75, 0)));
558        assert_eq!(FUSES_COST.cost_for(35, 0), Some(Money::new(100, 0)));
559        assert_eq!(FUSES_COST.cost_for(50, 0), Some(Money::new(150, 0)));
560
561        // Yearly consumption should not affect the result
562        assert_eq!(FUSES_COST.cost_for(25, 500000), Some(Money::new(75, 0)));
563    }
564
565    #[test]
566    fn cost_for_fuses_no_match() {
567        const FUSES_COST: Cost = Cost::fuses(&[(16, Money::new(50, 0)), (25, Money::new(75, 0))]);
568
569        // Test non-matching fuse sizes
570        assert_eq!(FUSES_COST.cost_for(20, 0), None);
571        assert_eq!(FUSES_COST.cost_for(63, 0), None);
572    }
573
574    #[test]
575    fn cost_for_fuses_yearly_consumption_with_limit() {
576        const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
577            (16, Some(5000), Money::new(50, 0)),
578            (16, None, Money::new(75, 0)),
579            (25, Some(10000), Money::new(100, 0)),
580            (25, None, Money::new(125, 0)),
581        ]);
582
583        // 16A fuse with consumption below limit - matches the entry with the limit
584        assert_eq!(
585            FUSES_WITH_CONSUMPTION.cost_for(16, 3000),
586            Some(Money::new(50, 0))
587        );
588
589        // 16A fuse with consumption at limit - matches the entry with the limit
590        assert_eq!(
591            FUSES_WITH_CONSUMPTION.cost_for(16, 5000),
592            Some(Money::new(50, 0))
593        );
594
595        // 16A fuse with consumption above limit - falls through to entry with no limit
596        assert_eq!(
597            FUSES_WITH_CONSUMPTION.cost_for(16, 6000),
598            Some(Money::new(75, 0))
599        );
600
601        // 16A fuse with very high consumption - falls through to entry with no limit
602        assert_eq!(
603            FUSES_WITH_CONSUMPTION.cost_for(16, 20000),
604            Some(Money::new(75, 0))
605        );
606
607        // 25A fuse with consumption at limit - matches the entry with 10000 limit
608        assert_eq!(
609            FUSES_WITH_CONSUMPTION.cost_for(25, 10000),
610            Some(Money::new(100, 0))
611        );
612
613        // 25A fuse with consumption above limit - falls through to entry with no limit
614        assert_eq!(
615            FUSES_WITH_CONSUMPTION.cost_for(25, 15000),
616            Some(Money::new(125, 0))
617        );
618
619        // 25A fuse with consumption below limit - matches the entry with the limit
620        assert_eq!(
621            FUSES_WITH_CONSUMPTION.cost_for(25, 5000),
622            Some(Money::new(100, 0))
623        );
624    }
625
626    #[test]
627    fn cost_for_fuses_yearly_consumption_no_limit() {
628        const FUSES_NO_LIMIT: Cost = Cost::fuses_with_yearly_consumption(&[
629            (16, None, Money::new(50, 0)),
630            (25, None, Money::new(75, 0)),
631        ]);
632
633        // Should match regardless of consumption when limit is None
634        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 0), Some(Money::new(50, 0)));
635        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 1000), Some(Money::new(50, 0)));
636        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 50000), Some(Money::new(50, 0)));
637        assert_eq!(FUSES_NO_LIMIT.cost_for(25, 100000), Some(Money::new(75, 0)));
638    }
639
640    #[test]
641    fn cost_for_fuses_yearly_consumption_no_fuse_match() {
642        const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
643            (16, Some(5000), Money::new(50, 0)),
644            (25, Some(10000), Money::new(100, 0)),
645        ]);
646
647        // Non-matching fuse size
648        assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(35, 5000), None);
649        assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(50, 10000), None);
650    }
651
652    #[test]
653    fn cost_for_fuses_yearly_consumption_max_limit_no_fallback() {
654        const FUSES_ONLY_LIMITS: Cost = Cost::fuses_with_yearly_consumption(&[
655            (16, Some(5000), Money::new(50, 0)),
656            (25, Some(10000), Money::new(100, 0)),
657        ]);
658
659        // Matching fuse size with consumption at or below limit - should match
660        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 0), Some(Money::new(50, 0)));
661        assert_eq!(
662            FUSES_ONLY_LIMITS.cost_for(16, 3000),
663            Some(Money::new(50, 0))
664        );
665        assert_eq!(
666            FUSES_ONLY_LIMITS.cost_for(16, 4999),
667            Some(Money::new(50, 0))
668        );
669        assert_eq!(
670            FUSES_ONLY_LIMITS.cost_for(16, 5000),
671            Some(Money::new(50, 0))
672        );
673        assert_eq!(
674            FUSES_ONLY_LIMITS.cost_for(25, 9999),
675            Some(Money::new(100, 0))
676        );
677        assert_eq!(
678            FUSES_ONLY_LIMITS.cost_for(25, 10000),
679            Some(Money::new(100, 0))
680        );
681
682        // Above limit with no fallback - should return None
683        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 5001), None);
684        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 10000), None);
685        assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 10001), None);
686        assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 20000), None);
687    }
688
689    #[test]
690    fn cost_for_fuse_range_within_range() {
691        const FUSE_BASED: Cost = Cost::fuse_range(&[
692            (16, 35, Money::new(54, 0)),
693            (35, u16::MAX, Money::new(108, 50)),
694        ]);
695
696        // Test values below the first range
697        assert_eq!(FUSE_BASED.cost_for(10, 0), None);
698        assert_eq!(FUSE_BASED.cost_for(15, 0), None);
699
700        // Test values within the first range
701        assert_eq!(FUSE_BASED.cost_for(16, 0), Some(Money::new(54, 0)));
702        assert_eq!(FUSE_BASED.cost_for(25, 0), Some(Money::new(54, 0)));
703        assert_eq!(FUSE_BASED.cost_for(35, 0), Some(Money::new(54, 0)));
704
705        // Test values within the second range
706        assert_eq!(FUSE_BASED.cost_for(36, 0), Some(Money::new(108, 50)));
707        assert_eq!(FUSE_BASED.cost_for(50, 0), Some(Money::new(108, 50)));
708        assert_eq!(FUSE_BASED.cost_for(200, 0), Some(Money::new(108, 50)));
709        assert_eq!(FUSE_BASED.cost_for(u16::MAX, 0), Some(Money::new(108, 50)));
710    }
711
712    #[test]
713    fn cost_for_fuse_range_multiple_ranges() {
714        const MULTI_RANGE: Cost = Cost::fuse_range(&[
715            (1, 15, Money::new(20, 0)),
716            (16, 35, Money::new(50, 0)),
717            (36, 63, Money::new(100, 0)),
718            (64, u16::MAX, Money::new(200, 0)),
719        ]);
720
721        // Test each range
722        assert_eq!(MULTI_RANGE.cost_for(10, 0), Some(Money::new(20, 0)));
723        assert_eq!(MULTI_RANGE.cost_for(25, 0), Some(Money::new(50, 0)));
724        assert_eq!(MULTI_RANGE.cost_for(50, 0), Some(Money::new(100, 0)));
725        assert_eq!(MULTI_RANGE.cost_for(100, 0), Some(Money::new(200, 0)));
726
727        // Yearly consumption should not affect range-based costs
728        assert_eq!(MULTI_RANGE.cost_for(25, 10000), Some(Money::new(50, 0)));
729    }
730
731    #[test]
732    fn include_matches_hours() {
733        let include = Include::Hours(Hours::new(6, 22, Stockholm));
734        let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
735        let timestamp_no_match = Stockholm.dt(2025, 1, 15, 23, 0, 0);
736
737        assert!(include.matches(timestamp_match));
738        assert!(!include.matches(timestamp_no_match));
739    }
740
741    #[test]
742    fn include_matches_months() {
743        let include = Include::Months(Months::new(November, March, Stockholm));
744        let timestamp_match = Stockholm.dt(2025, 1, 15, 12, 0, 0);
745        let timestamp_no_match = Stockholm.dt(2025, 7, 15, 12, 0, 0);
746
747        assert!(include.matches(timestamp_match));
748        assert!(!include.matches(timestamp_no_match));
749    }
750
751    #[test]
752    fn exclude_matches_weekends_saturday() {
753        let exclude = Exclude::Weekends(Stockholm);
754        // January 4, 2025 is a Saturday
755        let timestamp = Stockholm.dt(2025, 1, 4, 12, 0, 0);
756        assert!(exclude.matches(timestamp));
757    }
758
759    #[test]
760    fn exclude_matches_weekends_sunday() {
761        let exclude = Exclude::Weekends(Stockholm);
762        // January 5, 2025 is a Sunday
763        let timestamp = Stockholm.dt(2025, 1, 5, 12, 0, 0);
764        assert!(exclude.matches(timestamp));
765    }
766
767    #[test]
768    fn exclude_does_not_match_weekday() {
769        let exclude = Exclude::Weekends(Stockholm);
770        // January 6, 2025 is a Monday
771        let timestamp = Stockholm.dt(2025, 1, 6, 12, 0, 0);
772        assert!(!exclude.matches(timestamp));
773    }
774
775    #[test]
776    fn exclude_matches_swedish_new_year() {
777        let exclude = Exclude::Holidays(Country::SE, Stockholm);
778        // January 1 is a Swedish holiday
779        let timestamp = Stockholm.dt(2025, 1, 1, 12, 0, 0);
780        assert!(exclude.matches(timestamp));
781    }
782
783    #[test]
784    fn exclude_does_not_match_non_holiday() {
785        let exclude = Exclude::Holidays(Country::SE, Stockholm);
786        // January 2, 2025 is not a Swedish holiday
787        let timestamp = Stockholm.dt(2025, 1, 2, 12, 0, 0);
788        assert!(!exclude.matches(timestamp));
789    }
790
791    #[test]
792    fn cost_period_matches_with_single_include() {
793        let period = CostPeriod::builder()
794            .load(LoadType::High)
795            .fixed_cost(10, 0)
796            .hours(6, 22)
797            .build();
798
799        let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
800        let timestamp_no_match = Stockholm.dt(2025, 1, 15, 23, 0, 0);
801
802        assert!(period.matches(timestamp_match));
803        assert!(!period.matches(timestamp_no_match));
804    }
805
806    #[test]
807    fn cost_period_matches_with_multiple_includes() {
808        let period = CostPeriod::builder()
809            .load(LoadType::High)
810            .fixed_cost(10, 0)
811            .hours(6, 22)
812            .months(November, March)
813            .build();
814
815        // Winter daytime - should match
816        let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
817        // Winter nighttime - should not match (wrong hours)
818        let timestamp_wrong_hours = Stockholm.dt(2025, 1, 15, 23, 0, 0);
819        // Summer daytime - should not match (wrong months)
820        let timestamp_wrong_months = Stockholm.dt(2025, 7, 15, 14, 0, 0);
821
822        assert!(period.matches(timestamp_match));
823        assert!(!period.matches(timestamp_wrong_hours));
824        assert!(!period.matches(timestamp_wrong_months));
825    }
826
827    #[test]
828    fn cost_period_matches_with_exclude_weekends() {
829        let period = CostPeriod::builder()
830            .load(LoadType::High)
831            .fixed_cost(10, 0)
832            .hours(6, 22)
833            .exclude_weekends()
834            .build();
835
836        println!("Excludes: {:?}", period.exclude_period_types());
837        println!("Includes: {:?}", period.include_period_types());
838
839        // Monday daytime - should match
840        let timestamp_weekday = Stockholm.dt(2025, 1, 6, 14, 0, 0);
841        // Saturday daytime - should not match (excluded)
842        let timestamp_saturday = Stockholm.dt(2025, 1, 4, 14, 0, 0);
843
844        assert!(period.matches(timestamp_weekday));
845        assert!(!period.matches(timestamp_saturday));
846    }
847
848    #[test]
849    fn cost_period_matches_with_exclude_holidays() {
850        let period = CostPeriod::builder()
851            .load(LoadType::High)
852            .fixed_cost(10, 0)
853            .hours(6, 22)
854            .exclude_holidays(Country::SE)
855            .build();
856
857        // Regular weekday - should match
858        let timestamp_regular = Stockholm.dt(2025, 1, 2, 14, 0, 0);
859        // New Year's Day - should not match (excluded)
860        let timestamp_holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
861
862        assert!(period.matches(timestamp_regular));
863        assert!(!period.matches(timestamp_holiday));
864    }
865
866    #[test]
867    fn cost_period_matches_complex_scenario() {
868        // Winter high load period: Nov-Mar, 6-22, excluding weekends and holidays
869        let period = CostPeriod::builder()
870            .load(LoadType::High)
871            .fixed_cost(10, 0)
872            .months(November, March)
873            .hours(6, 22)
874            .exclude_weekends()
875            .exclude_holidays(Country::SE)
876            .build();
877
878        // Winter weekday daytime (not holiday) - should match
879        let timestamp_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
880
881        // Winter weekday nighttime - should not match (wrong hours)
882        let timestamp_wrong_hours = Stockholm.dt(2025, 1, 15, 23, 0, 0);
883
884        // Winter Saturday daytime - should not match (weekend)
885        let timestamp_weekend = Stockholm.dt(2025, 1, 4, 14, 0, 0);
886
887        // New Year's Day (holiday) - should not match
888        let timestamp_holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
889
890        // Summer weekday daytime - should not match (wrong months)
891        let timestamp_summer = Stockholm.dt(2025, 7, 15, 14, 0, 0);
892
893        assert!(period.matches(timestamp_match));
894        assert!(!period.matches(timestamp_wrong_hours));
895        assert!(!period.matches(timestamp_weekend));
896        assert!(!period.matches(timestamp_holiday));
897        assert!(!period.matches(timestamp_summer));
898    }
899
900    #[test]
901    fn cost_period_matches_base_load() {
902        // Base load period with no restrictions
903        let period = CostPeriod::builder()
904            .load(LoadType::Base)
905            .fixed_cost(5, 0)
906            .build();
907
908        // Should match any time
909        let timestamp1 = Stockholm.dt(2025, 1, 1, 0, 0, 0);
910        let timestamp2 = Stockholm.dt(2025, 7, 15, 23, 59, 59);
911        let timestamp3 = Stockholm.dt(2025, 1, 4, 12, 0, 0);
912
913        assert!(period.matches(timestamp1));
914        assert!(period.matches(timestamp2));
915        assert!(period.matches(timestamp3));
916    }
917
918    #[test]
919    fn include_matches_hours_wraparound() {
920        // Night hours crossing midnight: 22:00 to 05:59
921        let include = Include::Hours(Hours::new(22, 5, Stockholm));
922
923        // Should match late evening
924        let timestamp_evening = Stockholm.dt(2025, 1, 15, 22, 0, 0);
925        assert!(include.matches(timestamp_evening));
926
927        // Should match midnight
928        let timestamp_midnight = Stockholm.dt(2025, 1, 15, 0, 0, 0);
929        assert!(include.matches(timestamp_midnight));
930
931        // Should match early morning
932        let timestamp_morning = Stockholm.dt(2025, 1, 15, 5, 30, 0);
933        assert!(include.matches(timestamp_morning));
934
935        // Should not match daytime
936        let timestamp_day = Stockholm.dt(2025, 1, 15, 14, 0, 0);
937        assert!(!include.matches(timestamp_day));
938
939        // Should not match just after the range
940        let timestamp_after = Stockholm.dt(2025, 1, 15, 6, 0, 0);
941        assert!(!include.matches(timestamp_after));
942
943        // Should not match just before the range
944        let timestamp_before = Stockholm.dt(2025, 1, 15, 21, 59, 59);
945        assert!(!include.matches(timestamp_before));
946    }
947
948    #[test]
949    fn include_matches_months_wraparound() {
950        // Winter months crossing year boundary: November to March
951        let include = Include::Months(Months::new(November, March, Stockholm));
952
953        // Should match November (start)
954        let timestamp_nov = Stockholm.dt(2025, 11, 15, 12, 0, 0);
955        assert!(include.matches(timestamp_nov));
956
957        // Should match December
958        let timestamp_dec = Stockholm.dt(2025, 12, 15, 12, 0, 0);
959        assert!(include.matches(timestamp_dec));
960
961        // Should match January
962        let timestamp_jan = Stockholm.dt(2025, 1, 15, 12, 0, 0);
963        assert!(include.matches(timestamp_jan));
964
965        // Should match March (end)
966        let timestamp_mar = Stockholm.dt(2025, 3, 15, 12, 0, 0);
967        assert!(include.matches(timestamp_mar));
968
969        // Should not match summer months
970        let timestamp_jul = Stockholm.dt(2025, 7, 15, 12, 0, 0);
971        assert!(!include.matches(timestamp_jul));
972
973        // Should not match October (just before)
974        let timestamp_oct = Stockholm.dt(2025, 10, 31, 23, 59, 59);
975        assert!(!include.matches(timestamp_oct));
976
977        // Should not match April (just after)
978        let timestamp_apr = Stockholm.dt(2025, 4, 1, 0, 0, 0);
979        assert!(!include.matches(timestamp_apr));
980    }
981
982    #[test]
983    fn cost_period_matches_hours_wraparound() {
984        // Night period: 22:00 to 05:59
985        let period = CostPeriod::builder()
986            .load(LoadType::Low)
987            .fixed_cost(5, 0)
988            .hours(22, 5)
989            .build();
990
991        let timestamp_match_evening = Stockholm.dt(2025, 1, 15, 23, 0, 0);
992        let timestamp_match_morning = Stockholm.dt(2025, 1, 15, 3, 0, 0);
993        let timestamp_no_match = Stockholm.dt(2025, 1, 15, 14, 0, 0);
994
995        assert!(period.matches(timestamp_match_evening));
996        assert!(period.matches(timestamp_match_morning));
997        assert!(!period.matches(timestamp_no_match));
998    }
999
1000    #[test]
1001    fn cost_period_matches_with_both_excludes() {
1002        let period = CostPeriod::builder()
1003            .load(LoadType::High)
1004            .fixed_cost(10, 0)
1005            .hours(6, 22)
1006            .exclude_weekends()
1007            .exclude_holidays(Country::SE)
1008            .build();
1009
1010        // Regular weekday - should match
1011        let weekday = Stockholm.dt(2025, 1, 2, 14, 0, 0);
1012        assert!(period.matches(weekday));
1013
1014        // Weekend - should not match
1015        let saturday = Stockholm.dt(2025, 1, 4, 14, 0, 0);
1016        assert!(!period.matches(saturday));
1017
1018        // Holiday (New Year) - should not match
1019        let holiday = Stockholm.dt(2025, 1, 1, 14, 0, 0);
1020        assert!(!period.matches(holiday));
1021
1022        // Weekday but wrong hours - should not match
1023        let wrong_hours = Stockholm.dt(2025, 1, 2, 23, 0, 0);
1024        assert!(!period.matches(wrong_hours));
1025    }
1026
1027    #[test]
1028    fn exclude_matches_friday_is_not_weekend() {
1029        let exclude = Exclude::Weekends(Stockholm);
1030        // January 3, 2025 is a Friday
1031        let friday = Stockholm.dt(2025, 1, 3, 12, 0, 0);
1032        assert!(!exclude.matches(friday));
1033    }
1034
1035    #[test]
1036    fn exclude_matches_monday_is_not_weekend() {
1037        let exclude = Exclude::Weekends(Stockholm);
1038        // January 6, 2025 is a Monday
1039        let monday = Stockholm.dt(2025, 1, 6, 12, 0, 0);
1040        assert!(!exclude.matches(monday));
1041    }
1042
1043    #[test]
1044    fn exclude_matches_holiday_midsummer() {
1045        let exclude = Exclude::Holidays(Country::SE, Stockholm);
1046        // Midsummer 2025 (June 21)
1047        let midsummer = Stockholm.dt(2025, 6, 21, 12, 0, 0);
1048        assert!(exclude.matches(midsummer));
1049    }
1050
1051    #[test]
1052    fn cost_period_matches_month_and_hours() {
1053        // June with specific hours
1054        let period = CostPeriod::builder()
1055            .load(LoadType::Low)
1056            .fixed_cost(5, 0)
1057            .month(June)
1058            .hours(22, 5)
1059            .build();
1060
1061        // June during night hours - should match
1062        let match_june_night = Stockholm.dt(2025, 6, 15, 23, 0, 0);
1063        assert!(period.matches(match_june_night));
1064
1065        // June during day hours - should not match
1066        let june_day = Stockholm.dt(2025, 6, 15, 14, 0, 0);
1067        assert!(!period.matches(june_day));
1068
1069        // July during night hours - should not match (wrong month)
1070        let july_night = Stockholm.dt(2025, 7, 15, 23, 0, 0);
1071        assert!(!period.matches(july_night));
1072    }
1073
1074    #[test]
1075    fn cost_period_matches_months_and_hours_with_exclude() {
1076        // Winter high load: Nov-Mar, 6-22, excluding weekends and holidays
1077        let period = CostPeriod::builder()
1078            .load(LoadType::High)
1079            .fixed_cost(15, 0)
1080            .months(November, March)
1081            .hours(6, 22)
1082            .exclude_weekends()
1083            .exclude_holidays(Country::SE)
1084            .build();
1085
1086        // Perfect match: winter weekday during day hours
1087        let perfect = Stockholm.dt(2025, 1, 15, 10, 0, 0);
1088        assert!(period.matches(perfect));
1089
1090        // First hour of range
1091        let first_hour = Stockholm.dt(2025, 1, 15, 6, 0, 0);
1092        assert!(period.matches(first_hour));
1093
1094        // Last hour of range
1095        let last_hour = Stockholm.dt(2025, 1, 15, 22, 59, 59);
1096        assert!(period.matches(last_hour));
1097
1098        // Wrong hours (too early)
1099        let too_early = Stockholm.dt(2025, 1, 15, 5, 59, 59);
1100        assert!(!period.matches(too_early));
1101
1102        // Wrong hours (too late)
1103        let too_late = Stockholm.dt(2025, 1, 15, 23, 0, 0);
1104        assert!(!period.matches(too_late));
1105
1106        // Wrong month (summer)
1107        let summer = Stockholm.dt(2025, 7, 15, 10, 0, 0);
1108        assert!(!period.matches(summer));
1109
1110        // Weekend
1111        let weekend = Stockholm.dt(2025, 1, 4, 10, 0, 0);
1112        assert!(!period.matches(weekend));
1113    }
1114
1115    #[test]
1116    fn cost_period_matches_base_with_restrictions() {
1117        // Base load but with hour restrictions
1118        let period = CostPeriod::builder()
1119            .load(LoadType::Base)
1120            .fixed_cost(3, 0)
1121            .hours(0, 5)
1122            .build();
1123
1124        // Should match only during specified hours
1125        let match_night = Stockholm.dt(2025, 1, 15, 3, 0, 0);
1126        assert!(period.matches(match_night));
1127
1128        // Should not match outside hours
1129        let no_match_day = Stockholm.dt(2025, 1, 15, 14, 0, 0);
1130        assert!(!period.matches(no_match_day));
1131    }
1132
1133    #[test]
1134    fn cost_period_matches_single_month() {
1135        let period = CostPeriod::builder()
1136            .load(LoadType::High)
1137            .fixed_cost(10, 0)
1138            .month(December)
1139            .build();
1140
1141        // First day of December
1142        let dec_first = Stockholm.dt(2025, 12, 1, 0, 0, 0);
1143        assert!(period.matches(dec_first));
1144
1145        // Last day of December
1146        let dec_last = Stockholm.dt(2025, 12, 31, 23, 59, 59);
1147        assert!(period.matches(dec_last));
1148
1149        // November - should not match
1150        let nov = Stockholm.dt(2025, 11, 30, 12, 0, 0);
1151        assert!(!period.matches(nov));
1152
1153        // January - should not match
1154        let jan = Stockholm.dt(2025, 1, 1, 12, 0, 0);
1155        assert!(!period.matches(jan));
1156    }
1157
1158    #[test]
1159    fn cost_period_matches_all_hours() {
1160        // Full day coverage: 0-23
1161        let period = CostPeriod::builder()
1162            .load(LoadType::Low)
1163            .fixed_cost(5, 0)
1164            .hours(0, 23)
1165            .build();
1166
1167        let midnight = Stockholm.dt(2025, 1, 15, 0, 0, 0);
1168        let noon = Stockholm.dt(2025, 1, 15, 12, 0, 0);
1169        let almost_midnight = Stockholm.dt(2025, 1, 15, 23, 59, 59);
1170
1171        assert!(period.matches(midnight));
1172        assert!(period.matches(noon));
1173        assert!(period.matches(almost_midnight));
1174    }
1175
1176    #[test]
1177    fn cost_period_matches_edge_of_month_range() {
1178        // May to September
1179        let period = CostPeriod::builder()
1180            .load(LoadType::Low)
1181            .fixed_cost(5, 0)
1182            .months(May, September)
1183            .build();
1184
1185        // First second of May
1186        let may_start = Stockholm.dt(2025, 5, 1, 0, 0, 0);
1187        assert!(period.matches(may_start));
1188
1189        // Last second of April - should not match
1190        let april_end = Stockholm.dt(2025, 4, 30, 23, 59, 59);
1191        assert!(!period.matches(april_end));
1192
1193        // Last second of September
1194        let sept_end = Stockholm.dt(2025, 9, 30, 23, 59, 59);
1195        assert!(period.matches(sept_end));
1196
1197        // First second of October - should not match
1198        let oct_start = Stockholm.dt(2025, 10, 1, 0, 0, 0);
1199        assert!(!period.matches(oct_start));
1200    }
1201
1202    #[test]
1203    fn include_matches_month_boundary() {
1204        // Test first and last day of specific month
1205        let include = Include::Months(Months::new(February, February, Stockholm));
1206
1207        // First second of February
1208        let feb_start = Stockholm.dt(2025, 2, 1, 0, 0, 0);
1209        assert!(include.matches(feb_start));
1210
1211        // Last second of February
1212        let feb_end = Stockholm.dt(2025, 2, 28, 23, 59, 59);
1213        assert!(include.matches(feb_end));
1214
1215        // Last second of January
1216        let jan_end = Stockholm.dt(2025, 1, 31, 23, 59, 59);
1217        assert!(!include.matches(jan_end));
1218
1219        // First second of March
1220        let mar_start = Stockholm.dt(2025, 3, 1, 0, 0, 0);
1221        assert!(!include.matches(mar_start));
1222    }
1223
1224    #[test]
1225    fn include_matches_hours_exact_boundaries() {
1226        let include = Include::Hours(Hours::new(6, 22, Stockholm));
1227
1228        // First second of hour 6
1229        let start = Stockholm.dt(2025, 1, 15, 6, 0, 0);
1230        assert!(include.matches(start));
1231
1232        // Last second of hour 22
1233        let end = Stockholm.dt(2025, 1, 15, 22, 59, 59);
1234        assert!(include.matches(end));
1235
1236        // Last second of hour 5 (just before)
1237        let before = Stockholm.dt(2025, 1, 15, 5, 59, 59);
1238        assert!(!include.matches(before));
1239
1240        // First second of hour 23 (just after)
1241        let after = Stockholm.dt(2025, 1, 15, 23, 0, 0);
1242        assert!(!include.matches(after));
1243    }
1244
1245    #[test]
1246    fn exclude_matches_weekends_with_utc_timestamps() {
1247        let exclude = Exclude::Weekends(Stockholm);
1248
1249        // Saturday January 4, 2025 at 12:00 Stockholm time
1250        // = Saturday at 11:00 UTC (Stockholm is UTC+1 in winter)
1251        let saturday_utc = Utc.dt(2025, 1, 4, 11, 0, 0);
1252        assert!(exclude.matches(saturday_utc));
1253
1254        // Sunday January 5, 2025 at 12:00 Stockholm time
1255        // = Sunday at 11:00 UTC
1256        let sunday_utc = Utc.dt(2025, 1, 5, 11, 0, 0);
1257        assert!(exclude.matches(sunday_utc));
1258
1259        // Monday January 6, 2025 at 12:00 Stockholm time
1260        // = Monday at 11:00 UTC
1261        let monday_utc = Utc.dt(2025, 1, 6, 11, 0, 0);
1262        assert!(!exclude.matches(monday_utc));
1263    }
1264
1265    #[test]
1266    fn exclude_matches_weekends_timezone_boundary() {
1267        let exclude = Exclude::Weekends(Stockholm);
1268
1269        // Saturday January 4, 2025 at 00:00 Stockholm time
1270        // = Friday January 3, 2025 at 23:00 UTC
1271        // This is tricky: it's Friday in UTC but Saturday in Stockholm
1272        let friday_utc_saturday_stockholm = Utc.dt(2025, 1, 3, 23, 0, 0);
1273        assert!(
1274            exclude.matches(friday_utc_saturday_stockholm),
1275            "Should match because it's Saturday in Stockholm timezone"
1276        );
1277
1278        // Monday January 6, 2025 at 00:00 Stockholm time
1279        // = Sunday January 5, 2025 at 23:00 UTC
1280        // This is Sunday in UTC but Monday in Stockholm
1281        let sunday_utc_monday_stockholm = Utc.dt(2025, 1, 5, 23, 0, 0);
1282        assert!(
1283            !exclude.matches(sunday_utc_monday_stockholm),
1284            "Should not match because it's Monday in Stockholm timezone"
1285        );
1286
1287        // Sunday January 5, 2025 at 23:59 Stockholm time
1288        // = Sunday January 5, 2025 at 22:59 UTC
1289        let sunday_late_utc = Utc.dt(2025, 1, 5, 22, 59, 0);
1290        assert!(
1291            exclude.matches(sunday_late_utc),
1292            "Should match because it's still Sunday in Stockholm timezone"
1293        );
1294    }
1295
1296    #[test]
1297    fn exclude_matches_holidays_with_utc_timestamps() {
1298        let exclude = Exclude::Holidays(Country::SE, Stockholm);
1299
1300        // New Year's Day 2025 at 12:00 Stockholm time
1301        // = January 1, 2025 at 11:00 UTC
1302        let new_year_utc = Utc.dt(2025, 1, 1, 11, 0, 0);
1303        assert!(exclude.matches(new_year_utc));
1304
1305        // Regular day: January 2, 2025 at 12:00 Stockholm time
1306        // = January 2, 2025 at 11:00 UTC
1307        let regular_day_utc = Utc.dt(2025, 1, 2, 11, 0, 0);
1308        assert!(!exclude.matches(regular_day_utc));
1309    }
1310
1311    #[test]
1312    fn exclude_matches_holidays_timezone_boundary() {
1313        let exclude = Exclude::Holidays(Country::SE, Stockholm);
1314
1315        // New Year's Day 2025 at 00:00 Stockholm time
1316        // = December 31, 2024 at 23:00 UTC
1317        // This is Dec 31 in UTC but Jan 1 (holiday) in Stockholm
1318        let dec31_utc_jan1_stockholm = Utc.dt(2024, 12, 31, 23, 0, 0);
1319        assert!(
1320            exclude.matches(dec31_utc_jan1_stockholm),
1321            "Should match because it's New Year's Day in Stockholm timezone"
1322        );
1323
1324        // January 2, 2025 at 00:00 Stockholm time
1325        // = January 1, 2025 at 23:00 UTC
1326        // This is Jan 1 (holiday) in UTC but Jan 2 (not holiday) in Stockholm
1327        let jan1_utc_jan2_stockholm = Utc.dt(2025, 1, 1, 23, 0, 0);
1328        assert!(
1329            !exclude.matches(jan1_utc_jan2_stockholm),
1330            "Should not match because it's January 2 in Stockholm timezone"
1331        );
1332    }
1333
1334    #[test]
1335    fn exclude_matches_weekends_summer_timezone() {
1336        let exclude = Exclude::Weekends(Stockholm);
1337
1338        // Saturday June 7, 2025 at 12:00 Stockholm time (CEST = UTC+2)
1339        // = Saturday at 10:00 UTC
1340        let saturday_summer_utc = Utc.dt(2025, 6, 7, 10, 0, 0);
1341        assert!(exclude.matches(saturday_summer_utc));
1342
1343        // Saturday June 7, 2025 at 00:00 Stockholm time
1344        // = Friday June 6, 2025 at 22:00 UTC
1345        let friday_utc_saturday_stockholm_summer = Utc.dt(2025, 6, 6, 22, 0, 0);
1346        assert!(
1347            exclude.matches(friday_utc_saturday_stockholm_summer),
1348            "Should match because it's Saturday in Stockholm timezone (CEST)"
1349        );
1350    }
1351}