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