#![cfg(feature = "parser")]
use ical::{
recur::IcalRecurDateTime,
timezone::{IcalOffset, IcalTimezone},
tree::cst::IcalCst,
};
fn zone(raw: &str, tzid: &str) -> IcalTimezone {
let cst = IcalCst::parse(raw).expect("parse");
let ical = cst.decode();
IcalTimezone::of_calendar(&ical, tzid).expect("a VTIMEZONE")
}
const US: &str = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\n",
"BEGIN:VTIMEZONE\r\nTZID:America/New_York\r\n",
"BEGIN:DAYLIGHT\r\n",
"DTSTART:20070311T020000\r\n",
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\r\n",
"TZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nTZNAME:EDT\r\n",
"END:DAYLIGHT\r\n",
"BEGIN:STANDARD\r\n",
"DTSTART:20071104T020000\r\n",
"RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\r\n",
"TZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nTZNAME:EST\r\n",
"END:STANDARD\r\n",
"END:VTIMEZONE\r\nEND:VCALENDAR\r\n",
);
fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8) -> IcalRecurDateTime {
IcalRecurDateTime {
year,
month,
day,
hour,
minute,
second: 0,
}
}
#[test]
fn resolves_summer_and_winter() {
let zone = zone(US, "America/New_York");
assert_eq!(
zone.resolve(at(2026, 7, 15, 12, 0)),
IcalOffset::One(-4 * 3600)
);
assert_eq!(
zone.resolve(at(2026, 1, 15, 12, 0)),
IcalOffset::One(-5 * 3600)
);
}
#[test]
fn reports_the_spring_forward_gap() {
let zone = zone(US, "America/New_York");
assert_eq!(
zone.resolve(at(2026, 3, 8, 2, 30)),
IcalOffset::Gap {
before: -5 * 3600,
after: -4 * 3600,
}
);
assert_eq!(
zone.resolve(at(2026, 3, 8, 1, 59)),
IcalOffset::One(-5 * 3600)
);
assert_eq!(
zone.resolve(at(2026, 3, 8, 3, 0)),
IcalOffset::One(-4 * 3600)
);
}
#[test]
fn reports_the_fall_back_fold() {
let zone = zone(US, "America/New_York");
assert_eq!(
zone.resolve(at(2026, 11, 1, 1, 30)),
IcalOffset::Fold {
earlier: -4 * 3600,
later: -5 * 3600,
}
);
assert_eq!(
zone.resolve(at(2026, 11, 1, 0, 59)),
IcalOffset::One(-4 * 3600)
);
assert_eq!(
zone.resolve(at(2026, 11, 1, 2, 0)),
IcalOffset::One(-5 * 3600)
);
}
#[test]
fn a_gap_and_a_fold_have_no_single_offset() {
let zone = zone(US, "America/New_York");
assert_eq!(zone.resolve(at(2026, 3, 8, 2, 30)).unambiguous(), None);
assert_eq!(zone.resolve(at(2026, 11, 1, 1, 30)).unambiguous(), None);
assert_eq!(
zone.resolve(at(2026, 7, 15, 12, 0)).unambiguous(),
Some(-4 * 3600)
);
}
#[test]
fn a_time_before_every_transition_takes_the_offset_that_came_before() {
let zone = zone(US, "America/New_York");
assert_eq!(
zone.resolve(at(2000, 1, 1, 12, 0)),
IcalOffset::One(-5 * 3600)
);
}
#[test]
fn resolves_from_the_rfc_fixture() {
let raw = include_str!("corpus/rfc/rfc5545_timezone.ics");
let zone = zone(raw, "America/New_York");
assert_eq!(zone.observances.len(), 2);
assert_eq!(
zone.resolve(at(2007, 6, 1, 12, 0)),
IcalOffset::One(-4 * 3600)
);
assert_eq!(
zone.resolve(at(2008, 1, 1, 12, 0)),
IcalOffset::One(-5 * 3600)
);
assert_eq!(
zone.resolve(at(2007, 3, 11, 2, 30)),
IcalOffset::Gap {
before: -5 * 3600,
after: -4 * 3600,
}
);
}
#[test]
fn a_zone_that_never_shifts_resolves_to_its_one_offset() {
let raw = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\n",
"BEGIN:VTIMEZONE\r\nTZID:Asia/Kolkata\r\n",
"BEGIN:STANDARD\r\n",
"DTSTART:19700101T000000\r\n",
"TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530\r\nTZNAME:IST\r\n",
"END:STANDARD\r\n",
"END:VTIMEZONE\r\nEND:VCALENDAR\r\n",
);
let zone = zone(raw, "Asia/Kolkata");
assert_eq!(
zone.resolve(at(2026, 7, 15, 12, 0)),
IcalOffset::One(19_800)
);
assert_eq!(
zone.resolve(at(1900, 7, 15, 12, 0)),
IcalOffset::One(19_800)
);
}
#[test]
fn an_unknown_tzid_is_no_zone() {
let cst = IcalCst::parse(US).expect("parse");
let ical = cst.decode();
assert!(IcalTimezone::of_calendar(&ical, "Europe/Paris").is_none());
}