grid_tariffs/
costs.rs

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