grid_tariffs/
costs.rs

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