Skip to content

Commit cfc0f77

Browse files
authored
Further normalize MWDs, add Vancouver and Santiago tests (#504)
We were not handling the fact that the "first Friday of the month" is not necessarily after the "first sunday of the month". There's a case to be made here that this logic isn't worth it and we should just normalize the Mwds to `Seconds` instead of trying to turn them into something comparable. I don't have a strong opinion there, someone who wants to look into that can benchmark it. This also adds some tests for America/Santiago, which has some fun quirks I discovered.
1 parent 4212b8b commit cfc0f77

File tree

2 files changed

+98
-19
lines changed

2 files changed

+98
-19
lines changed

src/builtins/compiled/zoneddatetime.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,28 @@ mod tests {
666666

667667
const TROLL_FIRST_TRANSITION: &str = "2005-03-27T03:00:00+02:00[Antarctica/Troll]";
668668

669+
/// Vancouver transitions on the first Sunday in November, which may or may not be
670+
/// before the first Friday in November
671+
const VANCOUVER_FIRST_FRIDAY_IN_NOVEMBER_BEFORE_SUNDAY: &str =
672+
"2019-11-01T00:00:00-07:00[America/Vancouver]";
673+
const VANCOUVER_FIRST_FRIDAY_IN_NOVEMBER_AFTER_SUNDAY: &str =
674+
"2019-11-06T00:00:00-08:00[America/Vancouver]";
675+
676+
/// Chile tzdb has a transition on the "first saturday in april", except the transition occurs
677+
/// at 24:00:00, which is, of course, the day after. This is not the same thing as the first Sunday in April
678+
const SANTIAGO_DST_2024: &str = "2024-09-08T01:00:00-03:00[America/Santiago]";
679+
const SANTIAGO_STD_2025_APRIL: &str = "2025-04-05T23:00:00-04:00[America/Santiago]";
680+
const SANTIAGO_STD_2025_APRIL_PLUS_ONE: &str =
681+
"2025-04-05T23:00:00.000000001-04:00[America/Santiago]";
682+
const SANTIAGO_STD_2025_APRIL_MINUS_ONE: &str =
683+
"2025-04-05T23:59:59.999999999-03:00[America/Santiago]";
684+
const SANTIAGO_DST_2025_SEPT: &str = "2025-09-07T01:00:00-03:00[America/Santiago]";
685+
const SANTIAGO_DST_2025_SEPT_PLUS_ONE: &str =
686+
"2025-09-07T01:00:00.000000001-03:00[America/Santiago]";
687+
const SANTIAGO_DST_2025_SEPT_MINUS_ONE: &str =
688+
"2025-09-06T23:59:59.999999999-04:00[America/Santiago]";
689+
const SANTIAGO_STD_2026: &str = "2026-04-04T23:00:00-04:00[America/Santiago]";
690+
669691
// MUST only contain full strings
670692
// The second boolean is whether these are unambiguous when the offset is removed
671693
// As a rule of thumb, anything around an STD->DST transition
@@ -698,6 +720,16 @@ mod tests {
698720
(LONDON_POSIX_TRANSITION_2017_03_26_MINUS_ONE, true),
699721
(LONDON_POSIX_TRANSITION_2017_03_26_MINUS_ONE, true),
700722
(TROLL_FIRST_TRANSITION, true),
723+
(VANCOUVER_FIRST_FRIDAY_IN_NOVEMBER_BEFORE_SUNDAY, true),
724+
(VANCOUVER_FIRST_FRIDAY_IN_NOVEMBER_AFTER_SUNDAY, true),
725+
(SANTIAGO_DST_2024, true),
726+
(SANTIAGO_STD_2025_APRIL, false),
727+
(SANTIAGO_STD_2025_APRIL_PLUS_ONE, false),
728+
(SANTIAGO_STD_2025_APRIL_MINUS_ONE, false),
729+
(SANTIAGO_DST_2025_SEPT, true),
730+
(SANTIAGO_DST_2025_SEPT_PLUS_ONE, true),
731+
(SANTIAGO_DST_2025_SEPT_MINUS_ONE, true),
732+
(SANTIAGO_STD_2026, false),
701733
];
702734

703735
#[test]
@@ -766,6 +798,28 @@ mod tests {
766798
assert_tr(&zdt, Previous, STD_1998_01_31);
767799
assert_tr(&zdt, Next, DST_1999_04_04);
768800

801+
// Santiago tests, testing that transitions with the offset = 24:00:00
802+
// still work
803+
let zdt = parse_zdt_with_reject(SANTIAGO_DST_2025_SEPT_MINUS_ONE).unwrap();
804+
assert_tr(&zdt, Previous, SANTIAGO_STD_2025_APRIL);
805+
assert_tr(&zdt, Next, SANTIAGO_DST_2025_SEPT);
806+
let zdt = parse_zdt_with_reject(SANTIAGO_DST_2025_SEPT).unwrap();
807+
assert_tr(&zdt, Previous, SANTIAGO_STD_2025_APRIL);
808+
assert_tr(&zdt, Next, SANTIAGO_STD_2026);
809+
let zdt = parse_zdt_with_reject(SANTIAGO_DST_2025_SEPT_PLUS_ONE).unwrap();
810+
assert_tr(&zdt, Previous, SANTIAGO_DST_2025_SEPT);
811+
assert_tr(&zdt, Next, SANTIAGO_STD_2026);
812+
813+
let zdt = parse_zdt_with_reject(SANTIAGO_STD_2025_APRIL_MINUS_ONE).unwrap();
814+
assert_tr(&zdt, Previous, SANTIAGO_DST_2024);
815+
assert_tr(&zdt, Next, SANTIAGO_STD_2025_APRIL);
816+
let zdt = parse_zdt_with_reject(SANTIAGO_STD_2025_APRIL).unwrap();
817+
assert_tr(&zdt, Previous, SANTIAGO_DST_2024);
818+
assert_tr(&zdt, Next, SANTIAGO_DST_2025_SEPT);
819+
let zdt = parse_zdt_with_reject(SANTIAGO_STD_2025_APRIL_PLUS_ONE).unwrap();
820+
assert_tr(&zdt, Previous, SANTIAGO_STD_2025_APRIL);
821+
assert_tr(&zdt, Next, SANTIAGO_DST_2025_SEPT);
822+
769823
// Test case from intl402/Temporal/ZonedDateTime/prototype/getTimeZoneTransition/rule-change-without-offset-transition
770824
// This ensures we skip "fake" transition entries that do not actually change the offset
771825

src/tzdb.rs

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,13 +1056,24 @@ impl Mwd {
10561056
fn from_u8(month: u8, week: u8, day: u8) -> Self {
10571057
Self { month, week, day }
10581058
}
1059+
1060+
/// Given the day of the week of the 0th day in this month,
1061+
/// normalize the week to being a week number (1 = first week, ...)
1062+
/// rather than a weekday ordinal (1 = first friday, etc)
1063+
fn normalize_to_week_number(&mut self, day_of_week_zeroth_day: u8) {
1064+
if self.day <= day_of_week_zeroth_day {
1065+
self.week += 1;
1066+
}
1067+
}
10591068
}
10601069

10611070
/// Represents an MWD for a given time
10621071
#[derive(Debug)]
10631072
struct MwdForTime {
10641073
/// This will never have day = 5
10651074
mwd: Mwd,
1075+
/// The day of the week of the 0th day (the day before the month starts)
1076+
day_of_week_zeroth_day: u8,
10661077
/// This is the day of week of the 29th and the last day of the month,
10671078
/// if the month has more than 28 days.
10681079
/// Basically, this is the start and end of the "fifth $weekday of the month" period
@@ -1074,21 +1085,24 @@ impl MwdForTime {
10741085
let (year, month, day_of_month) = utils::ymd_from_epoch_milliseconds(seconds * 1_000);
10751086
let week_of_month = day_of_month / 7 + 1;
10761087
let day_of_week = utils::epoch_seconds_to_day_of_week(seconds);
1077-
let mwd = Mwd::from_u8(month, week_of_month, day_of_week);
1088+
let mut mwd = Mwd::from_u8(month, week_of_month, day_of_week);
10781089
let days_in_month = utils::iso_days_in_month(year, month);
1090+
let day_of_week_zeroth_day =
1091+
(i16::from(day_of_week) - i16::from(day_of_month)).rem_euclid(7) as u8;
1092+
mwd.normalize_to_week_number(day_of_week_zeroth_day);
10791093
if day_of_month > 28 {
1080-
let day_of_week_zeroth_day =
1081-
(i16::from(day_of_week) - i16::from(day_of_month)).rem_euclid(7) as u8;
10821094
let day_of_week_day_29 = (day_of_week_zeroth_day + 29).rem_euclid(7);
10831095
let day_of_week_last_day = (day_of_week_zeroth_day + days_in_month).rem_euclid(7);
10841096
Self {
10851097
mwd,
1098+
day_of_week_zeroth_day,
10861099
extra_days: Some((day_of_week_day_29, day_of_week_last_day)),
10871100
}
10881101
} else {
10891102
// No day 5
10901103
Self {
10911104
mwd,
1105+
day_of_week_zeroth_day,
10921106
extra_days: None,
10931107
}
10941108
}
@@ -1097,27 +1111,38 @@ impl MwdForTime {
10971111
/// MWDs from Posix data can contain `w=5`, which means the *last* $weekday of the month,
10981112
/// not the 5th. For MWDs in the same month, this normalizes the 5 to the actual number of the
10991113
/// last weekday of the month (5 or 4)
1114+
///
1115+
/// Furthermore, this turns the week number into a true week number: the "second friday in March"
1116+
/// will be turned into "the friday in the first week of March" or "the Friday in the second week of March"
1117+
/// depending on when March starts.
1118+
///
1119+
/// This normalization *only* applies to MWDs in the same month. For other MWDs, such normalization is irrelevant.
11001120
fn normalize_mwd(&self, other: &mut Mwd) {
1101-
// If we're in the same month, and the other mwd is looking for
1102-
// the last $weekday in the month, we need special handling
1103-
if self.mwd.month == other.month && other.week == 5 {
1104-
if let Some((day_29, last_day)) = self.extra_days {
1105-
if day_29 < last_day {
1106-
if other.day < day_29 || other.day > last_day {
1107-
// This day isn't found in the last week. Subtract one.
1108-
other.week = 4;
1121+
// If we're in the same month, normalization will actually have a useful effect
1122+
if self.mwd.month == other.month {
1123+
// First normalize MWDs that are like "the last $weekday in the month"
1124+
// the last $weekday in the month, we need special handling
1125+
if other.week == 5 {
1126+
if let Some((day_29, last_day)) = self.extra_days {
1127+
if day_29 < last_day {
1128+
if other.day < day_29 || other.day > last_day {
1129+
// This day isn't found in the last week. Subtract one.
1130+
other.week = 4;
1131+
}
1132+
} else {
1133+
// The extra part of the month crosses Sunday
1134+
if other.day < day_29 && other.day > last_day {
1135+
// This day isn't found in the last week. Subtract one.
1136+
other.week = 4;
1137+
}
11091138
}
11101139
} else {
1111-
// The extra part of the month crosses Sunday
1112-
if other.day < day_29 && other.day > last_day {
1113-
// This day isn't found in the last week. Subtract one.
1114-
other.week = 4;
1115-
}
1140+
// There is no week 5 in this month, normalize to 4
1141+
other.week = 4;
11161142
}
1117-
} else {
1118-
// There is no week 5 in this month, normalize to 4
1119-
other.week = 4;
11201143
}
1144+
1145+
other.normalize_to_week_number(self.day_of_week_zeroth_day);
11211146
}
11221147
}
11231148
}

0 commit comments

Comments
 (0)