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
*/