// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import "package:expect/expect.dart"; // Tests that local DateTime constructor works correctly around // time zone changes. void main() { // Find two points in time with different time zones. // Search linearly back from 2020-01-01 in steps of 60 days. // Stop if reaching 1970-01-01 (epoch) without finding anything. var time = DateTime.utc(2020, 1, 1).millisecondsSinceEpoch; var offset = DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds; var time2 = time; var offset2 = offset; // Whether the first change found moved the clock forward. bool changeForward = false; // 60 days. const delta = 60 * Duration.millisecondsPerDay; while (time2 > 0) { time2 -= delta; offset2 = DateTime.fromMillisecondsSinceEpoch(time2) .timeZoneOffset .inMilliseconds; if (verbose) { print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}"); } if (offset2 != offset) { // Two different time zones found. Now find the precise (to the minute) // time where a change happened, and test that. test(findChange(time2, time)); // Remeber if the change moved the clock forward or backward. changeForward = offset2 < offset; break; } } time = time2; // Find a change in the other direction. // Keep iterating backwards to find another time zone // where the change was in the other direction. while (time > 0) { time -= delta; offset = DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds; if (verbose) { print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}"); } if (offset != offset2) { if ((offset < offset2) != changeForward) { test(findChange(time, time2)); break; } else { // Another change in the same direction. // Probably rare, but move use this time // as end-point instead, so the binary search will be shorter. time2 = time; offset2 = offset; } } } } /// Tests that a local time zone change is correctly represented /// by local time [DateTime] objects created from date-time values. void test(TimeZoneChange change) { if (verbose) print("Test of $change"); // Sanity check. The time zones match the [change] one second // before and after the change. var before = DateTime.fromMillisecondsSinceEpoch( change.msSinceEpoch - Duration.millisecondsPerSecond); Expect.equals(change.msOffsetBefore, before.timeZoneOffset.inMilliseconds); var after = DateTime.fromMillisecondsSinceEpoch( change.msSinceEpoch + Duration.millisecondsPerSecond); Expect.equals(change.msOffsetAfter, after.timeZoneOffset.inMilliseconds); if (verbose) print("From MS : ${dtz(before)} --- ${dtz(after)}"); // Create local DateTime objects for the same YMDHMS as the // values above. See that we pick the correct local time for them. // One second before the change, even if clock moves backwards, // we pick a value that is in the earlier time zone. var localBefore = DateTime(before.year, before.month, before.day, before.hour, before.minute, before.second); Expect.equals(before, localBefore); // Asking for a calendar date one second after the change. var localAfter = DateTime(after.year, after.month, after.day, after.hour, after.minute, after.second); if (verbose) print("From YMDHMS: ${dtz(localBefore)} --- ${dtz(localAfter)}"); if (before.timeZoneOffset < after.timeZoneOffset) { // Clock moved forwards. // We're asking for a clock time which doesn't exist. if (verbose) { print("Forward: ${dtz(after)} vs ${dtz(localAfter)}"); } Expect.equals(after, localAfter); } else { // Clock moved backwards. // We're asking for a clock time which exists more than once. // Should be in the former time zone. Expect.equals(before.timeZoneOffset, localAfter.timeZoneOffset); } } /// Finds a time zone change between [before] and [after]. /// /// The [before] time must be before [after], /// and the local time zone at the two points must be different. /// /// Finds the point in time, with one minute precision, /// where the time zone changed, and returns this point, /// as well as the time zone offset before and after the change. TimeZoneChange findChange(int before, int after) { var min = Duration.millisecondsPerMinute; assert(before % min == 0); assert(after % min == 0); var offsetBefore = DateTime.fromMillisecondsSinceEpoch(before).timeZoneOffset.inMilliseconds; var offsetAfter = DateTime.fromMillisecondsSinceEpoch(after).timeZoneOffset.inMilliseconds; // Binary search for the precise (to 1 minute increments) // time where the change happened. while (after - before > min) { var mid = before + (after - before) ~/ 2; mid -= mid % min; var offsetMid = DateTime.fromMillisecondsSinceEpoch(mid).timeZoneOffset.inMilliseconds; if (verbose) { print( "Bsearch: ${tz(before, offsetBefore)} - ${tz(mid, offsetMid)} - ${tz(after, offsetAfter)}"); } if (offsetMid == offsetBefore) { before = mid; } else if (offsetMid == offsetAfter) { after = mid; } else { // Third timezone in the middle. Probably rare. // Use that as either before or after. // Keep the direction of the time zone change. var forwardChange = offsetAfter > offsetBefore; if ((offsetMid > offsetBefore) == forwardChange) { after = mid; offsetAfter = offsetMid; } else { before = mid; offsetBefore = offsetMid; } } } return TimeZoneChange(after, offsetBefore, offsetAfter); } /// A local time zone change. class TimeZoneChange { /// The point in time where the clocks were adjusted. final int msSinceEpoch; /// The time zone offset before the change. final int msOffsetBefore; /// The time zone offset since the change. final int msOffsetAfter; TimeZoneChange(this.msSinceEpoch, this.msOffsetBefore, this.msOffsetAfter); String toString() { var local = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch); var offsetBefore = Duration(milliseconds: msOffsetBefore); var offsetAfter = Duration(milliseconds: msOffsetAfter); return "$local (${ltz(offsetBefore)} -> ${ltz(offsetAfter)})"; } } // Helpers when printing timezones. /// Point in time in ms since epoch, and known offset in ms. String tz(int ms, int offset) => "${DateTime.fromMillisecondsSinceEpoch(ms)}" "${ltz(Duration(milliseconds: offset))}"; /// Time plus Zone from DateTime String dtz(DateTime dt) => "$dt${dt.isUtc ? "" : ltz(dt.timeZoneOffset)}"; /// Time zone from duration ("+h:ss" format). String ltz(Duration d) => "${d.isNegative ? "-" : "+"}${d.inHours}" ":${(d.inMinutes % 60).toString().padLeft(2, "0")}"; /// Set to true if debugging. const bool verbose = false;