ExecutionTimeUnixIntegrationTest.java

/*
 * Copyright 2015 jmrozanec
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * http://www.apache.org/licenses/LICENSE-2.0
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.cronutils.model.time.generator;

import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.model.time.ExecutionTime;
import com.cronutils.parser.CronParser;
import org.junit.jupiter.api.Test;

import java.time.*;
import java.util.*;

import static org.junit.jupiter.api.Assertions.*;

public class ExecutionTimeUnixIntegrationTest {

    private static final String LAST_EXECUTION_NOT_PRESENT_ERROR = "last execution was not present";
    private static final String NEXT_EXECUTION_NOT_PRESENT_ERROR = "next execution was not present";
    private static final ZoneId ZONE_ID_NEW_YORK = ZoneId.of("America/New_York");

    @Test
    public void testIsMatchForUnix01() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final String crontab = "* * * * *";//m,h,dom,M,dow
        final Cron cron = parser.parse(crontab);
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        final ZonedDateTime scanTime = ZonedDateTime.parse("2016-02-29T11:00:00.000-06:00");
        assertTrue(executionTime.isMatch(scanTime));
    }

    @Test
    public void testIsMatchForUnix02() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final String crontab = "0 * * * 1-5";//m,h,dom,M,dow
        final Cron cron = parser.parse(crontab);
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        final ZonedDateTime scanTime = ZonedDateTime.parse("2016-03-04T11:00:00.000-06:00");
        assertTrue(executionTime.isMatch(scanTime));
    }

    /**
     * Issue #37: for pattern "every 10 minutes", nextExecution returns a date from past.
     */
    @Test
    public void testEveryTenMinutesNextExecution() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("*/10 * * * *"));
        final ZonedDateTime time = ZonedDateTime.parse("2015-09-05T13:43:00.000-07:00");
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(time);
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-09-05T13:50:00.000-07:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #38: every 2 min schedule doesn't roll over to next hour.
     */
    @Test
    public void testEveryTwoMinRollsOverHour() {
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final Cron cron = new CronParser(cronDefinition).parse("*/2 * * * *");
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        final ZonedDateTime time = ZonedDateTime.parse("2015-09-05T13:56:00.000-07:00");
        final Optional<ZonedDateTime> nextExecutionTime = executionTime.nextExecution(time);
        if (nextExecutionTime.isPresent()) {
            final ZonedDateTime next = nextExecutionTime.get();
            final Optional<ZonedDateTime> shouldBeInNextHourExecution = executionTime.nextExecution(next);
            if (shouldBeInNextHourExecution.isPresent()) {
                assertEquals(next.plusMinutes(2), shouldBeInNextHourExecution.get());
                return;
            }
        }
        fail("one of the asserted values was not present.");
    }

    /**
     * Issue #41: for everything other than a dayOfWeek value == 1, nextExecution and lastExecution do not return correct results.
     */
    @Test
    public void testEveryTuesdayAtThirdHourOfDayNextExecution() {
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        final Cron myCron = parser.parse("0 3 * * 3");
        final ZonedDateTime time = ZonedDateTime.parse("2015-09-17T00:00:00.000-07:00");
        final Optional<ZonedDateTime> nextExecution = ExecutionTime.forCron(myCron).nextExecution(time);
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-09-23T03:00:00.000-07:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #41: for everything other than a dayOfWeek value == 1, nextExecution and lastExecution do not return correct results.
     */
    @Test
    public void testEveryTuesdayAtThirdHourOfDayLastExecution() {
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        final Cron myCron = parser.parse("0 3 * * 3");
        final ZonedDateTime time = ZonedDateTime.parse("2015-09-17T00:00:00.000-07:00");
        final Optional<ZonedDateTime> lastExecution = ExecutionTime.forCron(myCron).lastExecution(time);
        if (lastExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-09-16T03:00:00.000-07:00"), lastExecution.get());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #45: last execution does not match expected date. Result is not in same timezone as reference date.
     */
    @Test
    public void testMondayWeekdayLastExecution() {
        final Cron cron = getUnixCron("* * * * 1");
        final Optional<ZonedDateTime> lastExecution = getLastExecutionFor(cron, ZonedDateTime.parse("2015-10-13T17:26:54.468-07:00"));
        if (lastExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-10-12T23:59:00.000-07:00"), lastExecution.get());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    private Cron getUnixCron(final String cronExpression) {
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        return parser.parse(cronExpression);
    }

    private Optional<ZonedDateTime> getLastExecutionFor(final Cron cron, final ZonedDateTime dateTime) {
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        return  executionTime.lastExecution(dateTime);
    }

    private Optional<ZonedDateTime> getNextExecutionFor(final Cron cron, final ZonedDateTime dateTime) {
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        return  executionTime.nextExecution(dateTime);
    }

    /**
     * Issue #45: next execution does not match expected date. Result is not in same timezone as reference date.
     */
    @Test
    public void testMondayWeekdayNextExecution() {
        final String crontab = "* * * * 1";
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        final Cron cron = parser.parse(crontab);
        final ZonedDateTime date = ZonedDateTime.parse("2015-10-13T17:26:54.468-07:00");
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-10-19T00:00:00.000-07:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #50: last execution does not match expected date when cron specifies day of week and last execution is in previous month.
     */
    @Test
    public void testLastExecutionDaysOfWeekOverMonthBoundary() {
        final Cron cron = getUnixCron("0 11 * * 1");
        final Optional<ZonedDateTime> lastExecution = getLastExecutionFor(cron, ZonedDateTime.parse("2015-11-02T00:10:00Z"));
        if (lastExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-10-26T11:00:00Z"), lastExecution.get());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #52: "And" doesn't work for day of the week
     * 1,2 should be Monday and Tuesday, but instead it is treated as 1st/2nd of month.
     */
    @Test
    public void testWeekdayAndLastExecution() {
        final Cron cron = getUnixCron("* * * * 1,2");
        final Optional<ZonedDateTime> lastExecution = getLastExecutionFor(cron, ZonedDateTime.parse("2015-11-10T17:01:00Z"));
        if (lastExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-11-10T17:00:00Z"), lastExecution.get());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Isue #52: Additional test to ensure after fix that "And" and "Between" can both be used
     * 1,2-3 should be Monday, Tuesday and Wednesday.
     */
    @Test
    public void testWeekdayAndWithMixOfOnAndBetweenLastExecution() {
        final Cron cron = getUnixCron("* * * * 1,2-3");
        final Optional<ZonedDateTime> lastExecution = getLastExecutionFor(cron, ZonedDateTime.parse("2015-11-10T17:01:00Z"));
        if (lastExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-11-10T17:00:00Z"), lastExecution.get());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }

    }

    /**
     * Issue #59: Incorrect next execution time for "month" and "day of week".
     * Considers Month in range 0-11 instead of 1-12
     */
    @Test
    public void testCorrectMonthScaleForNextExecution1() {
        final Cron cron = getUnixCron("* * */3 */4 */5");
        final Optional<ZonedDateTime> nextExecution = getNextExecutionFor(cron, ZonedDateTime.parse("2015-12-10T16:32:56.586-08:00"));
        if (nextExecution.isPresent()) {
            //DoW: 0-6 -> 0, 5 (sunday, friday)
            //DoM: 1-31 -> 1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31
            //M: 1-12 -> 1, 5, 9
            assertEquals(ZonedDateTime.parse("2016-01-01T00:00:00.000-08:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #59: Incorrect next execution time for "day of month" in "time" situation
     * dom "* / 4" should mean 1, 5, 9, 13, 17th... of month instead of 4, 8, 12, 16th...
     */
    @Test
    public void testCorrectMonthScaleForNextExecution2() {
        final Cron cron = getUnixCron("* * */4 * *");
        final Optional<ZonedDateTime> nextExecution = getNextExecutionFor(cron, ZonedDateTime.parse("2015-12-10T16:32:56.586-08:00"));
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2015-12-13T00:00:00.000-08:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #59: Incorrect next execution time for "month" and "day of week".
     * Considers bad DoW
     */
    @Test
    public void testCorrectNextExecutionDoW() {
        //DoW: 0-6 -> 0, 4 (sunday, thursday)
        final Cron cron = getUnixCron("0 0 * * */4");
        Optional<ZonedDateTime> nextExecution = getNextExecutionFor(cron, ZonedDateTime.parse("2016-01-28T16:32:56.586-08:00"));
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2016-01-31T00:00:00.000-08:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
        nextExecution = getNextExecutionFor(cron, nextExecution.get());
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2016-02-04T00:00:00.000-08:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #69: Getting next execution fails on leap-year when using day-of-week.
     */
    @Test
    public void testCorrectNextExecutionDoWForLeapYear() {
        //DoW: 0-6 -> 1, 2, 3, 4, 5 -> in this year:
        final Optional<ZonedDateTime> nextExecution = getNextExecutionFor(getUnixCron("0 * * * 1-5"), ZonedDateTime.parse("2016-02-29T11:00:00.000-06:00"));
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2016-02-29T12:00:00.000-06:00"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }

    }

    /**
     * Issue #61: nextExecution over daylight savings is wrong.
     */
    @Test
    public void testNextExecutionDaylightSaving() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("0 17 * * *"));// daily at 17:00
        // Daylight savings for New York 2016 is Mar 13 at 2am
        final ZonedDateTime last = ZonedDateTime.of(2016, 3, 12, 17, 0, 0, 0, ZONE_ID_NEW_YORK);
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(last);
        if (nextExecution.isPresent()) {
            final long millis = Duration.between(last, nextExecution.get()).toMillis();
            assertEquals(23, (millis / 3600000));
            assertEquals(last.getZone(), nextExecution.get().getZone());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #61: lastExecution over daylight savings is wrong.
     */
    @Test
    public void testLastExecutionDaylightSaving() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("0 17 * * *"));// daily at 17:00
        // Daylight savings for New York 2016 is Mar 13 at 2am
        final ZonedDateTime now = ZonedDateTime.of(2016, 3, 12, 17, 0, 0, 0, ZoneId.of("America/Phoenix"));
        final Optional<ZonedDateTime> lastExecution = executionTime.lastExecution(now);
        if (lastExecution.isPresent()) {
            final long millis = Duration.between(lastExecution.get(), now).toMillis();
            assertEquals(24, (millis / 3600000));
            assertEquals(now.getZone(), lastExecution.get().getZone());
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #79: Next execution skipping valid date.
     */
    @Test
    public void testNextExecution2014() {
        final String crontab = "0 8 * * 1";//m,h,dom,m,dow ; every monday at 8AM
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        final Cron cron = parser.parse(crontab);
        final ZonedDateTime date = ZonedDateTime.parse("2014-11-30T00:00:00Z");
        final ExecutionTime executionTime = ExecutionTime.forCron(cron);
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2014-12-01T08:00:00Z"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }

    }

    /**
     * Issue #92: Next execution skipping valid date.
     */
    @Test
    public void testNextExecution2016() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("1 0 * * tue"));
        final ZonedDateTime date = ZonedDateTime.parse("2016-05-24T01:02:50Z");
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
        if (nextExecution.isPresent()) {
            assertEquals(ZonedDateTime.parse("2016-05-31T00:01:00Z"), nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #112: Calling nextExecution exactly on the first instant of the fallback hour (after the DST ends) makes it go back to DST.
     * https://github.com/jmrozanec/cron-utils/issues/112
     */
    @Test
    public void testWrongNextExecutionOnDSTEnd() {
        final ZoneId zone = ZoneId.of("America/Sao_Paulo");

        //2016-02-20T23:00-03:00[America/Sao_Paulo], first minute of fallback hour
        final ZonedDateTime date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(1456020000000L), zone);
        final ZonedDateTime expected = ZonedDateTime.ofInstant(Instant.ofEpochMilli(1456020000000L + 60000), zone);

        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("* * * * *"));
        final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
        if (nextExecution.isPresent()) {
            assertEquals(expected, nextExecution.get());
        } else {
            fail(NEXT_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #112: Calling nextExecution on a date-time during the overlap hour of DST causes an incorrect offset
     * to be returned.
     */
    @Test
    public void testDSTOverlap() {
        final ZoneId zoneId = ZONE_ID_NEW_YORK;

        // For the America/New_York time zone, DST ends (UTC-4:00 to UTC-5:00 / EDT -> EST) at 2:00 AM
        // on the these days for the years 2015-2026:
        final Set<LocalDate> dstDates = new HashSet<>();
        dstDates.add(LocalDate.of(2015, Month.NOVEMBER, 1));
        dstDates.add(LocalDate.of(2016, Month.NOVEMBER, 6));
        dstDates.add(LocalDate.of(2017, Month.NOVEMBER, 5));
        dstDates.add(LocalDate.of(2018, Month.NOVEMBER, 4));
        dstDates.add(LocalDate.of(2019, Month.NOVEMBER, 3));
        dstDates.add(LocalDate.of(2020, Month.NOVEMBER, 1));
        dstDates.add(LocalDate.of(2021, Month.NOVEMBER, 7));
        dstDates.add(LocalDate.of(2022, Month.NOVEMBER, 6));
        dstDates.add(LocalDate.of(2023, Month.NOVEMBER, 5));
        dstDates.add(LocalDate.of(2024, Month.NOVEMBER, 3));
        dstDates.add(LocalDate.of(2025, Month.NOVEMBER, 2));
        dstDates.add(LocalDate.of(2026, Month.NOVEMBER, 1));

        // Starting at 12 AM Nov. 1, 2015
        ZonedDateTime date = ZonedDateTime.of(2015, 11, 1, 0, 0, 0, 0, zoneId);

        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        // Scheduling pattern for 1:30 AM for the first 7 days of every November
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("30 1 1-7 11 *"));

        final ZoneOffset easternDaylightTimeOffset = ZoneOffset.ofHours(-4);
        final ZoneOffset easternStandardTimeOffset = ZoneOffset.ofHours(-5);

        for (int year = 2015; year <= 2026; year++) {
            boolean pastDSTEnd = false;
            int dayOfMonth = 1;
            while (dayOfMonth < 8) {
                final LocalDateTime expectedLocalDateTime = LocalDateTime.of(year, Month.NOVEMBER, dayOfMonth, 1, 30);
                final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
                assert (nextExecution.isPresent());

                final ZonedDateTime nextExecutionDate = nextExecution.get();

                final ZoneOffset expectedOffset = pastDSTEnd ? easternStandardTimeOffset : easternDaylightTimeOffset;

                if (dstDates.contains(LocalDate.of(year, Month.NOVEMBER, dayOfMonth))) {
                    if (!pastDSTEnd) {
                        // next iteration should be past the DST transition
                        pastDSTEnd = true;
                    }
                }
                dayOfMonth++;
                assertEquals(ZonedDateTime.ofInstant(expectedLocalDateTime, expectedOffset, zoneId), nextExecutionDate);
                date = nextExecutionDate;
            }
        }
    }

    /**
     * Test that a cron expression that only runs at a certain time that falls inside the DST start gap
     * does not run on the DST start day. Ex. 2:15 AM is an invalid local time for the America/New_York
     * time zone on the DST start days.
     */
    @Test
    public void testDSTGap() {
        final ZoneId zoneId = ZONE_ID_NEW_YORK;
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        // Run at 2:15 AM each day for March 7 to 14
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("15 2 7-14 3 *"));

        // Starting at 12 AM March. 7, 2015
        ZonedDateTime date = ZonedDateTime.of(2015, 3, 7, 0, 0, 0, 0, zoneId);

        // For America/New_York timezone, DST starts at 2 AM local time and moves forward 1 hour
        // DST dates for 2015-2026
        final Map<Integer, LocalDate> dstDates = new HashMap<>();
        dstDates.put(2015, LocalDate.of(2015, Month.MARCH, 8));
        dstDates.put(2016, LocalDate.of(2016, Month.MARCH, 13));
        dstDates.put(2017, LocalDate.of(2017, Month.MARCH, 12));
        dstDates.put(2018, LocalDate.of(2018, Month.MARCH, 11));
        dstDates.put(2019, LocalDate.of(2019, Month.MARCH, 10));
        dstDates.put(2020, LocalDate.of(2020, Month.MARCH, 8));
        dstDates.put(2021, LocalDate.of(2021, Month.MARCH, 14));
        dstDates.put(2022, LocalDate.of(2022, Month.MARCH, 13));
        dstDates.put(2023, LocalDate.of(2023, Month.MARCH, 12));
        dstDates.put(2024, LocalDate.of(2024, Month.MARCH, 10));
        dstDates.put(2025, LocalDate.of(2025, Month.MARCH, 9));
        dstDates.put(2026, LocalDate.of(2026, Month.MARCH, 8));

        final ZoneOffset easternDaylightTimeOffset = ZoneOffset.ofHours(-4);
        final ZoneOffset easternStandardTimeOffset = ZoneOffset.ofHours(-5);
        for (int year = 2015; year <= 2026; year++) {
            final LocalDate dstDateForYear = dstDates.get(year);
            boolean isPastDSTStart = false;
            int dayOfMonth = 7;
            while (dayOfMonth < 15) {
                final LocalDateTime localDateTime = LocalDateTime.of(year, Month.MARCH, dayOfMonth, 2, 15);
                // skip the DST start days... 2:15 AM does not exist in the local time
                if (localDateTime.toLocalDate().isEqual(dstDateForYear)) {
                    dayOfMonth++;
                    isPastDSTStart = true;
                    continue;
                }
                final ZoneOffset expectedOffset = isPastDSTStart ? easternDaylightTimeOffset : easternStandardTimeOffset;
                final ZonedDateTime expectedDateTime = ZonedDateTime.ofLocal(localDateTime, zoneId, expectedOffset);

                final Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(date);
                assert (nextExecution.isPresent());
                date = nextExecution.get();
                assertEquals(expectedDateTime, date);
                dayOfMonth++;
            }
        }
    }

    /**
     * Issue #125: Prints stack trace for NoSuchValueException for expressions with comma-separated values
     * https://github.com/jmrozanec/cron-utils/issues/125
     */
    @Test
    public void testNextExecutionProducesStackTraces() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("45 1,13 * * *"));
        executionTime.nextExecution(ZonedDateTime.parse("2016-05-24T01:02:50Z"));
    }

    /**
     * Issue #130: Wrong last execution time if schedule hit is less than one second ago
     * https://github.com/jmrozanec/cron-utils/issues/130
     */
    @Test
    public void exactHitReturnsFullIntervalDuration() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final Cron cron = parser.parse("0 12 * * *");
        final ZonedDateTime time = ZonedDateTime.of(2016, 12, 2, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"));
        final Optional<Duration> timeFromLastExecution = ExecutionTime.forCron(cron).timeFromLastExecution(time);
        if (timeFromLastExecution.isPresent()) {
            assertEquals(timeFromLastExecution.get(), Duration.ofHours(24));
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    /**
     * Issue #130: Wrong last execution time if schedule hit is less than one second ago
     * https://github.com/jmrozanec/cron-utils/issues/130
     */
    @Test
    public void fuzzyHitReturnsVerySmallIntervalDuration() {
        final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        final Cron cron = parser.parse("0 12 * * *");
        ZonedDateTime time = ZonedDateTime.of(2016, 12, 2, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"));
        final Duration diff = Duration.ofMillis(300);
        time = time.plus(diff);
        final Optional<Duration> timeFromLastExecution = ExecutionTime.forCron(cron).timeFromLastExecution(time);
        if (timeFromLastExecution.isPresent()) {
            assertEquals(timeFromLastExecution.get(), diff);
        } else {
            fail(LAST_EXECUTION_NOT_PRESENT_ERROR);
        }
    }

    @Test
    public void invalidDayInMonthCron() {
        final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
        final CronParser parser = new CronParser(cronDefinition);
        final Cron myCron = parser.parse("0 0 31 2 *");
        final ZonedDateTime time = ZonedDateTime.parse("2015-09-17T00:00:00.000-07:00");
        final Optional<ZonedDateTime> nextExecution = ExecutionTime.forCron(myCron).nextExecution(time);
        assertFalse(nextExecution.isPresent());
    }

    /**
     * https://github.com/jmrozanec/cron-utils/issues/336
     */
    @Test
    public void testEveryDayPerWeek() {
        CronParser cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        // every 3 days - Sun (1), Wed (4), Sat (7)
        String cronString = "0 0 * * */3";
        Cron cron = cronParser.parse(cronString);
        ExecutionTime executionTime = ExecutionTime.forCron(cron);
        Optional<ZonedDateTime> nextExecution = executionTime
                .nextExecution(ZonedDateTime.of(2018, 2, 8, 0, 0, 0, 0, ZoneId.of("UTC")));
        assertTrue(nextExecution.isPresent());
        assertEquals(ZonedDateTime.of(2018, 2, 10, 0, 0, 0, 0, ZoneId.of("UTC")), nextExecution.get());
    }
}