Support formatting and parsing of end-of-day 24:00 #297

Closed
jodastephen opened this Issue Apr 1, 2013 · 4 comments

Comments

Projects
None yet
2 participants
Owner

jodastephen commented Apr 1, 2013

This has been a requested of Joda-Time, as is a solid requirement, especially as it is part of the ISO-8601 spec.

We could have a special LocalTime value representing 24:00, but that seems to get very confusing throughout the rest of the API.

Since 24:00 is primarily a text representation issue, it makes sense to handle it only in the formatter. An option could convert a date-time at midnight to 24:00 on the previous day when formatting. Parsing could convert back. Formatting/parsing a LocalTime (rather than a LDT) is harder, as parsing needs to express the difference between 00:00 and 24:00.

Broken out from #115.

jodastephen was assigned Apr 17, 2013

Owner

jodastephen commented Apr 17, 2013

This is a special case of lenient resolve mode for time. If SMART, then allow 24:00, if STRICT then don't.

Adding another query, on DTFormatter, called parsedExcessDuration() to allow the full result of the parse to be obtained.

Contributor

RogerRiggs commented Apr 17, 2013

That's not such an obvious name and a very special use case, who is going to know to use it?

Owner

jodastephen commented Apr 17, 2013

Handles lenient time resolving and 24:00. Also 23:59:60 in instants only.

Handles case where date and time are sent as two separate fields in a file format. Allows the whole data to be recreated from the various parts.

TemporalAccessor parsed = formatter.parse(str);
LocalTime time = parsed.query(LocalTime::from);
Period extraDays = parsed.query(DateTimeFormatter.parsedExcessDays());
LocalDateTime dt = someDate.atTime(time).plus(extraDays);  // recombine
# HG changeset patch
# User scolebourne
# Date 1366214967 -3600
# Node ID 89a6008d82f75b69fc49c545a8e5861c5797fa83
# Parent  70e6a43073a23cc1ce9d72bd7e1e394078429efc
Handle resolver style for time, add leap-second and end-of-day support

See #303, #297, #282

diff --git a/src/share/classes/java/time/format/DateTimeFormatter.java b/src/share/classes/java/time/format/DateTimeFormatter.java
--- a/src/share/classes/java/time/format/DateTimeFormatter.java
+++ b/src/share/classes/java/time/format/DateTimeFormatter.java
@@ -77,6 +77,7 @@
 import java.text.ParseException;
 import java.text.ParsePosition;
 import java.time.DateTimeException;
+import java.time.Period;
 import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.chrono.Chronology;
@@ -1206,6 +1207,97 @@
                 .toFormatter(ResolverStyle.SMART, IsoChronology.INSTANCE);
     }

