diff --git a/src/main/java/com/cronutils/descriptor/DescriptionStrategyFactory.java b/src/main/java/com/cronutils/descriptor/DescriptionStrategyFactory.java index 61920609..4856d6e5 100755 --- a/src/main/java/com/cronutils/descriptor/DescriptionStrategyFactory.java +++ b/src/main/java/com/cronutils/descriptor/DescriptionStrategyFactory.java @@ -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 ""; diff --git a/src/main/java/com/cronutils/model/time/generator/OnDayOfWeekValueGenerator.java b/src/main/java/com/cronutils/model/time/generator/OnDayOfWeekValueGenerator.java index cacd1f6b..d65f28d2 100755 --- a/src/main/java/com/cronutils/model/time/generator/OnDayOfWeekValueGenerator.java +++ b/src/main/java/com/cronutils/model/time/generator/OnDayOfWeekValueGenerator.java @@ -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(); } @@ -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 diff --git a/src/test/java/com/cronutils/Issue716Test.java b/src/test/java/com/cronutils/Issue716Test.java new file mode 100644 index 00000000..f571a027 --- /dev/null +++ b/src/test/java/com/cronutils/Issue716Test.java @@ -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 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 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 saturdayDates() { + List 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 nextExecutions(ExecutionTime executionTime, ZonedDateTime start, int count) { + List result = new ArrayList<>(); + ZonedDateTime cursor = start; + for (int i = 0; i < count; i++) { + Optional next = executionTime.nextExecution(cursor); + assertTrue(next.isPresent()); + cursor = next.get(); + result.add(cursor); + } + return result; + } +}