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 @@ -56,6 +56,12 @@ public static DescriptionStrategy daysOfWeekInstance(final ResourceBundle bundle
case HASH:
return String.format("%s %s %s ", nominal.apply(on.getTime().getValue()), on.getNth(), bundle.getString("of_every_month"));
case L:
// Bare L means every Saturday ("7"/"SAT"), not the last occurrence of a weekday (#716).
// nL (e.g. 6L) still means the last matching weekday of every month.
if (on.getTime().getValue() == -1) {
return String.format("%s %s ", bundle.getString("every"),
DayOfWeek.SATURDAY.getDisplayName(TextStyle.FULL, bundle.getLocale()));
}
return String.format("%s %s %s ", bundle.getString("last"), nominal.apply(on.getTime().getValue()), bundle.getString("of_every_month"));
default:
return "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@ private int generateValue(final On on, final int year, final int month, final in
case HASH:
return generateHashValues(on, year, month);
case L:
return on.getTime().getValue() == -1 ? /* L by itself simply means “7” or “SAT” */
generateNoneValues(ON_SATURDAY, year, month, reference) :
generateLValues(on, year, month);
// Bare L means “7” or “SAT” in Quartz/Spring numbering (always Saturday),
// independent of how the current definition numbers days of week (#716).
return on.getTime().getValue() == -1
? generateNoneValues(ON_SATURDAY, ConstantsMapper.QUARTZ_WEEK_DAY, year, month, reference)
: generateLValues(on, year, month);
case NONE:
return generateNoneValues(on, year, month, reference);
return generateNoneValues(on, mondayDoWValue, year, month, reference);
default:
throw new NoSuchValueException();
}
Expand Down Expand Up @@ -126,18 +128,19 @@ private int generateLValues(final On on, final int year, final int month) throws
* pass it a -1 for the reference value when starting to generate a sequence of day values. That allows
* it to handle the special case of which day of the month is the initial matching value.
*
* @param on The expression object giving us the particular day of week we need.
* @param year The year for the calculation.
* @param month The month for the calculation.
* @param reference This value must either be -1 indicating you are starting the sequence generation or an actual
* day of month that meets the day of week criteria. So a value previously returned by this method.
* @return
* @param on The expression object giving us the particular day of week we need.
* @param sourceWeekDay Weekday numbering used by {@code on.getTime()} (definition scheme, or Quartz for bare L).
* @param year The year for the calculation.
* @param month The month for the calculation.
* @param reference This value must either be -1 indicating you are starting the sequence generation or an actual
* day of month that meets the day of week criteria. So a value previously returned by this method.
* @return day of month matching the requested day of week
*/
private int generateNoneValues(final On on, final int year, final int month, final int reference) {
private int generateNoneValues(final On on, final WeekDay sourceWeekDay, final int year, final int month, final int reference) {
// the day of week the first of the month is on
final int dowForFirstDoM = LocalDate.of(year, month, 1).getDayOfWeek().getValue();// 1-7
// the day of week we need, normalize to jdk8time
final int requiredDoW = ConstantsMapper.weekDayMapping(mondayDoWValue, ConstantsMapper.JAVA8, on.getTime().getValue());
// the day of week we need, normalize to jdk8time using the source numbering of {@code on}
final int requiredDoW = ConstantsMapper.weekDayMapping(sourceWeekDay, ConstantsMapper.JAVA8, on.getTime().getValue());
// the first day of the month
int baseDay = 1;// day 1 from given month
// the difference between the days of week
Expand Down
93 changes: 93 additions & 0 deletions src/test/java/com/cronutils/Issue716Test.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.cronutils;

import com.cronutils.descriptor.CronDescriptor;
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.DayOfWeek;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Issue #716: bare {@code L} in day-of-week means every Saturday ("7"/"SAT"),
* not the last occurrence of a weekday, and must not depend on the definition's
* day-of-week numbering.
*/
class Issue716Test {

private static final ZoneId UTC = ZoneId.of("UTC");
private static final ZonedDateTime START = ZonedDateTime.of(2025, 6, 1, 0, 0, 0, 0, UTC);

@Test
void bareLQuartzDescriptionIsEverySaturday() {
CronDescriptor descriptor = CronDescriptor.instance(Locale.UK);
CronParser quartz = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
String description = descriptor.describe(quartz.parse("0 0 0 ? * L *"));
assertEquals("at 00:00 every Saturday", description);
}

@Test
void bareLSpring53DescriptionIsEverySaturday() {
CronDescriptor descriptor = CronDescriptor.instance(Locale.UK);
CronParser spring53 = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING53));
String description = descriptor.describe(spring53.parse("0 0 0 ? * L"));
assertEquals("at 00:00 every Saturday", description);
}

@Test
void bareLQuartzSchedulesEverySaturday() {
CronParser quartz = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
List<ZonedDateTime> next = nextExecutions(ExecutionTime.forCron(quartz.parse("0 0 0 ? * L *")), START, 5);
assertEquals(saturdayDates(), next);
next.forEach(zdt -> assertEquals(DayOfWeek.SATURDAY, zdt.getDayOfWeek()));
}

@Test
void bareLSpring53SchedulesEverySaturday() {
CronParser spring53 = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING53));
List<ZonedDateTime> next = nextExecutions(ExecutionTime.forCron(spring53.parse("0 0 0 ? * L")), START, 5);
assertEquals(saturdayDates(), next);
next.forEach(zdt -> assertEquals(DayOfWeek.SATURDAY, zdt.getDayOfWeek()));
}

@Test
void nLStillMeansLastWeekdayOfMonth() {
CronDescriptor descriptor = CronDescriptor.instance(Locale.UK);
CronParser quartz = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
// Quartz: 6 = Friday; 6L = last Friday of every month
String description = descriptor.describe(quartz.parse("0 15 10 ? * 6L *"));
assertTrue(description.contains("last Friday of every month"), description);
}

private static List<ZonedDateTime> saturdayDates() {
List<ZonedDateTime> expected = new ArrayList<>();
expected.add(ZonedDateTime.of(2025, 6, 7, 0, 0, 0, 0, UTC));
expected.add(ZonedDateTime.of(2025, 6, 14, 0, 0, 0, 0, UTC));
expected.add(ZonedDateTime.of(2025, 6, 21, 0, 0, 0, 0, UTC));
expected.add(ZonedDateTime.of(2025, 6, 28, 0, 0, 0, 0, UTC));
expected.add(ZonedDateTime.of(2025, 7, 5, 0, 0, 0, 0, UTC));
return expected;
}

private static List<ZonedDateTime> nextExecutions(ExecutionTime executionTime, ZonedDateTime start, int count) {
List<ZonedDateTime> result = new ArrayList<>();
ZonedDateTime cursor = start;
for (int i = 0; i < count; i++) {
Optional<ZonedDateTime> next = executionTime.nextExecution(cursor);
assertTrue(next.isPresent());
cursor = next.get();
result.add(cursor);
}
return result;
}
}