+    //-----------------------------------------------------------------------
+    /**
+     * A query that provides access to the excess period that was parsed.
+     * <p>
+     * This returns a singleton {@linkplain TemporalQuery query} that provides
+     * access to additional information from the parse. The query always returns
+     * a non-null period, with a zero period returned instead of null.
+     * <p>
+     * There are two situations where this query may return a non-zero period.
+     * <p><ul>
+     * <li>If the {@code ResolverStyle} is {@code LENIENT} and a time is parsed
+     *  without a date, then the complete result of the parse consists of a
+     *  {@code LocalTime} and an excess {@code Period} in days.
+     * <p>
+     * <li>If the {@code ResolverStyle} is {@code SMART} and a time is parsed
+     *  without a date where the time is 24:00:00, then the complete result of
+     *  the parse consists of a {@code LocalTime} of 00:00:00 and an excess
+     *  {@code Period} of one day.
+     * </ul>
+     * <p>
+     * In both cases, if a date and time are parsed, then the excess days are
+     * added to the date, thus this query will return a zero period.
+     * <p>
+     * The {@code SMART} behaviour handles the common "end of day" 24:00 value.
+     * Processing in {@code LENIENT} mode also produces the same result:
+     * <pre>
+     *  Text to parse        Parsed object                         Excess duration
+     *  "2012-12-03T00:00"   LocalDateTime.of(2012, 12, 3, 0, 0)   ZERO
+     *  "2012-12-02T24:00"   LocalDateTime.of(2012, 12, 3, 0, 0)   ZERO
+     *  "00:00"              LocalTime.of(0, 0)                    ZERO
+     *  "24:00"              LocalTime.of(0, 0)                    Duration.ofDays(1)
+     * </pre>
+     * The query can be used as follows:
+     * <pre>
+     *  TemporalAccessor parsed = formatter.parse(str);
+     *  LocalTime time = parsed.query(LocalTime::from);
+     *  Period extraDays = parsed.query(DateTimeFormatter.parsedExtraDays());
+     * </pre>
+     */
+    public static final TemporalQuery<Period> parsedExcessDays() {
+        return PARSED_EXCESS_DAYS;
+    }
+    private static final TemporalQuery<Period> PARSED_EXCESS_DAYS = t -> {
+        if (t instanceof Parsed) {
+            return ((Parsed) t).excessDays;
+        } else {
+            return Period.ZERO;
+        }
+    };
+
+    /**
+     * A query that provides access to whether a leap-second was parsed.
+     * <p>
+     * This returns a singleton {@linkplain TemporalQuery query} that provides
+     * access to additional information from the parse. The query always returns
+     * a non-null boolean, true if parsing saw a leap-second, false if not.
+     * <p>
+     * Instant parsing handles the special "leap second" time of '23:59:60'.
+     * Leap seconds occur at '23:59:60' in the UTC time-zone, but at other
+     * local times in different time-zones. To avoid this potential ambiguity,
+     * the handling of leap-seconds is limited to
+     * {@link DateTimeFormatterBuilder#appendInstant()}, as that method
+     * always parses the instant with the UTC zone offset.
+     * <p>
+     * If the time '23:59:60' is received, then a simple conversion is applied,
+     * replacing the second-of-minute of 60 with 59. This query can be used
+     * on the parse result to determine if the leap-second adjustment was made.
+     * The query will return one second of excess if it did adjust to remove
+     * the leap-second, and zero if not. Note that applying a leap-second
+     * smoothing mechanism, such as UTC-SLS, is the responsibility of the
+     * application, as follows:
+     * <pre>
+     *  TemporalAccessor parsed = formatter.parse(str);
+     *  Instant instant = parsed.query(Instant::from);
+     *  if (parsed.query(DateTimeFormatter.parsedLeapSecond())) {
+     *    // validate leap-second is correct and apply correct smoothing
+     *  }
+     * </pre>
+     */
+    public static final TemporalQuery<Boolean> parsedLeapSecond() {
+        return PARSED_LEAP_SECOND;
+    }
+    private static final TemporalQuery<Boolean> PARSED_LEAP_SECOND = t -> {
+        if (t instanceof Parsed) {
+            return ((Parsed) t).leapSecond;
+        } else {
+            return Boolean.FALSE;
+        }
+    };
+
+    //-----------------------------------------------------------------------
     /**
      * Constructor.
      *
diff --git a/src/share/classes/java/time/format/DateTimeFormatterBuilder.java b/src/share/classes/java/time/format/DateTimeFormatterBuilder.java
--- a/src/share/classes/java/time/format/DateTimeFormatterBuilder.java
+++ b/src/share/classes/java/time/format/DateTimeFormatterBuilder.java
@@ -77,6 +77,7 @@
 import java.math.RoundingMode;
 import java.text.ParsePosition;
 import java.time.DateTimeException;
+import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
@@ -671,6 +672,11 @@
      * and optionally (@code NANO_OF_SECOND). The value of {@code INSTANT_SECONDS}
      * may be outside the maximum range of {@code LocalDateTime}.
      * <p>
+     * The {@linkplain ResolverStyle resolver style} has no effect on instant parsing.
+     * The end-of-day time of '24:00' is handled as midnight at the start of the following day.
+     * The leap-second time of '23:59:59' is handled to some degree, see
+     * {@link DateTimeFormatter#parsedLeapSecond()} for full details.
+     * <p>
      * An alternative to this method is to format/parse the instant as a single
      * epoch-seconds value. That is achieved using {@code appendValue(INSTANT_SECONDS)}.
      *
@@ -704,6 +710,11 @@
      * and optionally (@code NANO_OF_SECOND). The value of {@code INSTANT_SECONDS}
      * may be outside the maximum range of {@code LocalDateTime}.
      * <p>
+     * The {@linkplain ResolverStyle resolver style} has no effect on instant parsing.
+     * The end-of-day time of '24:00' is handled as midnight at the start of the following day.
+     * The leap-second time of '23:59:59' is handled to some degree, see
+     * {@link DateTimeFormatter#parsedLeapSecond()} for full details.
+     * <p>
      * An alternative to this method is to format/parse the instant as a single
      * epoch-seconds value. That is achieved using {@code appendValue(INSTANT_SECONDS)}.
      *
@@ -3030,10 +3041,18 @@
             Long nanoVal = newContext.getParsed(NANO_OF_SECOND);
             int sec = (secVal != null ? secVal.intValue() : 0);
             int nano = (nanoVal != null ? nanoVal.intValue() : 0);
+            int days = 0;
+            if (hour == 24 && min == 0 && sec == 0 && nano == 0) {
+                hour = 0;
+                days = 1;
+            } else if (hour == 23 && min == 59 && sec == 60) {
+                context.setParsedLeapSecond();
+                sec = 59;
+            }
             int year = (int) yearParsed % 10_000;
             long instantSecs;
             try {
-                LocalDateTime ldt = LocalDateTime.of(year, month, day, hour, min, sec, 0);
+                LocalDateTime ldt = LocalDateTime.of(year, month, day, hour, min, sec, 0).plusDays(days);
                 instantSecs = ldt.toEpochSecond(ZoneOffset.UTC);
                 instantSecs += Math.multiplyExact(yearParsed / 10_000L, SECONDS_PER_10000_YEARS);
             } catch (RuntimeException ex) {
diff --git a/src/share/classes/java/time/format/DateTimeParseContext.java b/src/share/classes/java/time/format/DateTimeParseContext.java
--- a/src/share/classes/java/time/format/DateTimeParseContext.java
+++ b/src/share/classes/java/time/format/DateTimeParseContext.java
@@ -61,6 +61,7 @@
  */
 package java.time.format;

