This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new a5d6c5ed8 Keep DateUtils.ceiling and round inside a DST overlap hour 
(#1842)
a5d6c5ed8 is described below

commit a5d6c5ed8847b2b14dacf062b646f94d6cc61a10
Author: Gary Gregory <[email protected]>
AuthorDate: Tue Oct 27 08:29:59 2026 -0400

    Keep DateUtils.ceiling and round inside a DST overlap hour (#1842)
    
    Expand DateUtils DST overlap regression coverage.
    
    Parameterize millisecond values around the rounding threshold and verify
    rounding to seconds. Add explicit-zone Calendar tests that check
    results,
    time zone preservation, and input immutability with UTC as the default.
    
    Clarify the test Javadoc to describe avoiding unintended overlap shifts.
---
 src/changes/changes.xml                            |  1 +
 .../apache/commons/lang3/time/DateUtilsTest.java   | 53 ++++++++++++++++++++--
 2 files changed, 49 insertions(+), 5 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 216a7e8d8..b77ce6dc2 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -77,6 +77,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Use StackTraceElement objects to build compact root cause stack 
traces, avoiding text parsing and preventing exception message text from being 
mistaken for stack frames.</action>
     <action                   type="fix" dev="ggregory" due-to="alhuda, Gary 
Gregory">Restrict LocaleUtils.toLocale to ASCII language, country, and numeric 
area codes; previously accepted non-ASCII codes now cause 
IllegalArgumentException (#1838).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Deprecate ExceptionUtils.removeCommonFrames(), which is now used only 
by tests. Root-cause stack trace generation compares StackTraceElement objects 
directly.</action>
+    <action                   type="fix" dev="ggregory" due-to="alhuda, Gary 
Gregory">Keep DateUtils.ceiling and round inside a DST overlap hour 
(#1842).</action>
     <!-- ADD -->
     <action issue="LANG-1790" type="add" dev="ggregory" due-to="Mert Kirpici, 
Ivan Šarić, Gary Gregory">Add optional FIFO ordering for TimedSemaphore 
blocking acquisitions.</action>
     <!-- UPDATE -->
diff --git a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java 
b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
index f2482d716..5cc4434fc 100644
--- a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
@@ -55,6 +55,7 @@
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junitpioneer.jupiter.DefaultLocale;
 import org.junitpioneer.jupiter.DefaultTimeZone;
 import org.junitpioneer.jupiter.ReadsDefaultLocale;
@@ -1808,22 +1809,27 @@ void testTruncateLang59() {
     }
 
     /**
-     * Tests that ceiling and round keep the offset of a time inside a DST 
overlap when a field below the target is at or above its halfway
-     * point, which modify() removes with Calendar.set() rather than the 
LANG-59 arithmetic used for the round-down case.
+     * Tests that clearing sub-hour fields does not spuriously select the 
later occurrence of an overlapping local time.
+     *
+     * @param milliseconds the millisecond value below, at, or above the 
rounding threshold.
      */
     @DefaultTimeZone("Europe/London")
-    @Test
-    void testCeilingAndRoundDstOverlap() {
+    @ParameterizedTest
+    @ValueSource(ints = { 245, 499, 500, 999 })
+    void testCeilingAndRoundDstOverlap(final int milliseconds) {
         // 2024-10-27 01:00-02:00 BST repeats as 01:00-02:00 GMT; 
ZonedDateTime.of() picks the earlier offset, BST.
-        final ZonedDateTime bst = ZonedDateTime.of(2024, 10, 27, 1, 8, 58, 
245_000_000, ZoneId.of("Europe/London"));
+        final ZonedDateTime bst = ZonedDateTime.of(2024, 10, 27, 1, 8, 58, 
milliseconds * 1_000_000, ZoneId.of("Europe/London"));
         assertEquals(ZoneOffset.ofHours(1), bst.getOffset());
         final ZonedDateTime gmt = bst.withLaterOffsetAtOverlap();
         assertEquals(ZoneOffset.UTC, gmt.getOffset());
         for (final ZonedDateTime zdt : new ZonedDateTime[] { bst, gmt }) {
             final Date date = Date.from(zdt.toInstant());
+            final ZonedDateTime second = zdt.truncatedTo(ChronoUnit.SECONDS);
             final ZonedDateTime minute = zdt.truncatedTo(ChronoUnit.MINUTES);
             final ZonedDateTime hour = zdt.truncatedTo(ChronoUnit.HOURS);
             
assertEquals(Date.from(zdt.truncatedTo(ChronoUnit.SECONDS).plusSeconds(1).toInstant()),
 DateUtils.ceiling(date, Calendar.SECOND), "Ceiling Calendar.SECOND " + zdt);
+            assertEquals(Date.from(second.plusSeconds(milliseconds >= 500 ? 1 
: 0).toInstant()), DateUtils.round(date, Calendar.SECOND),
+                    "Round Calendar.SECOND " + zdt);
             assertEquals(Date.from(minute.plusMinutes(1).toInstant()), 
DateUtils.ceiling(date, Calendar.MINUTE), "Ceiling Calendar.MINUTE " + zdt);
             assertEquals(Date.from(minute.plusMinutes(1).toInstant()), 
DateUtils.round(date, Calendar.MINUTE), "Round Calendar.MINUTE " + zdt);
             assertEquals(Date.from(hour.plusHours(1).toInstant()), 
DateUtils.ceiling(date, Calendar.HOUR_OF_DAY), "Ceiling Calendar.HOUR_OF_DAY " 
+ zdt);
@@ -1836,6 +1842,43 @@ void testCeilingAndRoundDstOverlap() {
         }
     }
 
+    /**
+     * Tests that clearing sub-hour fields does not spuriously select the 
later occurrence of an overlapping local time.
+     *
+     * @param milliseconds the millisecond value below, at, or above the 
rounding threshold.
+     */
+    @DefaultTimeZone("UTC")
+    @ParameterizedTest
+    @ValueSource(ints = { 245, 499, 500, 999 })
+    void testCeilingAndRoundDstOverlapCalendar(final int milliseconds) {
+        // 2024-10-27 01:00-02:00 BST repeats as 01:00-02:00 GMT; 
ZonedDateTime.of() picks the earlier offset, BST.
+        final ZonedDateTime bst = ZonedDateTime.of(2024, 10, 27, 1, 8, 58, 
milliseconds * 1_000_000, ZoneId.of("Europe/London"));
+        assertEquals(ZoneOffset.ofHours(1), bst.getOffset());
+        final ZonedDateTime gmt = bst.withLaterOffsetAtOverlap();
+        assertEquals(ZoneOffset.UTC, gmt.getOffset());
+        for (final ZonedDateTime zdt : new ZonedDateTime[] { bst, gmt }) {
+            final Calendar date = GregorianCalendar.from(zdt);
+            final Calendar original = (Calendar) date.clone();
+            final ZonedDateTime second = zdt.truncatedTo(ChronoUnit.SECONDS);
+            final ZonedDateTime minute = zdt.truncatedTo(ChronoUnit.MINUTES);
+            final ZonedDateTime hour = zdt.truncatedTo(ChronoUnit.HOURS);
+            
assertEquals(Date.from(zdt.truncatedTo(ChronoUnit.SECONDS).plusSeconds(1).toInstant()),
 DateUtils.ceiling(date, Calendar.SECOND).getTime(), "Ceiling Calendar.SECOND " 
+ zdt);
+            assertEquals(Date.from(second.plusSeconds(milliseconds >= 500 ? 1 
: 0).toInstant()), DateUtils.round(date, Calendar.SECOND).getTime(),
+                    "Round Calendar.SECOND " + zdt);
+            assertEquals(Date.from(minute.plusMinutes(1).toInstant()), 
DateUtils.ceiling(date, Calendar.MINUTE).getTime(), "Ceiling Calendar.MINUTE " 
+ zdt);
+            assertEquals(Date.from(minute.plusMinutes(1).toInstant()), 
DateUtils.round(date, Calendar.MINUTE).getTime(), "Round Calendar.MINUTE " + 
zdt);
+            assertEquals(Date.from(hour.plusHours(1).toInstant()), 
DateUtils.ceiling(date, Calendar.HOUR_OF_DAY).getTime(), "Ceiling 
Calendar.HOUR_OF_DAY " + zdt);
+            assertEquals(Date.from(hour.plusHours(1).toInstant()), 
DateUtils.ceiling(date, Calendar.HOUR).getTime(), "Ceiling Calendar.HOUR " + 
zdt);
+            assertEquals(Date.from(hour.toInstant()), DateUtils.round(date, 
Calendar.HOUR_OF_DAY).getTime(), "Round Calendar.HOUR_OF_DAY " + zdt);
+            // 38 minutes past rounds up to the next hour
+            final Calendar late = GregorianCalendar.from(zdt.withMinute(38));
+            assertEquals(Date.from(hour.plusHours(1).toInstant()), 
DateUtils.round(late, Calendar.HOUR_OF_DAY).getTime(), "Round up 
Calendar.HOUR_OF_DAY " + zdt);
+            assertEquals(Date.from(hour.plusHours(1).toInstant()), 
DateUtils.round(late, Calendar.HOUR).getTime(), "Round up Calendar.HOUR " + 
zdt);
+            assertEquals(zdt.getZone().getId(), DateUtils.ceiling(date, 
Calendar.HOUR_OF_DAY).getTimeZone().getID());
+            assertEquals(original, date, "Input calendar must remain 
unchanged");
+        }
+    }
+
     /**
      * Tests the calendar iterator for week ranges
      */

Reply via email to