Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.
Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.
TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.
Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.
Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
807 lines
22 KiB
Rust
807 lines
22 KiB
Rust
//! Recurrence expansion, checked against known-good outputs.
|
||
//!
|
||
//! Most of these are table-driven and deliberately boring: a rule, a window,
|
||
//! and the exact list of local times it should produce. The interesting ones
|
||
//! are the boundaries — daylight saving, leap day, the difference between an
|
||
//! excluded occurrence and a replaced one — because those are where the
|
||
//! previous iteration's hand-written expander went wrong, and it had no tests
|
||
//! at all to say so.
|
||
|
||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||
|
||
use chrono::{DateTime, TimeDelta, Utc};
|
||
use chrono_tz::Tz;
|
||
use pretty_assertions::assert_eq;
|
||
use runway_core::ical;
|
||
use runway_core::model::*;
|
||
use runway_core::recurrence::{RecurrenceError, Window, ZoneSource, Zones, expand};
|
||
use std::path::Path;
|
||
|
||
// ------------------------------------------------------------------ helpers --
|
||
|
||
fn denver() -> Tz {
|
||
Tz::America__Denver
|
||
}
|
||
|
||
fn instant(text: &str) -> DateTime<Utc> {
|
||
text.parse::<DateTime<Utc>>()
|
||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||
}
|
||
|
||
fn window(from: &str, to: &str) -> Window {
|
||
Window::new(instant(from), instant(to))
|
||
}
|
||
|
||
/// Wraps event bodies in a `VCALENDAR` and parses them.
|
||
fn calendar(events: &str) -> VCalendar {
|
||
let ics = format!(
|
||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//test//EN\r\n{}END:VCALENDAR\r\n",
|
||
events.replace('\n', "\r\n"),
|
||
);
|
||
ical::parse(&ics).unwrap_or_else(|e| panic!("fixture did not parse: {e}\n{ics}"))
|
||
}
|
||
|
||
/// A recurring event in Denver, 09:00–09:30, starting on the given date.
|
||
fn denver_series(start: &str, rrule: &str, extra: &str) -> VCalendar {
|
||
calendar(&format!(
|
||
"BEGIN:VEVENT
|
||
UID:series@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:{start}T090000
|
||
DTEND;TZID=America/Denver:{start}T093000
|
||
SUMMARY:Series
|
||
RRULE:{rrule}
|
||
{extra}END:VEVENT
|
||
"
|
||
))
|
||
}
|
||
|
||
fn occurrences(cal: &VCalendar, window: Window, tz: Tz) -> Vec<Occurrence> {
|
||
expand(cal, window, Zones::new(tz)).unwrap_or_else(|e| panic!("expansion failed: {e}"))
|
||
}
|
||
|
||
/// The local wall-clock start of each occurrence, as written.
|
||
fn local_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec<String> {
|
||
occurrences(cal, window, tz)
|
||
.iter()
|
||
.map(|o| match &o.start {
|
||
CalendarDateTime::Date { date } => date.to_string(),
|
||
other => other.naive_local().to_string(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The UTC instant of each occurrence, which is what shows a zone bug.
|
||
fn utc_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec<String> {
|
||
occurrences(cal, window, tz)
|
||
.iter()
|
||
.map(|o| o.start_utc.to_rfc3339())
|
||
.collect()
|
||
}
|
||
|
||
// -------------------------------------------------------------- basic rules --
|
||
|
||
#[test]
|
||
fn a_weekly_rule_produces_one_occurrence_a_week() {
|
||
let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO", "");
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec![
|
||
"2026-01-05 09:00:00",
|
||
"2026-01-12 09:00:00",
|
||
"2026-01-19 09:00:00",
|
||
"2026-01-26 09:00:00",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn count_and_until_bound_a_series_the_same_way() {
|
||
let by_count = denver_series("20260105", "FREQ=DAILY;COUNT=3", "");
|
||
let by_until = denver_series("20260105", "FREQ=DAILY;UNTIL=20260107T163000Z", "");
|
||
let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z");
|
||
|
||
let expected = vec![
|
||
"2026-01-05 09:00:00",
|
||
"2026-01-06 09:00:00",
|
||
"2026-01-07 09:00:00",
|
||
];
|
||
assert_eq!(local_starts(&by_count, span, denver()), expected);
|
||
assert_eq!(
|
||
local_starts(&by_until, span, denver()),
|
||
expected,
|
||
"UNTIL is a UTC instant while the occurrences are local; comparing the \
|
||
two in the wrong frame is how a series gains or loses its last event",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_monthly_nth_weekday_rule_lands_on_the_right_days() {
|
||
// The case the previous iteration built a form for and then dropped on the
|
||
// way to the API, so it never reached the server at all.
|
||
let cal = denver_series("20260120", "FREQ=MONTHLY;BYDAY=3TU", "");
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-05-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec![
|
||
"2026-01-20 09:00:00",
|
||
"2026-02-17 09:00:00",
|
||
"2026-03-17 09:00:00",
|
||
"2026-04-21 09:00:00",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_monthly_by_monthday_rule_skips_months_that_are_too_short() {
|
||
let cal = denver_series("20260131", "FREQ=MONTHLY;BYMONTHDAY=31", "");
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec![
|
||
"2026-01-31 09:00:00",
|
||
"2026-03-31 09:00:00",
|
||
"2026-05-31 09:00:00",
|
||
],
|
||
"February, April and June have no 31st, and the rule simply does not \
|
||
fire -- it does not roll over into the next month",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_leap_day_rule_only_fires_in_leap_years() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:leap@test
|
||
DTSTAMP:20240101T000000Z
|
||
DTSTART;VALUE=DATE:20240229
|
||
DTEND;VALUE=DATE:20240301
|
||
SUMMARY:Leap day
|
||
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2024-01-01T00:00:00Z", "2034-01-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec!["2024-02-29", "2028-02-29", "2032-02-29"],
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------- daylight saving
|
||
|
||
#[test]
|
||
fn a_weekly_meeting_keeps_its_local_time_across_spring_forward() {
|
||
// Denver moves to daylight time on 2026-03-08.
|
||
let cal = denver_series("20260302", "FREQ=WEEKLY;BYDAY=MO", "");
|
||
let span = window("2026-03-01T00:00:00Z", "2026-03-24T00:00:00Z");
|
||
|
||
assert_eq!(
|
||
local_starts(&cal, span, denver()),
|
||
vec![
|
||
"2026-03-02 09:00:00",
|
||
"2026-03-09 09:00:00",
|
||
"2026-03-16 09:00:00",
|
||
"2026-03-23 09:00:00",
|
||
],
|
||
"the meeting is at 09:00 every week, before and after the change",
|
||
);
|
||
assert_eq!(
|
||
utc_starts(&cal, span, denver()),
|
||
vec![
|
||
"2026-03-02T16:00:00+00:00",
|
||
"2026-03-09T15:00:00+00:00",
|
||
"2026-03-16T15:00:00+00:00",
|
||
"2026-03-23T15:00:00+00:00",
|
||
],
|
||
"and the instant moves by an hour, which is the whole point. The last \
|
||
iteration sent a fixed UTC offset in place of a zone, so this series \
|
||
drifted to 08:00 for the rest of the year",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_weekly_meeting_keeps_its_local_time_across_fall_back() {
|
||
// Denver returns to standard time on 2026-11-01.
|
||
let cal = denver_series("20261026", "FREQ=WEEKLY;BYDAY=MO", "");
|
||
let span = window("2026-10-20T00:00:00Z", "2026-11-17T00:00:00Z");
|
||
|
||
assert_eq!(
|
||
utc_starts(&cal, span, denver()),
|
||
vec![
|
||
"2026-10-26T15:00:00+00:00",
|
||
"2026-11-02T16:00:00+00:00",
|
||
"2026-11-09T16:00:00+00:00",
|
||
"2026-11-16T16:00:00+00:00",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_start_inside_the_spring_forward_gap_moves_past_it() {
|
||
// 02:30 on 2026-03-08 does not exist in Denver; the clock jumps 01:59 to
|
||
// 03:00. Something has to happen, and the choice is made once, explicitly:
|
||
// the time slides past the gap by the gap's own length.
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:gap@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260308T023000
|
||
DTEND;TZID=America/Denver:20260308T033000
|
||
SUMMARY:In the gap
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-03-08T00:00:00Z", "2026-03-09T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
assert_eq!(found.len(), 1);
|
||
assert_eq!(
|
||
found[0].start_utc.to_rfc3339(),
|
||
"2026-03-08T09:30:00+00:00",
|
||
"03:30 local -- slid past the gap, not silently dropped, not thrown \
|
||
back to the previous day, and not snapped to 03:00 alongside every \
|
||
other lost time that morning",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_ambiguous_start_takes_the_earlier_of_the_two() {
|
||
// 01:30 on 2026-11-01 happens twice in Denver.
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:ambiguous@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20261101T013000
|
||
DTEND;TZID=America/Denver:20261101T020000
|
||
SUMMARY:Twice over
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-11-01T00:00:00Z", "2026-11-02T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
assert_eq!(
|
||
found[0].start_utc.to_rfc3339(),
|
||
"2026-11-01T07:30:00+00:00",
|
||
"the first 01:30, which is what a person setting that time means",
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------- exclusions and extras
|
||
|
||
#[test]
|
||
fn exdate_removes_an_occurrence_the_rule_would_generate() {
|
||
let cal = denver_series(
|
||
"20260105",
|
||
"FREQ=WEEKLY;BYDAY=MO",
|
||
"EXDATE;TZID=America/Denver:20260112T090000,20260126T090000\n",
|
||
);
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec!["2026-01-05 09:00:00", "2026-01-19 09:00:00"],
|
||
"both values on the one EXDATE line have to be honoured",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rdate_adds_an_occurrence_the_rule_would_not() {
|
||
let cal = denver_series(
|
||
"20260105",
|
||
"FREQ=WEEKLY;BYDAY=MO;COUNT=2",
|
||
"RDATE;TZID=America/Denver:20260108T090000\n",
|
||
);
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec![
|
||
"2026-01-05 09:00:00",
|
||
"2026-01-08 09:00:00",
|
||
"2026-01-12 09:00:00",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_all_day_series_excludes_by_date() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:bins@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;VALUE=DATE:20260105
|
||
DTEND;VALUE=DATE:20260106
|
||
SUMMARY:Bin day
|
||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||
EXDATE;VALUE=DATE:20260119
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec!["2026-01-05", "2026-01-12", "2026-01-26"],
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------- overrides --
|
||
|
||
#[test]
|
||
fn an_override_replaces_the_occurrence_it_names_without_duplicating_it() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:standup@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260105T090000
|
||
DTEND;TZID=America/Denver:20260105T093000
|
||
SUMMARY:Standup
|
||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:standup@test
|
||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||
DTSTAMP:20260106T000000Z
|
||
DTSTART;TZID=America/Denver:20260112T140000
|
||
DTEND;TZID=America/Denver:20260112T143000
|
||
SUMMARY:Standup (moved)
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
let on_the_12th: Vec<&Occurrence> = found
|
||
.iter()
|
||
.filter(|o| o.start.date().to_string() == "2026-01-12")
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
on_the_12th.len(),
|
||
1,
|
||
"exactly one occurrence that day -- the master must not also generate \
|
||
its 09:00 version. Emitting both is what made a correct feed look \
|
||
like it was full of duplicates",
|
||
);
|
||
assert!(on_the_12th[0].is_override);
|
||
assert_eq!(
|
||
on_the_12th[0].start.naive_local().to_string(),
|
||
"2026-01-12 14:00:00"
|
||
);
|
||
assert_eq!(found.len(), 4, "four Mondays in January 2026");
|
||
}
|
||
|
||
#[test]
|
||
fn an_override_moved_to_another_day_lands_on_the_new_day() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:moved@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260105T090000
|
||
DTEND;TZID=America/Denver:20260105T093000
|
||
SUMMARY:Weekly
|
||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:moved@test
|
||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||
DTSTAMP:20260106T000000Z
|
||
DTSTART;TZID=America/Denver:20260114T110000
|
||
DTEND;TZID=America/Denver:20260114T113000
|
||
SUMMARY:Weekly (rescheduled)
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
assert_eq!(
|
||
local_starts(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver()
|
||
),
|
||
vec![
|
||
"2026-01-05 09:00:00",
|
||
"2026-01-14 11:00:00",
|
||
"2026-01-19 09:00:00",
|
||
],
|
||
"RECURRENCE-ID says which occurrence is replaced; DTSTART says when the \
|
||
replacement happens. Nothing should appear on the 12th",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_override_carries_the_recurrence_id_needed_to_edit_it_again() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:edit@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260105T090000
|
||
DTEND;TZID=America/Denver:20260105T093000
|
||
SUMMARY:Weekly
|
||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=2
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
|
||
assert_eq!(
|
||
found[1].recurrence_id,
|
||
Some(CalendarDateTime::Zoned {
|
||
local: chrono::NaiveDate::from_ymd_opt(2026, 1, 12)
|
||
.unwrap()
|
||
.and_hms_opt(9, 0, 0)
|
||
.unwrap(),
|
||
tzid: TzId::new("America/Denver").unwrap(),
|
||
}),
|
||
"a generated occurrence knows its own RECURRENCE-ID, so editing it \
|
||
sends back the value the server will write. The last iteration \
|
||
encoded this as \"{{uid}}-{{timestamp}}\" and split the string apart \
|
||
again at the other end",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_override_without_a_master_is_still_an_occurrence() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:orphan@test
|
||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||
DTSTAMP:20260106T000000Z
|
||
DTSTART;TZID=America/Denver:20260112T140000
|
||
DTEND;TZID=America/Denver:20260112T143000
|
||
SUMMARY:Orphaned exception
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
|
||
assert_eq!(
|
||
found.len(),
|
||
1,
|
||
"a published feed truncates series at its edge"
|
||
);
|
||
assert!(found[0].is_override);
|
||
}
|
||
|
||
// ------------------------------------------------------------------- windows --
|
||
|
||
#[test]
|
||
fn an_event_straddling_the_window_edge_is_included() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:long@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260104T220000
|
||
DTEND;TZID=America/Denver:20260105T020000
|
||
SUMMARY:Across midnight
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-01-05T00:00:00Z", "2026-01-06T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
assert_eq!(
|
||
found.len(),
|
||
1,
|
||
"it starts before the window but runs into it"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_event_wholly_outside_the_window_is_excluded() {
|
||
let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO;COUNT=2", "");
|
||
|
||
assert!(
|
||
occurrences(
|
||
&cal,
|
||
window("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"),
|
||
denver()
|
||
)
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn results_are_ordered_and_deterministic() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:b@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260105T140000
|
||
DTEND;TZID=America/Denver:20260105T150000
|
||
SUMMARY:Afternoon
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:a@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=America/Denver:20260105T090000
|
||
DTEND;TZID=America/Denver:20260105T100000
|
||
SUMMARY:Morning
|
||
END:VEVENT
|
||
",
|
||
);
|
||
let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z");
|
||
|
||
let first = occurrences(&cal, span, denver());
|
||
assert_eq!(
|
||
first.iter().map(Occurrence::uid).collect::<Vec<_>>(),
|
||
vec!["a@test", "b@test"],
|
||
);
|
||
assert_eq!(first, occurrences(&cal, span, denver()));
|
||
}
|
||
|
||
// ----------------------------------------------------------------- timezones --
|
||
|
||
#[test]
|
||
fn a_windows_zone_name_resolves_through_cldr() {
|
||
let zones = Zones::new(denver());
|
||
|
||
for (windows, iana) in [
|
||
("Pacific Standard Time", Tz::America__Los_Angeles),
|
||
("Eastern Standard Time", Tz::America__New_York),
|
||
("Mountain Standard Time", Tz::America__Denver),
|
||
("GTB Standard Time", Tz::Europe__Bucharest),
|
||
] {
|
||
let resolved = zones.resolve(&TzId::new(windows).unwrap());
|
||
assert_eq!(resolved.tz, iana, "{windows}");
|
||
assert_eq!(resolved.source, ZoneSource::WindowsName);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_iana_identifier_is_preferred_and_reported_as_such() {
|
||
let resolved = Zones::new(denver()).resolve(&TzId::new("Europe/Zurich").unwrap());
|
||
|
||
assert_eq!(resolved.tz, Tz::Europe__Zurich);
|
||
assert_eq!(resolved.source, ZoneSource::Iana);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unresolvable_zone_is_reported_rather_than_hidden() {
|
||
let resolved = Zones::new(denver()).resolve(&TzId::new("Customized Time Zone 3").unwrap());
|
||
|
||
assert_eq!(resolved.source, ZoneSource::Assumed);
|
||
assert_eq!(
|
||
resolved.tz,
|
||
denver(),
|
||
"something has to be assumed, but the caller is told it was assumed \
|
||
rather than being handed a silently wrong time",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_event_in_a_windows_zone_resolves_to_the_right_instant() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:exchange@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART;TZID=Pacific Standard Time:20260115T090000
|
||
DTEND;TZID=Pacific Standard Time:20260115T093000
|
||
SUMMARY:From Exchange
|
||
END:VEVENT
|
||
",
|
||
);
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
assert_eq!(
|
||
found[0].start_utc.to_rfc3339(),
|
||
"2026-01-15T17:00:00+00:00",
|
||
"09:00 Los Angeles in January is 17:00 UTC",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_floating_time_is_read_in_the_viewers_zone() {
|
||
let cal = calendar(
|
||
"BEGIN:VEVENT
|
||
UID:floating@test
|
||
DTSTAMP:20260101T000000Z
|
||
DTSTART:20260115T090000
|
||
DTEND:20260115T100000
|
||
SUMMARY:Nine, wherever you are
|
||
END:VEVENT
|
||
",
|
||
);
|
||
let span = window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z");
|
||
|
||
assert_eq!(
|
||
occurrences(&cal, span, denver())[0].start_utc.to_rfc3339(),
|
||
"2026-01-15T16:00:00+00:00",
|
||
);
|
||
assert_eq!(
|
||
occurrences(&cal, span, Tz::Europe__Zurich)[0]
|
||
.start_utc
|
||
.to_rfc3339(),
|
||
"2026-01-15T08:00:00+00:00",
|
||
"the same floating value is a different instant for a different reader, \
|
||
which is exactly what floating means",
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------- limits --
|
||
|
||
#[test]
|
||
fn a_runaway_rule_is_reported_not_truncated() {
|
||
let cal = denver_series("20260101", "FREQ=MINUTELY", "");
|
||
|
||
let result = expand(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"),
|
||
Zones::new(denver()),
|
||
);
|
||
|
||
assert!(
|
||
matches!(result, Err(RecurrenceError::TooManyOccurrences { .. })),
|
||
"half a million occurrences is not a calendar view; failing loudly \
|
||
beats returning a list that is quietly missing most of the year",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unusable_rule_names_the_event_and_the_rule() {
|
||
let mut cal = denver_series("20260105", "FREQ=WEEKLY", "");
|
||
cal.events[0].rrule = Some("FREQ=NONSENSE".to_owned());
|
||
|
||
match expand(
|
||
&cal,
|
||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||
Zones::new(denver()),
|
||
) {
|
||
Err(RecurrenceError::InvalidRule { uid, rrule, .. }) => {
|
||
assert_eq!(uid, "series@test");
|
||
assert_eq!(rrule, "FREQ=NONSENSE");
|
||
}
|
||
other => panic!("expected a typed error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------- against real data ---
|
||
|
||
fn outlook_feed() -> VCalendar {
|
||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("tests/golden/outlook/feed-overrides-windows-tz.ics");
|
||
ical::parse(&std::fs::read_to_string(path).unwrap()).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn the_outlook_feed_expands_without_duplicating_a_single_day() {
|
||
let cal = outlook_feed();
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2025-10-01T00:00:00Z", "2026-01-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
|
||
assert!(!found.is_empty());
|
||
|
||
// Within one series, no two occurrences may claim the same start. This is
|
||
// the assertion the old importer's title-matching heuristics were standing
|
||
// in for -- and it holds here because overrides suppress the occurrence
|
||
// they replace, not because anything was merged away.
|
||
let mut seen: Vec<(&str, DateTime<Utc>)> =
|
||
found.iter().map(|o| (o.uid(), o.start_utc)).collect();
|
||
let before = seen.len();
|
||
seen.sort();
|
||
seen.dedup();
|
||
assert_eq!(
|
||
before,
|
||
seen.len(),
|
||
"a series produced two occurrences at one instant"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_zone_in_the_outlook_feed_resolves() {
|
||
let cal = outlook_feed();
|
||
|
||
assert_eq!(
|
||
runway_core::recurrence::unresolved_zones(&cal, Zones::new(denver())),
|
||
Vec::<String>::new(),
|
||
"the feed names its zones in Windows form and every one of them maps",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_exchange_series_honours_its_exclusions() {
|
||
let cal = outlook_feed();
|
||
let master = cal
|
||
.events
|
||
.iter()
|
||
.find(|e| !e.exdate.is_empty() && e.rrule.is_some())
|
||
.expect("the feed has a series with exclusions");
|
||
let excluded: Vec<DateTime<Utc>> = master
|
||
.exdate
|
||
.iter()
|
||
.map(|d| Zones::new(denver()).instant(d))
|
||
.collect();
|
||
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2025-10-01T00:00:00Z", "2027-01-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
|
||
for instant in excluded {
|
||
assert!(
|
||
!found
|
||
.iter()
|
||
.any(|o| o.uid() == master.uid && o.start_utc == instant && !o.is_override),
|
||
"an EXDATE'd occurrence at {instant} was still generated",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn expanding_the_whole_feed_stays_cheap() {
|
||
// Not a benchmark, a guard: the old client re-expanded every rule in the
|
||
// browser on every view change.
|
||
let cal = outlook_feed();
|
||
let started = std::time::Instant::now();
|
||
let found = occurrences(
|
||
&cal,
|
||
window("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z"),
|
||
denver(),
|
||
);
|
||
let elapsed = started.elapsed();
|
||
|
||
assert!(!found.is_empty());
|
||
assert!(
|
||
elapsed < TimeDelta::seconds(5).to_std().unwrap(),
|
||
"three years of a real feed took {elapsed:?}",
|
||
);
|
||
}
|