Retry or compensate for erratic errors in DateTime creation. Add tests.

PiperOrigin-RevId: 277965229
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d73236a..cfa16f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
  * Bump SDK requirements up to 2.5.0 for dart:ffi availability.
  * Canonicalize the locale in the Intl.defaultLocale setter, so e.g. 'en-US'
    will get turned into the correct 'en_US'
+ * Attempt to compensate for erratic errors in DateTime creation better, and add
+   tests for the compensation.
 
 ## 0.16.0
  * Fix 'k' formatting (1 to 24 hours) which incorrectly showed 0 to 23.
diff --git a/lib/src/intl/date_format.dart b/lib/src/intl/date_format.dart
index 2046035..a6b0e24 100644
--- a/lib/src/intl/date_format.dart
+++ b/lib/src/intl/date_format.dart
@@ -250,6 +250,23 @@
     addPattern(newPattern);
   }
 
+  /// Allows specifying a different way of creating a DateTime instance for
+  /// testing.
+  ///
+  /// There can be rare and erratic errors in DateTime creation in both
+  /// JavaScript and the Dart VM, and this allows us to test ways of
+  /// compensating for them.
+  _DateTimeConstructor dateTimeConstructor = (int year, int month, int day,
+      int hour24, int minute, int second, int fractionalSecond, bool utc) {
+    if (utc) {
+      return DateTime.utc(
+          year, month, day, hour24, minute, second, fractionalSecond);
+    } else {
+      return DateTime(
+          year, month, day, hour24, minute, second, fractionalSecond);
+    }
+  };
+
   /// Return a string representing [date] formatted according to our locale
   /// and internal format.
   String format(DateTime date) {
@@ -319,7 +336,8 @@
   }
 
   DateTime _parseLoose(String inputString, bool utc) {
-    var dateFields = _DateBuilder(locale ?? Intl.defaultLocale);
+    var dateFields =
+        _DateBuilder(locale ?? Intl.defaultLocale, dateTimeConstructor);
     if (utc) dateFields.utc = true;
     var stream = _Stream(inputString);
     for (var field in _formatFields) {
@@ -347,7 +365,8 @@
   DateTime _parse(String inputString, {bool utc = false, bool strict = false}) {
     // TODO(alanknight): The Closure code refers to special parsing of numeric
     // values with no delimiters, which we currently don't do. Should we?
-    var dateFields = _DateBuilder(locale ?? Intl.defaultLocale);
+    var dateFields =
+        _DateBuilder(locale ?? Intl.defaultLocale, dateTimeConstructor);
     if (utc) dateFields.utc = true;
     dateFields._dateOnly = dateOnly;
     var stream = _Stream(inputString);
@@ -799,3 +818,7 @@
     return null;
   }
 }
+
+/// Defines a function type for creating DateTime instances.
+typedef _DateTimeConstructor = DateTime Function(int year, int month, int day,
+    int hour24, int minute, int second, int fractionalSecond, bool utc);
diff --git a/lib/src/intl/date_format_helpers.dart b/lib/src/intl/date_format_helpers.dart
index acfe003..3ae7fed 100644
--- a/lib/src/intl/date_format_helpers.dart
+++ b/lib/src/intl/date_format_helpers.dart
@@ -71,7 +71,12 @@
   // ignore: prefer_final_fields
   var _dateOnly = false;
 
-  _DateBuilder(this._locale);
+  /// The function we will call to create a DateTime from its component pieces.
+  ///
+  /// This is normally only modified in tests that want to introduce errors.
+  final _DateTimeConstructor _dateTimeConstructor;
+
+  _DateBuilder(this._locale, this._dateTimeConstructor);
 
   // Functions that exist just to be closurized so we can pass them to a general
   // method.
@@ -164,11 +169,11 @@
     if (_date != null) return _date;
 
     if (utc) {
-      _date = DateTime.utc(
-          year, month, day, hour24, minute, second, fractionalSecond);
+      _date = _dateTimeConstructor(
+          year, month, day, hour24, minute, second, fractionalSecond, utc);
     } else {
-      var preliminaryResult =
-          DateTime(year, month, day, hour24, minute, second, fractionalSecond);
+      var preliminaryResult = _dateTimeConstructor(
+          year, month, day, hour24, minute, second, fractionalSecond, utc);
       _date = _correctForErrors(preliminaryResult, retries);
     }
     return _date;
@@ -180,9 +185,13 @@
     // There are 3 kinds of errors that we know of
     //
     // 1 - Issue 15560, sometimes we get UTC even when we asked for local, or
-    // they get constructed as if in UTC and then have the offset
-    // subtracted. Retry, possibly several times, until we get something that
-    // looks valid, or we give up.
+    // they get constructed as if in UTC and then have the offset subtracted.
+    // Retry, possibly several times, until we get something that looks valid,
+    // or we give up.
+    //
+    // 1a) - It appears that sometimes we get incorrect timezone offsets that
+    // are not directly related to UTC. Also check for those and retry or
+    // compensate.
     //
     // 2 - Timezone transitions. If we ask for the time during a timezone
     // transition then it will offset it by that transition. This is
@@ -191,9 +200,8 @@
     // Brazil, and we can end up with 11:00pm the previous day. Add time to
     // compensate.
     //
-    // 3 - Invalid input which the constructor nevertheless accepts. Just
-    // return what it created, and verify will catch it if we're in strict
-    // mode.
+    // 3 - Invalid input which the constructor nevertheless accepts. Just return
+    // what it created, and verify will catch it if we're in strict mode.
 
     // If we've exhausted our retries, just return the input - it's not just a
     // flaky result.
@@ -202,7 +210,7 @@
     }
 
     var leapYear = _isLeapYear(result);
