More robust handling of DateTime parsing with daylight savings-type transitions, particularly when they occur at midnight (e.g. Brazil)

Retrying from 173286563 which had to be rolled back.
Cloned from CL 173286563 by 'g4 patch'.
Original change by alanknight@alanknight:dst:1458:citc on 2017/10/24 11:32:32.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=174055235
diff --git a/lib/src/intl/date_format.dart b/lib/src/intl/date_format.dart
index e9def84..a1fc6fb 100644
--- a/lib/src/intl/date_format.dart
+++ b/lib/src/intl/date_format.dart
@@ -324,6 +324,7 @@
     // values with no delimiters, which we currently don't do. Should we?
     var dateFields = new _DateBuilder();
     if (utc) dateFields.utc = true;
+    dateFields._dateOnly = this.dateOnly;
     var stream = new _Stream(inputString);
     _formatFields.forEach((f) => f.parse(stream, dateFields));
     if (strict && !stream.atEnd()) {
@@ -334,6 +335,13 @@
     return dateFields.asDate();
   }
 
+  /// Does our format only only date fields, and no time fields.
+  ///
+  /// For example, 'yyyy-MM-dd' would be true, but 'dd hh:mm' would be false.
+  bool get dateOnly => _dateOnly ??= _checkDateOnly;
+  bool _dateOnly;
+  bool get _checkDateOnly => _formatFields.every((each) => each.forDate);
+
   /// Given user input, attempt to parse the [inputString] into the anticipated
   /// format, treating it as being in UTC.
   ///
diff --git a/lib/src/intl/date_format_field.dart b/lib/src/intl/date_format_field.dart
index 495f8f9..8a5d25e 100644
--- a/lib/src/intl/date_format_field.dart
+++ b/lib/src/intl/date_format_field.dart
@@ -22,6 +22,10 @@
     _trimmedPattern = pattern.trim();
   }
 
+  /// Does this field potentially represent part of a Date, i.e. is not
+  /// time-specific.
+  bool get forDate => true;
+
   /// Return the width of [pattern]. Different widths represent different
   /// formatting options. See the comment for DateFormat for details.
   int get width => pattern.length;
@@ -253,6 +257,17 @@
     new _LoosePatternField(pattern, parent).parse(input, dateFields);
   }
 
+  bool _forDate;
+
+  /// Is this field involved in computing the date portion, as opposed to the
+  /// time.
+  ///
+  /// The [pattern] will contain one or more of a particular format character,
+  /// e.g. "yyyy" for a four-digit year. This hard-codes all the pattern
+  /// characters that pertain to dates. The remaining characters, 'ahHkKms' are
+  /// all time-related. See e.g. [formatField]
+  bool get forDate => _forDate ??= 'cdDEGLMQvyZz'.contains(pattern[0]);
+
   /// Parse a field representing part of a date pattern. Note that we do not
   /// return a value, but rather build up the result in [builder].
   void parseField(_Stream input, _DateBuilder builder) {
@@ -578,27 +593,8 @@
     return padTo(width, date.day);
   }
 
-  String formatDayOfYear(DateTime date) => padTo(width, dayNumberInYear(date));
-
-  /// Return the ordinal day, i.e. the day number in the year.
-  int dayNumberInYear(DateTime date) {
-    if (date.month == 1) return date.day;
-    if (date.month == 2) return date.day + 31;
-    return ordinalDayFromMarchFirst(date) + 59 + (isLeapYear(date) ? 1 : 0);
-  }
-
-  /// Return the day of the year counting March 1st as 1, after which the
-  /// number of days per month is constant, so it's easier to calculate.
-  /// Formula from http://en.wikipedia.org/wiki/Ordinal_date
-  int ordinalDayFromMarchFirst(DateTime date) =>
-      ((30.6 * date.month) - 91.4).floor() + date.day;
-
-  /// Return true if this is a leap year. Rely on [DateTime] to do the
-  /// underlying calculation, even though it doesn't expose the test to us.
-  bool isLeapYear(DateTime date) {
-    var feb29 = new DateTime(date.year, 2, 29);
-    return feb29.month == 2;
-  }
+  String formatDayOfYear(DateTime date) =>
+      padTo(width, _dayOfYear(date.month, date.day, _isLeapYear(date)));
 
   String formatDayOfWeek(DateTime date) {
     // Note that Dart's weekday returns 1 for Monday and 7 for Sunday.
diff --git a/lib/src/intl/date_format_helpers.dart b/lib/src/intl/date_format_helpers.dart
index 3ad651d..327714c 100644
--- a/lib/src/intl/date_format_helpers.dart
+++ b/lib/src/intl/date_format_helpers.dart
@@ -4,6 +4,31 @@
 
 part of intl;
 
+/// Given a month and day number, return the day of the year, all one-based.
+///
+/// For example,
+///  * January 2nd (1, 2) -> 2.
+///  * February 5th (2, 5) -> 36.
+///  * March 1st of a non-leap year (3, 1) -> 60.
+int _dayOfYear(int month, int day, bool leapYear) {
+  if (month == 1) return day;
+  if (month == 2) return day + 31;
+  return ordinalDayFromMarchFirst(month, day) + 59 + (leapYear ? 1 : 0);
+}
+
+/// Return true if this is a leap year. Rely on [DateTime] to do the
+/// underlying calculation, even though it doesn't expose the test to us.
+bool _isLeapYear(DateTime date) {
+  var feb29 = new DateTime(date.year, 2, 29);
+  return feb29.month == 2;
+}
+
+/// Return the day of the year counting March 1st as 1, after which the
+/// number of days per month is constant, so it's easier to calculate.
+/// Formula from http://en.wikipedia.org/wiki/Ordinal_date
+int ordinalDayFromMarchFirst(int month, int day) =>
+    ((30.6 * month) - 91.4).floor() + day;
+
 /// A class for holding onto the data for a date so that it can be built
 /// up incrementally.
 class _DateBuilder {
@@ -19,6 +44,18 @@
   bool pm = false;
   bool utc = false;
 
+  /// Is this constructing a pure date.
+  ///
+  /// This is important because some locales change times at midnight,
+  /// e.g. Brazil. So if we try to create a DateTime representing a date at
+  /// midnight on the day of transition it will jump forward or back 1 hour.  If
+  /// it jumps forward that's mostly harmless if we only care about the
+  /// date. But if it jumps backwards that will change the date, which is
+  /// bad. Compensate by adjusting the time portion forward. But only do that
+  /// when we're explicitly trying to construct a date, which we can tell from
+  /// the format.
+  bool _dateOnly = false;
+
   // Functions that exist just to be closurized so we can pass them to a general
   // method.
   void setYear(x) {
@@ -67,7 +104,16 @@
     // which will catch cases like "14:00:00 PM".
     var date = asDate();
     _verify(hour24, date.hour, date.hour, "hour", s, date);
-    _verify(day, date.day, date.day, "day", s, date);
+    if (day > 31) {
+      // We have an ordinal date, compute the corresponding date for the result
+      // and compare to that.
+      var leapYear = _isLeapYear(date);
+      var correspondingDay = _dayOfYear(date.month, date.day, leapYear);
+      _verify(day, correspondingDay, correspondingDay, "day", s, date);
+    } else {
+      // We have the day of the month, compare directly.
+      _verify(day, date.day, date.day, "day", s, date);
+    }
     _verify(year, date.year, date.year, "year", s, date);
   }
 
@@ -83,23 +129,61 @@
 
   /// Return a date built using our values. If no date portion is set,
   /// use the "Epoch" of January 1, 1970.
-  DateTime asDate({int retries: 10}) {
+  DateTime asDate({int retries: 3}) {
     // TODO(alanknight): Validate the date, especially for things which
     // can crash the VM, e.g. large month values.
-    var result;
     if (utc) {
-      result = new DateTime.utc(
+      return new DateTime.utc(
           year, month, day, hour24, minute, second, fractionalSecond);
     } else {
-      result = new DateTime(
+      var preliminaryResult = new DateTime(
           year, month, day, hour24, minute, second, fractionalSecond);
-      // TODO(alanknight): Issue 15560 means non-UTC dates occasionally come out
-      // in UTC, or, alternatively, are constructed as if in UTC and then have
-      // the offset subtracted. If that happens, retry, several times if
-      // necessary.
-      if (retries > 0 && (result.hour != hour24 || result.day != day)) {
-        result = asDate(retries: retries - 1);
-      }
+      return _correctForErrors(preliminaryResult, retries);
+    }
+  }
+
+  static final Duration _zeroDuration = new Duration();
+
+  /// Given a local DateTime, check for errors and try to compensate for them if
+  /// possible.
+  DateTime _correctForErrors(DateTime result, int retries) {
+    // 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.
+    //
+    // 2 - Timezone transitions. If we ask for the time during a timezone
+    // transition then it will offset it by that transition. This is
+    // particularly a problem if the timezone transition happens at midnight,
+    // and we're looking for a date with no time component. This happens in
+    // 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.
+    var leapYear = _isLeapYear(result);
+    var correspondingDay = _dayOfYear(result.month, result.day, leapYear);
+
+    if (result.isUtc &&
+        (result.hour != hour24 || result.day != correspondingDay)) {
+      // 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.
+      var retry = asDate(retries: retries - 1);
+      if (retry.timeZoneOffset != _zeroDuration) return retry;
+    }
+    if (_dateOnly && day != correspondingDay) {
+      // 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(new Duration(hours: (24 - result.hour)));
+      if (_dayOfYear(adjusted.month, adjusted.day, leapYear) == day)
+        return adjusted;
     }
     return result;
   }
diff --git a/test/brazil_timezone_test.dart b/test/brazil_timezone_test.dart
new file mode 100644
index 0000000..8bdcc03
--- /dev/null
+++ b/test/brazil_timezone_test.dart
@@ -0,0 +1,23 @@
+// Copyright (c) 2017, 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.
+
+/// Test date formatting and parsing while the system time zone is set to
+/// America/Sao Paulo.
+///
+/// In Brazil the time change spring/fall happens at midnight. This can make
+/// operations working with dates as midnight on a particular day fail. For
+/// example, in the (Brazilian) autumn, a date might "fall back" an hour and be
+/// on the previous day. This test verifies that we're handling those
+/// situations.
+
+// This test relies on setting the TZ environment variable to affect the
+// system's time zone calculations. That's only effective on Linux environments,
+// and would only work in a browser if we were able to set it before the browser
+// launched, which we aren't. So restrict this test to the VM and Linux.
+import 'timezone_test_core.dart';
+
+main() {
+  // The test date is Jan 1, so Brazilian Summer Time will be in effect.
+  testTimezone('America/Sao_Paulo', expectedUtcOffset: -2);
+}
diff --git a/test/date_time_format_test_core.dart b/test/date_time_format_test_core.dart
index c1a4dc0..e6f88f5 100644
--- a/test/date_time_format_test_core.dart
+++ b/test/date_time_format_test_core.dart
@@ -160,7 +160,7 @@
       var format = new DateFormat(skeleton, localeName);
       if (forceAscii) format.useNativeDigits = false;
       var actualResult = format.format(date);
-      var parsed = format.parse(actualResult);
+      var parsed = format.parseStrict(actualResult);
       var thenPrintAgain = format.format(parsed);
       expect(thenPrintAgain, equals(actualResult));
     }
@@ -399,7 +399,11 @@
   Map<int, DateTime> generateDates(int year, int leapDay) =>
       new Iterable.generate(365 + leapDay, (n) => n + 1)
           .map((day) {
-            var result = new DateTime(year, 1, day);
+            // Typically a "date" would have a time value of zero, but we
+            // give them an hour value, because they can get created with an
+            // offset to the previous day in time zones where the daylight
+            // savings transition happens at midnight (e.g. Brazil).
+            var result = new DateTime(year, 1, day, 3);
             // TODO(alanknight): This is a workaround for dartbug.com/15560.
             if (result.toUtc() == result) result = new DateTime(year, 1, day);
             return result;
@@ -415,7 +419,12 @@
       expect(formatted, (number + 1).toString());
       var formattedWithYear = withYear.format(date);
       var parsed = withYear.parse(formattedWithYear);
-      expect(parsed, date);
+      // Only compare the date portion, because time zone changes (e.g. DST) can
+      // cause the hour values to be different.
+      expect(parsed.year, date.year);
+      expect(parsed.month, date.month);
+      expect(parsed.day, date.day,
+          reason: 'Mismatch between parsed ($parsed) and original ($date)');
     });
   }
 
diff --git a/test/england_timezone_test.dart b/test/england_timezone_test.dart
new file mode 100644
index 0000000..a0e3630
--- /dev/null
+++ b/test/england_timezone_test.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2017, 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.
+
+/// Test date formatting and parsing while the system time zone is set to
+/// Europe/London.
+///
+/// This is the same as UTC for part of the year, which makes it an interesting
+/// edge case.
+import 'timezone_test_core.dart';
+
+main() {
+  testTimezone('Europe/London', expectedUtcOffset: 0);
+}
diff --git a/test/scorbeysund_timezone_test.dart b/test/scorbeysund_timezone_test.dart
new file mode 100644
index 0000000..a5a9b48
--- /dev/null
+++ b/test/scorbeysund_timezone_test.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2017, 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.
+
+/// Test date formatting and parsing while the system time zone is set to
+/// America/Scoresbysund.
+///
+/// This is the same as UTC for part of the year and -1:00 from UTC otherwise,
+/// which makes it an interesting edge case.
+import 'timezone_test_core.dart';
+
+main() {
+  testTimezone('America/Scoresbysund', expectedUtcOffset: -1);
+}
diff --git a/test/timezone_local_even_test_helper.dart b/test/timezone_local_even_test_helper.dart
new file mode 100644
index 0000000..cd06423
--- /dev/null
+++ b/test/timezone_local_even_test_helper.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2012, 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.
+
+/// Test date formatting and parsing using locale data directly imported.
+///
+/// This is a copy of date_time_format_local_even_test.dart which also
+/// verifies the time zone against an environment variable.
+
+import 'dart:io';
+
+import 'date_time_format_test_stub.dart';
+import 'package:intl/date_symbol_data_local.dart';
+import 'package:test/test.dart';
+
+main() {
+  var tzOffset = Platform.environment['EXPECTED_TZ_OFFSET_FOR_TEST'];
+  var timezoneName = Platform.environment['TZ'];
+  if (tzOffset != null) {
+    test("Actually running in the correct time zone: $timezoneName", () {
+      // Pick a constant Date so that the offset is known.
+      var d = new DateTime(2012, 1, 1, 7, 6, 5);
+      print("Time zone offset is ${d.timeZoneOffset.inHours}");
+      print("Time zone name is ${d.timeZoneName}");
+      expect(tzOffset, '${d.timeZoneOffset.inHours}');
+    });
+  }
+
+  // Run the main date formatting tests with a large set of locales.
+  runWith(evenLocales, null, initializeDateFormatting);
+}
diff --git a/test/timezone_test_core.dart b/test/timezone_test_core.dart
new file mode 100644
index 0000000..435f2a7
--- /dev/null
+++ b/test/timezone_test_core.dart
@@ -0,0 +1,57 @@
+// Copyright (c) 2017, 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.
+
+/// Test date formatting and parsing while the system time zone is set.
+import 'package:test/test.dart';
+import 'dart:io';
+import 'dart:convert';
+
+// This test relies on setting the TZ environment variable to affect the
+// system's time zone calculations. That's only effective on Linux environments,
+// and would only work in a browser if we were able to set it before the browser
+// launched, which we aren't. So restrict this test to the VM and Linux.
+@TestOn('vm')
+@TestOn('linux')
+
+/// The VM arguments we were given, most importantly package-root.
+final vmArgs = Platform.executableArguments;
+
+final dart = Platform.executable;
+
+/// Test for a particular timezone. In order to verify that we are in fact
+/// running in that time zone, verify that the DateTime offset is one of the
+/// expected values.
+testTimezone(String timezoneName, {int expectedUtcOffset}) {
+  // The VM can be invoked with a "-DPACKAGE_DIR=<directory>" argument to
+  // indicate the root of the Intl package. If it is not provided, we assume
+  // that the root of the Intl package is the current directory.
+  var packageDir = new String.fromEnvironment('PACKAGE_DIR');
+  var packageRelative = 'test/timezone_local_even_test_helper.dart';
+  var fileToSpawn =
+      packageDir == null ? packageRelative : '$packageDir/$packageRelative';
+
+  test("Run tests in $timezoneName time zone", () async {
+    List<String> args = []
+      ..addAll(vmArgs)
+      ..add(fileToSpawn);
+    var environment = <String, String>{'TZ': timezoneName};
+    if (expectedUtcOffset != null) {
+      environment['EXPECTED_TZ_OFFSET_FOR_TEST'] = '$expectedUtcOffset';
+    }
+    var result = await Process.run(dart, args,
+        stdoutEncoding: UTF8,
+        stderrEncoding: UTF8,
+        includeParentEnvironment: true,
+        environment: environment);
+    // Because the actual tests are run in a spawned parocess their output isn't
+    // directly visible here. To debug, it's necessary to look at the output of
+    // that test, so we print it here for convenience.
+    print("Spawning test to run in the $timezoneName time zone. Stderr is:");
+    print(result.stderr);
+    print("Spawned test in $timezoneName time zone has Stdout:");
+    print(result.stdout);
+    expect(result.exitCode, 0,
+        reason: "Spawned test failed. See the test log from stderr to debug");
+  });
+}
diff --git a/test/utc_timezone_test.dart b/test/utc_timezone_test.dart
new file mode 100644
index 0000000..0ef5240
--- /dev/null
+++ b/test/utc_timezone_test.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2017, 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.
+
+/// Test date formatting and parsing while the system time zone is set to
+/// UTC.
+import 'timezone_test_core.dart';
+
+main() {
+  testTimezone('UTC', expectedUtcOffset: 0);
+}