diff --git a/README.md b/README.md index 9a4b603..33a0944 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,18 @@ The special characters have the following meaning: | `W` | weekday | the weekday nearest to the given day | | `#` | nth | specify the Nth day of the month | -**Note:** the `L`, `W` and `#` special characters are described here for completeness but are **not implemented** by croncpp. An expression that uses one of them is rejected by `make_cron()` with a `bad_cronexpr` exception. +`L`, `W` and `#` are only meaningful in the day fields, and each applies to a single value, so they cannot be combined with a list, a range or an increment. Used anywhere else, or combined, they are rejected by `make_cron()` with a `bad_cronexpr` exception. + +| Expression | Field | Meaning | +| --- | --- | --- | +| `L` | days of month | the last day of the month | +| `LW` | days of month | the last weekday of the month | +| `15W` | days of month | the weekday nearest the 15th, without leaving the month | +| `L` | days of week | Saturday, as in Quartz | +| `5L` | days of week | the last Friday of the month | +| `5#2` | days of week | the second Friday of the month | + +`W` moves to the nearest Monday to Friday: back one day from a Saturday, forward one day from a Sunday. It never crosses into another month, so `1W` on a Saturday is the Monday after, and `31W` on a Sunday is the Friday before. The weekday numbers in `5L` and `5#2` follow the traits in use, so the last Friday is `5L` with `cron_standard_traits` and `6L` with `cron_quartz_traits`. **Note:** an expression describing a date that never occurs, such as `0 0 5 31 2 ?` for the 31st of February, is also rejected by `make_cron()` with a `bad_cronexpr` exception, because it has no next occurrence to compute. February is measured as a leap year, so the 29th is accepted and the 30th is not. @@ -58,6 +69,11 @@ Examples: | 0 0/5 14 * * ? | Every 5 minutes starting at 2 PM and ending at 2:55 PM, every day | | 0 10,44 14 ? 3 WED | 2:10 PM and at 2:44 PM every Wednesday of March | | 0 15 10 ? * MON-FRI | 10:15 AM every Monday, Tuesday, Wednesday, Thursday and Friday | +| 0 15 10 L * ? | 10:15 AM on the last day of every month | +| 0 15 10 LW * ? | 10:15 AM on the last weekday of every month | +| 0 15 10 15W * ? | 10:15 AM on the weekday nearest the 15th of every month | +| 0 15 10 ? * 5L | 10:15 AM on the last Friday of every month | +| 0 15 10 ? * 5#2 | 10:15 AM on the second Friday of every month | | 0 0 12 1/5 * ? | 12 PM every 5 days every month, starting on the first day of the month | | 0 11 11 11 11 ? | Every November 11th at 11:11 AM | diff --git a/include/croncpp.h b/include/croncpp.h index 45bd8ac..eb441e9 100644 --- a/include/croncpp.h +++ b/include/croncpp.h @@ -53,6 +53,23 @@ namespace cron static bool find_next(cronexpr const & cex, std::tm& date, size_t const dot); + + // The day fields accept qualifiers that a bitset cannot express, because + // the day they select depends on the month: "the last one", "the third + // Friday", "the weekday nearest the 15th". They are held alongside the + // bitsets and applied when a candidate date is tested. + struct day_of_month_options + { + bool last = false; // L, the last day of the month + bool nearest_weekday = false; // W, the nearest Monday to Friday + cron_int day = 0; // the day W applies to, 0 for LW + }; + + struct day_of_week_options + { + cron_int nth = 0; // #, the 1st to 5th such weekday of the month + bool last = false; // L, the last such weekday of the month + }; } struct bad_cronexpr : public std::runtime_error @@ -200,6 +217,9 @@ namespace cron std::bitset<12> months; std::string expr; + detail::day_of_month_options dom_options; + detail::day_of_week_options dow_options; + friend bool operator==(cronexpr const & e1, cronexpr const & e2); friend bool operator!=(cronexpr const & e1, cronexpr const & e2); @@ -217,12 +237,19 @@ namespace cron public: bool empty() const noexcept { + // a day of month selected by L or W leaves the bitset empty, because + // which day it is depends on the month + bool const no_day_of_month = + days_of_month.none() && + !dom_options.last && + !dom_options.nearest_weekday; + return seconds.none() || minutes.none() || hours.none() || days_of_week.none() || - days_of_month.none() || + no_day_of_month || months.none(); } }; @@ -235,7 +262,12 @@ namespace cron e1.hours == e2.hours && e1.days_of_week == e2.days_of_week && e1.days_of_month == e2.days_of_month && - e1.months == e2.months; + e1.months == e2.months && + e1.dom_options.last == e2.dom_options.last && + e1.dom_options.nearest_weekday == e2.dom_options.nearest_weekday && + e1.dom_options.day == e2.dom_options.day && + e1.dow_options.nth == e2.dow_options.nth && + e1.dow_options.last == e2.dow_options.last; } inline bool operator!=(cronexpr const & e1, cronexpr const & e2) @@ -350,6 +382,22 @@ namespace cron namespace detail { + inline bool is_leap_year(int const year) + { + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + } + + // month is 0 based, as in std::tm::tm_mon + inline int days_in_month(int const year, int const month) + { + static int const days[12] = + { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + + if (month == 1 && is_leap_year(year)) return 29; + + return days[month]; + } + inline cron_int to_cron_int(CRONCPP_STRING_VIEW text) { if (text.empty()) @@ -361,7 +409,7 @@ namespace cron { if (ch == 'L' || ch == 'W' || ch == '#') throw bad_cronexpr( - std::string("Special character '") + ch + "' is not supported"); + std::string("Special character '") + ch + "' is not valid in this field"); throw bad_cronexpr( "Invalid character in cron field: " + std::string(text)); @@ -532,12 +580,58 @@ namespace cron } } + inline bool has_list_or_range(std::string const & value) + { + return + utils::contains(value, ',') || + utils::contains(value, '-') || + utils::contains(value, '/'); + } + template static void set_cron_days_of_week( std::string value, - std::bitset<7>& target) + std::bitset<7>& target, + day_of_week_options & options) { auto days = utils::to_upper(value); + + if (days.size() == 1 && days[0] == '?') + days[0] = '*'; + + // On its own, L means Saturday, as in Quartz. After a weekday it + // means the last such weekday of the month, and # selects which one. + if (days == "L") + { + days = std::to_string(Traits::CRON_MAX_DAYS_OF_WEEK); + } + else if (utils::contains(days, '#') || (!days.empty() && days.back() == 'L')) + { + if (has_list_or_range(days)) + throw bad_cronexpr( + "The L and # special characters apply to a single day of week"); + + if (days.back() == 'L') + { + options.last = true; + days.pop_back(); + } + else + { + auto const parts = utils::split(days, '#'); + if (parts.size() != 2) + throw bad_cronexpr("The # special character must have two fields"); + + auto const nth = to_cron_int(parts[1]); + if (nth < 1 || nth > 5) + throw bad_cronexpr( + "The # special character must select the 1st to the 5th weekday"); + + options.nth = nth; + days = parts[0]; + } + } + auto days_replaced = detail::replace_ordinals( days, #ifdef CRONCPP_IS_CPP17 @@ -547,26 +641,64 @@ namespace cron #endif ); - if (days_replaced.size() == 1 && days_replaced[0] == '?') - days_replaced[0] = '*'; - set_cron_field( days_replaced, target, Traits::CRON_MIN_DAYS_OF_WEEK, Traits::CRON_MAX_DAYS_OF_WEEK); + + if ((options.nth != 0 || options.last) && target.count() != 1) + throw bad_cronexpr( + "The L and # special characters apply to a single day of week"); } template static void set_cron_days_of_month( std::string value, - std::bitset<31>& target) + std::bitset<31>& target, + day_of_month_options & options) { - if (value.size() == 1 && value[0] == '?') - value[0] = '*'; + auto days = utils::to_upper(value); + + if (days.size() == 1 && days[0] == '?') + days[0] = '*'; + + if (utils::contains(days, 'L') || utils::contains(days, 'W')) + { + if (has_list_or_range(days)) + throw bad_cronexpr( + "The L and W special characters apply to a single day of month"); + + if (days == "L") + { + options.last = true; + return; + } + + if (days == "LW") + { + options.last = true; + options.nearest_weekday = true; + return; + } + + if (days.back() != 'W') + throw bad_cronexpr("Invalid character in cron field: " + value); + + days.pop_back(); + + auto const day = to_cron_int(days); + if (day < Traits::CRON_MIN_DAYS_OF_MONTH || + day > Traits::CRON_MAX_DAYS_OF_MONTH) + throw bad_cronexpr("Specified range exceeds maximum"); + + options.nearest_weekday = true; + options.day = day; + return; + } set_cron_field( - value, + days, target, Traits::CRON_MIN_DAYS_OF_MONTH, Traits::CRON_MAX_DAYS_OF_MONTH); @@ -618,8 +750,12 @@ namespace cron // bit 0 is January and day 1 whatever the traits are. inline bool has_reachable_date( std::bitset<31> const & days_of_month, - std::bitset<12> const & months) + std::bitset<12> const & months, + day_of_month_options const & options) { + // the last day of a month always exists, whichever month it is + if (options.last) return true; + static int const last_day_of[12] = { 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; @@ -627,6 +763,12 @@ namespace cron { if (!months.test(month)) continue; + if (options.nearest_weekday) + { + if (static_cast(options.day) <= last_day_of[month]) return true; + continue; + } + for (size_t day = 0; day < days_of_month.size(); ++day) { if (days_of_month.test(day) && @@ -716,6 +858,12 @@ namespace cron date.tm_mday = val; break; case cron_field::month: + // Moving to a month shorter than the day currently set would roll + // over into the month after it: the 31st of February becomes the + // 3rd of March, and the search then misses the month it was aiming + // for. The day is searched again from the start of the month + // anyway, so begin it at the 1st. + date.tm_mday = 1; date.tm_mon = val; break; case cron_field::year: @@ -829,30 +977,89 @@ namespace cron return next_value; } + // The Quartz rule for W: move to the nearest Monday to Friday without + // leaving the month, so the 1st on a Saturday moves to the 3rd and the + // last day on a Sunday moves two days back. Returns 0 when the day does + // not exist in this month at all. + inline int nearest_weekday_to( + std::tm const & date, + int const target, + int const last) + { + if (target < 1 || target > last) return 0; + + int const offset = target - date.tm_mday; + int const weekday = ((date.tm_wday + offset) % 7 + 7) % 7; + + if (weekday == 6) return target > 1 ? target - 1 : target + 2; + if (weekday == 0) return target < last ? target + 1 : target - 2; + + return target; + } + + template + inline bool matches_day_of_month( + std::tm const & date, + std::bitset<31> const & days_of_month, + day_of_month_options const & options) + { + int const last = days_in_month(date.tm_year + 1900, date.tm_mon); + + if (options.last) + { + return options.nearest_weekday + ? date.tm_mday == nearest_weekday_to(date, last, last) + : date.tm_mday == last; + } + + if (options.nearest_weekday) + return date.tm_mday == nearest_weekday_to(date, options.day, last); + + return days_of_month.test(date.tm_mday - Traits::CRON_MIN_DAYS_OF_MONTH); + } + + inline bool matches_day_of_week( + std::tm const & date, + std::bitset<7> const & days_of_week, + day_of_week_options const & options) + { + if (!days_of_week.test(date.tm_wday)) return false; + + if (options.nth != 0) + return (date.tm_mday - 1) / 7 + 1 == options.nth; + + if (options.last) + return date.tm_mday + 7 > + days_in_month(date.tm_year + 1900, date.tm_mon); + + return true; + } + template static size_t find_next_day( std::tm& date, std::bitset<31> const & days_of_month, + day_of_month_options const & dom_options, size_t day_of_month, std::bitset<7> const & days_of_week, - size_t day_of_week, + day_of_week_options const & dow_options, std::bitset<7> const & marked_fields) { unsigned int count = 0; unsigned int maximum = 366; - // day_of_week comes from std::tm::tm_wday and is always 0 (Sunday) to - // 6 (Saturday), whatever the traits are. It is already the bit index, - // because the cron value of Sunday is CRON_MIN_DAYS_OF_WEEK and the - // bits are set at value - CRON_MIN_DAYS_OF_WEEK. + // The matchers read the day straight from date. tm_wday is always + // 0 (Sunday) to 6 (Saturday) whatever the traits are, and is already + // the bit index, because the cron value of Sunday is + // CRON_MIN_DAYS_OF_WEEK and the bits are set at + // value - CRON_MIN_DAYS_OF_WEEK. while ( - (!days_of_month.test(day_of_month - Traits::CRON_MIN_DAYS_OF_MONTH) || - !days_of_week.test(day_of_week)) + !(matches_day_of_month(date, days_of_month, dom_options) && + matches_day_of_week(date, days_of_week, dow_options)) && count++ < maximum) { add_to_field(date, cron_field::day_of_month, 1); day_of_month = date.tm_mday; - day_of_week = date.tm_wday; reset_all_fields(date, marked_fields); } @@ -926,14 +1133,14 @@ namespace cron if (!res) return res; } - unsigned int day_of_week = date.tm_wday; unsigned int day_of_month = date.tm_mday; auto updated_day_of_month = find_next_day( date, cex.days_of_month, + cex.dom_options, day_of_month, cex.days_of_week, - day_of_week, + cex.dow_options, marked_fields); if (day_of_month == updated_day_of_month) { @@ -1037,16 +1244,16 @@ namespace cron detail::set_cron_field(fields[1], cex.minutes, Traits::CRON_MIN_MINUTES, Traits::CRON_MAX_MINUTES); detail::set_cron_field(fields[2], cex.hours, Traits::CRON_MIN_HOURS, Traits::CRON_MAX_HOURS); - detail::set_cron_days_of_week(fields[5], cex.days_of_week); + detail::set_cron_days_of_week(fields[5], cex.days_of_week, cex.dow_options); - detail::set_cron_days_of_month(fields[3], cex.days_of_month); + detail::set_cron_days_of_month(fields[3], cex.days_of_month, cex.dom_options); detail::set_cron_month(fields[4], cex.months); // A date such as the 31st of February never arrives, so there is no // next occurrence to compute and the expression is rejected here rather // than leaving the caller to make sense of a search that never succeeds. - if (!detail::has_reachable_date(cex.days_of_month, cex.months)) + if (!detail::has_reachable_date(cex.days_of_month, cex.months, cex.dom_options)) throw bad_cronexpr("Date specified by the expression is invalid"); cex.expr = expr; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 84afc30..16f1a1f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES main.cpp test_dst.cpp test_oracle.cpp test_quartz.cpp test_standard.cpp) +set(SOURCES main.cpp test_dst.cpp test_oracle.cpp test_quartz.cpp test_special.cpp test_standard.cpp) add_executable(test_croncpp ${SOURCES}) diff --git a/test/test_special.cpp b/test/test_special.cpp new file mode 100644 index 0000000..04b6802 --- /dev/null +++ b/test/test_special.cpp @@ -0,0 +1,183 @@ +#include "catch.hpp" +#include "croncpp.h" + +#include + +#define CRON_EXPECT_EXCEPT(x) REQUIRE_THROWS_AS(make_cron(x), bad_cronexpr) + +using namespace cron; + +// The L, W and # special characters. The dates asserted below come from the +// calendar, not from croncpp: +// +// 2011-04-30 is a Saturday and the last day of April +// May 2011 has Fridays on the 6th, 13th, 20th and 27th +// July 2011 has five Fridays: the 1st, 8th, 15th, 22nd and 29th +// 2011-05-15 is a Sunday, 2011-10-15 a Saturday +// 2011-01-01 is a Saturday, 2011-07-31 a Sunday +// February 2011 has 28 days, February 2012 has 29 + +namespace +{ + template + std::string next_from(std::string const & expr, std::string const & from) + { + auto cex = make_cron(expr); + auto date = utils::to_tm(from); + + return utils::to_string(cron_next(cex, date)); + } +} + +TEST_CASE("special: # selects the nth weekday of the month", "[special]") +{ + // the expression from issue 18: the second Friday + REQUIRE(next_from("0 30 23 ? * 5#2", "2011-04-30 23:30:00") == "2011-05-13 23:30:00"); + + REQUIRE(next_from("0 30 23 ? * 5#1", "2011-04-30 23:30:00") == "2011-05-06 23:30:00"); + REQUIRE(next_from("0 30 23 ? * 5#3", "2011-04-30 23:30:00") == "2011-05-20 23:30:00"); + REQUIRE(next_from("0 30 23 ? * 5#4", "2011-04-30 23:30:00") == "2011-05-27 23:30:00"); + + // May has only four Fridays, so a fifth Friday is next found in July + REQUIRE(next_from("0 30 23 ? * 5#5", "2011-04-30 23:30:00") == "2011-07-29 23:30:00"); + + // consecutive occurrences are a month apart, not a week + REQUIRE(next_from("0 30 23 ? * 5#2", "2011-05-13 23:30:00") == "2011-06-10 23:30:00"); + + // written with a name rather than a number + REQUIRE(next_from("0 30 23 ? * FRI#2", "2011-04-30 23:30:00") == "2011-05-13 23:30:00"); +} + +TEST_CASE("special: # follows the numbering of the traits", "[special]") +{ + // standard counts Sunday as 0, quartz and oracle count it as 1 + auto const expected = "2011-05-13 23:30:00"; + + REQUIRE(next_from("0 30 23 ? * 5#2", "2011-04-30 23:30:00") == expected); + REQUIRE(next_from("0 30 23 ? * 6#2", "2011-04-30 23:30:00") == expected); + REQUIRE(next_from("0 30 23 ? * 6#2", "2011-04-30 23:30:00") == expected); +} + +TEST_CASE("special: L after a weekday selects the last one of the month", "[special]") +{ + REQUIRE(next_from("0 30 23 ? * 5L", "2011-04-30 23:30:00") == "2011-05-27 23:30:00"); + REQUIRE(next_from("0 30 23 ? * FRIL", "2011-04-30 23:30:00") == "2011-05-27 23:30:00"); + REQUIRE(next_from("0 30 23 ? * 5L", "2011-05-27 23:30:00") == "2011-06-24 23:30:00"); + + // in July the last Friday is also the fifth + REQUIRE(next_from("0 30 23 ? * 5L", "2011-06-30 23:30:00") == "2011-07-29 23:30:00"); +} + +TEST_CASE("special: L on its own in the day of week field means Saturday", "[special]") +{ + // as in Quartz, where L alone in that field is 7, meaning SAT + REQUIRE(next_from("0 30 23 ? * L", "2011-04-30 23:30:00") == "2011-05-07 23:30:00"); + REQUIRE(next_from("0 30 23 ? * L", "2011-05-07 23:30:00") == "2011-05-14 23:30:00"); +} + +TEST_CASE("special: L in the day of month field means the last day", "[special]") +{ + REQUIRE(next_from("0 30 23 L * ?", "2011-04-30 23:30:00") == "2011-05-31 23:30:00"); + REQUIRE(next_from("0 30 23 L * ?", "2011-01-31 23:30:00") == "2011-02-28 23:30:00"); + REQUIRE(next_from("0 30 23 L * ?", "2012-01-31 23:30:00") == "2012-02-29 23:30:00"); + REQUIRE(next_from("0 30 23 L 2 ?", "2011-03-01 00:00:00") == "2012-02-29 23:30:00"); +} + +TEST_CASE("special: W moves to the nearest weekday", "[special]") +{ + // the 15th of May 2011 is a Sunday, so it moves forward to Monday + REQUIRE(next_from("0 30 23 15W * ?", "2011-04-30 23:30:00") == "2011-05-16 23:30:00"); + + // the 15th of October 2011 is a Saturday, so it moves back to Friday + REQUIRE(next_from("0 30 23 15W * ?", "2011-09-30 23:30:00") == "2011-10-14 23:30:00"); + + // a weekday is left alone + REQUIRE(next_from("0 30 23 15W * ?", "2011-05-31 23:30:00") == "2011-06-15 23:30:00"); +} + +TEST_CASE("special: W does not leave the month", "[special]") +{ + // the 1st of January 2011 is a Saturday: stepping back would leave the + // month, so it moves forward to Monday the 3rd + REQUIRE(next_from("0 30 23 1W * ?", "2010-12-15 23:30:00") == "2011-01-03 23:30:00"); + + // the 31st of July 2011 is a Sunday: stepping forward would leave the + // month, so it moves back to Friday the 29th + REQUIRE(next_from("0 30 23 31W * ?", "2011-07-01 00:00:00") == "2011-07-29 23:30:00"); +} + +TEST_CASE("special: LW is the last weekday of the month", "[special]") +{ + // April 2011 ends on Saturday the 30th, so the last weekday is Friday + REQUIRE(next_from("0 30 23 LW * ?", "2011-04-01 00:00:00") == "2011-04-29 23:30:00"); + + // May 2011 ends on a Tuesday + REQUIRE(next_from("0 30 23 LW * ?", "2011-05-01 00:00:00") == "2011-05-31 23:30:00"); + + // December 2011 ends on Saturday the 31st + REQUIRE(next_from("0 30 23 LW * ?", "2011-12-01 00:00:00") == "2011-12-30 23:30:00"); +} + +TEST_CASE("special: qualified expressions are not equal to unqualified ones", "[special]") +{ + REQUIRE(make_cron("0 30 23 ? * 5#2") != make_cron("0 30 23 ? * 5#3")); + REQUIRE(make_cron("0 30 23 ? * 5#2") != make_cron("0 30 23 ? * 5")); + REQUIRE(make_cron("0 30 23 ? * 5L") != make_cron("0 30 23 ? * 5")); + REQUIRE(make_cron("0 30 23 ? * 5L") != make_cron("0 30 23 ? * 5#5")); + REQUIRE(make_cron("0 30 23 L * ?") != make_cron("0 30 23 1 * ?")); + REQUIRE(make_cron("0 30 23 15W * ?") != make_cron("0 30 23 15 * ?")); + REQUIRE(make_cron("0 30 23 LW * ?") != make_cron("0 30 23 L * ?")); + + REQUIRE(make_cron("0 30 23 ? * 5#2") == make_cron("0 30 23 ? * FRI#2")); + REQUIRE(make_cron("0 30 23 ? * 5L") == make_cron("0 30 23 ? * FRIL")); +} + +TEST_CASE("special: the qualifiers apply to a single value", "[special]") +{ + CRON_EXPECT_EXCEPT("0 30 23 ? * 5#2,6#3"); + CRON_EXPECT_EXCEPT("0 30 23 ? * 5-6#2"); + CRON_EXPECT_EXCEPT("0 30 23 ? * 5L,6L"); + CRON_EXPECT_EXCEPT("0 30 23 ? * 5-6L"); + CRON_EXPECT_EXCEPT("0 30 23 1,15W * ?"); + CRON_EXPECT_EXCEPT("0 30 23 1-15W * ?"); + CRON_EXPECT_EXCEPT("0 30 23 L,1 * ?"); +} + +TEST_CASE("special: malformed qualifiers are rejected", "[special]") +{ + CRON_EXPECT_EXCEPT("0 30 23 ? * 5#0"); // there is no zeroth Friday + CRON_EXPECT_EXCEPT("0 30 23 ? * 5#6"); // nor a sixth + CRON_EXPECT_EXCEPT("0 30 23 ? * 5#"); + CRON_EXPECT_EXCEPT("0 30 23 ? * #2"); + CRON_EXPECT_EXCEPT("0 30 23 ? * 5#2#3"); + CRON_EXPECT_EXCEPT("0 30 23 ? * 8#2"); // no such weekday + CRON_EXPECT_EXCEPT("0 30 23 W * ?"); + CRON_EXPECT_EXCEPT("0 30 23 0W * ?"); + CRON_EXPECT_EXCEPT("0 30 23 32W * ?"); + CRON_EXPECT_EXCEPT("0 30 23 WL * ?"); + CRON_EXPECT_EXCEPT("0 30 23 15L * ?"); + + // still not allowed in any other field + CRON_EXPECT_EXCEPT("0 30 23L * * ?"); + CRON_EXPECT_EXCEPT("0 30W 23 * * ?"); + CRON_EXPECT_EXCEPT("0 30 23 * L ?"); + CRON_EXPECT_EXCEPT("0 30 23 * 5#2 ?"); +} + +TEST_CASE("special: L and W against dates that never occur", "[special]") +{ + // the last day of February always exists + REQUIRE_NOTHROW(make_cron("0 0 0 L 2 *")); + REQUIRE_NOTHROW(make_cron("0 0 0 LW 2 *")); + + // but there is no 31st of February to move away from + CRON_EXPECT_EXCEPT("0 0 0 31W 2 *"); + REQUIRE_NOTHROW(make_cron("0 0 0 31W 1 *")); +} + +TEST_CASE("special: to_cronstr keeps the original text", "[special]") +{ + REQUIRE(to_cronstr(make_cron("0 30 23 ? * 5#2")) == "0 30 23 ? * 5#2"); + REQUIRE(to_cronstr(make_cron("0 30 23 LW * ?")) == "0 30 23 LW * ?"); + REQUIRE(to_cronstr(make_cron("0 30 23 15W * ?")) == "0 30 23 15W * ?"); +} diff --git a/test/test_standard.cpp b/test/test_standard.cpp index cc9f06e..3373b17 100644 --- a/test/test_standard.cpp +++ b/test/test_standard.cpp @@ -427,14 +427,18 @@ TEST_CASE("standard: dates that do occur are still accepted", "[std]") REQUIRE_NOTHROW(make_cron("0 0 0 * 2 *")); } -TEST_CASE("standard: unsupported special characters", "[std]") +// L, W and # are only meaningful in the day fields; see test_special.cpp for +// what they do there +TEST_CASE("standard: special characters outside the day fields", "[std]") { - CRON_EXPECT_EXCEPT("0 0 12 L * ?"); - CRON_EXPECT_EXCEPT("0 15 10 L * ?"); - CRON_EXPECT_EXCEPT("0 0 12 15W * ?"); - CRON_EXPECT_EXCEPT("0 0 12 LW * ?"); - CRON_EXPECT_EXCEPT("0 0 12 ? * 5L"); - CRON_EXPECT_EXCEPT("0 0 12 ? * 6#3"); + CRON_EXPECT_EXCEPT("L 0 12 * * ?"); + CRON_EXPECT_EXCEPT("0 L 12 * * ?"); + CRON_EXPECT_EXCEPT("0 0 L * * ?"); + CRON_EXPECT_EXCEPT("0 0 12 * L ?"); + CRON_EXPECT_EXCEPT("0 0 12W * * ?"); + CRON_EXPECT_EXCEPT("0 0 12 * 5W ?"); + CRON_EXPECT_EXCEPT("0 0 12 * 5#2 ?"); + CRON_EXPECT_EXCEPT("0 0#2 12 * * ?"); } TEST_CASE("standard: trailing garbage is not ignored", "[std]")