grid_tariffs/
costs.rs

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