+import java.time.Duration;
 import java.time.ZoneId;
 import java.time.chrono.Chronology;
 import java.time.chrono.IsoChronology;
@@ -374,6 +375,13 @@
         currentParsed().zone = zone;
     }

+    /**
+     * Stores the parsed leap second.
+     */
+    void setParsedLeapSecond() {
+        currentParsed().leapSecond = true;
+    }
+
     //-----------------------------------------------------------------------
     /**
      * Returns a string version of the context for debugging.
diff --git a/src/share/classes/java/time/format/Parsed.java b/src/share/classes/java/time/format/Parsed.java
--- a/src/share/classes/java/time/format/Parsed.java
+++ b/src/share/classes/java/time/format/Parsed.java
@@ -80,6 +80,7 @@
 import java.time.DateTimeException;
 import java.time.LocalDate;
 import java.time.LocalTime;
+import java.time.Period;
 import java.time.ZoneId;
 import java.time.chrono.ChronoLocalDate;
 import java.time.chrono.Chronology;
@@ -128,6 +129,10 @@
      */
     Chronology chrono;
     /**
+     * Whether a leap-second is parsed.
+     */
+    boolean leapSecond;
+    /**
      * The effective chronology.
      */
     Chronology effectiveChrono;
@@ -143,6 +148,10 @@
      * The resolved time.
      */
     private LocalTime time;
