diff --git a/src/main/java/com/cronutils/model/time/generator/OnDayOfMonthValueGenerator.java b/src/main/java/com/cronutils/model/time/generator/OnDayOfMonthValueGenerator.java index 17aea687..685eb16f 100644 --- a/src/main/java/com/cronutils/model/time/generator/OnDayOfMonthValueGenerator.java +++ b/src/main/java/com/cronutils/model/time/generator/OnDayOfMonthValueGenerator.java @@ -72,7 +72,12 @@ private int generateValue(final On on, final int year, final int month) throws N case L: final int daysBefore = on.getNth().getValue(); return LocalDate.of(year, month, 1).lengthOfMonth() - (daysBefore > 0 ? daysBefore : 0); - case W: // First work day of the week + case W: // nearest weekday to day-of-month + // Skip months shorter than the requested day (e.g. 31W in June) — matches Quartz (#715). + final int lengthOfMonth = LocalDate.of(year, month, 1).lengthOfMonth(); + if (dayOfMonth > lengthOfMonth) { + throw new NoSuchValueException(); + } final LocalDate doM = LocalDate.of(year, month, dayOfMonth); if (doM.getDayOfWeek() == DayOfWeek.SATURDAY) { //dayOfWeek is Saturday! if (dayOfMonth == 1) { //first day in month is Saturday! We execute on Monday @@ -80,10 +85,14 @@ private int generateValue(final On on, final int year, final int month) throws N } return dayOfMonth - 1; } - if (doM.getDayOfWeek() == DayOfWeek.SUNDAY && (dayOfMonth + 1) <= doM.lengthOfMonth()) { - return dayOfMonth + 1; + if (doM.getDayOfWeek() == DayOfWeek.SUNDAY) { + // Prefer Monday; if that would leave the month (last day is Sunday), use Friday. + if ((dayOfMonth + 1) <= lengthOfMonth) { + return dayOfMonth + 1; + } + return dayOfMonth - 2; } - return dayOfMonth; // first day of week is a weekday + return dayOfMonth; // day is already a weekday case LW: final LocalDate lastDayOfMonth = LocalDate.of(year, month, LocalDate.of(year, month, 1).lengthOfMonth()); final int dow = lastDayOfMonth.getDayOfWeek().getValue(); diff --git a/src/test/java/com/cronutils/Issue715Test.java b/src/test/java/com/cronutils/Issue715Test.java new file mode 100644 index 00000000..88cf7251 --- /dev/null +++ b/src/test/java/com/cronutils/Issue715Test.java @@ -0,0 +1,55 @@ +package com.cronutils; + +import com.cronutils.model.CronType; +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.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Issue #715: nW throws DateTimeException on short months instead of skipping them. + */ +public class Issue715Test { + + @Test + public void nextExecutionSkipsShortMonthsFor31WQuartz() { + final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)); + final ExecutionTime et = ExecutionTime.forCron(parser.parse("0 0 0 31W * ?")); + + ZonedDateTime current = ZonedDateTime.parse("2025-06-01T00:00:00Z"); + final List actual = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + final Optional next = et.nextExecution(current); + assertTrue(next.isPresent(), "expected next execution after " + current); + actual.add(next.get().toLocalDate().toString()); + current = next.get(); + } + + // Quartz 2.5.0 reference: skip June/Sep/Nov/Feb; weekday-adjust Aug and Jan + assertEquals(List.of( + "2025-07-31", + "2025-08-29", + "2025-10-31", + "2025-12-31", + "2026-01-30" + ), actual); + } + + @Test + public void nextExecutionSkipsShortMonthsFor31WSpring53() { + final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING53)); + final ExecutionTime et = ExecutionTime.forCron(parser.parse("0 0 0 31W * ?")); + + final Optional next = et.nextExecution(ZonedDateTime.parse("2025-06-01T00:00:00Z")); + assertTrue(next.isPresent()); + assertEquals("2025-07-31", next.get().toLocalDate().toString()); + } +} diff --git a/src/test/java/com/cronutils/model/time/generator/OnDayOfMonthValueGeneratorWTest.java b/src/test/java/com/cronutils/model/time/generator/OnDayOfMonthValueGeneratorWTest.java index 3a7ccada..32505718 100755 --- a/src/test/java/com/cronutils/model/time/generator/OnDayOfMonthValueGeneratorWTest.java +++ b/src/test/java/com/cronutils/model/time/generator/OnDayOfMonthValueGeneratorWTest.java @@ -111,6 +111,55 @@ public void testIsMatchFirstDaySaturdayValue() { testIsMatch(FIRST_DAY_SATURDAY_VALUE_MONTH, FIRST_DAY_SATURDAY_VALUE_DAY, FIRST_DAY_SATURDAY_VALUE_WEEKDAY); } + /** + * Issue #715: nW must not throw DateTimeException when the month has fewer than n days. + * Generator should report no value so execution moves to a longer month. + */ + @Test + public void testGenerateNextValueWhenDayExceedsMonthLength() { + // June 2015 has 30 days; 31W has no valid day in that month + fieldValueGenerator = createFieldValueGeneratorInstance(6, 31); + assertThrows(NoSuchValueException.class, () -> fieldValueGenerator.generateNextValue(1)); + } + + /** + * When n is the last day of a long month and falls on Sunday, W uses Friday (Quartz). + * 2025-08-31 is Sunday → 31W resolves to 2025-08-29. + */ + @Test + public void testGenerateNextValueLastDaySundayUsesFriday() throws NoSuchValueException { + fieldValueGenerator = new OnDayOfMonthValueGenerator( + new CronField( + CronFieldName.DAY_OF_MONTH, + new On( + new IntegerFieldValue(31), + new SpecialCharFieldValue(SpecialChar.W), new IntegerFieldValue(-1) + ), + constraints + ), 2025, 8); + assertEquals(29, fieldValueGenerator.generateNextValue(1)); + } + + @Test + public void testGeneratePreviousValueWhenDayExceedsMonthLength() { + fieldValueGenerator = createFieldValueGeneratorInstance(6, 31); + assertThrows(NoSuchValueException.class, () -> fieldValueGenerator.generatePreviousValue(OUT_OF_SCOPE_VALUE)); + } + + @Test + public void testGenerateCandidatesWhenDayExceedsMonthLength() { + fieldValueGenerator = createFieldValueGeneratorInstance(6, 31); + final List candidates = fieldValueGenerator.generateCandidatesNotIncludingIntervalExtremes(1, 32); + assertTrue(candidates.isEmpty()); + } + + @Test + public void testIsMatchWhenDayExceedsMonthLength() { + fieldValueGenerator = createFieldValueGeneratorInstance(6, 31); + assertFalse(fieldValueGenerator.isMatch(30)); + assertFalse(fieldValueGenerator.isMatch(31)); + } + private void testGenerateNextValue(final int month, final int day, final int weekday) throws NoSuchValueException { fieldValueGenerator = createFieldValueGeneratorInstance(month, day); assertEquals(weekday, fieldValueGenerator.generateNextValue(1));