You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by ke...@apache.org on 2014/03/28 18:33:36 UTC
git commit: AURORA-132: crontab(5) entry parser.
Repository: incubator-aurora
Updated Branches:
refs/heads/master 6db811d72 -> d209a21cf
AURORA-132: crontab(5) entry parser.
Testing Done:
./gradlew build
Bugs closed: AURORA-132
Reviewed at https://reviews.apache.org/r/19709/
Project: http://git-wip-us.apache.org/repos/asf/incubator-aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-aurora/commit/d209a21c
Tree: http://git-wip-us.apache.org/repos/asf/incubator-aurora/tree/d209a21c
Diff: http://git-wip-us.apache.org/repos/asf/incubator-aurora/diff/d209a21c
Branch: refs/heads/master
Commit: d209a21cfe819c8990be6d5e3de35dc5fc577a29
Parents: 6db811d
Author: Kevin Sweeney <ke...@apache.org>
Authored: Fri Mar 28 10:33:20 2014 -0700
Committer: Kevin Sweeney <ke...@apache.org>
Committed: Fri Mar 28 10:33:20 2014 -0700
----------------------------------------------------------------------
build.gradle | 1 +
.../aurora/scheduler/cron/CrontabEntry.java | 480 +++++++++++++++++++
.../aurora/scheduler/cron/CrontabEntryTest.java | 163 +++++++
3 files changed, 644 insertions(+)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/build.gradle
----------------------------------------------------------------------
diff --git a/build.gradle b/build.gradle
index f6eb2f3..f10f22c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -212,6 +212,7 @@ dependencies {
checkstyle 'com.puppycrawl.tools:checkstyle:5.6'
configurations.compile {
+ exclude module: 'junit-dep'
resolutionStrategy {
failOnVersionConflict()
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java
----------------------------------------------------------------------
diff --git a/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java
new file mode 100644
index 0000000..6411244
--- /dev/null
+++ b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java
@@ -0,0 +1,480 @@
+/**
+ * Copyright 2014 Apache Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.aurora.scheduler.cron;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Objects;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ContiguousSet;
+import com.google.common.collect.DiscreteDomain;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.ImmutableRangeSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Range;
+import com.google.common.collect.RangeSet;
+import com.google.common.collect.TreeRangeSet;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A pattern that describes one or more cron 5-tuples (minute, hour, dayOfMonth, month, dayOfWeek).
+ *
+ * CrontabEntries are immutable and thread-safe. Unless otherwise specified any public methods will
+ * throw {@link java.lang.NullPointerException} if given a {@code null} parameter.
+ *
+ * The quickest way to create a {@code CrontabEntry} is to use {@link #parse(String)} or
+ * {@link #tryParse(String)}.
+ */
+public final class CrontabEntry {
+ private static final Range<Integer> MINUTE =
+ Range.closed(0, 59).canonical(DiscreteDomain.integers());
+ private static final Range<Integer> HOUR =
+ Range.closed(0, 23).canonical(DiscreteDomain.integers());
+ private static final Range<Integer> DAY_OF_MONTH =
+ Range.closed(1, 31).canonical(DiscreteDomain.integers());
+ private static final Range<Integer> MONTH =
+ Range.closed(1, 12).canonical(DiscreteDomain.integers());
+ // NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday.
+ private static final Range<Integer> DAY_OF_WEEK =
+ Range.closed(0, 6).canonical(DiscreteDomain.integers());
+
+ private final RangeSet<Integer> minute;
+ private final RangeSet<Integer> hour;
+ private final RangeSet<Integer> dayOfMonth;
+ private final RangeSet<Integer> month;
+ private final RangeSet<Integer> dayOfWeek;
+
+ private CrontabEntry(
+ RangeSet<Integer> minute,
+ RangeSet<Integer> hour,
+ RangeSet<Integer> dayOfMonth,
+ RangeSet<Integer> month,
+ RangeSet<Integer> dayOfWeek) {
+
+ checkEnclosed("minute", MINUTE, minute);
+ checkEnclosed("hour", HOUR, hour);
+ checkEnclosed("dayOfMonth", DAY_OF_MONTH, dayOfMonth);
+ checkEnclosed("month", MONTH, month);
+ checkEnclosed("dayOfWeek", DAY_OF_WEEK, dayOfWeek);
+
+ this.minute = ImmutableRangeSet.copyOf(minute);
+ this.hour = ImmutableRangeSet.copyOf(hour);
+ this.dayOfMonth = ImmutableRangeSet.copyOf(dayOfMonth);
+ this.month = ImmutableRangeSet.copyOf(month);
+ this.dayOfWeek = ImmutableRangeSet.copyOf(dayOfWeek);
+
+ checkArgument(hasWildcardDayOfMonth() || hasWildcardDayOfWeek(),
+ "Specifying both dayOfWeek and dayOfMonth is not supported.");
+ }
+
+ private static void checkEnclosed(
+ String fieldName,
+ Range<Integer> fieldEnclosure,
+ RangeSet<Integer> field) {
+
+ checkArgument(fieldEnclosure.encloses(field.span()),
+ String.format(
+ "Bad specification for field %s: span(%s) = %s is not enclosed by boundary %s.",
+ fieldName,
+ field,
+ field.span(),
+ fieldEnclosure));
+ }
+
+ /**
+ * Create a new {@link CrontabEntry} from a crontab(5)-style schedule.
+ *
+ * The acceptable format of {@code schedule} is mostly compatible with FreeBSD's crontab(5)
+ * format, excluding "extensions" like "@every_minute."
+ *
+ * A crontab(5) entry consists of 5 fields (minute, hour, dayOfMonth, month, dayOfWeek) and for
+ * each field supports singletons ("50"), wildcards ("*"), ranges ("1-50", "MON-SAT"), and
+ * "skips" ("1-50/2", "*/2").
+ *
+ * See http://www.freebsd.org/cgi/man.cgi?query=crontab&sektion=5 for full syntax examples.
+ *
+ * NOTE: While entries such as "Thursdays that fall on the 15th day of the month" are expressible
+ * in the original BSD syntax, this parser rejects them with {@link IllegalArgumentException}.
+ *
+ * @param schedule The crontab entry to parse.
+ * @return A new entry if parsing was successful.
+ * @throws IllegalArgumentException if parsing failed for any reason.
+ */
+ public static CrontabEntry parse(String schedule) throws IllegalArgumentException {
+ return new Parser(schedule).get();
+ }
+
+ /**
+ * Create a new {@link CrontabEntry} from a crontab(5)-style schedule.
+ *
+ * @see #parse(String)
+ * @param schedule The crontab entry to parse.
+ * @return A new entry if parsing was successful, absent otherwise.
+ */
+ public static Optional<CrontabEntry> tryParse(String schedule) {
+ try {
+ return Optional.of(parse(schedule));
+ } catch (IllegalArgumentException e) {
+ return Optional.absent();
+ }
+ }
+
+ private static CrontabEntry from(
+ RangeSet<Integer> minute,
+ RangeSet<Integer> hour,
+ RangeSet<Integer> dayOfMonth,
+ RangeSet<Integer> month,
+ RangeSet<Integer> dayOfWeek) throws IllegalArgumentException {
+
+ return new CrontabEntry(minute, hour, dayOfMonth, month, dayOfWeek);
+ }
+
+ private RangeSet<Integer> getMinute() {
+ return minute;
+ }
+
+ private RangeSet<Integer> getHour() {
+ return hour;
+ }
+
+ private RangeSet<Integer> getDayOfMonth() {
+ return dayOfMonth;
+ }
+
+ private RangeSet<Integer> getMonth() {
+ return month;
+ }
+
+ /**
+ * The days of the week this entry matches. 0 is Sun and 6 is Sat.
+ *
+ * @return An immutable view of the days of the week this entry matches within [0,7).
+ */
+ public RangeSet<Integer> getDayOfWeek() {
+ return dayOfWeek;
+ }
+
+ @VisibleForTesting
+ boolean hasWildcardMinute() {
+ return getMinute().encloses(MINUTE);
+ }
+
+ @VisibleForTesting
+ boolean hasWildcardHour() {
+ return getHour().encloses(HOUR);
+ }
+
+ /**
+ * True if this entry covers all possible days of the month.
+ */
+ public boolean hasWildcardDayOfMonth() {
+ return getDayOfMonth().encloses(DAY_OF_MONTH);
+ }
+
+
+ @VisibleForTesting
+ boolean hasWildcardMonth() {
+ return getMonth().encloses(MONTH);
+ }
+
+ /**
+ * True if this entry covers all possible days of the week.
+ */
+ public boolean hasWildcardDayOfWeek() {
+ return getDayOfWeek().encloses(DAY_OF_WEEK);
+ }
+
+ private String fieldToString(RangeSet<Integer> rangeSet, Range<Integer> coveringRange) {
+ if (rangeSet.asRanges().size() == 1 && rangeSet.encloses(coveringRange)) {
+ return "*";
+ }
+ List<String> components = Lists.newArrayList();
+ for (Range<Integer> range : rangeSet.asRanges()) {
+ ContiguousSet<Integer> set = ContiguousSet.create(range, DiscreteDomain.integers());
+ if (set.size() == 1) {
+ components.add(set.first().toString());
+ } else {
+ components.add(set.first() + "-" + set.last());
+ }
+ }
+ return Joiner.on(",").join(components);
+ }
+
+ /**
+ * The minute component, in canonical form.
+ */
+ public String getMinuteAsString() {
+ return fieldToString(getMinute(), MINUTE);
+ }
+
+ /**
+ * The hour component, in canonical form.
+ */
+ public String getHourAsString() {
+ return fieldToString(getHour(), HOUR);
+ }
+
+ /**
+ * The dayOfMonth component, in canonical form.
+ */
+ public String getDayOfMonthAsString() {
+ return fieldToString(getDayOfMonth(), DAY_OF_MONTH);
+ }
+
+ /**
+ * The month component, in canonical form.
+ */
+ public String getMonthAsString() {
+ return fieldToString(getMonth(), MONTH);
+ }
+
+ /**
+ * The dayOfWeek component, in canonical form.
+ */
+ public String getDayOfWeekAsString() {
+ return fieldToString(getDayOfWeek(), DAY_OF_WEEK);
+ }
+
+ /**
+ * Returns a parsable string representation schedule such that
+ * c.equals(CrontabEntry.parse(c.toString())
+ */
+ @Override
+ public String toString() {
+ return Joiner.on(" ").join(
+ getMinuteAsString(),
+ getHourAsString(),
+ getDayOfMonthAsString(),
+ getMonthAsString(),
+ getDayOfWeekAsString());
+ }
+
+ /**
+ * True when both sides would match the same set of instants.
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof CrontabEntry)) {
+ return false;
+ }
+ CrontabEntry that = (CrontabEntry) o;
+ return Objects.equal(getMinute(), that.getMinute())
+ && Objects.equal(getHour(), that.getHour())
+ && Objects.equal(getDayOfMonth(), that.getDayOfMonth())
+ && Objects.equal(getMonth(), that.getMonth())
+ && Objects.equal(getDayOfWeek(), that.getDayOfWeek());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getMinute(), getHour(), getDayOfWeek(), getMonth(), getDayOfMonth());
+ }
+
+ private static class Parser {
+ private static final Pattern CRONTAB_ENTRY = Pattern.compile(
+ "^(?<minute>\\S+)"
+ + "\\s+(?<hour>\\S+)"
+ + "\\s+(?<dayOfMonth>\\S+)"
+ + "\\s+(?<month>\\S+)"
+ + "\\s+(?<dayOfWeek>\\S+)$"
+ );
+
+ // A single time like "5", "10", "50".
+ private static final Pattern NUMBER = Pattern.compile("^(?<number>\\d+)$");
+ // A wildcard ("*").
+ private static final Pattern WILDCARD = Pattern.compile("^\\*$");
+ // A range like "1-2", "5-10", "14-50".
+ private static final Pattern RANGE = Pattern.compile("^(?<lower>\\d+)-(?<upper>\\d+)$");
+ // A wildcard with a "skip" like "*/5", "*/10"
+ private static final Pattern WILDCARD_WITH_SKIP = Pattern.compile("^\\*/(?<skip>\\d+)$");
+ // A range with a "skip" like "1-2/2", "0-59/5"
+ private static final Pattern RANGE_WITH_SKIP =
+ Pattern.compile("^(?<lower>\\d+)-(?<upper>\\d+)/(?<skip>\\d+)$");
+
+ private static final BiMap<String, Integer> MONTH_NAMES = ImmutableBiMap
+ .<String, Integer>builder()
+ .put("JAN", 1)
+ .put("FEB", 2)
+ .put("MAR", 3)
+ .put("APR", 4)
+ .put("MAY", 5)
+ .put("JUN", 6)
+ .put("JUL", 7)
+ .put("AUG", 8)
+ .put("SEP", 9)
+ .put("OCT", 10)
+ .put("NOV", 11)
+ .put("DEC", 12)
+ .build();
+
+ // NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday.
+ private static final BiMap<String, Integer> DAY_NAMES = ImmutableBiMap
+ .<String, Integer>builder()
+ .put("SUN", 0)
+ .put("MON", 1)
+ .put("TUE", 2)
+ .put("WED", 3)
+ .put("THU", 4)
+ .put("FRI", 5)
+ .put("SAT", 6)
+ .build();
+
+ private final String rawMinute;
+ private final String rawHour;
+ private final String rawDayOfMonth;
+ private final String rawMonth;
+ private final String rawDayOfWeek;
+
+ Parser(String schedule) throws IllegalArgumentException {
+ Matcher matcher = CRONTAB_ENTRY.matcher(schedule);
+ checkArgument(matcher.matches(), "Invalid cron schedule " + schedule);
+
+ rawMinute = checkNotNull(matcher.group("minute"));
+ rawHour = checkNotNull(matcher.group("hour"));
+ rawDayOfMonth = checkNotNull(matcher.group("dayOfMonth"));
+ rawMonth = checkNotNull(matcher.group("month"));
+ rawDayOfWeek = checkNotNull(matcher.group("dayOfWeek"));
+ }
+
+ CrontabEntry get() throws IllegalArgumentException {
+ return CrontabEntry.from(
+ parseMinute(),
+ parseHour(),
+ parseDayOfMonth(),
+ parseMonth(),
+ parseDayOfWeek());
+ }
+
+ private List<String> getComponents(String rawField) {
+ return Splitter.on(",").omitEmptyStrings().splitToList(rawField);
+ }
+
+ private String replaceNameAliases(String rawComponent, Map<String, Integer> names) {
+ String component = rawComponent.toUpperCase();
+ for (Map.Entry<String, Integer> entry : names.entrySet()) {
+ if (component.contains(entry.getKey())) {
+ component = component.replaceAll(entry.getKey(), entry.getValue().toString());
+ }
+ }
+ return component;
+ }
+
+ private static RangeSet<Integer> parseComponent(
+ final Range<Integer> enclosure,
+ String rawComponent) throws IllegalArgumentException {
+
+ if (WILDCARD.matcher(rawComponent).matches()) {
+ return ImmutableRangeSet.of(enclosure);
+ }
+
+ Matcher matcher;
+ if ((matcher = NUMBER.matcher(rawComponent)).matches()) {
+ int number = Integer.parseInt(matcher.group("number"));
+ Range<Integer> range = Range.singleton(number).canonical(DiscreteDomain.integers());
+
+ checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range);
+
+ return ImmutableRangeSet.of(range);
+ } else if ((matcher = RANGE.matcher(rawComponent)).matches()) {
+ int lower = Integer.parseInt(matcher.group("lower"));
+ int upper = Integer.parseInt(matcher.group("upper"));
+ Range<Integer> range = Range.closed(lower, upper).canonical(DiscreteDomain.integers());
+
+ checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range);
+
+ return ImmutableRangeSet.of(range);
+ } else if ((matcher = WILDCARD_WITH_SKIP.matcher(rawComponent)).matches()) {
+ int skip = Integer.parseInt(matcher.group("skip"));
+ int start = enclosure.lowerEndpoint();
+
+ checkArgument(skip > 0);
+
+ ImmutableRangeSet.Builder<Integer> rangeSet = ImmutableRangeSet.builder();
+ for (int i = start; enclosure.contains(i); i += skip) {
+ rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers()));
+ }
+ return rangeSet.build();
+ } else if ((matcher = RANGE_WITH_SKIP.matcher(rawComponent)).matches()) {
+ final int lower = Integer.parseInt(matcher.group("lower"));
+ final int upper = Integer.parseInt(matcher.group("upper"));
+ final int skip = Integer.parseInt(matcher.group("skip"));
+ Range<Integer> range = Range.closed(lower, upper).canonical(DiscreteDomain.integers());
+
+ checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range);
+ checkArgument(skip > 0, "skip value " + skip + " must be >0");
+ checkArgument(skip < upper, "skip value " + skip + " must be smaller than " + upper);
+
+ ImmutableRangeSet.Builder<Integer> rangeSet = ImmutableRangeSet.builder();
+ for (int i = lower; range.contains(i); i += skip) {
+ rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers()));
+ }
+ return rangeSet.build();
+ } else {
+ throw new IllegalArgumentException(
+ "Cron schedule component " + rawComponent + " does not match any known patterns.");
+ }
+ }
+
+ private RangeSet<Integer> parseMinute() {
+ RangeSet<Integer> minutes = TreeRangeSet.create();
+ for (String component : getComponents(rawMinute)) {
+ minutes.addAll(parseComponent(MINUTE, component));
+ }
+ return ImmutableRangeSet.copyOf(minutes);
+ }
+
+ private RangeSet<Integer> parseHour() {
+ RangeSet<Integer> hours = TreeRangeSet.create();
+ for (String component : getComponents(rawHour)) {
+ hours.addAll(parseComponent(HOUR, component));
+ }
+ return ImmutableRangeSet.copyOf(hours);
+ }
+
+ private RangeSet<Integer> parseDayOfWeek() {
+ RangeSet<Integer> daysOfWeek = TreeRangeSet.create();
+ for (String component : getComponents(rawDayOfWeek)) {
+ daysOfWeek.addAll(parseComponent(DAY_OF_WEEK, replaceNameAliases(component, DAY_NAMES)));
+ }
+ return ImmutableRangeSet.copyOf(daysOfWeek);
+ }
+
+ private RangeSet<Integer> parseMonth() {
+ RangeSet<Integer> months = TreeRangeSet.create();
+ for (String component : getComponents(rawMonth)) {
+ months.addAll(parseComponent(MONTH, replaceNameAliases(component, MONTH_NAMES)));
+ }
+ return ImmutableRangeSet.copyOf(months);
+ }
+
+ private RangeSet<Integer> parseDayOfMonth() {
+ RangeSet<Integer> daysOfMonth = TreeRangeSet.create();
+ for (String component : getComponents(rawDayOfMonth)) {
+ daysOfMonth.addAll(parseComponent(DAY_OF_MONTH, component));
+ }
+ return ImmutableRangeSet.copyOf(daysOfMonth);
+ }
+ }
+}
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java
----------------------------------------------------------------------
diff --git a/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java
new file mode 100644
index 0000000..2bb848a
--- /dev/null
+++ b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java
@@ -0,0 +1,163 @@
+/**
+ * Copyright 2014 Apache Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.aurora.scheduler.cron;
+
+import java.util.List;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class CrontabEntryTest {
+ @Test
+ public void testHashCodeAndEquals() {
+
+ List<CrontabEntry> entries = ImmutableList.of(
+ CrontabEntry.parse("* * * * *"),
+ CrontabEntry.parse("0-59 * * * *"),
+ CrontabEntry.parse("0-57,58,59 * * * *"),
+ CrontabEntry.parse("* 23,1,2,4,0-22 * * *"),
+ CrontabEntry.parse("1-50,0,51-59 * * * sun-sat"));
+
+ for (CrontabEntry lhs : entries) {
+ for (CrontabEntry rhs : entries) {
+ assertEquals(lhs, rhs);
+ }
+ }
+
+ Set<CrontabEntry> equivalentEntries = Sets.newHashSet(entries);
+ assertTrue(equivalentEntries.size() == 1);
+ }
+
+ @Test
+ public void testEqualsCoverage() {
+ assertNotEquals(CrontabEntry.parse("* * * * *"), new Object());
+
+ assertNotEquals(CrontabEntry.parse("* * * * *"), CrontabEntry.parse("1 * * * *"));
+ assertEquals(CrontabEntry.parse("1,2,3 * * * *"), CrontabEntry.parse("1-3 * * * *"));
+
+ assertNotEquals(CrontabEntry.parse("* 0-22 * * *"), CrontabEntry.parse("* * * * *"));
+ assertEquals(CrontabEntry.parse("* 0-23 * * *"), CrontabEntry.parse("* * * * *"));
+
+ assertNotEquals(CrontabEntry.parse("1 1 1-30 * *"), CrontabEntry.parse("1 1 * * *"));
+ assertEquals(CrontabEntry.parse("1 1 1-31 * *"), CrontabEntry.parse("1 1 * * *"));
+
+ assertNotEquals(CrontabEntry.parse("1 1 * JAN,FEB-NOV *"), CrontabEntry.parse("1 1 * * *"));
+ assertEquals(CrontabEntry.parse("1 1 * JAN,FEB-DEC *"), CrontabEntry.parse("1 1 * * *"));
+
+ assertNotEquals(CrontabEntry.parse("* * * * SUN"), CrontabEntry.parse("* * * * SAT"));
+ assertEquals(CrontabEntry.parse("* * * * 0"), CrontabEntry.parse("* * * * SUN"));
+ }
+
+ @Test
+ public void testSkip() {
+ assertEquals(CrontabEntry.parse("*/15 * * * *"), CrontabEntry.parse("0,15,30,45 * * * *"));
+ assertEquals(
+ CrontabEntry.parse("* */2 * * *"),
+ CrontabEntry.parse("0-59 0,2,4,6,8,10,12-23/2 * * *"));
+ }
+
+ @Test
+ public void testToString() {
+ assertEquals("0-58 * * * *", CrontabEntry.parse("0,1-57,58 * * * *").toString());
+ assertEquals("* * * * *", CrontabEntry.parse("* * * * *").toString());
+ }
+
+ @Test
+ public void testWildcards() {
+ CrontabEntry wildcardMinuteEntry = CrontabEntry.parse("* 1 1 1 *");
+ assertEquals("*", wildcardMinuteEntry.getMinuteAsString());
+ assertTrue(wildcardMinuteEntry.hasWildcardMinute());
+ assertFalse(wildcardMinuteEntry.hasWildcardHour());
+ assertFalse(wildcardMinuteEntry.hasWildcardDayOfMonth());
+ assertFalse(wildcardMinuteEntry.hasWildcardMonth());
+ assertTrue(wildcardMinuteEntry.hasWildcardDayOfWeek());
+
+ CrontabEntry wildcardHourEntry = CrontabEntry.parse("1 * 1 1 *");
+ assertEquals("*", wildcardHourEntry.getHourAsString());
+ assertFalse(wildcardHourEntry.hasWildcardMinute());
+ assertTrue(wildcardHourEntry.hasWildcardHour());
+ assertFalse(wildcardHourEntry.hasWildcardDayOfMonth());
+ assertFalse(wildcardHourEntry.hasWildcardMonth());
+ assertTrue(wildcardHourEntry.hasWildcardDayOfWeek());
+
+ CrontabEntry wildcardDayOfMonth = CrontabEntry.parse("1 1 * 1 *");
+ assertEquals("*", wildcardDayOfMonth.getDayOfMonthAsString());
+ assertFalse(wildcardDayOfMonth.hasWildcardMinute());
+ assertFalse(wildcardDayOfMonth.hasWildcardHour());
+ assertTrue(wildcardDayOfMonth.hasWildcardDayOfMonth());
+ assertFalse(wildcardDayOfMonth.hasWildcardMonth());
+ assertTrue(wildcardDayOfMonth.hasWildcardDayOfWeek());
+
+ CrontabEntry wildcardMonth = CrontabEntry.parse("1 1 1 * *");
+ assertEquals("*", wildcardMonth.getMonthAsString());
+ assertFalse(wildcardMonth.hasWildcardMinute());
+ assertFalse(wildcardMonth.hasWildcardHour());
+ assertFalse(wildcardMonth.hasWildcardDayOfMonth());
+ assertTrue(wildcardMonth.hasWildcardMonth());
+ assertTrue(wildcardMonth.hasWildcardDayOfWeek());
+
+ CrontabEntry wildcardDayOfWeek = CrontabEntry.parse("1 1 1 1 *");
+ assertEquals("*", wildcardDayOfWeek.getDayOfWeekAsString());
+ assertFalse(wildcardDayOfWeek.hasWildcardMinute());
+ assertFalse(wildcardDayOfWeek.hasWildcardHour());
+ assertFalse(wildcardDayOfWeek.hasWildcardDayOfMonth());
+ assertFalse(wildcardDayOfWeek.hasWildcardMonth());
+ assertTrue(wildcardDayOfWeek.hasWildcardDayOfWeek());
+ }
+
+ @Test
+ public void testEqualsIsCanonical() {
+ String rawEntry = "* * */3 * *";
+ CrontabEntry input = CrontabEntry.parse(rawEntry);
+ assertNotEquals(
+ rawEntry + " is not the canonical form of " + input,
+ rawEntry,
+ input.toString());
+ assertEquals(
+ "The form returned by toString is canonical",
+ input.toString(),
+ CrontabEntry.parse(input.toString()).toString());
+ }
+
+ @Test
+ public void testBadEntries() {
+ List<String> badPatterns = ImmutableList.of(
+ "* * * * MON-SUN",
+ "* * **",
+ "0-59 0-59 * * *",
+ "1/1 * * * *",
+ "5 5 * MAR-JAN *",
+ "*/0 * * * *",
+ "0-59/0 * * * *",
+ "0-59/60 * * * *",
+ "* * * *, *",
+ "* * 1 * 1"
+ );
+
+ for (String pattern : badPatterns) {
+ assertNull(CrontabEntry.tryParse(pattern).orNull());
+ }
+ }
+}