+    /**
+     * The excess period from time-only parsing.
+     */
+    Period excessDays = Period.ZERO;

     /**
      * Creates an instance.
@@ -159,6 +168,7 @@
         cloned.fieldValues.putAll(this.fieldValues);
         cloned.zone = this.zone;
         cloned.chrono = this.chrono;
+        cloned.leapSecond = this.leapSecond;
         return cloned;
     }

@@ -232,6 +242,7 @@
         resolveFields();
         resolveTimeLenient();
         crossCheck();
+        resolvePeriod();
         return this;
     }

@@ -308,17 +319,24 @@
     private void resolveTimeFields() {
         // simplify fields
         if (fieldValues.containsKey(CLOCK_HOUR_OF_DAY)) {
-            long ch = fieldValues.remove(CLOCK_HOUR_OF_DAY);
+            long ch = CLOCK_HOUR_OF_DAY.checkValidValue(fieldValues.remove(CLOCK_HOUR_OF_DAY));  // always strict
             updateCheckConflict(CLOCK_HOUR_OF_DAY, HOUR_OF_DAY, ch == 24 ? 0 : ch);
         }
         if (fieldValues.containsKey(CLOCK_HOUR_OF_AMPM)) {
-            long ch = fieldValues.remove(CLOCK_HOUR_OF_AMPM);
+            long ch = CLOCK_HOUR_OF_AMPM.checkValidValue(fieldValues.remove(CLOCK_HOUR_OF_AMPM));  // always strict
             updateCheckConflict(CLOCK_HOUR_OF_AMPM, HOUR_OF_AMPM, ch == 12 ? 0 : ch);
         }
         if (fieldValues.containsKey(AMPM_OF_DAY) && fieldValues.containsKey(HOUR_OF_AMPM)) {
             long ap = fieldValues.remove(AMPM_OF_DAY);
             long hap = fieldValues.remove(HOUR_OF_AMPM);
-            updateCheckConflict(AMPM_OF_DAY, HOUR_OF_DAY, ap * 12 + hap);
+            updateCheckConflict(AMPM_OF_DAY, HOUR_OF_DAY, Math.addExact(Math.multiplyExact(ap, 12), hap));
+        }
+        if (fieldValues.containsKey(NANO_OF_DAY)) {
+            long nod = fieldValues.remove(NANO_OF_DAY);
+            updateCheckConflict(NANO_OF_DAY, HOUR_OF_DAY, nod / 3600_000_000_000L);
+            updateCheckConflict(NANO_OF_DAY, MINUTE_OF_HOUR, (nod / 60_000_000_000L) % 60);
+            updateCheckConflict(NANO_OF_DAY, SECOND_OF_MINUTE, (nod / 1_000_000_000L) % 60);
+            updateCheckConflict(NANO_OF_DAY, NANO_OF_SECOND, nod % 1_000_000_000L);
         }
         if (fieldValues.containsKey(MICRO_OF_DAY)) {
             long cod = fieldValues.remove(MICRO_OF_DAY);
@@ -356,18 +374,14 @@
             }
         }

-        // convert to time if possible
-        if (fieldValues.containsKey(NANO_OF_DAY)) {
-            long nod = fieldValues.remove(NANO_OF_DAY);
-            updateCheckConflict(LocalTime.ofNanoOfDay(nod));
-        }
+        // convert to time if all four fields available (optimization)
         if (fieldValues.containsKey(HOUR_OF_DAY) && fieldValues.containsKey(MINUTE_OF_HOUR) &&
                 fieldValues.containsKey(SECOND_OF_MINUTE) && fieldValues.containsKey(NANO_OF_SECOND)) {
-            int hodVal = HOUR_OF_DAY.checkValidIntValue(fieldValues.remove(HOUR_OF_DAY));
-            int mohVal = MINUTE_OF_HOUR.checkValidIntValue(fieldValues.remove(MINUTE_OF_HOUR));
-            int somVal = SECOND_OF_MINUTE.checkValidIntValue(fieldValues.remove(SECOND_OF_MINUTE));
-            int nosVal = NANO_OF_SECOND.checkValidIntValue(fieldValues.remove(NANO_OF_SECOND));
-            updateCheckConflict(LocalTime.of(hodVal, mohVal, somVal, nosVal));
+            long hod = fieldValues.remove(HOUR_OF_DAY);
+            long moh = fieldValues.remove(MINUTE_OF_HOUR);
+            long som = fieldValues.remove(SECOND_OF_MINUTE);
+            long nos = fieldValues.remove(NANO_OF_SECOND);
+            resolveTime(hod, moh, som, nos);
         }
     }

@@ -377,7 +391,7 @@
         // which would break updateCheckConflict(field)

         if (time == null) {
-            // can only get here if NANO_OF_SECOND not present
+            // NANO_OF_SECOND merged with MILLI/MICRO above
             if (fieldValues.containsKey(MILLI_OF_SECOND)) {
                 long los = fieldValues.remove(MILLI_OF_SECOND);
                 if (fieldValues.containsKey(MICRO_OF_SECOND)) {
@@ -395,43 +409,87 @@
                 long cos = fieldValues.remove(MICRO_OF_SECOND);
                 fieldValues.put(NANO_OF_SECOND, cos * 1_000L);
             }
-        }

-        // merge hour/minute/second/nano leniently
-        Long hod = fieldValues.get(HOUR_OF_DAY);
-        if (hod != null) {
-            int hodVal = HOUR_OF_DAY.checkValidIntValue(hod);
-            Long moh = fieldValues.get(MINUTE_OF_HOUR);
-            Long som = fieldValues.get(SECOND_OF_MINUTE);
-            Long nos = fieldValues.get(NANO_OF_SECOND);
+            // merge hour/minute/second/nano leniently
+            Long hod = fieldValues.get(HOUR_OF_DAY);
+            if (hod != null) {
+                Long moh = fieldValues.get(MINUTE_OF_HOUR);
+                Long som = fieldValues.get(SECOND_OF_MINUTE);
+                Long nos = fieldValues.get(NANO_OF_SECOND);

-            // check for invalid combinations that cannot be defaulted
-            if (time == null) {
+                // check for invalid combinations that cannot be defaulted
                 if ((moh == null && (som != null || nos != null)) ||
                         (moh != null && som == null && nos != null)) {
                     return;
                 }
+
+                // default as necessary and build time
+                long mohVal = (moh != null ? moh : 0);
+                long somVal = (som != null ? som : 0);
+                long nosVal = (nos != null ? nos : 0);
+                resolveTime(hod, mohVal, somVal, nosVal);
+                fieldValues.remove(HOUR_OF_DAY);
+                fieldValues.remove(MINUTE_OF_HOUR);
+                fieldValues.remove(SECOND_OF_MINUTE);
+                fieldValues.remove(NANO_OF_SECOND);
             }
+        }

-            // default as necessary and build time
-            int mohVal = (moh != null ? MINUTE_OF_HOUR.checkValidIntValue(moh) : (time != null ? time.getMinute() : 0));
-            int somVal = (som != null ? SECOND_OF_MINUTE.checkValidIntValue(som) : (time != null ? time.getSecond() : 0));
-            int nosVal = (nos != null ? NANO_OF_SECOND.checkValidIntValue(nos) : (time != null ? time.getNano() : 0));
-            updateCheckConflict(LocalTime.of(hodVal, mohVal, somVal, nosVal));
-            fieldValues.remove(HOUR_OF_DAY);
-            fieldValues.remove(MINUTE_OF_HOUR);
-            fieldValues.remove(SECOND_OF_MINUTE);
-            fieldValues.remove(NANO_OF_SECOND);
+        // validate remaining
+        if (resolverStyle != ResolverStyle.LENIENT && fieldValues.size() > 0) {
+            for (Entry<TemporalField, Long> entry : fieldValues.entrySet()) {
+                TemporalField field = entry.getKey();
+                if (field instanceof ChronoField && ((ChronoField) field).isTimeBased()) {
+                    ((ChronoField) field).checkValidValue(entry.getValue());
+                }
+            }
         }
     }

-    private void updateCheckConflict(LocalTime lt) {
+    private void resolveTime(long hod, long moh, long som, long nos) {
+        if (resolverStyle == ResolverStyle.LENIENT) {
+            long totalNanos = Math.multiplyExact(hod, 3600_000_000_000L);
+            totalNanos = Math.addExact(totalNanos, Math.multiplyExact(moh, 60_000_000_000L));
+            totalNanos = Math.addExact(totalNanos, Math.multiplyExact(som, 1_000_000_000L));
+            totalNanos = Math.addExact(totalNanos, nos);
+            int excessDays = (int) Math.floorDiv(totalNanos, 86400_000_000_000L);  // safe int cast
+            long nod = Math.floorMod(totalNanos, 86400_000_000_000L);
+            updateCheckConflict(LocalTime.ofNanoOfDay(nod), Period.ofDays(excessDays));
+        } else {  // STRICT or SMART
+            int mohVal = MINUTE_OF_HOUR.checkValidIntValue(moh);
+            int nosVal = NANO_OF_SECOND.checkValidIntValue(nos);
+            // handle 24:00 end of day
+            if (resolverStyle == ResolverStyle.SMART && hod == 24 && mohVal == 0 && som == 0 && nosVal == 0) {
+                updateCheckConflict(LocalTime.MIDNIGHT, Period.ofDays(1));
+            } else {
+                int hodVal = HOUR_OF_DAY.checkValidIntValue(hod);
+                int somVal = SECOND_OF_MINUTE.checkValidIntValue(som);
+                updateCheckConflict(LocalTime.of(hodVal, mohVal, somVal, nosVal), Period.ZERO);
+            }
+        }
+    }
+
+    private void resolvePeriod() {
+        // add whole days if we have both date and time
+        if (date != null && time != null && excessDays.isZero() == false) {
+            date = date.plus(excessDays);
+            excessDays = Period.ZERO;
+        }
+    }
+
+    private void updateCheckConflict(LocalTime timeToSet, Period periodToSet) {
         if (time != null) {
-            if (lt != null && time.equals(lt) == false) {
-                throw new DateTimeException("Conflict found: Fields resolved to two different times: " + time + " " + lt);
+            if (time.equals(timeToSet) == false) {
+                throw new DateTimeException("Conflict found: Fields resolved to different times: " + time + " " + timeToSet);
+            }
+            if (excessDays.isZero() == false && periodToSet.isZero() == false && excessDays.equals(periodToSet) == false) {
+                throw new DateTimeException("Conflict found: Fields resolved to different excess periods: " + excessDays + " " + periodToSet);
+            } else {
+                excessDays = periodToSet;
             }
         } else {
-            time = lt;
+            time = timeToSet;
+            excessDays = periodToSet;
         }
     }

diff --git a/test/java/time/tck/java/time/format/TCKDateTimeParseResolver.java b/test/java/time/tck/java/time/format/TCKDateTimeParseResolver.java
--- a/test/java/time/tck/java/time/format/TCKDateTimeParseResolver.java
+++ b/test/java/time/tck/java/time/format/TCKDateTimeParseResolver.java
@@ -88,11 +88,15 @@
 import static java.time.temporal.ChronoField.YEAR;
 import static java.time.temporal.ChronoField.YEAR_OF_ERA;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.fail;

 import java.time.LocalDate;
 import java.time.LocalTime;
+import java.time.Period;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
 import java.time.temporal.IsoFields;
 import java.time.temporal.TemporalAccessor;
 import java.time.temporal.TemporalField;
@@ -519,4 +523,130 @@
         assertEquals(accessor.query(TemporalQuery.localTime()), null);
     }

+    //-----------------------------------------------------------------------
+    @DataProvider(name="resolveFourToTime")
+    Object[][] data_resolveFourToTime() {
+        return new Object[][]{
+                // merge
+                {null, 0, 0, 0, 0, LocalTime.of(0, 0, 0, 0), Period.ZERO},
+                {null, 1, 0, 0, 0, LocalTime.of(1, 0, 0, 0), Period.ZERO},
+                {null, 0, 2, 0, 0, LocalTime.of(0, 2, 0, 0), Period.ZERO},
+                {null, 0, 0, 3, 0, LocalTime.of(0, 0, 3, 0), Period.ZERO},
+                {null, 0, 0, 0, 4, LocalTime.of(0, 0, 0, 4), Period.ZERO},
+                {null, 1, 2, 3, 4, LocalTime.of(1, 2, 3, 4), Period.ZERO},
+                {null, 23, 59, 59, 123456789, LocalTime.of(23, 59, 59, 123456789), Period.ZERO},
+
+                {ResolverStyle.STRICT, 14, 59, 60, 123456789, null, null},
+                {ResolverStyle.SMART, 14, 59, 60, 123456789, null, null},
+                {ResolverStyle.LENIENT, 14, 59, 60, 123456789, LocalTime.of(15, 0, 0, 123456789), Period.ZERO},
+
+                {ResolverStyle.STRICT, 23, 59, 60, 123456789, null, null},
+                {ResolverStyle.SMART, 23, 59, 60, 123456789, null, null},
+                {ResolverStyle.LENIENT, 23, 59, 60, 123456789, LocalTime.of(0, 0, 0, 123456789), Period.ofDays(1)},
+
+                {ResolverStyle.STRICT, 24, 0, 0, 0, null, null},
+                {ResolverStyle.SMART, 24, 0, 0, 0, LocalTime.of(0, 0, 0, 0), Period.ofDays(1)},
+                {ResolverStyle.LENIENT, 24, 0, 0, 0, LocalTime.of(0, 0, 0, 0), Period.ofDays(1)},
+
+                {ResolverStyle.STRICT, 24, 1, 0, 0, null, null},
+                {ResolverStyle.SMART, 24, 1, 0, 0, null, null},
+                {ResolverStyle.LENIENT, 24, 1, 0, 0, LocalTime.of(0, 1, 0, 0), Period.ofDays(1)},
+
+                {ResolverStyle.STRICT, 25, 0, 0, 0, null, null},
+                {ResolverStyle.SMART, 25, 0, 0, 0, null, null},
+                {ResolverStyle.LENIENT, 25, 0, 0, 0, LocalTime.of(1, 0, 0, 0), Period.ofDays(1)},
+
+                {ResolverStyle.STRICT, 49, 2, 3, 4, null, null},
+                {ResolverStyle.SMART, 49, 2, 3, 4, null, null},
+                {ResolverStyle.LENIENT, 49, 2, 3, 4, LocalTime.of(1, 2, 3, 4), Period.ofDays(2)},
+
+                {ResolverStyle.STRICT, -1, 2, 3, 4, null, null},
+                {ResolverStyle.SMART, -1, 2, 3, 4, null, null},
+                {ResolverStyle.LENIENT, -1, 2, 3, 4, LocalTime.of(23, 2, 3, 4), Period.ofDays(-1)},
+
+                {ResolverStyle.STRICT, -6, 2, 3, 4, null, null},
+                {ResolverStyle.SMART, -6, 2, 3, 4, null, null},
+                {ResolverStyle.LENIENT, -6, 2, 3, 4, LocalTime.of(18, 2, 3, 4), Period.ofDays(-1)},
+
+                {ResolverStyle.STRICT, 25, 61, 61, 1_123456789, null, null},
+                {ResolverStyle.SMART, 25, 61, 61, 1_123456789, null, null},
+                {ResolverStyle.LENIENT, 25, 61, 61, 1_123456789, LocalTime.of(2, 2, 2, 123456789), Period.ofDays(1)},
+        };
+    }
+
+    @Test(dataProvider="resolveFourToTime")
+    public void test_resolveFourToTime(ResolverStyle style,
+                       long hour, long min, long sec, long nano, LocalTime expectedTime, Period excessPeriod) {
+        DateTimeFormatter f = new DateTimeFormatterBuilder()
+                .parseDefaulting(HOUR_OF_DAY, hour)
+                .parseDefaulting(MINUTE_OF_HOUR, min)
+                .parseDefaulting(SECOND_OF_MINUTE, sec)
+                .parseDefaulting(NANO_OF_SECOND, nano).toFormatter();
+
+        ResolverStyle[] styles = (style != null ? new ResolverStyle[] {style} : ResolverStyle.values());
+        for (ResolverStyle s : styles) {
+            if (expectedTime != null) {
+                TemporalAccessor accessor = f.withResolverStyle(s).parse("");
+                assertEquals(accessor.query(TemporalQuery.localDate()), null, "ResolverStyle: " + s);
+                assertEquals(accessor.query(TemporalQuery.localTime()), expectedTime, "ResolverStyle: " + s);
+                assertEquals(accessor.query(DateTimeFormatter.parsedExcessDays()), excessPeriod, "ResolverStyle: " + s);
+            } else {
+                try {
+                    f.withResolverStyle(style).parse("");
+                    fail();
+                } catch (DateTimeParseException ex) {
+                    // expected
+                }
+            }
+        }
+    }
+
+    @Test(dataProvider="resolveFourToTime")
+    public void test_resolveThreeToTime(ResolverStyle style,
+                                       long hour, long min, long sec, long nano, LocalTime expectedTime, Period excessPeriod) {
+        DateTimeFormatter f = new DateTimeFormatterBuilder()
+                .parseDefaulting(HOUR_OF_DAY, hour)
+                .parseDefaulting(MINUTE_OF_HOUR, min)
+                .parseDefaulting(SECOND_OF_MINUTE, sec).toFormatter();
+
+        ResolverStyle[] styles = (style != null ? new ResolverStyle[] {style} : ResolverStyle.values());
+        for (ResolverStyle s : styles) {
+            if (expectedTime != null) {
+                TemporalAccessor accessor = f.withResolverStyle(s).parse("");
+                assertEquals(accessor.query(TemporalQuery.localDate()), null, "ResolverStyle: " + s);
+                assertEquals(accessor.query(TemporalQuery.localTime()), expectedTime.minusNanos(nano), "ResolverStyle: " + s);
+                assertEquals(accessor.query(DateTimeFormatter.parsedExcessDays()), excessPeriod, "ResolverStyle: " + s);
+            } else {
+                try {
+                    f.withResolverStyle(style).parse("");
+                    fail();
+                } catch (DateTimeParseException ex) {
+                    // expected
+                }
+            }
+        }
+    }
+
+    @Test(dataProvider="resolveFourToTime")
+    public void test_resolveFourToDateTime(ResolverStyle style,
+                       long hour, long min, long sec, long nano, LocalTime expectedTime, Period excessPeriod) {
+        DateTimeFormatter f = new DateTimeFormatterBuilder()
+                .parseDefaulting(YEAR, 2012).parseDefaulting(MONTH_OF_YEAR, 6).parseDefaulting(DAY_OF_MONTH, 30)
+                .parseDefaulting(HOUR_OF_DAY, hour)
+                .parseDefaulting(MINUTE_OF_HOUR, min)
+                .parseDefaulting(SECOND_OF_MINUTE, sec)
+                .parseDefaulting(NANO_OF_SECOND, nano).toFormatter();
+
+        ResolverStyle[] styles = (style != null ? new ResolverStyle[] {style} : ResolverStyle.values());
+        if (expectedTime != null && excessPeriod != null) {
+            LocalDate expectedDate = LocalDate.of(2012, 6, 30).plus(excessPeriod);
+            for (ResolverStyle s : styles) {
+                TemporalAccessor accessor = f.withResolverStyle(s).parse("");
+                assertEquals(accessor.query(TemporalQuery.localDate()), expectedDate, "ResolverStyle: " + s);
+                assertEquals(accessor.query(TemporalQuery.localTime()), expectedTime, "ResolverStyle: " + s);
+                assertEquals(accessor.query(DateTimeFormatter.parsedExcessDays()), Period.ZERO, "ResolverStyle: " + s);
+            }
+        }
+    }
+
 }
diff --git a/test/java/time/tck/java/time/format/TCKInstantPrinterParser.java b/test/java/time/tck/java/time/format/TCKInstantPrinterParser.java
--- a/test/java/time/tck/java/time/format/TCKInstantPrinterParser.java
+++ b/test/java/time/tck/java/time/format/TCKInstantPrinterParser.java
@@ -64,8 +64,13 @@

 import java.time.DateTimeException;
 import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.Period;
+import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.ResolverStyle;
+import java.time.temporal.TemporalAccessor;

 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
@@ -209,6 +214,8 @@
                 {182, 234000000, "1970-01-01T00:03:02.234000Z"},
                 {182, 234000000, "1970-01-01T00:03:02.234000000Z"},

+                {((23 * 60) + 59) * 60 + 59, 123456789, "1970-01-01T23:59:59.123456789Z"},
+
                 {Instant.MAX.getEpochSecond(), 999999999, "+1000000000-12-31T23:59:59.999999999Z"},
                 {Instant.MIN.getEpochSecond(), 0, "-1000000000-01-01T00:00:00.000000000Z"},
         };
@@ -219,6 +226,8 @@
         Instant expected = Instant.ofEpochSecond(instantSecs, nano);
         DateTimeFormatter f = new DateTimeFormatterBuilder().appendInstant(-1).toFormatter();
         assertEquals(f.parse(input, Instant::from), expected);
+        assertEquals(f.parse(input).query(DateTimeFormatter.parsedExcessDays()), Period.ZERO);
+        assertEquals(f.parse(input).query(DateTimeFormatter.parsedLeapSecond()), Boolean.FALSE);
     }

     @Test(dataProvider="parseDigits")
@@ -227,6 +236,8 @@
         if (input.charAt(input.length() - 11) == '.') {
             Instant expected = Instant.ofEpochSecond(instantSecs, nano);
             assertEquals(f.parse(input, Instant::from), expected);
+            assertEquals(f.parse(input).query(DateTimeFormatter.parsedExcessDays()), Period.ZERO);
+            assertEquals(f.parse(input).query(DateTimeFormatter.parsedLeapSecond()), Boolean.FALSE);
         } else {
             try {
                 f.parse(input, Instant::from);
@@ -237,6 +248,30 @@
         }
     }

+    @Test
+    public void test_parse_endOfDay() {
+        Instant expected = OffsetDateTime.of(1970, 2, 4, 0, 0, 0, 0, ZoneOffset.UTC).toInstant();
+        DateTimeFormatter f = new DateTimeFormatterBuilder().appendInstant(-1).toFormatter();
+        for (ResolverStyle style : ResolverStyle.values()) {
+            TemporalAccessor parsed = f.withResolverStyle(style).parse("1970-02-03T24:00:00Z");
+            assertEquals(parsed.query(Instant::from), expected);
+            assertEquals(parsed.query(DateTimeFormatter.parsedExcessDays()), Period.ZERO);
+            assertEquals(parsed.query(DateTimeFormatter.parsedLeapSecond()), Boolean.FALSE);
+        }
+    }
+
+    @Test
+    public void test_parse_leapSecond() {
+        Instant expected = OffsetDateTime.of(1970, 2, 3, 23, 59, 59, 123456789, ZoneOffset.UTC).toInstant();
+        DateTimeFormatter f = new DateTimeFormatterBuilder().appendInstant(-1).toFormatter();
+        for (ResolverStyle style : ResolverStyle.values()) {
+            TemporalAccessor parsed = f.withResolverStyle(style).parse("1970-02-03T23:59:60.123456789Z");
+            assertEquals(parsed.query(Instant::from), expected);
+            assertEquals(parsed.query(DateTimeFormatter.parsedExcessDays()), Period.ZERO);
+            assertEquals(parsed.query(DateTimeFormatter.parsedLeapSecond()), Boolean.TRUE);
+        }
+    }
+
     //-----------------------------------------------------------------------
     @Test(expectedExceptions=IllegalArgumentException.class)
     public void test_appendInstant_tooSmall() {

jodastephen closed this May 2, 2013

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment