diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.AdjustmentRule.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.AdjustmentRule.cs index caac7f0a4b3cdf..fb148e31235f12 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.AdjustmentRule.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.AdjustmentRule.cs @@ -76,6 +76,24 @@ private AdjustmentRule( _noDaylightTransitions = noDaylightTransitions; } + internal static AdjustmentRule CreateAdjustmentRule( + DateTime dateStart, + DateTime dateEnd, + TimeSpan daylightDelta, + TransitionTime daylightTransitionStart, + TransitionTime daylightTransitionEnd, + TimeSpan baseUtcOffsetDelta) + { + return new AdjustmentRule( + dateStart, + dateEnd, + daylightDelta, + daylightTransitionStart, + daylightTransitionEnd, + baseUtcOffsetDelta, + noDaylightTransitions: false); + } + public static AdjustmentRule CreateAdjustmentRule( DateTime dateStart, DateTime dateEnd, diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs index fd2d2556527c6d..6e595737317d18 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs @@ -114,6 +114,17 @@ private TimeZoneInfo(byte[] data, string id, bool dstDisabled) ValidateTimeZoneInfo(_id, _baseUtcOffset, _adjustmentRules, out _supportsDaylightSavingTime); } + // The TransitionTime fields are not used when AdjustmentRule.NoDaylightTransitions == true. + // However, there are some cases in the past where DST = true, and the daylight savings offset + // now equals what the current BaseUtcOffset is. In that case, the AdjustmentRule.DaylightOffset + // is going to be TimeSpan.Zero. But we still need to return 'true' from AdjustmentRule.HasDaylightSaving. + // To ensure we always return true from HasDaylightSaving, make a "special" dstStart that will make the logic + // in HasDaylightSaving return true. + private static readonly TransitionTime s_daylightRuleMarker = TransitionTime.CreateFixedDateRule(DateTime.MinValue.AddMilliseconds(2), 1, 1); + + // Truncate the date and the time to Milliseconds precision + private static DateTime GetTimeOnlyInMillisecondsPrecision(DateTime input) => new DateTime((input.TimeOfDay.Ticks / TimeSpan.TicksPerMillisecond) * TimeSpan.TicksPerMillisecond); + /// /// Returns a cloned array of AdjustmentRule objects /// @@ -128,11 +139,20 @@ public AdjustmentRule[] GetAdjustmentRules() // as the rules now is public, we should fill it properly so the caller doesn't have to know how we use it internally // and can use it as it is used in Windows - AdjustmentRule[] rules = new AdjustmentRule[_adjustmentRules.Length]; + List rulesList = new List(_adjustmentRules.Length); for (int i = 0; i < _adjustmentRules.Length; i++) { - AdjustmentRule? rule = _adjustmentRules[i]; + AdjustmentRule rule = _adjustmentRules[i]; + + if (rule.NoDaylightTransitions && + rule.DaylightTransitionStart != s_daylightRuleMarker && + rule.DaylightDelta == TimeSpan.Zero && rule.BaseUtcOffsetDelta == TimeSpan.Zero) + { + // This rule has no time transition, ignore it. + continue; + } + DateTime start = rule.DateStart.Kind == DateTimeKind.Utc ? // At the daylight start we didn't start the daylight saving yet then we convert to Local time // by adding the _baseUtcOffset to the UTC time @@ -144,13 +164,51 @@ public AdjustmentRule[] GetAdjustmentRules() new DateTime(rule.DateEnd.Ticks + _baseUtcOffset.Ticks + rule.DaylightDelta.Ticks, DateTimeKind.Unspecified) : rule.DateEnd; - TransitionTime startTransition = TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, start.Hour, start.Minute, start.Second), start.Month, start.Day); - TransitionTime endTransition = TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, end.Hour, end.Minute, end.Second), end.Month, end.Day); + if (start.Year == end.Year || !rule.NoDaylightTransitions) + { + // If the rule is covering only one year then the start and end transitions would occur in that year, we don't need to split the rule. + // Also, rule.NoDaylightTransitions be false in case the rule was created from a POSIX time zone string and having a DST transition. We can represent this in one rule too + TransitionTime startTransition = rule.NoDaylightTransitions ? TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(start), start.Month, start.Day) : rule.DaylightTransitionStart; + TransitionTime endTransition = rule.NoDaylightTransitions ? TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(end), end.Month, end.Day) : rule.DaylightTransitionEnd; + rulesList.Add(AdjustmentRule.CreateAdjustmentRule(start.Date, end.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta)); + } + else + { + // For rules spanning more than one year. The time transition inside this rule would apply for the whole time spanning these years + // and not for partial time of every year. + // AdjustmentRule cannot express such rule using the DaylightTransitionStart and DaylightTransitionEnd because + // the DaylightTransitionStart and DaylightTransitionEnd express the transition for every year. + // We split the rule into more rules. The first rule will start from the start year of the original rule and ends at the end of the same year. + // The second splitted rule would cover the middle range of the original rule and ranging from the year start+1 to + // year end-1. The transition time in this rule would start from Jan 1st to end of December. + // The last splitted rule would start from the Jan 1st of the end year of the original rule and ends at the end transition time of the original rule. + + // Add the first rule. + DateTime endForFirstRule = new DateTime(start.Year + 1, 1, 1).AddMilliseconds(-1); // At the end of the first year + TransitionTime startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(start), start.Month, start.Day); + TransitionTime endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(endForFirstRule), endForFirstRule.Month, endForFirstRule.Day); + rulesList.Add(AdjustmentRule.CreateAdjustmentRule(start.Date, endForFirstRule.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta)); + + // Check if there is range of years between the start and the end years + if (end.Year - start.Year > 1) + { + // Add the middle rule. + DateTime middleYearStart = new DateTime(start.Year + 1, 1, 1); + DateTime middleYearEnd = new DateTime(end.Year, 1, 1).AddMilliseconds(-1); + startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(middleYearStart), middleYearStart.Month, middleYearStart.Day); + endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(middleYearEnd), middleYearEnd.Month, middleYearEnd.Day); + rulesList.Add(AdjustmentRule.CreateAdjustmentRule(middleYearStart.Date, middleYearEnd.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta)); + } - rules[i] = TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule(start.Date, end.Date, rule.DaylightDelta, startTransition, endTransition); + // Add the end rule. + DateTime endYearStart = new DateTime(end.Year, 1, 1); // At the beginning of the last year + startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(endYearStart), endYearStart.Month, endYearStart.Day); + endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(end), end.Month, end.Day); + rulesList.Add(AdjustmentRule.CreateAdjustmentRule(endYearStart.Date, end.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta)); + } } - return rules; + return rulesList.ToArray(); } private static void PopulateAllSystemTimeZones(CachedData cachedData) @@ -957,7 +1015,7 @@ private static void TZif_GenerateAdjustmentRule(ref int index, TimeSpan timeZone // is going to be TimeSpan.Zero. But we still need to return 'true' from AdjustmentRule.HasDaylightSaving. // To ensure we always return true from HasDaylightSaving, make a "special" dstStart that will make the logic // in HasDaylightSaving return true. - dstStart = TransitionTime.CreateFixedDateRule(DateTime.MinValue.AddMilliseconds(2), 1, 1); + dstStart = s_daylightRuleMarker; } else { @@ -1068,7 +1126,7 @@ private static TZifType TZif_GetEarlyDateTransitionType(TZifType[] transitionTyp /// Creates an AdjustmentRule given the POSIX TZ environment variable string. /// /// - /// See http://man7.org/linux/man-pages/man3/tzset.3.html for the format and semantics of this POSX string. + /// See http://man7.org/linux/man-pages/man3/tzset.3.html for the format and semantics of this POSIX string. /// private static AdjustmentRule? TZif_CreateAdjustmentRuleForPosixFormat(string posixFormat, DateTime startTransitionDate, TimeSpan timeZoneBaseUtcOffset) { diff --git a/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs b/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs index ab96edddb8bc31..35861477d23883 100644 --- a/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs +++ b/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs @@ -1819,6 +1819,69 @@ public static void IsDaylightSavingTime_CasablancaMultiYearDaylightSavings(strin Assert.Equal(offset, s_casablancaTz.GetUtcOffset(dt)); } + [Fact] + [PlatformSpecific(~TestPlatforms.Windows)] + public static void TestSplittingRulesWhenReported() + { + // This test confirm we are splitting the rules which span multiple years on Linux + // we use "America/Los_Angeles" which has the rule covering 2/9/1942 to 8/14/1945 + // with daylight transition by 01:00:00. This rule should be split into 3 rules: + // - rule 1 from 2/9/1942 to 12/31/1942 + // - rule 2 from 1/1/1943 to 12/31/1944 + // - rule 3 from 1/1/1945 to 8/14/1945 + TimeZoneInfo.AdjustmentRule[] rules = TimeZoneInfo.FindSystemTimeZoneById(s_strPacific).GetAdjustmentRules(); + + bool ruleEncountered = false; + for (int i = 0; i < rules.Length; i++) + { + if (rules[i].DateStart == new DateTime(1942, 2, 9)) + { + Assert.True(i + 2 <= rules.Length - 1); + TimeSpan daylightDelta = TimeSpan.FromHours(1); + + // DateStart : 2/9/1942 12:00:00 AM (Unspecified) + // DateEnd : 12/31/1942 12:00:00 AM (Unspecified) + // DaylightDelta : 01:00:00 + // DaylightTransitionStart : ToD:02:00:00 M:2, D:9, W:1, DoW:Sunday, FixedDate:True + // DaylightTransitionEnd : ToD:23:59:59.9990000 M:12, D:31, W:1, DoW:Sunday, FixedDate:True + + Assert.Equal(new DateTime(1942, 12, 31), rules[i].DateEnd); + Assert.Equal(daylightDelta, rules[i].DaylightDelta); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 2, 0, 0), 2, 9), rules[i].DaylightTransitionStart); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 23, 59, 59, 999), 12, 31), rules[i].DaylightTransitionEnd); + + // DateStart : 1/1/1943 12:00:00 AM (Unspecified) + // DateEnd : 12/31/1944 12:00:00 AM (Unspecified) + // DaylightDelta : 01:00:00 + // DaylightTransitionStart : ToD:00:00:00 M:1, D:1, W:1, DoW:Sunday, FixedDate:True + // DaylightTransitionEnd : ToD:23:59:59.9990000 M:12, D:31, W:1, DoW:Sunday, FixedDate:True + + Assert.Equal(new DateTime(1943, 1, 1), rules[i + 1].DateStart); + Assert.Equal(new DateTime(1944, 12, 31), rules[i + 1].DateEnd); + Assert.Equal(daylightDelta, rules[i + 1].DaylightDelta); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 0, 0, 0), 1, 1), rules[i + 1].DaylightTransitionStart); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 23, 59, 59, 999), 12, 31), rules[i + 1].DaylightTransitionEnd); + + // DateStart : 1/1/1945 12:00:00 AM (Unspecified) + // DateEnd : 8/14/1945 12:00:00 AM (Unspecified) + // DaylightDelta : 01:00:00 + // DaylightTransitionStart : ToD:00:00:00 M:1, D:1, W:1, DoW:Sunday, FixedDate:True + // DaylightTransitionEnd : ToD:15:59:59.9990000 M:8, D:14, W:1, DoW:Sunday, FixedDate:True + + Assert.Equal(new DateTime(1945, 1, 1), rules[i + 2].DateStart); + Assert.Equal(new DateTime(1945, 8, 14), rules[i + 2].DateEnd); + Assert.Equal(daylightDelta, rules[i + 2].DaylightDelta); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 0, 0, 0), 1, 1), rules[i + 2].DaylightTransitionStart); + Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 15, 59, 59, 999), 8, 14), rules[i + 2].DaylightTransitionEnd); + + ruleEncountered = true; + break; + } + } + + Assert.True(ruleEncountered, "The 1942 rule of America/Los_Angeles not found."); + } + [Theory] [PlatformSpecific(TestPlatforms.AnyUnix)] // Linux will use local mean time for DateTimes before standard time came into effect. // in 1996 Europe/Lisbon changed from standard time to DST without changing the UTC offset