oxedyne/fe2o3/fe2o3_datime/src/calendar/holiday_engines.rs
21.9 KiB, 55 runs
created by r1870400018:8388, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Advanced holiday calculation engines for different jurisdictions. |
| 2 | //! |
| 3 | //! This module provides sophisticated holiday engines that can automatically |
| 4 | //! calculate complex holidays like Easter, Thanksgiving, and other date-based |
| 5 | //! holidays for various countries and jurisdictions. |
| 6 | //! |
| 7 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 8 | //! Anthropic Claude |
| 9 | |
| 10 | use crate::{ |
| 11 | calendar::CalendarDate, |
| 12 | constant::{DayOfWeek, MonthOfYear}, |
| 13 | time::CalClockZone, |
| 14 | }; |
| 15 | use oxedyne_fe2o3_core::prelude::*; |
| 16 | use std::collections::HashMap; |
| 17 | |
| 18 | #[derive(Clone, Debug, PartialEq)] |
| 19 | pub enum HolidayType { |
| 20 | Fixed { month: u8, day: u8 }, |
| 21 | EasterBased { offset_days: i32 }, |
| 22 | Relative { month: u8, weekday: DayOfWeek, occurrence: i32 }, |
| 23 | LastWeekday { month: u8, weekday: DayOfWeek }, |
| 24 | FirstWeekdayAfter { month: u8, day: u8, weekday: DayOfWeek }, |
| 25 | } |
| 26 | |
| 27 | #[derive(Clone, Debug)] |
| 28 | pub struct HolidayDefinition { |
| 29 | pub name: String, |
| 30 | pub holiday_type: HolidayType, |
| 31 | pub weekend_adjustment: WeekendAdjustment, |
| 32 | pub description: Option<String>, |
| 33 | } |
| 34 | |
| 35 | /// How a holiday landing on a weekend is observed. |
| 36 | #[derive(Clone, Debug, PartialEq)] |
| 37 | pub enum WeekendAdjustment { |
| 38 | None, |
| 39 | Nearest, // Saturday to Friday, Sunday to Monday |
| 40 | Monday, // the following Monday, from either weekend day |
| 41 | Friday, // the preceding Friday, from either weekend day |
| 42 | Custom { saturday_shift: i32, sunday_shift: i32 }, |
| 43 | } |
| 44 | |
| 45 | #[derive(Clone, Debug)] |
| 46 | pub struct HolidayEngine { |
| 47 | jurisdiction: String, |
| 48 | holidays: HashMap<String, HolidayDefinition>, |
| 49 | default_weekend_adjustment: WeekendAdjustment, |
| 50 | } |
| 51 | |
| 52 | impl HolidayEngine { |
| 53 | pub fn new<S: Into<String>>(jurisdiction: S) -> Self { |
| 54 | Self { |
| 55 | jurisdiction: jurisdiction.into(), |
| 56 | holidays: HashMap::new(), |
| 57 | default_weekend_adjustment: WeekendAdjustment::None, |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | pub fn with_default_weekend_adjustment(mut self, adjustment: WeekendAdjustment) -> Self { |
| 62 | self.default_weekend_adjustment = adjustment; |
| 63 | self |
| 64 | } |
| 65 | |
| 66 | pub fn add_holiday(mut self, holiday: HolidayDefinition) -> Self { |
| 67 | self.holidays.insert(holiday.name.clone(), holiday); |
| 68 | self |
| 69 | } |
| 70 | |
| 71 | pub fn calculate_holidays(&self, year: i32, zone: CalClockZone) -> Outcome<Vec<(String, CalendarDate)>> { |
| 72 | let mut holidays = Vec::new(); |
| 73 | |
| 74 | for (name, definition) in &self.holidays { |
| 75 | let calculated_date = res!(self.calculate_holiday(definition, year, zone.clone())); |
| 76 | holidays.push((name.clone(), calculated_date)); |
| 77 | } |
| 78 | |
| 79 | // Sort by date. |
| 80 | holidays.sort_by(|a, b| a.1.cmp(&b.1)); |
| 81 | Ok(holidays) |
| 82 | } |
| 83 | |
| 84 | pub fn calculate_holiday(&self, definition: &HolidayDefinition, year: i32, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 85 | let base_date = match &definition.holiday_type { |
| 86 | HolidayType::Fixed { month, day } => { |
| 87 | let month_enum = res!(MonthOfYear::from_number(*month)); |
| 88 | res!(CalendarDate::from_ymd(year, month_enum, *day, zone.clone())) |
| 89 | }, |
| 90 | HolidayType::EasterBased { offset_days } => { |
| 91 | let easter = res!(Self::calculate_easter(year, zone.clone())); |
| 92 | res!(easter.add_days(*offset_days)) |
| 93 | }, |
| 94 | HolidayType::Relative { month, weekday, occurrence } => { |
| 95 | res!(Self::calculate_nth_weekday(year, *month, *weekday, *occurrence, zone.clone())) |
| 96 | }, |
| 97 | HolidayType::LastWeekday { month, weekday } => { |
| 98 | res!(Self::calculate_last_weekday(year, *month, *weekday, zone.clone())) |
| 99 | }, |
| 100 | HolidayType::FirstWeekdayAfter { month, day, weekday } => { |
| 101 | res!(Self::calculate_first_weekday_after(year, *month, *day, *weekday, zone.clone())) |
| 102 | }, |
| 103 | }; |
| 104 | |
| 105 | // Apply weekend adjustment. |
| 106 | self.apply_weekend_adjustment(&base_date, &definition.weekend_adjustment) |
| 107 | } |
| 108 | |
| 109 | fn apply_weekend_adjustment(&self, date: &CalendarDate, adjustment: &WeekendAdjustment) -> Outcome<CalendarDate> { |
| 110 | let dow = date.day_of_week(); |
| 111 | |
| 112 | match adjustment { |
| 113 | WeekendAdjustment::None => Ok(date.clone()), |
| 114 | WeekendAdjustment::Nearest => { |
| 115 | match dow { |
| 116 | DayOfWeek::Saturday => date.add_days(-1), // Friday |
| 117 | DayOfWeek::Sunday => date.add_days(1), // Monday |
| 118 | _ => Ok(date.clone()), |
| 119 | } |
| 120 | }, |
| 121 | WeekendAdjustment::Monday => { |
| 122 | match dow { |
| 123 | DayOfWeek::Saturday | DayOfWeek::Sunday => { |
| 124 | // Find next Monday. |
| 125 | let days_to_monday = match dow { |
| 126 | DayOfWeek::Saturday => 2, |
| 127 | DayOfWeek::Sunday => 1, |
| 128 | _ => 0, |
| 129 | }; |
| 130 | date.add_days(days_to_monday) |
| 131 | }, |
| 132 | _ => Ok(date.clone()), |
| 133 | } |
| 134 | }, |
| 135 | WeekendAdjustment::Friday => { |
| 136 | match dow { |
| 137 | DayOfWeek::Saturday | DayOfWeek::Sunday => { |
| 138 | // Find previous Friday. |
| 139 | let days_to_friday = match dow { |
| 140 | DayOfWeek::Saturday => -1, |
| 141 | DayOfWeek::Sunday => -2, |
| 142 | _ => 0, |
| 143 | }; |
| 144 | date.add_days(days_to_friday) |
| 145 | }, |
| 146 | _ => Ok(date.clone()), |
| 147 | } |
| 148 | }, |
| 149 | WeekendAdjustment::Custom { saturday_shift, sunday_shift } => { |
| 150 | match dow { |
| 151 | DayOfWeek::Saturday => date.add_days(*saturday_shift), |
| 152 | DayOfWeek::Sunday => date.add_days(*sunday_shift), |
| 153 | _ => Ok(date.clone()), |
| 154 | } |
| 155 | }, |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// The Western computus, as used by most Western churches. |
| 160 | pub fn calculate_easter(year: i32, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 161 | // Use the algorithm from Oudin (1940) for Gregorian calendar Easter. |
| 162 | let a = year % 19; |
| 163 | let b = year / 100; |
| 164 | let c = year % 100; |
| 165 | let d = b / 4; |
| 166 | let e = b % 4; |
| 167 | let f = (b + 8) / 25; |
| 168 | let g = (b - f + 1) / 3; |
| 169 | let h = (19 * a + b - d - g + 15) % 30; |
| 170 | let i = c / 4; |
| 171 | let k = c % 4; |
| 172 | let l = (32 + 2 * e + 2 * i - h - k) % 7; |
| 173 | let m = (a + 11 * h + 22 * l) / 451; |
| 174 | let month = (h + l - 7 * m + 114) / 31; |
| 175 | let day = ((h + l - 7 * m + 114) % 31) + 1; |
| 176 | |
| 177 | let month_enum = res!(MonthOfYear::from_number(month as u8)); |
| 178 | CalendarDate::from_ymd(year, month_enum, day as u8, zone) |
| 179 | } |
| 180 | |
| 181 | fn calculate_nth_weekday(year: i32, month: u8, weekday: DayOfWeek, occurrence: i32, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 182 | let month_enum = res!(MonthOfYear::from_number(month)); |
| 183 | let first_of_month = res!(CalendarDate::from_ymd(year, month_enum, 1, zone.clone())); |
| 184 | |
| 185 | // Find the first occurrence of the target weekday. |
| 186 | let first_weekday_day = { |
| 187 | let first_dow = first_of_month.day_of_week(); |
| 188 | let target_dow_num = weekday.of() as i32; |
| 189 | let first_dow_num = first_dow.of() as i32; |
| 190 | |
| 191 | let days_to_add = if target_dow_num >= first_dow_num { |
| 192 | target_dow_num - first_dow_num |
| 193 | } else { |
| 194 | 7 - (first_dow_num - target_dow_num) |
| 195 | }; |
| 196 | 1 + days_to_add |
| 197 | }; |
| 198 | |
| 199 | // Calculate the nth occurrence. |
| 200 | let target_day = first_weekday_day + (occurrence - 1) * 7; |
| 201 | |
| 202 | // Verify the day is within the month. |
| 203 | let days_in_month = month_enum.days_in_month(year); |
| 204 | if target_day > days_in_month as i32 { |
| 205 | return Err(err!("No {}th {} in {} {}", occurrence, weekday, month_enum, year; Invalid, Input)); |
| 206 | } |
| 207 | |
| 208 | CalendarDate::from_ymd(year, month_enum, target_day as u8, zone) |
| 209 | } |
| 210 | |
| 211 | fn calculate_last_weekday(year: i32, month: u8, weekday: DayOfWeek, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 212 | let month_enum = res!(MonthOfYear::from_number(month)); |
| 213 | let days_in_month = month_enum.days_in_month(year); |
| 214 | |
| 215 | // Start from the last day and work backwards. |
| 216 | for day in (1..=days_in_month).rev() { |
| 217 | let candidate = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone())); |
| 218 | if candidate.day_of_week() == weekday { |
| 219 | return Ok(candidate); |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | Err(err!("No {} found in {} {}", weekday, month_enum, year; Invalid, Input)) |
| 224 | } |
| 225 | |
| 226 | fn calculate_first_weekday_after(year: i32, month: u8, day: u8, weekday: DayOfWeek, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 227 | let month_enum = res!(MonthOfYear::from_number(month)); |
| 228 | let start_date = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone())); |
| 229 | |
| 230 | // Search forward for the target weekday. |
| 231 | let mut current = res!(start_date.add_days(1)); // Start from the day after. |
| 232 | for _ in 0..7 { // Maximum 7 days to find any weekday. |
| 233 | if current.day_of_week() == weekday { |
| 234 | return Ok(current); |
| 235 | } |
| 236 | current = res!(current.add_days(1)); |
| 237 | } |
| 238 | |
| 239 | Err(err!("Could not find {} after {} {} {}", weekday, month_enum, day, year; Invalid, Input)) |
| 240 | } |
| 241 | |
| 242 | pub fn is_holiday(&self, date: &CalendarDate) -> Outcome<bool> { |
| 243 | let year = date.year(); |
| 244 | let holidays = res!(self.calculate_holidays(year, date.zone().clone())); |
| 245 | |
| 246 | for (_, holiday_date) in holidays { |
| 247 | if holiday_date == *date { |
| 248 | return Ok(true); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | Ok(false) |
| 253 | } |
| 254 | |
| 255 | pub fn jurisdiction(&self) -> &str { |
| 256 | &self.jurisdiction |
| 257 | } |
| 258 | |
| 259 | pub fn holiday_names(&self) -> Vec<String> { |
| 260 | self.holidays.keys().cloned().collect() |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Pre-built engines for common jurisdictions. |
| 265 | impl HolidayEngine { |
| 266 | pub fn us_federal() -> Self { |
| 267 | Self::new("United States Federal") |
| 268 | .with_default_weekend_adjustment(WeekendAdjustment::Nearest) |
| 269 | .add_holiday(HolidayDefinition { |
| 270 | name: "New Year's Day".to_string(), |
| 271 | holiday_type: HolidayType::Fixed { month: 1, day: 1 }, |
| 272 | weekend_adjustment: WeekendAdjustment::Nearest, |
| 273 | description: Some("January 1st".to_string()), |
| 274 | }) |
| 275 | .add_holiday(HolidayDefinition { |
| 276 | name: "Martin Luther King Jr. Day".to_string(), |
| 277 | holiday_type: HolidayType::Relative { |
| 278 | month: 1, |
| 279 | weekday: DayOfWeek::Monday, |
| 280 | occurrence: 3 |
| 281 | }, |
| 282 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 283 | description: Some("Third Monday in January".to_string()), |
| 284 | }) |
| 285 | .add_holiday(HolidayDefinition { |
| 286 | name: "Presidents' Day".to_string(), |
| 287 | holiday_type: HolidayType::Relative { |
| 288 | month: 2, |
| 289 | weekday: DayOfWeek::Monday, |
| 290 | occurrence: 3 |
| 291 | }, |
| 292 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 293 | description: Some("Third Monday in February".to_string()), |
| 294 | }) |
| 295 | .add_holiday(HolidayDefinition { |
| 296 | name: "Memorial Day".to_string(), |
| 297 | holiday_type: HolidayType::LastWeekday { |
| 298 | month: 5, |
| 299 | weekday: DayOfWeek::Monday |
| 300 | }, |
| 301 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 302 | description: Some("Last Monday in May".to_string()), |
| 303 | }) |
| 304 | .add_holiday(HolidayDefinition { |
| 305 | name: "Independence Day".to_string(), |
| 306 | holiday_type: HolidayType::Fixed { month: 7, day: 4 }, |
| 307 | weekend_adjustment: WeekendAdjustment::Nearest, |
| 308 | description: Some("July 4th".to_string()), |
| 309 | }) |
| 310 | .add_holiday(HolidayDefinition { |
| 311 | name: "Labour Day".to_string(), |
| 312 | holiday_type: HolidayType::Relative { |
| 313 | month: 9, |
| 314 | weekday: DayOfWeek::Monday, |
| 315 | occurrence: 1 |
| 316 | }, |
| 317 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 318 | description: Some("First Monday in September".to_string()), |
| 319 | }) |
| 320 | .add_holiday(HolidayDefinition { |
| 321 | name: "Columbus Day".to_string(), |
| 322 | holiday_type: HolidayType::Relative { |
| 323 | month: 10, |
| 324 | weekday: DayOfWeek::Monday, |
| 325 | occurrence: 2 |
| 326 | }, |
| 327 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 328 | description: Some("Second Monday in October".to_string()), |
| 329 | }) |
| 330 | .add_holiday(HolidayDefinition { |
| 331 | name: "Veterans Day".to_string(), |
| 332 | holiday_type: HolidayType::Fixed { month: 11, day: 11 }, |
| 333 | weekend_adjustment: WeekendAdjustment::Nearest, |
| 334 | description: Some("November 11th".to_string()), |
| 335 | }) |
| 336 | .add_holiday(HolidayDefinition { |
| 337 | name: "Thanksgiving".to_string(), |
| 338 | holiday_type: HolidayType::Relative { |
| 339 | month: 11, |
| 340 | weekday: DayOfWeek::Thursday, |
| 341 | occurrence: 4 |
| 342 | }, |
| 343 | weekend_adjustment: WeekendAdjustment::None, // Always on Thursday. |
| 344 | description: Some("Fourth Thursday in November".to_string()), |
| 345 | }) |
| 346 | .add_holiday(HolidayDefinition { |
| 347 | name: "Christmas Day".to_string(), |
| 348 | holiday_type: HolidayType::Fixed { month: 12, day: 25 }, |
| 349 | weekend_adjustment: WeekendAdjustment::Nearest, |
| 350 | description: Some("December 25th".to_string()), |
| 351 | }) |
| 352 | } |
| 353 | |
| 354 | pub fn uk() -> Self { |
| 355 | Self::new("United Kingdom") |
| 356 | .with_default_weekend_adjustment(WeekendAdjustment::Monday) |
| 357 | .add_holiday(HolidayDefinition { |
| 358 | name: "New Year's Day".to_string(), |
| 359 | holiday_type: HolidayType::Fixed { month: 1, day: 1 }, |
| 360 | weekend_adjustment: WeekendAdjustment::Monday, |
| 361 | description: Some("January 1st".to_string()), |
| 362 | }) |
| 363 | .add_holiday(HolidayDefinition { |
| 364 | name: "Good Friday".to_string(), |
| 365 | holiday_type: HolidayType::EasterBased { offset_days: -2 }, |
| 366 | weekend_adjustment: WeekendAdjustment::None, // Always on Friday. |
| 367 | description: Some("Friday before Easter".to_string()), |
| 368 | }) |
| 369 | .add_holiday(HolidayDefinition { |
| 370 | name: "Easter Monday".to_string(), |
| 371 | holiday_type: HolidayType::EasterBased { offset_days: 1 }, |
| 372 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 373 | description: Some("Monday after Easter".to_string()), |
| 374 | }) |
| 375 | .add_holiday(HolidayDefinition { |
| 376 | name: "Early May Bank Holiday".to_string(), |
| 377 | holiday_type: HolidayType::Relative { |
| 378 | month: 5, |
| 379 | weekday: DayOfWeek::Monday, |
| 380 | occurrence: 1 |
| 381 | }, |
| 382 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 383 | description: Some("First Monday in May".to_string()), |
| 384 | }) |
| 385 | .add_holiday(HolidayDefinition { |
| 386 | name: "Spring Bank Holiday".to_string(), |
| 387 | holiday_type: HolidayType::LastWeekday { |
| 388 | month: 5, |
| 389 | weekday: DayOfWeek::Monday |
| 390 | }, |
| 391 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 392 | description: Some("Last Monday in May".to_string()), |
| 393 | }) |
| 394 | .add_holiday(HolidayDefinition { |
| 395 | name: "Summer Bank Holiday".to_string(), |
| 396 | holiday_type: HolidayType::LastWeekday { |
| 397 | month: 8, |
| 398 | weekday: DayOfWeek::Monday |
| 399 | }, |
| 400 | weekend_adjustment: WeekendAdjustment::None, // Always on Monday. |
| 401 | description: Some("Last Monday in August".to_string()), |
| 402 | }) |
| 403 | .add_holiday(HolidayDefinition { |
| 404 | name: "Christmas Day".to_string(), |
| 405 | holiday_type: HolidayType::Fixed { month: 12, day: 25 }, |
| 406 | weekend_adjustment: WeekendAdjustment::Monday, |
| 407 | description: Some("December 25th".to_string()), |
| 408 | }) |
| 409 | .add_holiday(HolidayDefinition { |
| 410 | name: "Boxing Day".to_string(), |
| 411 | holiday_type: HolidayType::Fixed { month: 12, day: 26 }, |
| 412 | weekend_adjustment: WeekendAdjustment::Monday, |
| 413 | description: Some("December 26th".to_string()), |
| 414 | }) |
| 415 | } |
| 416 | |
| 417 | pub fn ecb() -> Self { |
| 418 | Self::new("European Central Bank") |
| 419 | .with_default_weekend_adjustment(WeekendAdjustment::None) |
| 420 | .add_holiday(HolidayDefinition { |
| 421 | name: "New Year's Day".to_string(), |
| 422 | holiday_type: HolidayType::Fixed { month: 1, day: 1 }, |
| 423 | weekend_adjustment: WeekendAdjustment::None, |
| 424 | description: Some("January 1st".to_string()), |
| 425 | }) |
| 426 | .add_holiday(HolidayDefinition { |
| 427 | name: "Good Friday".to_string(), |
| 428 | holiday_type: HolidayType::EasterBased { offset_days: -2 }, |
| 429 | weekend_adjustment: WeekendAdjustment::None, |
| 430 | description: Some("Friday before Easter".to_string()), |
| 431 | }) |
| 432 | .add_holiday(HolidayDefinition { |
| 433 | name: "Easter Monday".to_string(), |
| 434 | holiday_type: HolidayType::EasterBased { offset_days: 1 }, |
| 435 | weekend_adjustment: WeekendAdjustment::None, |
| 436 | description: Some("Monday after Easter".to_string()), |
| 437 | }) |
| 438 | .add_holiday(HolidayDefinition { |
| 439 | name: "Labour Day".to_string(), |
| 440 | holiday_type: HolidayType::Fixed { month: 5, day: 1 }, |
| 441 | weekend_adjustment: WeekendAdjustment::None, |
| 442 | description: Some("May 1st".to_string()), |
| 443 | }) |
| 444 | .add_holiday(HolidayDefinition { |
| 445 | name: "Christmas Day".to_string(), |
| 446 | holiday_type: HolidayType::Fixed { month: 12, day: 25 }, |
| 447 | weekend_adjustment: WeekendAdjustment::None, |
| 448 | description: Some("December 25th".to_string()), |
| 449 | }) |
| 450 | .add_holiday(HolidayDefinition { |
| 451 | name: "Boxing Day".to_string(), |
| 452 | holiday_type: HolidayType::Fixed { month: 12, day: 26 }, |
| 453 | weekend_adjustment: WeekendAdjustment::None, |
| 454 | description: Some("December 26th".to_string()), |
| 455 | }) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | #[cfg(test)] |
| 460 | mod tests { |
| 461 | use super::*; |
| 462 | use crate::time::CalClockZone; |
| 463 | |
| 464 | #[test] |
| 465 | fn test_easter_calculation() { |
| 466 | let zone = CalClockZone::utc(); |
| 467 | |
| 468 | // Test known Easter dates. |
| 469 | let easter_2024 = HolidayEngine::calculate_easter(2024, zone.clone()).unwrap(); |
| 470 | assert_eq!(easter_2024.month(), 3); |
| 471 | assert_eq!(easter_2024.day(), 31); |
| 472 | |
| 473 | let easter_2025 = HolidayEngine::calculate_easter(2025, zone.clone()).unwrap(); |
| 474 | assert_eq!(easter_2025.month(), 4); |
| 475 | assert_eq!(easter_2025.day(), 20); |
| 476 | } |
| 477 | |
| 478 | #[test] |
| 479 | fn test_us_federal_holidays() { |
| 480 | let zone = CalClockZone::utc(); |
| 481 | let engine = HolidayEngine::us_federal(); |
| 482 | let holidays_2024 = engine.calculate_holidays(2024, zone).unwrap(); |
| 483 | |
| 484 | // Should have 10 federal holidays. |
| 485 | assert_eq!(holidays_2024.len(), 10); |
| 486 | |
| 487 | // Check a few specific holidays. |
| 488 | let new_years = holidays_2024.iter().find(|(name, _)| name == "New Year's Day").unwrap(); |
| 489 | assert_eq!(new_years.1.month(), 1); |
| 490 | assert_eq!(new_years.1.day(), 1); |
| 491 | |
| 492 | let thanksgiving = holidays_2024.iter().find(|(name, _)| name == "Thanksgiving").unwrap(); |
| 493 | assert_eq!(thanksgiving.1.month(), 11); |
| 494 | assert_eq!(thanksgiving.1.day(), 28); // Fourth Thursday in November 2024. |
| 495 | } |
| 496 | |
| 497 | #[test] |
| 498 | fn test_weekend_adjustment() { |
| 499 | let zone = CalClockZone::utc(); |
| 500 | let engine = HolidayEngine::us_federal(); |
| 501 | |
| 502 | // Test a year where July 4th falls on a weekend. |
| 503 | let july_4_2021 = CalendarDate::from_ymd(2021, MonthOfYear::July, 4, zone.clone()).unwrap(); |
| 504 | assert_eq!(july_4_2021.day_of_week(), DayOfWeek::Sunday); |
| 505 | |
| 506 | let adjusted = engine.apply_weekend_adjustment(&july_4_2021, &WeekendAdjustment::Nearest).unwrap(); |
| 507 | assert_eq!(adjusted.day(), 5); // Should be observed on Monday. |
| 508 | } |
| 509 | |
| 510 | #[test] |
| 511 | fn test_uk_holidays() { |
| 512 | let zone = CalClockZone::utc(); |
| 513 | let engine = HolidayEngine::uk(); |
| 514 | let holidays_2024 = engine.calculate_holidays(2024, zone).unwrap(); |
| 515 | |
| 516 | // Should have 8 UK holidays. |
| 517 | assert_eq!(holidays_2024.len(), 8); |
| 518 | |
| 519 | // Check Good Friday (Easter-based). |
| 520 | let good_friday = holidays_2024.iter().find(|(name, _)| name == "Good Friday").unwrap(); |
| 521 | assert_eq!(good_friday.1.month(), 3); |
| 522 | assert_eq!(good_friday.1.day(), 29); // Good Friday 2024. |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn test_holiday_type_calculations() { |
| 527 | let zone = CalClockZone::utc(); |
| 528 | |
| 529 | // Test relative calculation (3rd Monday in January). |
| 530 | let mlk_day = HolidayEngine::calculate_nth_weekday(2024, 1, DayOfWeek::Monday, 3, zone.clone()).unwrap(); |
| 531 | assert_eq!(mlk_day.day(), 15); // January 15, 2024. |
| 532 | |
| 533 | // Test last weekday calculation (last Monday in May). |
| 534 | let memorial_day = HolidayEngine::calculate_last_weekday(2024, 5, DayOfWeek::Monday, zone.clone()).unwrap(); |
| 535 | assert_eq!(memorial_day.day(), 27); // May 27, 2024. |
| 536 | } |
| 537 | } |