diff --git a/quartz/src/main/java/org/quartz/impl/triggers/CalendarIntervalTriggerImpl.java b/quartz/src/main/java/org/quartz/impl/triggers/CalendarIntervalTriggerImpl.java index f1609af0f..c64805595 100644 --- a/quartz/src/main/java/org/quartz/impl/triggers/CalendarIntervalTriggerImpl.java +++ b/quartz/src/main/java/org/quartz/impl/triggers/CalendarIntervalTriggerImpl.java @@ -19,9 +19,14 @@ package org.quartz.impl.triggers; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.Calendar; import java.util.Date; import java.util.TimeZone; +import java.util.function.Supplier; import org.quartz.CalendarIntervalScheduleBuilder; import org.quartz.CalendarIntervalTrigger; @@ -36,13 +41,12 @@ import org.quartz.Trigger; import org.quartz.TriggerUtils; - /** *

A concrete {@link Trigger} that is used to fire a {@link org.quartz.JobDetail} * based upon repeating calendar time intervals.

* *

The trigger will fire every N (see {@link #setRepeatInterval(int)} ) units of calendar time - * (see {@link #setRepeatIntervalUnit(org.quartz.DateBuilder.IntervalUnit)}) as specified in the trigger's definition. + * (see {@link #setRepeatIntervalUnit(IntervalUnit)}) as specified in the trigger's definition. * This trigger can achieve schedules that are not possible with {@link SimpleTrigger} (e.g * because months are not a fixed number of seconds) or {@link CronTrigger} (e.g. because * "every 5 months" is not an even divisor of 12).

@@ -78,7 +82,7 @@ public class CalendarIntervalTriggerImpl extends AbstractTrigger YEAR_TO_GIVEUP_SCHEDULING_AT) { + if (c.get(Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) { nextFireTime = null; } } @@ -552,9 +556,9 @@ public void updateWithNewCalendar(org.quartz.Calendar calendar, long misfireThre break; //avoid infinite loop - java.util.Calendar c = java.util.Calendar.getInstance(); + Calendar c = Calendar.getInstance(); c.setTime(nextFireTime); - if (c.get(java.util.Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) { + if (c.get(Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) { nextFireTime = null; } @@ -596,9 +600,9 @@ public Date computeFirstFireTime(org.quartz.Calendar calendar) { break; //avoid infinite loop - java.util.Calendar c = java.util.Calendar.getInstance(); + Calendar c = Calendar.getInstance(); c.setTime(nextFireTime); - if (c.get(java.util.Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) { + if (c.get(Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) { return null; } } @@ -662,6 +666,52 @@ public void setPreviousFireTime(Date previousFireTime) { this.previousFireTime = previousFireTime; } + private ChronoUnit intervalUnitToChronoUnit() { + switch (getRepeatIntervalUnit()) { + case MILLISECOND: + return ChronoUnit.MILLIS; + case SECOND: + return ChronoUnit.SECONDS; + case MINUTE: + return ChronoUnit.MINUTES; + case HOUR: + return ChronoUnit.HOURS; + case DAY: + return ChronoUnit.DAYS; + case WEEK: + return ChronoUnit.WEEKS; + case MONTH: + return ChronoUnit.MONTHS; + case YEAR: + return ChronoUnit.YEARS; + default: + throw new IllegalStateException("Unknown repeat interval unit: " + getRepeatIntervalUnit()); + } + } + + private int intervalUnitToCalendarField() { + switch (getRepeatIntervalUnit()) { + case MILLISECOND: + return Calendar.MILLISECOND; + case SECOND: + return Calendar.SECOND; + case MINUTE: + return Calendar.MINUTE; + case HOUR: + return Calendar.HOUR_OF_DAY; + case DAY: + return Calendar.DAY_OF_MONTH; + case WEEK: + return Calendar.WEEK_OF_YEAR; + case MONTH: + return Calendar.MONTH; + case YEAR: + return Calendar.YEAR; + default: + throw new IllegalStateException("Unknown repeat interval unit: " + getRepeatIntervalUnit()); + } + } + /** *

* Returns the next time at which the DateIntervalTrigger will @@ -698,148 +748,41 @@ protected Date getFireTimeAfter(Date afterTime, boolean ignoreEndTime) { return new Date(startMillis); } - - long secondsAfterStart = 1 + (afterMillis - startMillis) / 1000L; + final ChronoUnit chronoUnit = intervalUnitToChronoUnit(); - Date time = null; - long repeatLong = getRepeatInterval(); - - Calendar aTime = Calendar.getInstance(); - aTime.setTime(afterTime); + final ZoneId zoneId = timeZone == null ? ZoneId.systemDefault() : timeZone.toZoneId(); + final ZonedDateTime aDateTime = ZonedDateTime.ofInstant(afterTime.toInstant(), zoneId); + final ZonedDateTime sDateTime = ZonedDateTime.ofInstant(startTime.toInstant(), zoneId); + final long unitsSinceStart = chronoUnit.between(sDateTime, aDateTime); - Calendar sTime = Calendar.getInstance(); - if(timeZone != null) - sTime.setTimeZone(timeZone); - sTime.setTime(getStartTime()); - sTime.setLenient(true); - - if(getRepeatIntervalUnit().equals(IntervalUnit.SECOND)) { - long jumpCount = secondsAfterStart / repeatLong; - if(secondsAfterStart % repeatLong != 0) - jumpCount++; - sTime.add(Calendar.SECOND, getRepeatInterval() * (int)jumpCount); - time = sTime.getTime(); + Supplier advancer = new Supplier<>() { + private int intervalUnitsSinceStart = getRepeatInterval() * (int)(unitsSinceStart / getRepeatInterval()); + @Override + public ZonedDateTime get() { + ZonedDateTime result = sDateTime.plus(intervalUnitsSinceStart, chronoUnit); + if(result.getYear() >= YEAR_TO_GIVEUP_SCHEDULING_AT) { + return null; + } + intervalUnitsSinceStart += getRepeatInterval(); + return result; + } + }; + + ZonedDateTime nextFireDateTime = advancer.get(); + while(nextFireDateTime != null && !nextFireDateTime.isAfter(aDateTime)) { + nextFireDateTime = advancer.get(); } - else if(getRepeatIntervalUnit().equals(IntervalUnit.MINUTE)) { - long jumpCount = secondsAfterStart / (repeatLong * 60L); - if(secondsAfterStart % (repeatLong * 60L) != 0) - jumpCount++; - sTime.add(Calendar.MINUTE, getRepeatInterval() * (int)jumpCount); - time = sTime.getTime(); + + if(nextFireDateTime != null && chronoUnit.compareTo(ChronoUnit.DAYS) >= 0) { + nextFireDateTime = advanceIfNeeded(nextFireDateTime, sDateTime, aDateTime, advancer); } - else if(getRepeatIntervalUnit().equals(IntervalUnit.HOUR)) { - long jumpCount = secondsAfterStart / (repeatLong * 60L * 60L); - if(secondsAfterStart % (repeatLong * 60L * 60L) != 0) - jumpCount++; - sTime.add(Calendar.HOUR_OF_DAY, getRepeatInterval() * (int)jumpCount); - time = sTime.getTime(); + + if(nextFireDateTime == null) { + return null; } - else { // intervals a day or greater ... - int initialHourOfDay = sTime.get(Calendar.HOUR_OF_DAY); - - if(getRepeatIntervalUnit().equals(IntervalUnit.DAY)) { - sTime.setLenient(true); - - // Because intervals greater than an hour have an non-fixed number - // of seconds in them (due to daylight savings, variation number of - // days in each month, leap year, etc. ) we can't jump forward an - // exact number of seconds to calculate the fire time as we can - // with the second, minute and hour intervals. But, rather - // than slowly crawling our way there by iteratively adding the - // increment to the start time until we reach the "after time", - // we can first make a big leap most of the way there... - - long jumpCount = secondsAfterStart / (repeatLong * 24L * 60L * 60L); - // if we need to make a big jump, jump most of the way there, - // but not all the way because in some cases we may over-shoot or under-shoot - if(jumpCount > 20) { - if(jumpCount < 50) - jumpCount = (long) (jumpCount * 0.80); - else if(jumpCount < 500) - jumpCount = (long) (jumpCount * 0.90); - else - jumpCount = (long) (jumpCount * 0.95); - sTime.add(java.util.Calendar.DAY_OF_YEAR, (int) (getRepeatInterval() * jumpCount)); - } - - // now baby-step the rest of the way there... - while(!sTime.getTime().after(afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.DAY_OF_YEAR, getRepeatInterval()); - } - while(daylightSavingHourShiftOccurredAndAdvanceNeeded(sTime, initialHourOfDay, afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.DAY_OF_YEAR, getRepeatInterval()); - } - time = sTime.getTime(); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.WEEK)) { - sTime.setLenient(true); - - // Because intervals greater than an hour have an non-fixed number - // of seconds in them (due to daylight savings, variation number of - // days in each month, leap year, etc. ) we can't jump forward an - // exact number of seconds to calculate the fire time as we can - // with the second, minute and hour intervals. But, rather - // than slowly crawling our way there by iteratively adding the - // increment to the start time until we reach the "after time", - // we can first make a big leap most of the way there... - - long jumpCount = secondsAfterStart / (repeatLong * 7L * 24L * 60L * 60L); - // if we need to make a big jump, jump most of the way there, - // but not all the way because in some cases we may over-shoot or under-shoot - if(jumpCount > 20) { - if(jumpCount < 50) - jumpCount = (long) (jumpCount * 0.80); - else if(jumpCount < 500) - jumpCount = (long) (jumpCount * 0.90); - else - jumpCount = (long) (jumpCount * 0.95); - sTime.add(java.util.Calendar.WEEK_OF_YEAR, (int) (getRepeatInterval() * jumpCount)); - } - - while(!sTime.getTime().after(afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.WEEK_OF_YEAR, getRepeatInterval()); - } - while(daylightSavingHourShiftOccurredAndAdvanceNeeded(sTime, initialHourOfDay, afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.WEEK_OF_YEAR, getRepeatInterval()); - } - time = sTime.getTime(); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.MONTH)) { - sTime.setLenient(true); - - // because of the large variation in size of months, and - // because months are already large blocks of time, we will - // just advance via brute-force iteration. - - while(!sTime.getTime().after(afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.MONTH, getRepeatInterval()); - } - while(daylightSavingHourShiftOccurredAndAdvanceNeeded(sTime, initialHourOfDay, afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.MONTH, getRepeatInterval()); - } - time = sTime.getTime(); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.YEAR)) { - - while(!sTime.getTime().after(afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.YEAR, getRepeatInterval()); - } - while(daylightSavingHourShiftOccurredAndAdvanceNeeded(sTime, initialHourOfDay, afterTime) && - (sTime.get(java.util.Calendar.YEAR) < YEAR_TO_GIVEUP_SCHEDULING_AT)) { - sTime.add(java.util.Calendar.YEAR, getRepeatInterval()); - } - time = sTime.getTime(); - } - } // case of interval of a day or greater - + Date time = Date.from(nextFireDateTime.toInstant()); + if (!ignoreEndTime && (endMillis <= time.getTime())) { return null; } @@ -847,16 +790,36 @@ else if(getRepeatIntervalUnit().equals(IntervalUnit.YEAR)) { return time; } - private boolean daylightSavingHourShiftOccurredAndAdvanceNeeded(Calendar newTime, int initialHourOfDay, Date afterTime) { - if(isPreserveHourOfDayAcrossDaylightSavings() && newTime.get(Calendar.HOUR_OF_DAY) != initialHourOfDay) { - newTime.set(Calendar.HOUR_OF_DAY, initialHourOfDay); - if (newTime.get(Calendar.HOUR_OF_DAY) != initialHourOfDay) { - return isSkipDayIfHourDoesNotExist(); - } else { - return !newTime.getTime().after(afterTime); + private ZonedDateTime advanceIfNeeded(ZonedDateTime nextFireDateTime, + ZonedDateTime startDateTime, + ZonedDateTime afterDateTime, + Supplier advancer) { + if(isPreserveHourOfDayAcrossDaylightSavings()) { + int initialHourOfDay = startDateTime.getHour(); + // This may iterate multiple times in the case of skipDayIfHourDoesNotExist and yearly intervals + while(nextFireDateTime != null && nextFireDateTime.getHour() != initialHourOfDay) { + ZonedDateTime adjustedDateTime; + try { + adjustedDateTime = nextFireDateTime.withHour(initialHourOfDay); + } catch (DateTimeException ignored) { + adjustedDateTime = nextFireDateTime; + } + if (adjustedDateTime.getHour() == initialHourOfDay) { + if (adjustedDateTime.isAfter(afterDateTime)) { + return adjustedDateTime; + } else { + nextFireDateTime = advancer.get(); + } + } else { + if (isSkipDayIfHourDoesNotExist()) { + nextFireDateTime = advancer.get(); + } else { + return nextFireDateTime; + } + } } } - return false; + return nextFireDateTime; } /** @@ -891,28 +854,8 @@ public Date getFinalFireTime() { lTime.setTimeZone(timeZone); lTime.setTime(fTime); lTime.setLenient(true); - - if(getRepeatIntervalUnit().equals(IntervalUnit.SECOND)) { - lTime.add(java.util.Calendar.SECOND, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.MINUTE)) { - lTime.add(java.util.Calendar.MINUTE, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.HOUR)) { - lTime.add(java.util.Calendar.HOUR_OF_DAY, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.DAY)) { - lTime.add(java.util.Calendar.DAY_OF_YEAR, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.WEEK)) { - lTime.add(java.util.Calendar.WEEK_OF_YEAR, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.MONTH)) { - lTime.add(java.util.Calendar.MONTH, -1 * getRepeatInterval()); - } - else if(getRepeatIntervalUnit().equals(IntervalUnit.YEAR)) { - lTime.add(java.util.Calendar.YEAR, -1 * getRepeatInterval()); - } + + lTime.add(intervalUnitToCalendarField(), -1 * getRepeatInterval()); return lTime.getTime(); } diff --git a/quartz/src/test/java/org/quartz/CalendarIntervalTriggerTest.java b/quartz/src/test/java/org/quartz/CalendarIntervalTriggerTest.java index fdbdae378..a31a5ea96 100644 --- a/quartz/src/test/java/org/quartz/CalendarIntervalTriggerTest.java +++ b/quartz/src/test/java/org/quartz/CalendarIntervalTriggerTest.java @@ -24,10 +24,15 @@ import java.text.ParseException; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.time.zone.ZoneOffsetTransition; +import java.time.zone.ZoneRules; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.TimeZone; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.quartz.DateBuilder.IntervalUnit; @@ -105,6 +110,29 @@ void testYearlyIntervalGetFireTimeAfter() { assertEquals(targetCalendar.getTime(), secondTime, "Year increment result not as expected."); } + @Test + void testYearlyIntervalGetFireTimeAfterLeap() { + Calendar startCalendar = Calendar.getInstance(); + final int leapYear = 4 * (startCalendar.get(Calendar.YEAR) / 4); + startCalendar.set(leapYear, Calendar.FEBRUARY, 29, 9, 30, 17); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setRepeatIntervalUnit(IntervalUnit.YEAR); + yearlyTrigger.setRepeatInterval(1); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 5); + Calendar testCal = Calendar.getInstance(); + assertEquals(List.of(leapYear, leapYear + 1,leapYear + 2,leapYear + 3,leapYear + 4), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.YEAR)) + .collect(Collectors.toList())); + assertEquals(List.of(29,28,28,28,29), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.DAY_OF_MONTH)) + .collect(Collectors.toList())); + } + @Test void testMonthlyIntervalGetFireTimeAfter() { @@ -128,6 +156,85 @@ void testMonthlyIntervalGetFireTimeAfter() { assertEquals(targetCalendar.getTime(), fifthTime, "Month increment result not as expected."); } + + @Test + void testMonthIntervalGetFireTimeAfterOn31() { + Calendar startCalendar = Calendar.getInstance(); + int year = startCalendar.get(Calendar.YEAR); + startCalendar.set(year, Calendar.MARCH, 31, 9, 30, 17); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setRepeatIntervalUnit(IntervalUnit.MONTH); + yearlyTrigger.setRepeatInterval(1); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 3); + Calendar testCal = Calendar.getInstance(); + assertEquals(List.of(31,30,31), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.DAY_OF_MONTH)) + .collect(Collectors.toList())); + } + + private Calendar get30MinBeforeSpringForward() { + Calendar cal = Calendar.getInstance(); + ZoneId zoneId = ZoneId.of("America/New_York"); + ZoneRules zoneRules = zoneId.getRules(); + Instant now = Instant.now(); + ZoneOffsetTransition transition = zoneRules.nextTransition(now); + if(transition == null) { + // Should not happen + return cal; + } + + if(transition.isOverlap()) { + transition = zoneRules.previousTransition(now); + } + Instant transitionInstant = transition.getInstant(); + cal.setTimeZone(TimeZone.getTimeZone(zoneId)); + cal.setTimeInMillis(transitionInstant.minus(30, ChronoUnit.MINUTES).toEpochMilli()); + cal.setLenient(true); + return cal; + } + + @Test + void testMonthlyIntervalGetFireTimeAfterSpringForward() { + Calendar startCalendar = get30MinBeforeSpringForward(); + + startCalendar.add(Calendar.MONTH, - 1); + startCalendar.add(Calendar.HOUR_OF_DAY, 1); + int month = startCalendar.get(Calendar.MONTH); + int day = startCalendar.get(Calendar.DAY_OF_MONTH); + int hour = startCalendar.get(Calendar.HOUR_OF_DAY); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setRepeatIntervalUnit(IntervalUnit.MONTH); + yearlyTrigger.setRepeatInterval(1); + yearlyTrigger.setTimeZone(startCalendar.getTimeZone()); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 3); + + Calendar testCal = Calendar.getInstance(startCalendar.getTimeZone()); + ZonedDateTime localDateTime = startCalendar.toInstant().atZone(startCalendar.getTimeZone().toZoneId()); + String hint = "Failed for " + localDateTime; + // Check that no month is skipped + assertEquals(List.of(month, month + 1, month + 2), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.MONTH)) + .collect(Collectors.toList()), hint); + // Check that day of month is preserved + assertEquals(List.of(day, day, day), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.DAY_OF_MONTH)) + .collect(Collectors.toList()), hint); + // Check that spring forward gap does not affect the following trigger times + assertEquals(List.of(hour, hour+1, hour), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.HOUR_OF_DAY)) + .collect(Collectors.toList()), hint); + } + @Test void testWeeklyIntervalGetFireTimeAfter() { @@ -174,6 +281,76 @@ void testDailyIntervalGetFireTimeAfter() { assertEquals(targetCalendar.getTime(), fifthTime, "Day increment result not as expected."); } + + @Test + void testDailyIntervalGetFireTimeAfterSpringForward() { + Calendar startCalendar = get30MinBeforeSpringForward(); + + startCalendar.add(Calendar.DAY_OF_MONTH, - 1); + startCalendar.add(Calendar.HOUR_OF_DAY, 1); + startCalendar.add(Calendar.MINUTE, 10); + int day = startCalendar.get(Calendar.DAY_OF_YEAR); + int hour = startCalendar.get(Calendar.HOUR_OF_DAY); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setRepeatIntervalUnit(IntervalUnit.DAY); + yearlyTrigger.setRepeatInterval(1); + yearlyTrigger.setTimeZone(startCalendar.getTimeZone()); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 3); + + Calendar testCal = Calendar.getInstance(startCalendar.getTimeZone()); + ZonedDateTime localDateTime = startCalendar.toInstant().atZone(startCalendar.getTimeZone().toZoneId()); + String hint = "Failed for " + localDateTime; + // Check that no day is skipped + assertEquals(List.of(day, day + 1, day + 2), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.DAY_OF_YEAR)) + .collect(Collectors.toList()), hint); + // Check that spring forward gap does not affect the following trigger times + assertEquals(List.of(hour, hour+1, hour), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.HOUR_OF_DAY)) + .collect(Collectors.toList()), hint); + } + + @Test + void testDailyIntervalGetFireTimeAfterSpringForwardPreserveHour() { + Calendar startCalendar = get30MinBeforeSpringForward(); + + startCalendar.add(Calendar.DAY_OF_MONTH, - 1); + startCalendar.add(Calendar.HOUR_OF_DAY, 1); + startCalendar.add(Calendar.MINUTE, 10); + int day = startCalendar.get(Calendar.DAY_OF_YEAR); + int hour = startCalendar.get(Calendar.HOUR_OF_DAY); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setRepeatIntervalUnit(IntervalUnit.DAY); + yearlyTrigger.setRepeatInterval(1); + yearlyTrigger.setTimeZone(startCalendar.getTimeZone()); + + yearlyTrigger.setPreserveHourOfDayAcrossDaylightSavings(true); + yearlyTrigger.setSkipDayIfHourDoesNotExist(true); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 3); + + Calendar testCal = Calendar.getInstance(startCalendar.getTimeZone()); + ZonedDateTime localDateTime = startCalendar.toInstant().atZone(startCalendar.getTimeZone().toZoneId()); + String hint = "Failed for " + localDateTime; + // Check that a DTS day got skipped + assertEquals(List.of(day, day + 2, day + 3), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.DAY_OF_YEAR)) + .collect(Collectors.toList()), hint); + // Check that the hour is preserved + assertEquals(List.of(hour, hour, hour), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.HOUR_OF_DAY)) + .collect(Collectors.toList()), hint); + } + @Test void testHourlyIntervalGetFireTimeAfter() { @@ -197,6 +374,31 @@ void testHourlyIntervalGetFireTimeAfter() { assertEquals(targetCalendar.getTime(), fifthTime, "Hour increment result not as expected."); } + + @Test + void testHourlyIntervalGetFireTimeAfterSpringForward() { + Calendar startCalendar = get30MinBeforeSpringForward(); + + int hour = startCalendar.get(Calendar.HOUR_OF_DAY); + + CalendarIntervalTriggerImpl yearlyTrigger = new CalendarIntervalTriggerImpl(); + yearlyTrigger.setStartTime(startCalendar.getTime()); + yearlyTrigger.setTimeZone(startCalendar.getTimeZone()); + yearlyTrigger.setRepeatIntervalUnit(DateBuilder.IntervalUnit.HOUR); + yearlyTrigger.setRepeatInterval(1); + + List fireTimes = TriggerUtils.computeFireTimes(yearlyTrigger, null, 4); + ZonedDateTime localDateTime = startCalendar.toInstant().atZone(startCalendar.getTimeZone().toZoneId()); + String hint = "Failed for " + localDateTime; + + Calendar testCal = Calendar.getInstance(startCalendar.getTimeZone()); + // Check that hourly firings are continuous across the spring forward gap + assertEquals(List.of(hour, hour + 2, hour + 3, hour + 4), fireTimes.stream() + .peek(testCal::setTime) + .map(d -> testCal.get(Calendar.HOUR_OF_DAY)) + .collect(Collectors.toList()), hint); + } + @Test void testMinutelyIntervalGetFireTimeAfter() {