From 3bd2576a53be1f1429632282fe55803923b93d3c Mon Sep 17 00:00:00 2001 From: Marius Bancila Date: Wed, 12 Aug 2026 12:54:08 +0300 Subject: [PATCH] support the year field --- README.md | 10 +- include/croncpp.h | 204 ++++++++++++++++++++++++++++++++----- test/CMakeLists.txt | 2 +- test/test_years.cpp | 241 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 429 insertions(+), 28 deletions(-) create mode 100644 test/test_years.cpp diff --git a/README.md b/README.md index 33a0944..b055dfb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/mariusbancila/croncpp/actions/workflows/ci.yml/badge.svg)](https://github.com/mariusbancila/croncpp/actions/workflows/ci.yml) ## CRON expressions -A CRON expression is a string composed of six fields (in some implementation seven) separated by a whites space representing a time schedule. The general form is the following (with the `years` being optional): +A CRON expression is a string composed of six or seven fields separated by a white space representing a time schedule. The general form is the following (with the `years` being optional): ``` @@ -21,7 +21,7 @@ The following values are allowed for these fields: | days of month | 1-31 | 1-31 | 1-31 | 1-31 | `*` `,` `-` `?` `L` `W` | | months | yes | 1-12 | 0-11 | 1-12 | `*` `,` `-` | | days of week | yes | 0-6 | 1-7 | 1-7 | `*` `,` `-` `?` `L` `#` | -| years | no | 1970-2099 | 1970-2099 | 1970-2099 | `*` `,` `-` | +| years | no | 1970-2099 | 1970-2099 | 1970-2099 | `*` `,` `-` `/` | \* - As described on Wikipedia [Cron](https://en.wikipedia.org/wiki/Cron) @@ -55,6 +55,10 @@ The special characters have the following meaning: `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`. +The `years` field is optional, as in Quartz, and may be left out entirely. An expression whose years have all gone by has no next occurrence, so `cron_next()` reports failure for it: `INVALID_TIME` from the `std::time_t` overload, and a zeroed `std::tm` from the other. + +A traits type opts into the year field by declaring `CRON_MIN_YEARS` and `CRON_MAX_YEARS`, as all three supplied ones do. A traits type written without them keeps accepting six fields and rejects a seventh, so custom traits written against an earlier version of croncpp continue to work unchanged. Note that the range croncpp can store is fixed at 1970-2099 whatever the traits say, so a traits type may narrow that range but not widen it. + **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. Examples: @@ -74,6 +78,8 @@ Examples: | 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 15 10 * * ? 2005 | 10:15 AM every day during the year 2005 | +| 0 15 10 ? * 5L 2002-2006 | 10:15 AM on the last Friday of every month during 2002 to 2006 | | 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 eb441e9..11ebb1c 100644 --- a/include/croncpp.h +++ b/include/croncpp.h @@ -10,6 +10,7 @@ #include #include #include +#include #if __cplusplus > 201402L #include @@ -34,6 +35,25 @@ namespace cron constexpr size_t INVALID_INDEX = static_cast(-1); + // The years a cronexpr can hold. cronexpr is not a template, so this range + // is fixed for every traits type; a traits type may accept a narrower range + // but not a wider one. + constexpr int CRON_YEAR_BASE = 1970; + constexpr size_t CRON_YEAR_COUNT = 130; // through 2099, as in Quartz + + // A traits type opts into the optional year field by declaring + // CRON_MIN_YEARS and CRON_MAX_YEARS. One that does not, as any written + // before the field existed, keeps taking six fields and rejects a seventh. + template struct make_void { using type = void; }; + template using void_t = typename make_void::type; + + template + struct supports_years : std::false_type {}; + + template + struct supports_years> + : std::true_type {}; + class cronexpr; namespace detail @@ -103,6 +123,9 @@ namespace cron static const cron_int CRON_MAX_YEARS_DIFF = 4; + static const int CRON_MIN_YEARS = 1970; + static const int CRON_MAX_YEARS = 2099; + #ifdef CRONCPP_IS_CPP17 static const inline std::vector DAYS = { "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }; static const inline std::vector MONTHS = { "NIL", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }; @@ -143,6 +166,9 @@ namespace cron static const cron_int CRON_MAX_YEARS_DIFF = 4; + static const int CRON_MIN_YEARS = 1970; + static const int CRON_MAX_YEARS = 2099; + #ifdef CRONCPP_IS_CPP17 static const inline std::vector DAYS = { "NIL", "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }; static const inline std::vector MONTHS = { "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }; @@ -184,6 +210,9 @@ namespace cron static const cron_int CRON_MAX_YEARS_DIFF = 4; + static const int CRON_MIN_YEARS = 1970; + static const int CRON_MAX_YEARS = 2099; + #ifdef CRONCPP_IS_CPP17 static const inline std::vector DAYS = { "NIL", "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }; static const inline std::vector MONTHS = { "NIL", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }; @@ -215,6 +244,7 @@ namespace cron std::bitset<7> days_of_week; std::bitset<31> days_of_month; std::bitset<12> months; + std::bitset years; std::string expr; detail::day_of_month_options dom_options; @@ -250,7 +280,8 @@ namespace cron hours.none() || days_of_week.none() || no_day_of_month || - months.none(); + months.none() || + years.none(); } }; @@ -263,6 +294,7 @@ namespace cron e1.days_of_week == e2.days_of_week && e1.days_of_month == e2.days_of_month && e1.months == e2.months && + e1.years == e2.years && 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 && @@ -398,7 +430,8 @@ namespace cron return days[month]; } - inline cron_int to_cron_int(CRONCPP_STRING_VIEW text) + template + inline T to_cron_number(CRONCPP_STRING_VIEW text) { if (text.empty()) throw bad_cronexpr("Cron field value cannot be empty"); @@ -419,10 +452,10 @@ namespace cron try { auto const value = std::stoul(std::string(text)); - if (value > static_cast((std::numeric_limits::max)())) + if (value > static_cast((std::numeric_limits::max)())) throw bad_cronexpr("Cron field value is out of range"); - return static_cast(value); + return static_cast(value); } catch (std::invalid_argument const & ex) { @@ -439,6 +472,11 @@ namespace cron return ch == ',' || ch == '-' || ch == '/'; } + inline cron_int to_cron_int(CRONCPP_STRING_VIEW text) + { + return to_cron_number(text); + } + static std::string replace_ordinals( std::string text, std::vector const & replacement) @@ -476,13 +514,14 @@ namespace cron return text; } - static std::pair make_range( + template + static std::pair make_range( CRONCPP_STRING_VIEW field, - cron_int const minval, - cron_int const maxval) + T const minval, + T const maxval) { - cron_int first = 0; - cron_int last = 0; + T first = 0; + T last = 0; if (field.size() == 1 && field[0] == '*') { first = minval; @@ -490,7 +529,7 @@ namespace cron } else if (!utils::contains(field, '-')) { - first = to_cron_int(field); + first = to_cron_number(field); last = first; } else @@ -499,8 +538,8 @@ namespace cron if (parts.size() != 2) throw bad_cronexpr("Specified range requires two fields"); - first = to_cron_int(parts[0]); - last = to_cron_int(parts[1]); + first = to_cron_number(parts[0]); + last = to_cron_number(parts[1]); } if (first > maxval || last > maxval) @@ -519,12 +558,12 @@ namespace cron return { first, last }; } - template + template static void set_cron_field( CRONCPP_STRING_VIEW value, std::bitset& target, - cron_int const minval, - cron_int const maxval) + T const minval, + T const maxval) { if(value.length() > 0 && value[value.length()-1] == ',') throw bad_cronexpr("Value cannot end with comma"); @@ -538,13 +577,13 @@ namespace cron if (!utils::contains(field, '/')) { #ifdef CRONCPP_IS_CPP17 - auto[first, last] = detail::make_range(field, minval, maxval); + auto[first, last] = detail::make_range(field, minval, maxval); #else - auto range = detail::make_range(field, minval, maxval); + auto range = detail::make_range(field, minval, maxval); auto first = range.first; auto last = range.second; #endif - for (cron_int i = first - minval; i <= last - minval; ++i) + for (T i = first - minval; i <= last - minval; ++i) { target.set(i); } @@ -556,9 +595,9 @@ namespace cron throw bad_cronexpr("Incrementer must have two fields"); #ifdef CRONCPP_IS_CPP17 - auto[first, last] = detail::make_range(parts[0], minval, maxval); + auto[first, last] = detail::make_range(parts[0], minval, maxval); #else - auto range = detail::make_range(parts[0], minval, maxval); + auto range = detail::make_range(parts[0], minval, maxval); auto first = range.first; auto last = range.second; #endif @@ -568,11 +607,11 @@ namespace cron last = maxval; } - auto delta = detail::to_cron_int(parts[1]); + auto delta = detail::to_cron_number(parts[1]); if(delta <= 0) throw bad_cronexpr("Incrementer must be a positive value"); - for (cron_int i = first - minval; i <= last - minval; i += delta) + for (T i = first - minval; i <= last - minval; i += delta) { target.set(i); } @@ -726,6 +765,76 @@ namespace cron Traits::CRON_MAX_MONTHS); } + // The optional year field. The primary template is used when the traits + // type declares a year range; the specialisation keeps traits written + // before the field existed compiling, with a seventh field rejected. + template ::value> + struct year_field + { + static void set( + std::string const & value, + std::bitset & target) + { + static_assert(Traits::CRON_MIN_YEARS >= CRON_YEAR_BASE, + "the traits accept years earlier than croncpp can store"); + static_assert(Traits::CRON_MAX_YEARS < + CRON_YEAR_BASE + static_cast(CRON_YEAR_COUNT), + "the traits accept years later than croncpp can store"); + + // An unrestricted field means every year the traits allow, which + // may be narrower than the range croncpp stores. + if (value == "*") + { + set_all(target); + return; + } + + // indexed from CRON_YEAR_BASE, so that the stored range does not + // depend on the traits + set_cron_field( + value, + target, + CRON_YEAR_BASE, + static_cast(CRON_YEAR_BASE + CRON_YEAR_COUNT) - 1); + + // a traits type may accept a narrower range than croncpp stores + for (size_t i = 0; i < target.size(); ++i) + { + if (!target.test(i)) continue; + + int const year = static_cast(i) + CRON_YEAR_BASE; + if (year < Traits::CRON_MIN_YEARS || year > Traits::CRON_MAX_YEARS) + throw bad_cronexpr("Specified year is out of range"); + } + } + + // used when the expression leaves the field out altogether + static void set_all(std::bitset & target) + { + for (int year = Traits::CRON_MIN_YEARS; + year <= Traits::CRON_MAX_YEARS; + ++year) + target.set(static_cast(year - CRON_YEAR_BASE)); + } + }; + + template + struct year_field + { + static void set( + std::string const &, + std::bitset &) + { + throw bad_cronexpr("These traits do not support a year field"); + } + + // these traits have no notion of a year, so no year is excluded + static void set_all(std::bitset & target) + { + target.set(); + } + }; + template inline size_t next_set_bit( std::bitset const & target, @@ -1164,13 +1273,47 @@ namespace cron marked_fields); if (month != updated_month) { - if (date.tm_year - dot > Traits::CRON_MAX_YEARS_DIFF) + // An expression that names its years bounds the search by itself, + // and may legitimately reach further ahead than this cap allows. + if (cex.years.all() && date.tm_year - dot > Traits::CRON_MAX_YEARS_DIFF) return false; res = find_next(cex, date, dot); if (!res) return res; } + if (!cex.years.all()) + { + int const year = date.tm_year + 1900; + size_t const index = year > CRON_YEAR_BASE + ? static_cast(year - CRON_YEAR_BASE) + : 0; + + if (index >= CRON_YEAR_COUNT) return false; + + if (!cex.years.test(index)) + { + auto const next_year = + next_set_bit(cex.years, 0, CRON_YEAR_COUNT, index); + if (INVALID_INDEX == next_year) return false; + + // Jump straight to the start of that year rather than walking + // the months towards it, which for a distant year would recurse + // once per month along the way. + std::tm start = std::tm(); + start.tm_year = static_cast(next_year) + CRON_YEAR_BASE - 1900; + start.tm_mon = 0; + start.tm_mday = 1; + start.tm_isdst = -1; + + if (INVALID_TIME == utils::tm_to_time(start)) return false; + + date = start; + + return find_next(cex, date, date.tm_year); + } + } + return res; } @@ -1237,8 +1380,14 @@ namespace cron std::remove_if(std::begin(fields), std::end(fields), [](CRONCPP_STRING_VIEW s) {return s.empty(); }), std::end(fields)); - if (fields.size() != 6) - throw bad_cronexpr("cron expression must have six fields"); + // the year is optional, and only for traits that declare a range for it + bool const years_allowed = supports_years::value; + + if (fields.size() != 6 && !(years_allowed && fields.size() == 7)) + throw bad_cronexpr( + years_allowed + ? "cron expression must have six or seven fields" + : "cron expression must have six fields"); detail::set_cron_field(fields[0], cex.seconds, Traits::CRON_MIN_SECONDS, Traits::CRON_MAX_SECONDS); detail::set_cron_field(fields[1], cex.minutes, Traits::CRON_MIN_MINUTES, Traits::CRON_MAX_MINUTES); @@ -1250,6 +1399,11 @@ namespace cron detail::set_cron_month(fields[4], cex.months); + if (fields.size() == 7) + detail::year_field::set(fields[6], cex.years); + else + detail::year_field::set_all(cex.years); // no year field named + // 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. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 16f1a1f..ee26233 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_special.cpp test_standard.cpp) +set(SOURCES main.cpp test_dst.cpp test_oracle.cpp test_quartz.cpp test_special.cpp test_standard.cpp test_years.cpp) add_executable(test_croncpp ${SOURCES}) diff --git a/test/test_years.cpp b/test/test_years.cpp new file mode 100644 index 0000000..82b8f7c --- /dev/null +++ b/test/test_years.cpp @@ -0,0 +1,241 @@ +#include "catch.hpp" +#include "croncpp.h" + +#include +#include + +#define CRON_EXPECT_EXCEPT(x) REQUIRE_THROWS_AS(make_cron(x), bad_cronexpr) + +using namespace cron; + +namespace +{ + // A traits type as it would have been written before the year field + // existed: no CRON_MIN_YEARS, so it keeps taking six fields. + struct traits_without_years + { + static const cron_int CRON_MIN_SECONDS = 0; + static const cron_int CRON_MAX_SECONDS = 59; + + static const cron_int CRON_MIN_MINUTES = 0; + static const cron_int CRON_MAX_MINUTES = 59; + + static const cron_int CRON_MIN_HOURS = 0; + static const cron_int CRON_MAX_HOURS = 23; + + static const cron_int CRON_MIN_DAYS_OF_WEEK = 0; + static const cron_int CRON_MAX_DAYS_OF_WEEK = 6; + + static const cron_int CRON_MIN_DAYS_OF_MONTH = 1; + static const cron_int CRON_MAX_DAYS_OF_MONTH = 31; + + static const cron_int CRON_MIN_MONTHS = 1; + static const cron_int CRON_MAX_MONTHS = 12; + + static const cron_int CRON_MAX_YEARS_DIFF = 4; + +#ifdef CRONCPP_IS_CPP17 + static const inline std::vector DAYS = { "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }; + static const inline std::vector MONTHS = { "NIL", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }; +#else + static std::vector& DAYS() + { + static std::vector days = { "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }; + return days; + } + + static std::vector& MONTHS() + { + static std::vector months = { "NIL", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }; + return months; + } +#endif + }; + + // A traits type that accepts a narrower range than croncpp stores. + struct traits_with_narrow_years : traits_without_years + { + static const int CRON_MIN_YEARS = 2000; + static const int CRON_MAX_YEARS = 2050; + }; + + template + std::time_t next_of(std::string const & expr, std::string const & from) + { + auto cex = make_cron(expr); + auto date = utils::to_tm(from); + + return cron_next(cex, utils::tm_to_time(date)); + } + + template + std::string next_str(std::string const & expr, std::string const & from) + { + auto const result = next_of(expr, from); + if (INVALID_TIME == result) return "none"; + + std::tm tm; + if (utils::time_to_tm(&result, &tm) == nullptr) return "invalid"; + + return utils::to_string(tm); + } + + std::string next(std::string const & expr, std::string const & from) + { + return next_str(expr, from); + } +} + +TEST_CASE("years: the field is optional", "[years]") +{ + REQUIRE_NOTHROW(make_cron("0 15 10 * * ?")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? *")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2005")); + + // an unrestricted year field means the same as leaving it out + REQUIRE(make_cron("0 15 10 * * ?") == make_cron("0 15 10 * * ? *")); + + CRON_EXPECT_EXCEPT("0 15 10 * * ? 2005 7"); + CRON_EXPECT_EXCEPT("0 15 10 * * "); +} + +TEST_CASE("years: a single year", "[years]") +{ + REQUIRE(next("0 15 10 * * ? 2005", "2004-06-01 00:00:00") == "2005-01-01 10:15:00"); + REQUIRE(next("0 15 10 * * ? 2005", "2005-06-01 00:00:00") == "2005-06-01 10:15:00"); + + // the year has gone by, so there is no next occurrence at all + REQUIRE(next("0 15 10 * * ? 2005", "2020-06-01 00:00:00") == "none"); + REQUIRE(next_of("0 15 10 * * ? 2005", "2020-06-01 00:00:00") == INVALID_TIME); +} + +TEST_CASE("years: ranges, lists and increments", "[years]") +{ + REQUIRE(next("0 15 10 * * ? 2002-2006", "2001-06-01 00:00:00") == "2002-01-01 10:15:00"); + REQUIRE(next("0 15 10 * * ? 2002-2006", "2006-12-31 23:00:00") == "none"); + REQUIRE(next("0 15 10 * * ? 2002,2010", "2003-01-01 00:00:00") == "2010-01-01 10:15:00"); + // 2000/5 is 2000, 2005, 2010 and so on, so a date inside 2005 matches it + REQUIRE(next("0 15 10 * * ? 2000/5", "2001-01-01 00:00:00") == "2005-01-01 10:15:00"); + REQUIRE(next("0 15 10 * * ? 2000/5", "2005-06-01 00:00:00") == "2005-06-01 10:15:00"); + REQUIRE(next("0 15 10 * * ? 2000/5", "2006-06-01 00:00:00") == "2010-01-01 10:15:00"); +} + +TEST_CASE("years: a year further off than the search normally reaches", "[years]") +{ + // the search gives up after CRON_MAX_YEARS_DIFF years when no year is + // named; naming one has to lift that + REQUIRE(next("0 15 10 * * ? 2050", "2026-06-01 00:00:00") == "2050-01-01 10:15:00"); + REQUIRE(next("0 15 10 * * ? 2099", "1980-06-01 00:00:00") == "2099-01-01 10:15:00"); +} + +TEST_CASE("years: combined with the other fields", "[years]") +{ + // the leap day exists in 2024 but in none of 2021 to 2023 + REQUIRE(next("0 0 0 29 2 ? 2024", "2020-06-01 00:00:00") == "2024-02-29 00:00:00"); + REQUIRE(next("0 0 0 29 2 ? 2021-2023", "2020-06-01 00:00:00") == "none"); + + // the last Friday of January 2002, written in quartz numbering + REQUIRE(next_str("0 15 10 ? * 6L 2002-2006", "2001-06-01 00:00:00") + == "2002-01-25 10:15:00"); + + REQUIRE(next("0 15 10 ? * 5#2 2005", "2004-06-01 00:00:00") == "2005-01-14 10:15:00"); + REQUIRE(next("0 15 10 L * ? 2005", "2004-06-01 00:00:00") == "2005-01-31 10:15:00"); +} + +TEST_CASE("years: outside the supported range", "[years]") +{ + CRON_EXPECT_EXCEPT("0 15 10 * * ? 1969"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? 2100"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? 1960-1980"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? 2090-2110"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? 0"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? 20050"); + CRON_EXPECT_EXCEPT("0 15 10 * * ? YEAR"); + + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 1970")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2099")); +} + +TEST_CASE("years: expressions differing only by year are not equal", "[years]") +{ + REQUIRE(make_cron("0 15 10 * * ? 2005") != make_cron("0 15 10 * * ? 2006")); + REQUIRE(make_cron("0 15 10 * * ? 2005") != make_cron("0 15 10 * * ?")); + REQUIRE(make_cron("0 15 10 * * ? 2002-2003") == make_cron("0 15 10 * * ? 2002,2003")); +} + +TEST_CASE("years: every traits type accepts the field", "[years]") +{ + REQUIRE(next_str("0 15 10 * * ? 2005", "2004-06-01 00:00:00") == "2005-01-01 10:15:00"); + REQUIRE(next_str("0 15 10 * * ? 2005", "2004-06-01 00:00:00") == "2005-01-01 10:15:00"); + REQUIRE(next_str("0 15 10 * * ? 2005", "2004-06-01 00:00:00") == "2005-01-01 10:15:00"); +} + +TEST_CASE("years: the traits opt in is detected", "[years]") +{ + static_assert(supports_years::value, "supplied traits support years"); + static_assert(supports_years::value, "supplied traits support years"); + static_assert(supports_years::value, "supplied traits support years"); + static_assert(!supports_years::value, "these do not declare a range"); + static_assert(supports_years::value, "these do declare one"); + + REQUIRE(supports_years::value); + REQUIRE(!supports_years::value); + REQUIRE(supports_years::value); +} + +TEST_CASE("years: traits that do not declare a year range", "[years]") +{ + // six fields go on working, exactly as before the field existed + REQUIRE_NOTHROW(make_cron("0 15 10 * * ?")); + REQUIRE_NOTHROW(make_cron("0 0 0 29 2 *")); + REQUIRE(next_str("0 15 10 * * ?", "2004-06-01 00:00:00") == "2004-06-01 10:15:00"); + REQUIRE(next_str("0 15 10 ? * 5#2", "2011-04-30 23:30:00") == "2011-05-13 10:15:00"); + + // a seventh is refused rather than silently ignored + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 2005"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? *"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 2002-2006"), bad_cronexpr); +} + +TEST_CASE("years: traits that declare a narrower range than croncpp stores", "[years]") +{ + // inside the range the traits allow + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2000")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2050")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2000-2050")); + REQUIRE(next_str("0 15 10 * * ? 2005", "2004-06-01 00:00:00") + == "2005-01-01 10:15:00"); + + // outside it, even though croncpp itself could store those years + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 1999"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 2051"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 1990-2010"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 2040-2060"), bad_cronexpr); + REQUIRE_THROWS_AS(make_cron("0 15 10 * * ? 1999,2005"), bad_cronexpr); + + // the same years are fine for the supplied traits, so this is the traits + // narrowing the range and not croncpp rejecting them outright + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 1999")); + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? 2051")); + + // an unrestricted year field means every year the traits allow, not every + // year croncpp is able to store + REQUIRE_NOTHROW(make_cron("0 15 10 * * ? *")); + REQUIRE(next_str("0 15 10 * * ? *", "2004-06-01 00:00:00") + == "2004-06-01 10:15:00"); + REQUIRE(next_str("0 15 10 * * ? *", "2060-06-01 00:00:00") == "none"); + + // and it is then the same as naming that whole range + REQUIRE(make_cron("0 15 10 * * ? *") == + make_cron("0 15 10 * * ? 2000-2050")); + + // leaving the field out entirely is unrestricted in the same way + REQUIRE(make_cron("0 15 10 * * ?") == + make_cron("0 15 10 * * ? *")); +} + +TEST_CASE("years: to_cronstr keeps the original text", "[years]") +{ + REQUIRE(to_cronstr(make_cron("0 15 10 * * ? 2005")) == "0 15 10 * * ? 2005"); + REQUIRE(to_cronstr(make_cron("0 15 10 * * ? 2002-2006")) == "0 15 10 * * ? 2002-2006"); +}