Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,27 @@ 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
return 3;
}
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();
Expand Down
55 changes: 55 additions & 0 deletions src/test/java/com/cronutils/Issue715Test.java
Original file line number Diff line number Diff line change
@@ -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<String> actual = new ArrayList<>();
for (int i = 0; i < 5; i++) {
final Optional<ZonedDateTime> 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<ZonedDateTime> next = et.nextExecution(ZonedDateTime.parse("2025-06-01T00:00:00Z"));
assertTrue(next.isPresent());
assertEquals("2025-07-31", next.get().toLocalDate().toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Integer> 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));
Expand Down