-    var correspondingDay = _dayOfYear(result.month, result.day, leapYear);
+    var resultDayOfYear = _dayOfYear(result.month, result.day, leapYear);
 
     // Check for the UTC failure. Are we expecting to produce a local time, but
     // the result is UTC. However, the local time might happen to be the same as
@@ -211,25 +219,55 @@
     if (!utc &&
         result.isUtc &&
         (result.hour != hour24 ||
-            result.day != correspondingDay ||
+            result.day != resultDayOfYear ||
             !DateTime.now().isUtc)) {
       // This may be a UTC failure. Retry and if the result doesn't look
       // like it's in the UTC time zone, use that instead.
       _retried++;
       return asDate(retries: retries - 1);
     }
-    if (_dateOnly && day != correspondingDay) {
+
+    if (_dateOnly && result.hour != 0) {
+      // This could be a flake, try again.
+      var tryAgain = asDate(retries: retries - 1);
+      if (tryAgain != result) {
+        // Trying again gave a different answer, so presumably it worked.
+        return tryAgain;
+      }
+
+      // Trying again didn't work, try to force the offset.
+      var expectedDayOfYear = _dayOfYear(month, day, leapYear);
+
       // If we're _dateOnly, then hours should be zero, but might have been
-      // offset to e.g. 11:00pm the previous day. Add that time back in. We
-      // only care about jumps backwards. If we were offset to e.g. 1:00am the
-      // same day that's all right for a date. It gets the day correct, and we
-      // have no way to even represent midnight on a day when it doesn't
-      // happen.
-      var adjusted = result.add(Duration(hours: 24 - result.hour));
-      if (_dayOfYear(adjusted.month, adjusted.day, leapYear) == day) {
+      // offset to e.g. 11:00pm the previous day. Add that time back in. This
+      // might be because of an erratic error, but it might also be because of a
+      // time zone (Brazil) where there is no midnight at a daylight savings
+      // time transition. In that case we will retry, but eventually give up and
+      // return 1:00am on the correct date.
+      var daysPrevious = expectedDayOfYear - resultDayOfYear;
+      // For example, if it's the day before at 11:00pm, we offset by (24 - 23),
+      // so +1. If it's the same day at 1:00am, we offset by (0 - 1), so -1.
+      var offset = (daysPrevious * 24) - result.hour;
+      var adjusted = result.add(Duration(hours: offset));
+      // Check if the adjustment worked. This can fail on a time zone transition
+      // where midnight doesn't exist.
+      if (adjusted.hour == 0) {
         return adjusted;
       }
+      // Adjusting did not work. Just check if the adjusted date is right. And
+      // if it's not, just give up and return [result]. The scenario where this
+      // might correctly happen is if we're in a Brazil time zone, jump forward
+      // to 1:00 am because of a DST transition, and trying to go backwards 1
+      // hour takes us back to 11:00pm the day before. In that case the 1:00am
+      // answer on the correct date is preferable.
+      var adjustedDayOfYear =
+          _dayOfYear(adjusted.month, adjusted.day, leapYear);
+      if (adjustedDayOfYear != expectedDayOfYear) {
+        return result;
+      }
+      return adjusted;
     }
+    // None of our corrections applied, just return the uncorrected date.
     return result;
   }
 }
