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