diff --git a/CHANGELOG.md b/CHANGELOG.md index feba12b43..5c4d709cc 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ # 🚀Changelog ------------------------------------------------------------------------------------------------------------- -# 5.8.41(2025-09-04) +# 5.8.41(2025-09-06) ### 🐣新特性 * 【core 】 增加`WeakKeyValueConcurrentMap`及其关联类,同时废弃`WeakConcurrentMap`并替换(issue#4039@Github) @@ -20,6 +20,7 @@ * 【db 】 修复`Condition`的`Condition("discount_end_time", "!=", (String) null)`方法生成SQL时,生成SQL不符合预期要求的错误(pr#4042@Github) * 【core 】 修复`IoUtil`的`closeIfPosible`拼写错误,新建一个`closeIfPossible`方法,原方法标记deprecated(issue#4047@Github) * 【http 】 修复`HttpRequest.sendRedirectIfPossible`未对308做判断问题。(issue#4053@Github) +* 【cron 】 修复`CronPatternUtil.nextDateAfter`当日为L时计算错误问题。(issue#4056@Github) ------------------------------------------------------------------------------------------------------------- # 5.8.40(2025-08-26) diff --git a/hutool-cron/pom.xml b/hutool-cron/pom.xml index 1fde97010..bf5b55aea 100755 --- a/hutool-cron/pom.xml +++ b/hutool-cron/pom.xml @@ -36,6 +36,12 @@ hutool-setting ${project.parent.version} + + org.quartz-scheduler + quartz + 2.4.0 + test + diff --git a/hutool-cron/src/main/java/cn/hutool/cron/pattern/CronPattern.java b/hutool-cron/src/main/java/cn/hutool/cron/pattern/CronPattern.java index 1c80205d3..05eb6bfff 100755 --- a/hutool-cron/src/main/java/cn/hutool/cron/pattern/CronPattern.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/pattern/CronPattern.java @@ -6,11 +6,7 @@ import cn.hutool.cron.pattern.matcher.PatternMatcher; import cn.hutool.cron.pattern.parser.PatternParser; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.GregorianCalendar; -import java.util.List; -import java.util.TimeZone; +import java.util.*; /** * 定时任务表达式
diff --git a/hutool-cron/src/main/java/cn/hutool/cron/pattern/Part.java b/hutool-cron/src/main/java/cn/hutool/cron/pattern/Part.java index 7178aab32..0c75db25a 100644 --- a/hutool-cron/src/main/java/cn/hutool/cron/pattern/Part.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/pattern/Part.java @@ -23,7 +23,7 @@ public enum Part { SECOND(Calendar.SECOND, 0, 59), MINUTE(Calendar.MINUTE, 0, 59), HOUR(Calendar.HOUR_OF_DAY, 0, 23), - DAY_OF_MONTH(Calendar.DAY_OF_MONTH, 1, 31), + DAY_OF_MONTH(Calendar.DAY_OF_MONTH, 1, 32), MONTH(Calendar.MONTH, Month.JANUARY.getValueBaseOne(), Month.DECEMBER.getValueBaseOne()), DAY_OF_WEEK(Calendar.DAY_OF_WEEK, Week.SUNDAY.ordinal(), Week.SATURDAY.ordinal()), YEAR(Calendar.YEAR, 1970, 2099); diff --git a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/BoolArrayMatcher.java b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/BoolArrayMatcher.java index d60909ec5..46f20adeb 100644 --- a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/BoolArrayMatcher.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/BoolArrayMatcher.java @@ -18,7 +18,12 @@ public class BoolArrayMatcher implements PartMatcher { * 用户定义此字段的最小值 */ private final int minValue; - private final boolean[] bValues; + /** + * 用户定义此字段的最大值 + * @since 5.8.41 + */ + private final int maxValue; + protected final boolean[] bValues; /** * 构造 @@ -29,26 +34,37 @@ public class BoolArrayMatcher implements PartMatcher { Assert.isTrue(CollUtil.isNotEmpty(intValueList), "Values must be not empty!"); bValues = new boolean[Collections.max(intValueList) + 1]; int min = Integer.MAX_VALUE; + int max = 0; for (Integer value : intValueList) { min = Math.min(min, value); + max = Math.max(max, value); bValues[value] = true; } this.minValue = min; + this.maxValue = max; } @Override public boolean match(Integer value) { - if (null == value || value >= bValues.length) { - return false; + if(null != value && value >= minValue && value <= maxValue){ + return bValues[value]; } - return bValues[value]; + return false; } @Override public int nextAfter(int value) { - if(value > minValue){ - while(value < bValues.length){ - if(bValues[value]){ + final int maxValue = this.maxValue; + if(value == maxValue){ + return value; + } + final int minValue = this.minValue; + if(value > minValue && value < maxValue){ + final boolean[] bValues = this.bValues; + // 最大值永远小于数组长度,只需判断最大值边界 + while(value <= maxValue){ + if(value == maxValue || bValues[value]){ + // 达到最大值或达到第一个匹配值 return value; } value++; @@ -70,6 +86,16 @@ public class BoolArrayMatcher implements PartMatcher { return this.minValue; } + /** + * 获取表达式定义的最大值 + * + * @return 最大值 + * @since 5.8.41 + */ + public int getMaxValue() { + return this.maxValue; + } + @Override public String toString() { return StrUtil.format("Matcher:{}", new Object[]{this.bValues}); diff --git a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/DayOfMonthMatcher.java b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/DayOfMonthMatcher.java index c1deb0f1c..cfe240a84 100644 --- a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/DayOfMonthMatcher.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/DayOfMonthMatcher.java @@ -11,6 +11,12 @@ import java.util.List; * @author Looly */ public class DayOfMonthMatcher extends BoolArrayMatcher { + + /** + * 最后一天 + */ + private static final int LAST_DAY = 32; + /** * 构造 * @@ -23,47 +29,135 @@ public class DayOfMonthMatcher extends BoolArrayMatcher { /** * 给定的日期是否匹配当前匹配器 * - * @param value 被检查的值,此处为日 + * @param dayValue 被检查的值,此处为日 * @param month 实际的月份,从1开始 * @param isLeapYear 是否闰年 * @return 是否匹配 */ - public boolean match(int value, int month, boolean isLeapYear) { - return (super.match(value) // 在约定日范围内的某一天 - //匹配器中用户定义了最后一天(31表示最后一天) - || (value > 27 && match(31) && isLastDayOfMonth(value, month, isLeapYear))); + public boolean match(int dayValue, int month, boolean isLeapYear) { + return (super.match(dayValue) // 在约定日范围内的某一天 + //匹配器中用户定义了最后一天(32表示最后一天) + || matchLastDay(dayValue, month, isLeapYear)); } /** - * 是否为本月最后一天,规则如下: - *
-	 * 1、闰年2月匹配是否为29
-	 * 2、其它月份是否匹配最后一天的日期(可能为30或者31)
-	 * 
+ * 获取指定日之后的匹配值,也可以是其本身
+ * 如果表达式中存在最后一天(如使用"L"),则: + * * - * @param value 被检查的值 + * @param dayValue 指定的天值 * @param month 月份,从1开始 - * @param isLeapYear 是否闰年 - * @return 是否为本月最后一天 + * @param isLeapYear 是否为闰年 + * @return 匹配到的值或之后的值 + * @since 5.8.41 */ - private static boolean isLastDayOfMonth(int value, int month, boolean isLeapYear) { - return value == Month.getLastDay(month - 1, isLeapYear); + public int nextAfter(int dayValue, final int month, final boolean isLeapYear) { + final int maxValue = getMaxValue(month, isLeapYear); + final int minValue = getMinValue(month, isLeapYear); + if (dayValue > minValue) { + final boolean[] bValues = this.bValues; + // 最大值永远小于数组长度,只需判断最大值边界 + while (dayValue <= maxValue) { + // 匹配到有效值 + if (bValues[dayValue] || + // 如果最大值不在有效值中,这个最大值表示最后一天,则在包含了最后一天的情况下返回最后一天 + (dayValue == maxValue && match(LAST_DAY))) { + return dayValue; + } + dayValue++; + } + } + + // 两种情况返回最小值 + // 一是给定值小于最小值,那下一个匹配值就是最小值 + // 二是给定值大于最大值,那下一个匹配值也是下一轮的最小值 + return minValue; } + /** + * 是否包含最后一天 + * + * @return 包含最后一天 + */ public boolean isLast() { - return match(31); + return match(32); } /** * 检查value是这个月的最后一天 + * * @param value 被检查的值 - * @return + * @param month 月份,从1开始 + * @param isLeapYear 是否闰年 + * @return 是否是这个月的最后 */ - public boolean isLastDay(Integer value,Integer month, boolean isLeapYear) { - if(isLastDayOfMonth(value, month, isLeapYear)) { - return match(31); - } - return false; + public boolean isLastDay(Integer value, Integer month, boolean isLeapYear) { + return matchLastDay(value, month, isLeapYear); } + /** + * 获取表达式定义中指定月的最小日的值 + * + * @param month 月,base1 + * @param isLeapYear 是否闰年 + * @return 匹配的最小值 + * @since 5.8.41 + */ + public int getMinValue(final int month, final boolean isLeapYear) { + final int minValue = super.getMinValue(); + if (LAST_DAY == minValue) { + // 用户指定了 L 等表示最后一天 + return getLastDay(month, isLeapYear); + } + return minValue; + } + + /** + * 获取表达式定义中指定月的最大日的值
+ * 首先获取表达式定义的最大值,如果这个值大于本月最后一天,则返回最后一天,否则返回用户定义的最大值
+ * 注意最后一天可能不是表达式中定义的有效值 + * + * @param month 月,base1 + * @param isLeapYear 是否闰年 + * @return 匹配的最大值 + * @since 5.8.41 + */ + public int getMaxValue(final int month, final boolean isLeapYear) { + return Math.min(super.getMaxValue(), getLastDay(month, isLeapYear)); + } + + /** + * 是否匹配本月最后一天,规则如下: + *
+	 * 1、闰年2月匹配是否为29
+	 * 2、其它月份是否匹配最后一天的日期(可能为30或者31)
+	 * 3、表达式包含最后一天(使用31表示)
+	 * 
+ * + * @param dayValue 被检查的值 + * @param month 月,base1 + * @param isLeapYear 是否闰年 + * @return 是否为本月最后一天 + */ + private boolean matchLastDay(final int dayValue, final int month, final boolean isLeapYear) { + return dayValue > 27 + // 表达式中定义包含了最后一天 + && match(LAST_DAY) + // 用户指定的日正好是最后一天 + && dayValue == getLastDay(month, isLeapYear); + } + + /** + * 获取最后一天 + * + * @param month 月,base1 + * @param isLeapYear 是否闰年 + * @return 最后一天 + */ + private static int getLastDay(final int month, final boolean isLeapYear) { + return Month.getLastDay(month - 1, isLeapYear); + } } diff --git a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/PatternMatcher.java b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/PatternMatcher.java index 146beec0b..e73c7d3bf 100755 --- a/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/PatternMatcher.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/pattern/matcher/PatternMatcher.java @@ -177,8 +177,6 @@ public class PatternMatcher { * @return {@link Calendar},毫秒数为0 */ private int[] nextMatchValuesAfter(int[] values) { - final int[] newValues = values.clone(); - int i = Part.YEAR.ordinal(); // 新值,-1表示标识为回退 int nextValue = 0; @@ -189,30 +187,20 @@ public class PatternMatcher { continue; } - // pr#1189 - if (i == Part.DAY_OF_MONTH.ordinal() - && matchers[i] instanceof DayOfMonthMatcher - && ((DayOfMonthMatcher) matchers[i]).isLastDay(values[i],values[i+1],DateUtil.isLeapYear(values[Part.YEAR.ordinal()]))) { - int newMonth = newValues[Part.MONTH.ordinal()]; - int newYear = newValues[Part.YEAR.ordinal()]; - nextValue = getLastDay(newMonth, newYear); - } else { - nextValue = matchers[i].nextAfter(values[i]); - } + nextValue = getNextMatch(values, i, 0); if (nextValue > values[i]) { // 此部分正常获取新值,结束循环,后续的部分置最小值 - newValues[i] = nextValue; + values[i] = nextValue; i--; break; } else if (nextValue < values[i]) { - // 回退前保存最新值 - newValues[i] = nextValue; // 此部分下一个值获取到的值产生回退,回到上一个部分,继续获取新值 i++; nextValue = -1;// 标记回退查找 break; } + // 值不变,检查下一个部分 i--; } @@ -224,17 +212,12 @@ public class PatternMatcher { // 周不参与计算 i++; continue; - } else if (i == Part.DAY_OF_MONTH.ordinal() - && matchers[i] instanceof DayOfMonthMatcher - && ((DayOfMonthMatcher) matchers[i]).isLastDay(values[i],values[i+1],DateUtil.isLeapYear(values[Part.YEAR.ordinal()]))) { - int newMonth = newValues[Part.MONTH.ordinal()]; - int newYear = newValues[Part.YEAR.ordinal()]; - nextValue = getLastDay(newMonth, newYear); - } else { - nextValue = matchers[i].nextAfter(values[i] + 1); } + + nextValue = getNextMatch(values, i, 1); + if (nextValue > values[i]) { - newValues[i] = nextValue; + values[i] = nextValue; i--; break; } @@ -243,8 +226,32 @@ public class PatternMatcher { } // 修改值以下的字段全部归最小值 - setToMin(newValues, i); - return newValues; + setToMin(values, i); + return values; + } + + /** + * 获取指定部分的下一个匹配值,三种结果: + * + * + * @param newValues 时间字段值,{second, minute, hour, dayOfMonth, monthBase1, dayOfWeekBase0, year} + * @param partOrdinal 序号 + * @param plusValue 获取的偏移值 + * @return 下一个值 + */ + private int getNextMatch(final int[] newValues, final int partOrdinal, final int plusValue) { + if (partOrdinal == Part.DAY_OF_MONTH.ordinal() && matchers[partOrdinal] instanceof DayOfMonthMatcher) { + // 对于日需要考虑月份和闰年,单独处理 + final boolean isLeapYear = DateUtil.isLeapYear(newValues[Part.YEAR.ordinal()]); + final int month = newValues[Part.MONTH.ordinal()]; + return ((DayOfMonthMatcher) matchers[partOrdinal]).nextAfter(newValues[partOrdinal] + plusValue, month, isLeapYear); + } + + return matchers[partOrdinal].nextAfter(newValues[partOrdinal] + plusValue); } /** @@ -253,19 +260,21 @@ public class PatternMatcher { * @param values 值数组 * @param toPart 截止的部分 */ - private void setToMin(int[] values, int toPart) { + private void setToMin(final int[] values, final int toPart) { Part part; - for (int i = 0; i <= toPart; i++) { + for (int i = toPart; i >= 0; i--) { part = Part.of(i); - if (part == Part.DAY_OF_MONTH - && get(part) instanceof DayOfMonthMatcher - && ((DayOfMonthMatcher) get(part)).isLast()) { - int newMonth = values[Part.MONTH.ordinal()]; - int newYear = values[Part.YEAR.ordinal()]; - values[i] = getLastDay(newMonth, newYear); - } else { - values[i] = getMin(part); + if (part == Part.DAY_OF_MONTH) { + final boolean isLeapYear = DateUtil.isLeapYear(values[Part.YEAR.ordinal()]); + final int month = values[Part.MONTH.ordinal()]; + final PartMatcher partMatcher = get(part); + if (partMatcher instanceof DayOfMonthMatcher) { + values[i] = ((DayOfMonthMatcher) partMatcher).getMinValue(month, isLeapYear); + continue; + } } + + values[i] = getMin(part); } } diff --git a/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternNextMatchTest.java b/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternNextMatchTest.java index 04b0b985e..643e9d187 100644 --- a/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternNextMatchTest.java +++ b/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternNextMatchTest.java @@ -3,11 +3,14 @@ package cn.hutool.cron.pattern; import cn.hutool.core.date.DateField; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; -import static org.junit.jupiter.api.Assertions.*; +import cn.hutool.core.lang.Console; import org.junit.jupiter.api.Test; import java.util.Calendar; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class CronPatternNextMatchTest { @Test @@ -182,6 +185,16 @@ public class CronPatternNextMatchTest { } } + @Test + public void testLastDayOfMonthForEveryYear3() { + DateTime date = DateUtil.parse("2022-03-08 07:44:16"); + DateTime result = DateUtil.parse("2023-02-28 03:02:01"); + // 匹配每一年2月的最后一天 + CronPattern pattern = new CronPattern("1 2 3 L 2 ?"); + Calendar calendar = pattern.nextMatchAfter(date.toCalendar()); + Console.log(DateUtil.date(calendar)); + } + @Test public void testEveryHour() { DateTime date = DateUtil.parse("2022-02-28 07:44:16"); diff --git a/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternUtilTest.java b/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternUtilTest.java index 9921e7500..e08bb9564 100644 --- a/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternUtilTest.java +++ b/hutool-cron/src/test/java/cn/hutool/cron/pattern/CronPatternUtilTest.java @@ -1,12 +1,15 @@ package cn.hutool.cron.pattern; +import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.Date; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; + public class CronPatternUtilTest { @Test @@ -44,4 +47,37 @@ public class CronPatternUtilTest { assertEquals("2018-10-31 03:00:00", matchedDates.get(3).toString()); assertEquals("2018-10-31 04:00:00", matchedDates.get(4).toString()); } + + @Test + public void issue4056Test() { + // "*/5"和"1/5"意义相同,从1号开始,每5天一个匹配,则匹配的天为: + // 2025-02-01, 2025-02-06, 2025-02-11, 2025-02-16, 2025-02-21, 2025-02-26 + // 2025-03-01, 2025-03-06, 2025-03-11, 2025-03-16, 2025-03-21, 2025-03-26, 2025-03-31 + final String cron = "0 0 0 */5 * ? *"; + final CronPattern cronPattern = new CronPattern(cron); + + // 2025-02-28不应该在匹配之列 + boolean match = cronPattern.match(DateUtil.parse("2025-02-28 00:00:00").toCalendar(), true); + Assertions.assertFalse( match); + + match = cronPattern.match(DateUtil.parse("2025-03-01 00:00:00").toCalendar(), true); + Assertions.assertTrue( match); + + match = cronPattern.match(DateUtil.parse("2025-03-31 00:00:00").toCalendar(), true); + Assertions.assertTrue( match); + } + + @Test + public void issue4056Test2() { + final String cron = "0 0 0 */5 * ? *"; + final CronPattern cronPattern = new CronPattern(cron); + + final DateTime judgeTime = DateUtil.parse("2025-02-27 23:59:59"); + final Date nextDate = CronPatternUtil.nextDateAfter(cronPattern, judgeTime); + // "*/5"和"1/5"意义相同,从1号开始,每5天一个匹配,则匹配的天为: + // 2025-02-01, 2025-02-06, 2025-02-11, 2025-02-16, 2025-02-21, 2025-02-26 + // 2025-03-01, 2025-03-06, 2025-03-11, 2025-03-16, 2025-03-21, 2025-03-26, 2025-03-31 + // 下一个匹配日期应为2025-03-01 + Assertions.assertEquals("2025-03-01 00:00:00", nextDate.toString()); + } } diff --git a/hutool-cron/src/test/java/cn/hutool/cron/pattern/Issue4056Test.java b/hutool-cron/src/test/java/cn/hutool/cron/pattern/Issue4056Test.java new file mode 100644 index 000000000..e0b95ae30 --- /dev/null +++ b/hutool-cron/src/test/java/cn/hutool/cron/pattern/Issue4056Test.java @@ -0,0 +1,140 @@ +package cn.hutool.cron.pattern; + +import cn.hutool.core.date.DateTime; +import cn.hutool.core.date.DateUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.quartz.CronExpression; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; + +public class Issue4056Test { + + /** + * 见:https://github.com/quartz-scheduler/quartz/issues/1298 + * Quartz-2.5.0这块有bug,只能使用2.4.0测认 + * + * @throws ParseException 解析错误 + */ + @Test + void testCronAll() throws ParseException { + final ArrayList cronsList = new ArrayList<>(); + final ArrayList judgeTimes = new ArrayList<>(); + + // 1. Cron 表达式(40个) + cronsList.add("0 0 0 * * ? *"); // 每天00:00 + cronsList.add("0 0 12 * * ? *"); // 每天中午12:00 + cronsList.add("0 0 18 * * ? *"); // 每天傍晚18:00 + cronsList.add("0 0 6,12,18 * * ? *"); // 每天6点、12点、18点 + cronsList.add("0 0 */6 * * ? *"); // 每6小时 + cronsList.add("0 30 */8 * * ? *"); // 每8小时的30分 + cronsList.add("0 */15 * * * ? *"); // 每15分钟 + cronsList.add("0 */5 9-17 * * ? *"); // 工作时间内每5分钟 + cronsList.add("0 0 0-23/2 * * ? *"); // 每2小时 + cronsList.add("0 0 0 */8 * ? *"); // 每8天的00:00 + cronsList.add("0 0 12 15 * ? *"); // 每月15日12:00 + cronsList.add("0 0 0 L * ? *"); // 每月最后一天00:00 + cronsList.add("0 0 0 29 2 ? *"); // 2月29日00:00(闰年) + cronsList.add("0 0 0 1 1 ? *"); // 每年1月1日00:00 + cronsList.add("0 0/30 * * * ? *"); // 每小时0分和30分 + cronsList.add("0 0 */4 * * ? *"); // 每4小时 + cronsList.add("0 0 0 1/3 * ? *"); // 每3天00:00 + cronsList.add("0 0 2 28-31 * ? *"); // 每月最后几天2:00 + cronsList.add("0 0 0 1,15 * ? *"); // 每月1日和15日00:00 + cronsList.add("0 0 0 1/5 * ? *"); // 每5天00:00 + cronsList.add("0 0 0 1/10 * ? *"); // 每10天00:00 + cronsList.add("0 0 0 1 */3 ? *"); // 每3个月的第1天00:00 + cronsList.add("0 0 0 25 12 ? *"); // 圣诞节00:00 + cronsList.add("0 0 12 31 12 ? *"); // 新年前夜12:00 + cronsList.add("0 0 0 14 2 ? *"); // 情人节00:00 + cronsList.add("0 0 10 1 5 ? *"); // 劳动节10:00 + cronsList.add("0 0 9 8 3 ? *"); // 妇女节09:00 + cronsList.add("0 0 0 1 4 ? *"); // 愚人节00:00 + cronsList.add("0 0 12 4 7 ? *"); // 美国独立日12:00 + cronsList.add("0 0 0 31 10 ? *"); // 万圣节00:00 + cronsList.add("0 7,19,31,43,55 * * * ? *"); // 特定分钟 + cronsList.add("0 */7 * * * ? *"); // 每7分钟 + cronsList.add("0 15-45/5 * * * ? *"); // 每小时的15-45分之间每5分钟 + cronsList.add("0 0-30/2 * * * ? *"); // 每小时前30分钟每2分钟 + cronsList.add("0 45 23 * * ? *"); // 每天23:45 + cronsList.add("0 59 23 * * ? *"); // 每天23:59 + cronsList.add("0 0 */3 * * ? *"); // 每3小时 + cronsList.add("0 0 9-18/2 * * ? *"); // 9点到18点每2小时 + cronsList.add("0 0 22-2 * * ? *"); // 22点到次日2点每小时 + cronsList.add("0 30 16 L * ? *"); // 每月最后一天16:30 + + + // 2. 测试时间 (50个) + judgeTimes.add(DateUtil.parse("2025-02-01 18:20:10")); + judgeTimes.add(DateUtil.parse("2024-02-29 10:00:00")); + judgeTimes.add(DateUtil.parse("2025-12-31 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-01-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-06-15 12:00:00")); + judgeTimes.add(DateUtil.parse("2025-03-30 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-02-28 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-03-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-01-31 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-04-30 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-06-30 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-09-30 23:59:59")); + judgeTimes.add(DateUtil.parse("2026-01-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2024-02-28 00:00:00")); + judgeTimes.add(DateUtil.parse("2024-02-29 00:00:00")); + judgeTimes.add(DateUtil.parse("2024-02-29 23:59:59")); + judgeTimes.add(DateUtil.parse("2023-02-28 23:59:59")); + judgeTimes.add(DateUtil.parse("2028-02-29 12:00:00")); + judgeTimes.add(DateUtil.parse("2025-06-15 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-06-15 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-03-31 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-04-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-07-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-10-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-01-06 09:00:00")); + judgeTimes.add(DateUtil.parse("2025-01-10 17:00:00")); + judgeTimes.add(DateUtil.parse("2025-01-11 12:00:00")); + judgeTimes.add(DateUtil.parse("2025-01-12 12:00:00")); + judgeTimes.add(DateUtil.parse("2025-03-09 01:59:59")); + judgeTimes.add(DateUtil.parse("2025-03-09 03:00:00")); + judgeTimes.add(DateUtil.parse("2025-11-02 01:59:59")); + judgeTimes.add(DateUtil.parse("2025-11-02 01:00:00")); + judgeTimes.add(DateUtil.parse("2024-12-31 23:59:59")); + judgeTimes.add(DateUtil.parse("2024-01-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2026-12-31 23:59:59")); + judgeTimes.add(DateUtil.parse("2026-01-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-05-15 08:45:30")); + judgeTimes.add(DateUtil.parse("2025-08-22 14:20:15")); + judgeTimes.add(DateUtil.parse("2025-11-03 19:10:45")); + judgeTimes.add(DateUtil.parse("2025-02-14 09:30:00")); + judgeTimes.add(DateUtil.parse("2025-07-07 07:07:07")); + judgeTimes.add(DateUtil.parse("2025-09-09 09:09:09")); + judgeTimes.add(DateUtil.parse("2025-10-10 10:10:10")); + judgeTimes.add(DateUtil.parse("2025-12-12 12:12:12")); + judgeTimes.add(DateUtil.parse("2025-03-03 03:03:03")); + judgeTimes.add(DateUtil.parse("2025-06-06 06:06:06")); + judgeTimes.add(DateUtil.parse("2025-04-16 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-04-30 23:59:59")); + judgeTimes.add(DateUtil.parse("2025-05-01 00:00:00")); + judgeTimes.add(DateUtil.parse("2025-05-01 00:00:01")); + + // 3. 计算并比对结果 + for (final String cron : cronsList) { + final CronPattern hutoolCorn = new CronPattern(cron); + final CronExpression quartzCorn = new CronExpression(cron); + for (final DateTime judgeTime : judgeTimes) { + final Date quartzDate = quartzCorn.getNextValidTimeAfter(judgeTime); + final Date hutoolDate = CronPatternUtil.nextDateAfter(hutoolCorn, judgeTime); + Assertions.assertEquals(quartzDate, hutoolDate); + } + } + } + + @Test + void issue4056Test() { + final String cron = "0 0 0 1/3 * ? *"; + final CronPattern hutoolCorn = new CronPattern(cron); + final Date hutoolDate = CronPatternUtil.nextDateAfter(hutoolCorn, DateUtil.parse("2025-02-28 00:00:00")); + System.out.println(DateUtil.formatDateTime(hutoolDate)); + } +}