diff --git a/test/date_format_flake_test.dart b/test/date_format_flake_test.dart
new file mode 100644
index 0000000..c71158f
--- /dev/null
+++ b/test/date_format_flake_test.dart
@@ -0,0 +1,88 @@
+// Copyright (c) 2019, 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.
+
+// Tests for what happens when DateTime instance creation does flaky things.
+
+import 'package:intl/intl.dart';
+import 'package:test/test.dart';
+
+/// Holds methods we can tear off and use to modify the way DateFormat creates
+/// DateTimes and introduce errors.
+///
+/// It only handles errors being off by integer numbers of hours, which are the
+/// cases we've observed. See https://github.com/dart-lang/sdk/issues/15560 ,
+/// but this also happens in JavaScript, and can produce other offsets than
+/// UTC-current.
+class DateCreationTweaks {
+  /// When we want a flake that only happens once, use this variable.
+  bool firstTime = true;
+
+  /// The error
+  final int hoursWrong;
+
+  DateCreationTweaks(this.hoursWrong);
+
+  /// Create a DateTime, but if [firstTime] is true add [hoursWrong] to the
+  /// result, simulating a flaky error in the hours on DateTime creation.
+  DateTime withFlakyErrors(int year, int month, int day, int hour24, int minute,
+      int second, int fractionalSecond, bool utc) {
+    DateTime date;
+    if (utc) {
+      date = DateTime.utc(
+          year, month, day, hour24, minute, second, fractionalSecond);
+    } else {
+      date =
+          DateTime(year, month, day, hour24, minute, second, fractionalSecond);
+      if (firstTime) {
+        date = date.add(Duration(hours: hoursWrong));
+      }
+    }
+    firstTime = false;
+    return date;
+  }
+
+  /// Create a DateTime, but always add [hoursWrong] to it, simulating a time
+  /// zone transition issue.
+  DateTime withTimeZoneTransition(int year, int month, int day, int hour24,
+      int minute, int second, int fractionalSecond, bool utc) {
+    DateTime date;
+    if (utc) {
+      date = DateTime.utc(
+          year, month, day, hour24, minute, second, fractionalSecond);
+    } else {
+      date =
+          DateTime(year, month, day, hour24, minute, second, fractionalSecond);
+      date = date.add(Duration(hours: hoursWrong));
+    }
+    return date;
+  }
+}
+
+void main() {
+  group('Flaky hours in date construction of ', () {
+    for (var i = -23; i <= 23; i++) {
+      test('$i', () {
+        var format = DateFormat('yyyy-MM-dd')
+          ..dateTimeConstructor = DateCreationTweaks(i).withFlakyErrors;
+        var date = '2037-12-30';
+        var parsed = format.parseStrict(date);
+        expect(parsed.hour, 0);
+        expect(parsed.day, 30);
+      });
+    }
+  });
+
+  group('Time zone errors in date construction of ', () {
+    for (var i = -1; i <= 1; i++) {
+      test('$i', () {
+        var format = DateFormat('yyyy-MM-dd')
+          ..dateTimeConstructor = DateCreationTweaks(i).withTimeZoneTransition;
+        var date = '2037-12-30';
+        var parsed = format.parse(date);
+        expect(parsed.day, 30);
+        expect(parsed.hour, 0);
+      });
+    }
+  });
+}