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