Files
runway/crates/runway-core/tests/series.rs
T
connor c4e8ede28c Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
2026-08-26 17:07:38 -04:00

825 lines
25 KiB
Rust

//! Editing and deleting parts of a series.
//!
//! The three scopes are the subtlest thing a calendar does, and v1 shipped them
//! across 1,165 lines of duplicated handlers with no automated coverage at all.
//! Each case here states what should end up in the stored `.ics`, then checks
//! it by expanding the result — because the question that matters is not "what
//! did we write" but "what does a calendar client see afterwards".
#![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::{Window, Zones, expand};
use runway_core::series::{
DeleteOutcome, EditOutcome, SeriesError, apply_delete, apply_edit, set_until, strip_bounds,
};
fn denver() -> Tz {
Tz::America__Denver
}
fn zones() -> Zones {
Zones::new(denver())
}
fn at(day: u32, hour: u32) -> CalendarDateTime {
CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 1, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
}
}
fn instant(text: &str) -> DateTime<Utc> {
text.parse().unwrap()
}
/// A weekly Monday 09:00 series starting 2026-01-05, six occurrences.
fn weekly_series() -> VCalendar {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:standup@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
DTEND;TZID=America/Denver:20260105T093000\r\n\
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=6\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
ical::parse(ics).unwrap()
}
fn one_off() -> VCalendar {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:dentist@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
DTEND;TZID=America/Denver:20260105T093000\r\n\
SUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
ical::parse(ics).unwrap()
}
/// The edited form of an occurrence: moved to 14:00 and renamed.
fn moved_to_afternoon(day: u32) -> VEvent {
let mut event =
VEvent::with_uid("ignored-by-the-server", at(day, 14)).titled("Standup (moved)");
event.end = Some(EventEnd::DateTime { dtend: at(day, 15) });
event
}
/// Local start times an outcome produces over January, as a calendar would
/// show them.
fn shown(calendars: &[&VCalendar]) -> Vec<String> {
let window = Window::new(
instant("2026-01-01T00:00:00Z"),
instant("2026-03-01T00:00:00Z"),
);
let mut out: Vec<(DateTime<Utc>, String)> = Vec::new();
for calendar in calendars {
for occurrence in expand(calendar, window, zones()).unwrap() {
out.push((
occurrence.start_utc,
format!(
"{} {}",
occurrence.start.naive_local().format("%Y-%m-%d %H:%M"),
occurrence.title().unwrap_or("(untitled)"),
),
));
}
}
out.sort_by_key(|(instant, _)| *instant);
out.into_iter().map(|(_, label)| label).collect()
}
fn replaced(outcome: EditOutcome) -> VCalendar {
match outcome {
EditOutcome::Replace(calendar) => calendar,
EditOutcome::Split { .. } => panic!("expected a single resource, got a split"),
}
}
// ------------------------------------------------------------- entire series --
#[test]
fn editing_the_whole_series_edits_the_master_in_place() {
let series = weekly_series();
let mut renamed = series.master().unwrap().clone();
renamed.summary = Some("Standup (renamed)".to_owned());
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(result.events.len(), 1, "one master, no override");
assert_eq!(
result.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
"the rule is untouched",
);
assert!(shown(&[&result]).iter().all(|s| s.contains("renamed")));
assert_eq!(shown(&[&result]).len(), 6);
}
#[test]
fn editing_the_whole_series_keeps_its_identity() {
let series = weekly_series();
let mut renamed = series.master().unwrap().clone();
renamed.uid = "a-uid-the-client-made-up".to_owned();
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(
result.master().unwrap().uid,
"standup@test",
"the UID is the server's business; taking it from the request would let \
a mistyped edit overwrite a different event",
);
}
#[test]
fn moving_a_series_carries_its_overrides_with_it() {
// The case that would otherwise lose data: an override names an occurrence
// of the *old* pattern, so moving the master leaves it pointing at nothing.
let mut series = weekly_series();
let mut exception = moved_to_afternoon(12);
exception.uid = "standup@test".to_owned();
exception.recurrence_id = Some(at(12, 9));
series.events.push(exception);
let mut moved = series.master().unwrap().clone();
moved.dtstart = at(5, 11);
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
assert_eq!(result.overrides().count(), 1, "the override survived");
assert_eq!(
result.overrides().next().unwrap().recurrence_id,
Some(at(12, 11)),
"and its RECURRENCE-ID moved by the same two hours, so it still names \
a real occurrence instead of being silently discarded",
);
}
// ----------------------------------------------------------------- this only --
#[test]
fn editing_one_occurrence_writes_an_override_and_nothing_else() {
let series = weekly_series();
let result = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
assert_eq!(result.events.len(), 2, "master plus one override");
let exception = result.overrides().next().unwrap();
assert_eq!(exception.uid, "standup@test", "one resource, one UID");
assert_eq!(exception.recurrence_id, Some(at(12, 9)));
assert_eq!(exception.dtstart, at(12, 14));
assert!(
exception.rrule.is_none(),
"an override describes one occurrence; a second RRULE would make this \
two series in one resource",
);
assert!(
result.master().unwrap().exdate.is_empty(),
"no EXDATE: an EXDATE says the occurrence does not happen, an override \
says it happens differently, and writing both is contradictory",
);
}
#[test]
fn an_edited_occurrence_appears_once_at_its_new_time() {
let series = weekly_series();
let result = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
assert_eq!(
shown(&[&result]),
vec![
"2026-01-05 09:00 Standup",
"2026-01-12 14:00 Standup (moved)",
"2026-01-19 09:00 Standup",
"2026-01-26 09:00 Standup",
"2026-02-02 09:00 Standup",
"2026-02-09 09:00 Standup",
],
"still six occurrences, one of them moved -- not seven, and not five",
);
}
#[test]
fn editing_the_same_occurrence_twice_replaces_its_override() {
let series = weekly_series();
let once = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
let mut again = moved_to_afternoon(12);
again.dtstart = at(12, 16);
again.end = Some(EventEnd::DateTime { dtend: at(12, 17) });
let twice = replaced(
apply_edit(
&once,
EditScope::ThisOnly,
Some(&at(12, 9)),
&again,
zones(),
)
.unwrap(),
);
assert_eq!(
twice.overrides().count(),
1,
"a second edit of the same occurrence must replace the override, not \
add a duplicate",
);
assert_eq!(twice.overrides().next().unwrap().dtstart, at(12, 16));
}
#[test]
fn editing_one_occurrence_without_saying_which_is_refused() {
let series = weekly_series();
assert_eq!(
apply_edit(
&series,
EditScope::ThisOnly,
None,
&moved_to_afternoon(12),
zones()
)
.unwrap_err(),
SeriesError::MissingRecurrenceId,
);
}
// ----------------------------------------------------------- this and future --
#[test]
fn splitting_a_series_truncates_the_first_and_starts_a_second() {
let series = weekly_series();
let outcome = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap();
let EditOutcome::Split {
existing,
new_series,
} = outcome
else {
panic!("this-and-future is two resources, not one");
};
let rule = existing.master().unwrap().rrule.clone().unwrap();
assert!(
rule.contains("UNTIL=20260119T155959Z"),
"the original ends one second before the split point: {rule}",
);
assert!(
!rule.contains("COUNT"),
"COUNT and UNTIL are mutually exclusive: {rule}",
);
assert_ne!(
new_series.master().unwrap().uid,
"standup@test",
"a new series is a new resource with its own UID",
);
assert_eq!(
new_series.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=4"),
"it inherits the pattern and what is left of the bound: two of the six \
stayed with the original, so four remain. Carrying COUNT=6 over would \
add occurrences; dropping it would turn \"six times\" into forever",
);
}
#[test]
fn a_split_series_shows_the_right_occurrences_on_both_sides() {
let series = weekly_series();
let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap()
else {
panic!("expected a split");
};
assert_eq!(
shown(&[&existing]),
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
"the old series stops before the split",
);
assert_eq!(
shown(&[&new_series]),
vec![
"2026-01-19 14:00 Standup (moved)",
"2026-01-26 14:00 Standup (moved)",
"2026-02-02 14:00 Standup (moved)",
"2026-02-09 14:00 Standup (moved)",
],
"and the new one takes over at the new time, for the four that were \
left -- six in total across both halves, as originally asked for",
);
assert!(
!shown(&[&existing, &new_series]).contains(&"2026-01-19 09:00 Standup".to_owned()),
"nothing may appear at the old time on the split day",
);
}
#[test]
fn splitting_at_the_first_occurrence_is_just_editing_the_series() {
let series = weekly_series();
let outcome = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(5, 9)),
&moved_to_afternoon(5),
zones(),
)
.unwrap();
let result = replaced(outcome);
assert_eq!(
result.master().unwrap().uid,
"standup@test",
"splitting at the very start would leave an empty truncated series and \
a new one, which is two resources where one belongs",
);
assert_eq!(
result.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
"and the rule survives: the person asked to change every occurrence \
from the first one onwards, not to stop the series recurring",
);
assert_eq!(shown(&[&result]).len(), 6);
assert!(shown(&[&result]).iter().all(|s| s.contains("14:00")));
}
#[test]
fn clearing_the_rule_on_the_whole_series_makes_it_a_single_event() {
// Under EntireSeries the submitted event *is* the master, so dropping the
// rule is an instruction, not an omission. The split path is the opposite
// case and carries the rule forward; see the test above.
let series = weekly_series();
let mut once_only = series.master().unwrap().clone();
once_only.rrule = None;
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &once_only, zones()).unwrap());
assert_eq!(shown(&[&result]), vec!["2026-01-05 09:00 Standup"]);
}
#[test]
fn a_split_hands_later_overrides_to_the_new_series() {
let mut series = weekly_series();
for day in [12, 26] {
let mut exception = moved_to_afternoon(day);
exception.uid = "standup@test".to_owned();
exception.recurrence_id = Some(at(day, 9));
series.events.push(exception);
}
let EditOutcome::Split { existing, .. } = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap() else {
panic!("expected a split");
};
let kept: Vec<_> = existing.overrides().collect();
assert_eq!(kept.len(), 1, "only the override before the split stays");
assert_eq!(kept[0].recurrence_id, Some(at(12, 9)));
}
// ------------------------------------------------------------------ deleting --
#[test]
fn deleting_one_occurrence_adds_an_exdate() {
let series = weekly_series();
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
else {
panic!("deleting one occurrence keeps the resource");
};
assert_eq!(result.master().unwrap().exdate, vec![at(12, 9)]);
assert_eq!(
shown(&[&result]),
vec![
"2026-01-05 09:00 Standup",
"2026-01-19 09:00 Standup",
"2026-01-26 09:00 Standup",
"2026-02-02 09:00 Standup",
"2026-02-09 09:00 Standup",
],
"the 12th is gone and the rest are untouched",
);
}
#[test]
fn deleting_an_occurrence_that_was_edited_removes_its_override_too() {
let series = weekly_series();
let edited = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
let DeleteOutcome::Replace(result) =
apply_delete(&edited, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
assert_eq!(
result.overrides().count(),
0,
"an EXDATE without removing the override would leave the moved copy \
showing on a day the person just deleted",
);
assert!(!shown(&[&result]).iter().any(|s| s.contains("2026-01-12")));
}
#[test]
fn deleting_this_and_future_truncates_the_series() {
let series = weekly_series();
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisAndFuture, Some(&at(19, 9)), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
assert_eq!(
shown(&[&result]),
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
);
}
#[test]
fn deleting_the_entire_series_removes_the_resource() {
let series = weekly_series();
assert_eq!(
apply_delete(&series, EditScope::EntireSeries, None, zones()).unwrap(),
DeleteOutcome::Remove,
"there is nothing left to write back",
);
}
// -------------------------------------------------------------- one-off events
#[test]
fn every_scope_means_the_same_thing_for_a_one_off_event() {
let event = one_off();
let mut renamed = event.master().unwrap().clone();
renamed.summary = Some("Dentist (rescheduled)".to_owned());
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
let result =
replaced(apply_edit(&event, scope, Some(&at(5, 9)), &renamed, zones()).unwrap());
assert_eq!(result.events.len(), 1, "{scope:?}");
assert_eq!(result.master().unwrap().uid, "dentist@test", "{scope:?}");
assert_eq!(shown(&[&result]).len(), 1, "{scope:?}");
}
}
#[test]
fn deleting_a_one_off_event_removes_it_whatever_the_scope() {
let event = one_off();
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
assert_eq!(
apply_delete(&event, scope, Some(&at(5, 9)), zones()).unwrap(),
DeleteOutcome::Remove,
"{scope:?}",
);
}
}
// ---------------------------------------------------------------- rule edits --
#[test]
fn setting_until_replaces_any_existing_bound() {
let until = instant("2026-01-19T15:59:59Z");
assert_eq!(
set_until("FREQ=WEEKLY;BYDAY=MO;COUNT=6", until).unwrap(),
"FREQ=WEEKLY;BYDAY=MO;UNTIL=20260119T155959Z",
);
assert_eq!(
set_until("FREQ=WEEKLY;UNTIL=20270101T000000Z", until).unwrap(),
"FREQ=WEEKLY;UNTIL=20260119T155959Z",
);
}
#[test]
fn setting_until_leaves_every_other_part_exactly_as_written() {
// Exchange writes WKST=SU and orders its parts its own way. Parsing the
// rule and rendering it back would normalise both; this touches only the
// bound.
let rule = "FREQ=WEEKLY;INTERVAL=1;BYDAY=WE;WKST=SU";
let bounded = set_until(rule, instant("2026-01-19T15:59:59Z")).unwrap();
assert!(bounded.starts_with(rule), "{bounded}");
}
#[test]
fn an_unbounded_series_stays_unbounded_when_split() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:forever@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let series = ical::parse(ics).unwrap();
let EditOutcome::Split { new_series, .. } = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap() else {
panic!("expected a split");
};
assert_eq!(
new_series.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO"),
"there was no bound to divide up",
);
}
#[test]
fn a_counted_series_keeps_its_total_across_a_split() {
// The property that matters: however the series is cut, the number of
// occurrences the person asked for is the number they end up with.
let series = weekly_series();
for split_day in [12, 19, 26] {
let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(split_day, 9)),
&moved_to_afternoon(split_day),
zones(),
)
.unwrap()
else {
panic!("expected a split at day {split_day}");
};
assert_eq!(
shown(&[&existing, &new_series]).len(),
6,
"splitting at the {split_day}th changed the total",
);
}
}
#[test]
fn stripping_bounds_leaves_the_pattern() {
assert_eq!(
strip_bounds("FREQ=MONTHLY;BYDAY=3TU;COUNT=12"),
"FREQ=MONTHLY;BYDAY=3TU",
);
assert_eq!(strip_bounds("FREQ=DAILY"), "FREQ=DAILY");
}
#[test]
fn a_rule_with_no_frequency_is_refused() {
assert!(set_until("COUNT=5", instant("2026-01-19T15:59:59Z")).is_err());
assert!(set_until("", instant("2026-01-19T15:59:59Z")).is_err());
}
// ------------------------------------------------------------ written output --
#[test]
fn every_outcome_is_still_valid_icalendar() {
// The results are written to a real server, so they have to survive the
// round trip the whole of M3 exists to guarantee.
let series = weekly_series();
let mut outcomes = vec![replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
)];
if let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap()
{
outcomes.push(existing);
outcomes.push(new_series);
}
for calendar in &outcomes {
let written = ical::write(calendar);
let reparsed = ical::parse(&written).expect("outcomes must be readable");
assert_eq!(&reparsed, calendar, "changed meaning on the way out");
for line in written.split("\r\n") {
assert!(line.len() <= 75, "unfolded line: {line}");
}
}
}
#[test]
fn an_edit_bumps_the_sequence_number() {
let series = weekly_series();
let before = series.master().unwrap().sequence;
let mut renamed = series.master().unwrap().clone();
renamed.summary = Some("Renamed".to_owned());
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(
result.master().unwrap().sequence,
before + 1,
"SEQUENCE is how other clients know a revision happened; leaving it \
still makes an edited invitation look unchanged",
);
assert!(result.master().unwrap().last_modified.is_some());
}
#[test]
fn a_resource_with_only_overrides_cannot_be_edited() {
let mut orphan = weekly_series();
orphan.events.retain(VEvent::is_override);
orphan.events.push({
let mut exception = moved_to_afternoon(12);
exception.recurrence_id = Some(at(12, 9));
exception
});
assert_eq!(
apply_edit(
&orphan,
EditScope::EntireSeries,
None,
&moved_to_afternoon(12),
zones()
)
.unwrap_err(),
SeriesError::NoMaster,
"there is no series to apply a scope to, and guessing would be worse \
than saying so",
);
}
#[test]
fn an_edit_across_a_dst_boundary_keeps_the_wall_clock_time() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:dst@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260302T090000\r\n\
DTEND;TZID=America/Denver:20260302T093000\r\n\
SUMMARY:Weekly\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let series = ical::parse(ics).unwrap();
// Denver springs forward on 2026-03-08, so the split point is on the far
// side of the change and UNTIL has to be computed from the right offset.
let split_at = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 16)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisAndFuture, Some(&split_at), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
let rule = result.master().unwrap().rrule.clone().unwrap();
assert!(
rule.contains("UNTIL=20260316T145959Z"),
"09:00 Denver on 16 March is 15:00 UTC because daylight time has begun; \
computing it from the winter offset would delete a week too many or \
too few: {rule}",
);
let window = Window::new(
instant("2026-03-01T00:00:00Z"),
instant("2026-04-01T00:00:00Z"),
);
let remaining = expand(&result, window, zones()).unwrap();
assert_eq!(
remaining.len(),
2,
"2 and 9 March survive, 16 and 23 do not"
);
}
#[test]
fn scopes_round_trip_through_json() {
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
let encoded = serde_json::to_string(&scope).unwrap();
assert_eq!(serde_json::from_str::<EditScope>(&encoded).unwrap(), scope);
}
assert_eq!(
serde_json::to_string(&EditScope::ThisAndFuture).unwrap(),
"\"this_and_future\"",
"the wire form is stable; v1 dispatched on 53 hand-written string \
literals and a typo was a runtime fallthrough",
);
}
#[test]
fn a_shifted_series_keeps_its_duration() {
let series = weekly_series();
let mut moved = series.master().unwrap().clone();
moved.dtstart = at(5, 11);
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
assert_eq!(result.master().unwrap().duration(), TimeDelta::hours(1));
}