grid_tariffs/
costs.rs

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