diff --git a/.gitignore b/.gitignore index 8eb3b06b..64d30ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ /spec/reports/ /tmp/ +# rubymine +.idea + # rspec failure tracking .rspec_status diff --git a/CHANGELOG.md b/CHANGELOG.md index c0022b92..ffc1259e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Indonesian translations. ([#505](https://github.com/seejohnrun/ice_cube/pull/505)) by [@achmiral](https://github.com/achmiral) +- Support for BYSETPOS ### Changed - Removed use of `delegate` method added in [66f1d797](https://github.com/ice-cube-ruby/ice_cube/commit/66f1d797092734563bfabd2132c024c7d087f683) , reverting to previous implementation. ([#522](https://github.com/ice-cube-ruby/ice_cube/pull/522)) by [@pacso](https://github.com/pacso) diff --git a/ice_cube.gemspec b/ice_cube.gemspec index 5191c6fa..b1924c18 100644 --- a/ice_cube.gemspec +++ b/ice_cube.gemspec @@ -17,7 +17,6 @@ Gem::Specification.new do |s| s.version = IceCube::VERSION s.platform = Gem::Platform::RUBY s.files = Dir["lib/**/*.rb", "config/**/*.yml"] - s.test_files = Dir.glob("spec/*.rb") s.require_paths = ["lib"] s.add_development_dependency("rake") diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 3c283f53..4d7108f6 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -49,6 +49,10 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :WeeklyBySetPos, "ice_cube/validations/weekly_by_set_pos" + autoload :MonthlyBySetPos, "ice_cube/validations/monthly_by_set_pos" + autoload :YearlyBySetPos, "ice_cube/validations/yearly_by_set_pos" + autoload :HourOfDay, "ice_cube/validations/hour_of_day" autoload :MonthOfYear, "ice_cube/validations/month_of_year" autoload :MinuteOfHour, "ice_cube/validations/minute_of_hour" diff --git a/lib/ice_cube/builders/string_builder.rb b/lib/ice_cube/builders/string_builder.rb index c87e97ed..07703631 100644 --- a/lib/ice_cube/builders/string_builder.rb +++ b/lib/ice_cube/builders/string_builder.rb @@ -61,7 +61,7 @@ def ordinal(number) ord = IceCube::I18n.t("ice_cube.integer.ordinals")[number] || IceCube::I18n.t("ice_cube.integer.ordinals")[number % 10] || IceCube::I18n.t("ice_cube.integer.ordinals")[:default] - number >= 0 ? ord : IceCube::I18n.t("ice_cube.integer.negative", ordinal: ord) + (number >= 0) ? ord : IceCube::I18n.t("ice_cube.integer.negative", ordinal: ord) end end diff --git a/lib/ice_cube/input_alignment.rb b/lib/ice_cube/input_alignment.rb index 8900fa15..3cdd5070 100644 --- a/lib/ice_cube/input_alignment.rb +++ b/lib/ice_cube/input_alignment.rb @@ -28,12 +28,12 @@ def interval_validation end def interval_value - @interval_value ||= rule_part == :interval ? value : interval_validation.interval + @interval_value ||= (rule_part == :interval) ? value : interval_validation.interval end def fixed_validations @fixed_validations ||= @rule.validations.values.flatten.select { |v| - interval_type = (v.type == :wday ? :day : v.type) + interval_type = ((v.type == :wday) ? :day : v.type) v.class < Validations::FixedValue && interval_type == rule.base_interval_validation.type } diff --git a/lib/ice_cube/null_i18n.rb b/lib/ice_cube/null_i18n.rb index 028ab2c5..0ec25e23 100644 --- a/lib/ice_cube/null_i18n.rb +++ b/lib/ice_cube/null_i18n.rb @@ -5,7 +5,7 @@ module NullI18n def self.t(key, options = {}) base = key.to_s.split(".").reduce(config) { |hash, current_key| hash[current_key] } - base = base[options[:count] == 1 ? "one" : "other"] if options[:count] + base = base[(options[:count] == 1) ? "one" : "other"] if options[:count] case base when Hash diff --git a/lib/ice_cube/occurrence.rb b/lib/ice_cube/occurrence.rb index 4c51f962..3892f6c6 100644 --- a/lib/ice_cube/occurrence.rb +++ b/lib/ice_cube/occurrence.rb @@ -90,7 +90,7 @@ def to_s(format = nil) else t0, t1 = start_time.to_s, end_time.to_s end - duration > 0 ? "#{t0} - #{t1}" : t0 + (duration > 0) ? "#{t0} - #{t1}" : t0 end def overnight? diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 429bd84c..946b96a8 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -2,7 +2,18 @@ module IceCube class IcalParser def self.schedule_from_ical(ical_string, options = {}) data = {} + + # First join lines that are wrapped + lines = [] ical_string.each_line do |line| + if lines[-1] && line =~ /\A[ \t].+/ + lines[-1] = lines[-1].strip + line.sub(/\A[ \t]+/, "") + else + lines << line + end + end + + lines.each do |line| (property, value) = line.split(":") (property, _tzid) = property.split(";") case property @@ -75,7 +86,7 @@ def self.rule_from_ical(ical) when "BYYEARDAY" validations[:day_of_year] = value.split(",").map(&:to_i) when "BYSETPOS" - # noop + params[:validations][:by_set_pos] = value.split(",").collect(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/rules/monthly_rule.rb b/lib/ice_cube/rules/monthly_rule.rb index 6aadf5e7..9decdbca 100644 --- a/lib/ice_cube/rules/monthly_rule.rb +++ b/lib/ice_cube/rules/monthly_rule.rb @@ -10,6 +10,7 @@ class MonthlyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::MonthlyInterval + include Validations::MonthlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/weekly_rule.rb b/lib/ice_cube/rules/weekly_rule.rb index e24f77f9..12e8b485 100644 --- a/lib/ice_cube/rules/weekly_rule.rb +++ b/lib/ice_cube/rules/weekly_rule.rb @@ -10,6 +10,7 @@ class WeeklyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::WeeklyInterval + include Validations::WeeklyBySetPos attr_reader :week_start diff --git a/lib/ice_cube/rules/yearly_rule.rb b/lib/ice_cube/rules/yearly_rule.rb index d92148c1..0ebb3057 100644 --- a/lib/ice_cube/rules/yearly_rule.rb +++ b/lib/ice_cube/rules/yearly_rule.rb @@ -10,6 +10,7 @@ class YearlyRule < ValidatedRule include Validations::DayOfYear include Validations::YearlyInterval + include Validations::YearlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/schedule.rb b/lib/ice_cube/schedule.rb index 68f01cc9..036c9f2b 100644 --- a/lib/ice_cube/schedule.rb +++ b/lib/ice_cube/schedule.rb @@ -191,7 +191,7 @@ def previous_occurrences(num, from) from = TimeUtil.match_zone(from, start_time) or raise ArgumentError, "Time required, got #{from.inspect}" return [] if from <= start_time a = enumerate_occurrences(start_time, from - 1).to_a - a.size > num ? a[-1 * num, a.size] : a + (a.size > num) ? a[-1 * num, a.size] : a end # The remaining occurrences (same requirements as all_occurrences) diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index 001d927d..d14c74bf 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -1,5 +1,7 @@ require "date" require "time" +require "active_support" +require "active_support/core_ext" module IceCube module TimeUtil @@ -51,7 +53,7 @@ def self.match_zone(input_time, reference) else time.getlocal(reference.utc_offset) end - Date === input_time ? beginning_of_date(time, reference) : time + (Date === input_time) ? beginning_of_date(time, reference) : time end # Ensure that this is either nil, or a time @@ -193,6 +195,36 @@ def self.which_occurrence_in_month(time, wday) [nth_occurrence_of_weekday, this_weekday_in_month_count] end + # Use Activesupport CoreExt functions to manipulate time + def self.start_of_month time + time.beginning_of_month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.end_of_month time + time.end_of_month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.start_of_year time + time.beginning_of_year + end + + # Use Activesupport CoreExt functions to manipulate time + def self.end_of_year time + time.end_of_year + end + + # Use Activesupport CoreExt functions to manipulate time + def self.previous_month time + time - 1.month + end + + # Use Activesupport CoreExt functions to manipulate time + def self.previous_year time + time - 1.year + end + # Get the days in the month for +time def self.days_in_month(time) date = Date.new(time.year, time.month, 1) @@ -286,12 +318,12 @@ def to_time def add(type, val) type = :day if type == :wday @time += case type - when :year then TimeUtil.days_in_n_years(@time, val) * ONE_DAY - when :month then TimeUtil.days_in_n_months(@time, val) * ONE_DAY - when :day then val * ONE_DAY - when :hour then val * ONE_HOUR - when :min then val * ONE_MINUTE - when :sec then val + when :year then TimeUtil.days_in_n_years(@time, val) * ONE_DAY + when :month then TimeUtil.days_in_n_months(@time, val) * ONE_DAY + when :day then val * ONE_DAY + when :hour then val * ONE_HOUR + when :min then val * ONE_MINUTE + when :sec then val end end @@ -318,20 +350,20 @@ def sec=(value) end def clear_sec - @time.sec > 0 ? @time -= @time.sec : @time + (@time.sec > 0) ? @time -= @time.sec : @time end def clear_min - @time.min > 0 ? @time -= (@time.min * ONE_MINUTE) : @time + (@time.min > 0) ? @time -= (@time.min * ONE_MINUTE) : @time end def clear_hour - @time.hour > 0 ? @time -= (@time.hour * ONE_HOUR) : @time + (@time.hour > 0) ? @time -= (@time.hour * ONE_HOUR) : @time end # Move to the first of the month, 0 hours def clear_day - @time.day > 1 ? @time -= (@time.day - 1) * ONE_DAY : @time + (@time.day > 1) ? @time -= (@time.day - 1) * ONE_DAY : @time end # Clear to january 1st diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index 068356ea..faa5ee95 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -18,7 +18,8 @@ class ValidatedRule < Rule :base_sec, :base_min, :base_day, :base_hour, :base_month, :base_wday, :day_of_year, :second_of_minute, :minute_of_hour, :day_of_month, :hour_of_day, :month_of_year, :day_of_week, - :interval + :interval, + :by_set_pos ] attr_reader :validations diff --git a/lib/ice_cube/validations/day_of_week.rb b/lib/ice_cube/validations/day_of_week.rb index e00193b7..c04695bc 100644 --- a/lib/ice_cube/validations/day_of_week.rb +++ b/lib/ice_cube/validations/day_of_week.rb @@ -29,12 +29,12 @@ def dst_adjust? def validate(step_time, start_time) wday = step_time.wday - offset = day < wday ? (7 - wday + day) : (day - wday) + offset = (day < wday) ? (7 - wday + day) : (day - wday) wrapper = TimeUtil::TimeWrapper.new(step_time) wrapper.add :day, offset loop do which_occ, num_occ = TimeUtil.which_occurrence_in_month(wrapper.to_time, day) - this_occ = occ < 0 ? (num_occ + occ + 1) : occ + this_occ = (occ < 0) ? (num_occ + occ + 1) : occ break offset if which_occ == this_occ wrapper.add :day, 7 offset += 7 diff --git a/lib/ice_cube/validations/day_of_year.rb b/lib/ice_cube/validations/day_of_year.rb index 239f241e..c9efecdb 100644 --- a/lib/ice_cube/validations/day_of_year.rb +++ b/lib/ice_cube/validations/day_of_year.rb @@ -28,9 +28,9 @@ def dst_adjust? def validate(step_time, start_time) days_in_year = TimeUtil.days_in_year(step_time) - yday = day < 0 ? day + days_in_year + 1 : day + yday = (day < 0) ? day + days_in_year + 1 : day offset = yday - step_time.yday - offset >= 0 ? offset : offset + days_in_year + (offset >= 0) ? offset : offset + days_in_year end def build_s(builder) diff --git a/lib/ice_cube/validations/fixed_value.rb b/lib/ice_cube/validations/fixed_value.rb index 2cfdd5fb..243d4b8c 100644 --- a/lib/ice_cube/validations/fixed_value.rb +++ b/lib/ice_cube/validations/fixed_value.rb @@ -26,7 +26,7 @@ def validate(time, start_time) def validate_interval_lock(time, start_time) t0 = starting_unit(start_time) t1 = time.send(type) - t0 >= t1 ? t0 - t1 : INTERVALS[type] - t1 + t0 + (t0 >= t1) ? t0 - t1 : INTERVALS[type] - t1 + t0 end # Lock the hour if explicitly set by hour_of_day, but allow for the nearest @@ -73,11 +73,11 @@ def validate_day_lock(time, start_time) if value && value > 0 until_next_month = days_in_month + sleeps else - until_next_month = start < 28 ? days_in_month : TimeUtil.days_to_next_month(date) + until_next_month = (start < 28) ? days_in_month : TimeUtil.days_to_next_month(date) until_next_month += sleeps - month_overflow end - sleeps >= 0 ? sleeps : until_next_month + (sleeps >= 0) ? sleeps : until_next_month end def starting_unit(start_time) diff --git a/lib/ice_cube/validations/lock.rb b/lib/ice_cube/validations/lock.rb index 954463c1..33249382 100644 --- a/lib/ice_cube/validations/lock.rb +++ b/lib/ice_cube/validations/lock.rb @@ -26,7 +26,7 @@ def validate(time, start_time) def validate_interval_lock(time, start_time) t0 = starting_unit(start_time) t1 = time.send(type) - t0 >= t1 ? t0 - t1 : INTERVALS[type] - t1 + t0 + (t0 >= t1) ? t0 - t1 : INTERVALS[type] - t1 + t0 end # Lock the hour if explicitly set by hour_of_day, but allow for the nearest @@ -73,11 +73,11 @@ def validate_day_lock(time, start_time) if value && value > 0 until_next_month = days_in_month + sleeps else - until_next_month = start < 28 ? days_in_month : TimeUtil.days_to_next_month(date) + until_next_month = (start < 28) ? days_in_month : TimeUtil.days_to_next_month(date) until_next_month += sleeps - month_overflow end - sleeps >= 0 ? sleeps : until_next_month + (sleeps >= 0) ? sleeps : until_next_month end def starting_unit(start_time) diff --git a/lib/ice_cube/validations/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb new file mode 100644 index 00000000..c4a53b3f --- /dev/null +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -0,0 +1,73 @@ +module IceCube + module Validations::MonthlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).cover?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) + eom_date = Date.new(step_time.year, step_time.month, -1) + end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) + + # Needs to start on the first day of the month + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb new file mode 100644 index 00000000..1e8fd083 --- /dev/null +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -0,0 +1,90 @@ +module IceCube + module Validations::WeeklyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).cover?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes + step_time_date = step_time.to_date + start_day_of_week = TimeUtil.sym_to_wday(rule.week_start) + step_time_day_of_week = step_time_date.wday + days_delta = step_time_day_of_week - start_day_of_week + days_to_start = (days_delta >= 0) ? days_delta : 7 + days_delta + start_of_week_date = step_time_date - days_to_start + end_of_week_date = start_of_week_date + 6 + start_of_week = IceCube::TimeUtil.build_in_zone( + [start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time + ) + end_of_week = IceCube::TimeUtil.build_in_zone( + [end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time + ) + + # Needs to start on the first day of the week at the step_time's hour, min, sec + start_of_week_adjusted = IceCube::TimeUtil.build_in_zone( + [ + start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, + step_time.hour, step_time.min, step_time.sec + ], step_time + ) + new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb new file mode 100644 index 00000000..441ec14e --- /dev/null +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -0,0 +1,73 @@ +module IceCube + module Validations::YearlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).cover?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) + end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) + + # Needs to start on the first day of the year + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) + + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder["BYSETPOS"] << by_set_pos + end + + nil + end + end +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb new file mode 100644 index 00000000..0ebc14a7 --- /dev/null +++ b/spec/examples/by_set_pos_spec.rb @@ -0,0 +1,194 @@ +require File.dirname(__FILE__) + "/../spec_helper" + +module IceCube + describe WeeklyRule, "BYSETPOS" do + it "should behave correctly" do + # Weekly on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 1, 1), Time.new(2024, 1, 1))) + .to eq([ + Time.new(2023, 1, 2, 12, 0, 0), + Time.new(2023, 1, 9, 12, 0, 0), + Time.new(2023, 1, 16, 12, 0, 0), + Time.new(2023, 1, 23, 12, 0, 0) + ]) + end + + it "should work with intervals" do + # Every 2 weeks on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 1, 1), Time.new(2024, 1, 1))) + .to eq([ + Time.new(2023, 1, 9, 12, 0, 0), + Time.new(2023, 1, 23, 12, 0, 0), + Time.new(2023, 2, 6, 12, 0, 0), + Time.new(2023, 2, 20, 12, 0, 0) + ]) + end + end + + describe MonthlyRule, "BYSETPOS" do + it "should behave correctly" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))) + .to eq([ + Time.new(2015, 6, 24, 12, 0, 0), + Time.new(2015, 7, 22, 12, 0, 0), + Time.new(2015, 8, 26, 12, 0, 0), + Time.new(2015, 9, 23, 12, 0, 0) + ]) + end + + it "should work with intervals" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4;INTERVAL=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))) + .to eq([ + Time.new(2015, 7, 22, 12, 0, 0), + Time.new(2015, 9, 23, 12, 0, 0), + Time.new(2015, 11, 25, 12, 0, 0), + Time.new(2016, 1, 27, 12, 0, 0) + ]) + end + + context "when billing occurs at the end of the month" do + it "should return the correct end of the months dates" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=28,29,30,31;BYSETPOS=-1" + schedule.start_time = Time.new(2022, 11, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 10, 1), Time.new(2023, 10, 31))).to eq([ + Time.new(2022, 11, 30, 12, 0, 0), + Time.new(2022, 12, 31, 12, 0, 0), + Time.new(2023, 1, 31, 12, 0, 0), + Time.new(2023, 2, 28, 12, 0, 0) + ]) + end + end + + context "when the rule is the first 4 Wednesdays ..." do + it "returns the first 4 Wednesdays ..." do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))).to eq([ + Time.new(2015, 6, 24, 12, 0, 0), + Time.new(2015, 7, 22, 12, 0, 0), + Time.new(2015, 8, 26, 12, 0, 0), + Time.new(2015, 9, 23, 12, 0, 0) + ]) + end + end + + context "when event occurs on a leap year" do + it "returns the correct end of the months date including the leap month" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=28,29,30,31;BYSETPOS=-1" + schedule.start_time = Time.new(2019, 11, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2019, 10, 1), Time.new(2020, 10, 31))).to eq([ + Time.new(2019, 11, 30, 12, 0, 0), + Time.new(2019, 12, 31, 12, 0, 0), + Time.new(2020, 1, 31, 12, 0, 0), + Time.new(2020, 2, 29, 12, 0, 0) + ]) + end + end + + context "when the rule is the first 4 last days of the month by set pos" do + it "returns the first 4 last days of the month by set pos" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=28,29,30,31;BYSETPOS=-1" + schedule.start_time = Time.new(2022, 11, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 10, 1), Time.new(2023, 10, 31))).to eq([ + Time.new(2022, 11, 30, 12, 0, 0), + Time.new(2022, 12, 31, 12, 0, 0), + Time.new(2023, 1, 31, 12, 0, 0), + Time.new(2023, 2, 28, 12, 0, 0) + ]) + end + end + + context "when the rule is for the first 4 second last days of the month by set pos" do + it "returns the first 4 previous last days of the month by set pos" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=27,28,29,30,31;BYSETPOS=-2" + schedule.start_time = Time.new(2022, 11, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 10, 1), Time.new(2023, 10, 31))).to eq([ + Time.new(2022, 11, 29, 12, 0, 0), + Time.new(2022, 12, 30, 12, 0, 0), + Time.new(2023, 1, 30, 12, 0, 0), + Time.new(2023, 2, 27, 12, 0, 0) + ]) + end + end + + context "when the rule is the first 4 after the last days of the month by set pos" do + it "returns the first 4 previous last days of the month by set pos" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=28,29,30,31;BYSETPOS=4" + schedule.start_time = Time.new(2023, 2, 22, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 10, 1), Time.new(2023, 10, 31))).to eq([ + Time.new(2023, 3, 31, 12, 0, 0), + Time.new(2023, 5, 31, 12, 0, 0), + Time.new(2023, 7, 31, 12, 0, 0), + Time.new(2023, 8, 31, 12, 0, 0) + ]) + end + end + + context "when the rule is the first 4 after the last wednesday of the month" do + it "returns the first 4 previous last days of the month by set pos" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 2, 22, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 10, 1), Time.new(2023, 10, 31))).to eq([ + Time.new(2023, 2, 22, 12, 0, 0), + Time.new(2023, 3, 29, 12, 0, 0), + Time.new(2023, 4, 26, 12, 0, 0), + Time.new(2023, 5, 31, 12, 0, 0) + ]) + end + end + end + + describe YearlyRule, "BYSETPOS" do + it "should behave correctly" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1" + schedule.start_time = Time.new(1966, 7, 5) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))) + .to eq([ + Time.new(2015, 7, 31), + Time.new(2016, 7, 31) + ]) + end + + it "should work with intervals" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;INTERVAL=2" + schedule.start_time = Time.new(1966, 7, 5) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2023, 1, 1))) + .to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + Time.new(2022, 7, 31) + ]) + end + + it "should work with counts" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3" + schedule.start_time = Time.new(2016, 1, 1) + expect(schedule.occurrences_between(Time.new(2016, 1, 1), Time.new(2050, 1, 1))) + .to eq([ + Time.new(2016, 7, 31), + Time.new(2017, 7, 31), + Time.new(2018, 7, 31) + ]) + end + + it "should work with counts and intervals" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3;INTERVAL=2" + schedule.start_time = Time.new(2016, 1, 1) + expect(schedule.occurrences_between(Time.new(2016, 1, 1), Time.new(2050, 1, 1))) + .to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31) + ]) + end + end +end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2ab66c3c..257bf7b6 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -84,6 +84,25 @@ module IceCube expect(rule).to eq(IceCube::Rule.weekly(2, :monday)) end + it "should be able to parse by_set_pos start (BYSETPOS)" do + rule = IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-1,1") + expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) + end + + it "should raise when by_set_pos is out of range (BYSETPOS)" do + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=0") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + end + it "should return no occurrences after daily interval with count is over" do schedule = IceCube::Schedule.new(Time.now) schedule.add_recurrence_rule(IceCube::Rule.from_ical("FREQ=DAILY;COUNT=5")) @@ -429,5 +448,13 @@ def sorted_ical(ical) it_behaves_like "an invalid ical string" end end + + describe "ical data with wrapping" do + it "matches simple daily" do + ical_string = "DTSTART:20130314T201500Z\nDTEND:20130314T201545Z\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNT\n IL=20130531T100000Z\nDESCRIPTION:This is a test event\nSUMMARY:Test Event\n" + schedule = IceCube::Schedule.from_ical(ical_string) + expect(schedule.to_ical.split("\n").find { |x| x =~ /RRULE/ }).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") + end + end end end diff --git a/spec/examples/ice_cube_spec.rb b/spec/examples/ice_cube_spec.rb index 354d2e48..d925f63d 100644 --- a/spec/examples/ice_cube_spec.rb +++ b/spec/examples/ice_cube_spec.rb @@ -668,7 +668,7 @@ def quick_attempt_test time = Time.now 10.times do - (yield).next_occurrence(Time.now) + yield.next_occurrence(Time.now) end total = Time.now - time expect(total).to be < 0.1 diff --git a/spec/examples/occurrence_spec.rb b/spec/examples/occurrence_spec.rb index 80c18421..be358104 100644 --- a/spec/examples/occurrence_spec.rb +++ b/spec/examples/occurrence_spec.rb @@ -1,4 +1,6 @@ require File.dirname(__FILE__) + "/../spec_helper" +require "active_support/all" +require "active_support/core_ext" describe Occurrence do it "reports as a Time" do @@ -28,7 +30,7 @@ time_now = Time.current occurrence = Occurrence.new(time_now) - expect(occurrence.to_s(:short)).to eq time_now.to_s(:short) + expect(occurrence.strftime("%Y-%m-%d")).to eq time_now.strftime("%Y-%m-%d") end end diff --git a/spec/examples/time_util_spec.rb b/spec/examples/time_util_spec.rb index 43491a8b..ce2918c9 100644 --- a/spec/examples/time_util_spec.rb +++ b/spec/examples/time_util_spec.rb @@ -92,6 +92,15 @@ module IceCube end end + describe :serialize_time do + subject(:serialize_time) { TimeUtil.serialize_time(time) } + let(:time) { Time.utc(2014, 4, 4, 10, 30, 0) } + let(:iso_time_str) { "2014-04-04T18:30:00+08:00" } + it "supports ISO8601 time strings" do + expect(serialize_time).to eq(iso_time_str) + end + end + describe :match_zone do let(:date) { Date.new(2014, 1, 1) } diff --git a/spec/examples/to_yaml_spec.rb b/spec/examples/to_yaml_spec.rb index e7c62c59..28bd9d83 100644 --- a/spec/examples/to_yaml_spec.rb +++ b/spec/examples/to_yaml_spec.rb @@ -78,7 +78,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .day_of_year" do - schedule1 = Schedule.new(Time.now) + schedule1 = Schedule.new(Time.zone.now) schedule1.add_recurrence_rule Rule.yearly.day_of_year(100, 200) yaml_string = schedule1.to_yaml @@ -90,7 +90,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .hour_of_day" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.daily.hour_of_day(1, 2) yaml_string = schedule.to_yaml @@ -101,7 +101,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .minute_of_hour" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.daily.minute_of_hour(0, 30) yaml_string = schedule.to_yaml @@ -112,7 +112,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .month_of_year" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.yearly.month_of_year(:april, :may) yaml_string = schedule.to_yaml @@ -123,7 +123,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .second_of_minute" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.daily.second_of_minute(1, 2) yaml_string = schedule.to_yaml @@ -134,7 +134,7 @@ module IceCube end it "should be able to make a round-trip to YAML whilst preserving exception rules" do - original_schedule = Schedule.new(Time.now) + original_schedule = Schedule.new(Time.zone.now) original_schedule.add_recurrence_rule Rule.daily.day(:monday, :wednesday) original_schedule.add_exception_rule Rule.daily.day(:wednesday)