diff --git a/Gemfile b/Gemfile index 05bfbe32..36edd3ef 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,10 @@ compatible_rails_versions = [ gem "activesupport", (ENV["RAILS_VERSION"] || compatible_rails_versions), require: false gem "i18n", require: false gem "tzinfo", require: false # only needed explicitly for RAILS_VERSION=3 + +gem "base64", require: false # remove base64 deprecation warnings for Ruby 3.3+ +gem "bigdecimal", require: false # remove bigdecimal deprecation warnings for Ruby 3.3+ +gem "mutex_m", require: false # ActiveSupport dependency on Ruby 3.4+ +gem "ostruct", require: false # remove ostruct deprecation warnings for Ruby 3.4+ +gem "logger", require: false # remove logger deprecation warnings for Ruby 3.4+ +gem "benchmark", require: false # remove benchmark deprecation warnings for Ruby 3.4+ diff --git a/README.md b/README.md index 0b4aef7f..4818fe62 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,31 @@ schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(:march) schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(3) ``` +### BYSETPOS (select the nth occurrence) + +BYSETPOS selects the nth occurrence within each interval after all other BYxxx +filters/expansions are applied. Use positive values (from the start) or +negative values (from the end). Repeated values do not duplicate occurrences, +and positions beyond the set size yield no occurrence for that interval. +RFC 5545 requires BYSETPOS to be used with another BYxxx rule part; IceCube +allows BYSETPOS without another BYxxx and applies it to the single occurrence +in each interval. + +```ruby +# last weekday of the month +schedule.add_recurrence_rule( + IceCube::Rule.monthly.day(:monday, :tuesday, :wednesday, :thursday, :friday).by_set_pos(-1) +) + +# second occurrence in each day's expanded set +schedule.add_recurrence_rule( + IceCube::Rule.daily.hour_of_day(9, 17).by_set_pos(2) +) +``` + +Note: If you expand with BYHOUR/BYMINUTE/BYSECOND, any unspecified smaller +time components are inherited from the schedule's start_time. + ### Hourly (by hour of day) ```ruby diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index eb301660..bf71d7d6 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,15 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :BySetPosHelper, "ice_cube/validations/by_set_pos_helper" + autoload :SecondlyBySetPos, "ice_cube/validations/secondly_by_set_pos" + autoload :MinutelyBySetPos, "ice_cube/validations/minutely_by_set_pos" + autoload :HourlyBySetPos, "ice_cube/validations/hourly_by_set_pos" + autoload :DailyBySetPos, "ice_cube/validations/daily_by_set_pos" + 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/occurrence.rb b/lib/ice_cube/occurrence.rb index 3892f6c6..76b036ab 100644 --- a/lib/ice_cube/occurrence.rb +++ b/lib/ice_cube/occurrence.rb @@ -85,11 +85,8 @@ def to_time # time formats and is only used when ActiveSupport is available. # def to_s(format = nil) - if format && to_time.public_method(:to_s).arity != 0 - t0, t1 = start_time.to_s(format), end_time.to_s(format) - else - t0, t1 = start_time.to_s, end_time.to_s - end + t0 = format_time(start_time, format) + t1 = format_time(end_time, format) (duration > 0) ? "#{t0} - #{t1}" : t0 end @@ -98,5 +95,18 @@ def overnight? midnight = Time.new(offset.year, offset.month, offset.day) midnight < end_time end + + private + + # Normalize formatted output across ActiveSupport versions: + # Rails 7.1+ prefers to_fs, older versions use to_formatted_s or to_s(:format). + def format_time(time, format) + return time.to_s unless format + return time.to_fs(format) if time.respond_to?(:to_fs) + return time.to_formatted_s(format) if time.respond_to?(:to_formatted_s) + return time.to_s(format) if time.public_method(:to_s).arity != 0 + + time.to_s + end end end diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 429bd84c..41c44fc9 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 + validations[:by_set_pos] = value.split(",").map(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/rule.rb b/lib/ice_cube/rule.rb index 30689267..d756d705 100644 --- a/lib/ice_cube/rule.rb +++ b/lib/ice_cube/rule.rb @@ -49,11 +49,11 @@ def to_hash raise MethodNotImplemented, "Expected to be overridden by subclasses" end - def next_time(time, schedule, closing_time) + def next_time(time, schedule, closing_time, increment: true) end def on?(time, schedule) - next_time(time, schedule, time).to_i == time.to_i + next_time(time, schedule, time, increment: false).to_i == time.to_i end class << self diff --git a/lib/ice_cube/rules/daily_rule.rb b/lib/ice_cube/rules/daily_rule.rb index ed5014f2..ab46c3d9 100644 --- a/lib/ice_cube/rules/daily_rule.rb +++ b/lib/ice_cube/rules/daily_rule.rb @@ -10,6 +10,7 @@ class DailyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::DailyInterval + include Validations::DailyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/hourly_rule.rb b/lib/ice_cube/rules/hourly_rule.rb index 1ce85d10..9d1c4757 100644 --- a/lib/ice_cube/rules/hourly_rule.rb +++ b/lib/ice_cube/rules/hourly_rule.rb @@ -10,6 +10,7 @@ class HourlyRule < ValidatedRule include Validations::DayOfYear include Validations::HourlyInterval + include Validations::HourlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/minutely_rule.rb b/lib/ice_cube/rules/minutely_rule.rb index 91fe9103..7bb2470f 100644 --- a/lib/ice_cube/rules/minutely_rule.rb +++ b/lib/ice_cube/rules/minutely_rule.rb @@ -10,6 +10,7 @@ class MinutelyRule < ValidatedRule include Validations::DayOfYear include Validations::MinutelyInterval + include Validations::MinutelyBySetPos def initialize(interval = 1) super 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/secondly_rule.rb b/lib/ice_cube/rules/secondly_rule.rb index 5b32e61a..b988ebd8 100644 --- a/lib/ice_cube/rules/secondly_rule.rb +++ b/lib/ice_cube/rules/secondly_rule.rb @@ -10,6 +10,7 @@ class SecondlyRule < ValidatedRule include Validations::DayOfYear include Validations::SecondlyInterval + include Validations::SecondlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/weekly_rule.rb b/lib/ice_cube/rules/weekly_rule.rb index fd2ffd72..9c69613d 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 06b8e4dd..c7d9c75c 100644 --- a/lib/ice_cube/schedule.rb +++ b/lib/ice_cube/schedule.rb @@ -442,13 +442,22 @@ def enumerate_occurrences(opening_time, closing_time = nil, options = {}) # Get the next time after (or including) a specific time def next_time(time, closing_time) loop do - min_time = recurrence_rules_with_implicit_start_occurrence.reduce(nil) do |best_time, rule| - new_time = rule.next_time(time, start_time, best_time || closing_time) - [best_time, new_time].compact.min + # Probe all rules without consuming counts so we can pick the earliest + # candidate, then charge counts only to rules that emitted that time. + min_time = nil + candidates = [] + recurrence_rules_with_implicit_start_occurrence.each do |rule| + candidate = rule.next_time(time, start_time, min_time || closing_time, increment: false) + next unless candidate + candidates << [rule, candidate] + min_time = candidate if min_time.nil? || candidate < min_time rescue StopIteration - best_time + next end break unless min_time + candidates.each do |rule, candidate| + rule.increment_uses if candidate == min_time && rule.respond_to?(:increment_uses) + end next (time = min_time + 1) if exception_time?(min_time) break Occurrence.new(min_time, min_time + duration) end diff --git a/lib/ice_cube/single_occurrence_rule.rb b/lib/ice_cube/single_occurrence_rule.rb index 92961bf2..40c03298 100644 --- a/lib/ice_cube/single_occurrence_rule.rb +++ b/lib/ice_cube/single_occurrence_rule.rb @@ -11,7 +11,7 @@ def terminating? true end - def next_time(t, _, closing_time) + def next_time(t, _, closing_time, increment: true) unless closing_time && closing_time < t time if time.to_i >= t.to_i end diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index 068356ea..2167c509 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -18,7 +18,10 @@ 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, + # BYSETPOS selects the nth occurrence within the set after all other + # BYxxx filters/expansions are applied (RFC 5545), so it must run last. + :by_set_pos ] attr_reader :validations @@ -44,17 +47,24 @@ def other_interval_validations # Compute the next time after (or including) the specified time in respect # to the given start time - def next_time(time, start_time, closing_time) + # When increment is false, callers are probing for the next candidate and + # must not consume COUNT. + def next_time(time, start_time, closing_time, increment: true) @time = time @start_time ||= realign(time, start_time) @time = @start_time if @time < @start_time return nil unless find_acceptable_time_before(closing_time) - @uses += 1 if @time + @uses += 1 if @time && increment @time end + def increment_uses + # Count is consumed only when the rule's occurrence is emitted. + @uses += 1 + end + def realign(opening_time, start_time) start_time end diff --git a/lib/ice_cube/validations/by_set_pos_helper.rb b/lib/ice_cube/validations/by_set_pos_helper.rb new file mode 100644 index 00000000..09125fa2 --- /dev/null +++ b/lib/ice_cube/validations/by_set_pos_helper.rb @@ -0,0 +1,84 @@ +module IceCube + module Validations::BySetPosHelper + module_function + + def interval_bounds(interval_type, step_time, week_start: nil) + case interval_type + when :year + # Build a year window in the schedule's zone so BYSETPOS is applied + # per-year, matching RFC 5545 interval semantics. + [ + TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time), + TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) + ] + when :month + # Build a month window in the schedule's zone so BYSETPOS is applied + # per-month, preserving DST/zone handling. + 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) + [start_of_month, end_of_month] + when :week + raise ArgumentError, "week_start is required for weekly interval bounds" unless week_start + # Use Date arithmetic to avoid DST surprises, then rebuild in the schedule's zone. + # WKST drives the interval boundary per RFC 5545. + step_time_date = step_time.to_date + start_day_of_week = TimeUtil.sym_to_wday(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 + [ + TimeUtil.build_in_zone([start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time), + TimeUtil.build_in_zone([end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time) + ] + when :day + # Build a day window in the schedule's zone so BYSETPOS is applied + # per-day (important for day-level grouping). + [ + TimeUtil.beginning_of_date(step_time, step_time), + TimeUtil.end_of_date(step_time, step_time) + ] + when :hour + # Build an hour window in the schedule's zone so BYSETPOS is applied + # per-hour (sub-day grouping stays intact). + [ + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, 0, 0], step_time), + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, 59, 59], step_time) + ] + when :min + # Build a minute window in the schedule's zone so BYSETPOS is applied + # per-minute (sub-hour grouping stays intact). + [ + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 0], step_time), + TimeUtil.build_in_zone([step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, 59], step_time) + ] + when :sec + # Build a second window in the schedule's zone so BYSETPOS is applied + # per-second (the set size is typically 1). + second = TimeUtil.build_in_zone( + [step_time.year, step_time.month, step_time.day, step_time.hour, step_time.min, step_time.sec], step_time + ) + [second, second] + else + raise ArgumentError, "Unsupported interval type: #{interval_type}" + end + end + + def build_filtered_schedule(rule, start_time) + # Strip BYSETPOS/COUNT/UNTIL so the candidate set is complete, and avoid + # recursive BYSETPOS evaluation when we rebuild the temporary rule. + filtered_hash = rule.to_hash.reject { |key, _| [:by_set_pos, :count, :until].include?(key) } + if filtered_hash[:validations] + filtered_hash[:validations] = filtered_hash[:validations].reject { |key, _| key == :by_set_pos } + filtered_hash.delete(:validations) if filtered_hash[:validations].empty? + end + + # Use the schedule start_time to preserve implicit anchors like minute/second. + IceCube::Schedule.new(start_time) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(filtered_hash)) + end + end + end +end diff --git a/lib/ice_cube/validations/daily_by_set_pos.rb b/lib/ice_cube/validations/daily_by_set_pos.rb new file mode 100644 index 00000000..170810f3 --- /dev/null +++ b/lib/ice_cube/validations/daily_by_set_pos.rb @@ -0,0 +1,75 @@ +module IceCube + module Validations::DailyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(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 + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_day, end_of_day = Validations::BySetPosHelper.interval_bounds(:day, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + occurrences = new_schedule.occurrences_between(start_of_day, end_of_day) + 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.validations_array(: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/hourly_by_set_pos.rb b/lib/ice_cube/validations/hourly_by_set_pos.rb new file mode 100644 index 00000000..1cf90c37 --- /dev/null +++ b/lib/ice_cube/validations/hourly_by_set_pos.rb @@ -0,0 +1,74 @@ +module IceCube + module Validations::HourlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(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 + # Use the smallest expanded unit so we don't skip intra-hour candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + :hour + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_hour, end_of_hour = Validations::BySetPosHelper.interval_bounds(:hour, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + occurrences = new_schedule.occurrences_between(start_of_hour, end_of_hour) + 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.validations_array(: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/minutely_by_set_pos.rb b/lib/ice_cube/validations/minutely_by_set_pos.rb new file mode 100644 index 00000000..8e36fb55 --- /dev/null +++ b/lib/ice_cube/validations/minutely_by_set_pos.rb @@ -0,0 +1,73 @@ +module IceCube + module Validations::MinutelyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(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 + # Use the smallest expanded unit so we don't skip intra-minute candidates. + return :sec if rule.validations[:second_of_minute] + :min + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_minute, end_of_minute = Validations::BySetPosHelper.interval_bounds(:min, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + occurrences = new_schedule.occurrences_between(start_of_minute, end_of_minute) + 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.validations_array(: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/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb new file mode 100644 index 00000000..45a08b7a --- /dev/null +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -0,0 +1,79 @@ +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).include?(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 + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_month, end_of_month = Validations::BySetPosHelper.interval_bounds(:month, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + 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.validations_array(: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/secondly_by_set_pos.rb b/lib/ice_cube/validations/secondly_by_set_pos.rb new file mode 100644 index 00000000..70c84f9a --- /dev/null +++ b/lib/ice_cube/validations/secondly_by_set_pos.rb @@ -0,0 +1,72 @@ +module IceCube + module Validations::SecondlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(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 + # There are no smaller units than seconds to expand. + :sec + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_second, end_of_second = Validations::BySetPosHelper.interval_bounds(:sec, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + occurrences = new_schedule.occurrences_between(start_of_second, end_of_second) + 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.validations_array(: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..8a553fc9 --- /dev/null +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -0,0 +1,78 @@ +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).include?(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 + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_week, end_of_week = Validations::BySetPosHelper.interval_bounds( + :week, step_time, week_start: rule.week_start + ) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + 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.validations_array(: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..85bb4a71 --- /dev/null +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -0,0 +1,80 @@ +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).include?(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 + # Use the smallest expanded unit so we don't skip intra-day candidates. + return :sec if rule.validations[:second_of_minute] + return :min if rule.validations[:minute_of_hour] + return :hour if rule.validations[:hour_of_day] + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Compute the interval bounds and build a filtered schedule that preserves + # implicit anchors while avoiding BYSETPOS/COUNT/UNTIL truncation. + start_of_year, end_of_year = Validations::BySetPosHelper.interval_bounds(:year, step_time) + new_schedule = Validations::BySetPosHelper.build_filtered_schedule(rule, start_time) + + # Build the full candidate set for this interval, then map the selected + # occurrence to positive/negative positions. + 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.validations_array(: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/active_support_spec.rb b/spec/examples/active_support_spec.rb index 51973415..2a3f2b97 100644 --- a/spec/examples/active_support_spec.rb +++ b/spec/examples/active_support_spec.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + "/../spec_helper" +require "logger" require "active_support" require "active_support/time" require "active_support/version" diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb new file mode 100644 index 00000000..d735460a --- /dev/null +++ b/spec/examples/by_set_pos_spec.rb @@ -0,0 +1,782 @@ +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, 01, 01), Time.new(2024, 01, 01))). + 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, 01, 01), Time.new(2024, 01, 01))). + 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 + + it "should support positive positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE;BYSETPOS=1") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,16,9,0,0), + Time.new(2023,1,23,9,0,0) + ]) + end + + it "should support multiple positive positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO,WE,FR;BYSETPOS=1,3") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,6,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,13,9,0,0), + Time.new(2023,1,16,9,0,0), + Time.new(2023,1,20,9,0,0) + ]) + end + + it "should support mixed positive and negative positions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE,FR;BYSETPOS=1,-1") + schedule.start_time = Time.new(2023, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,6,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,13,9,0,0) + ]) + end + + it "should work with hour expansions" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO;BYHOUR=1,2;BYSETPOS=2") + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,2,0,0), + Time.new(2023,1,9,2,0,0), + Time.new(2023,1,16,2,0,0), + Time.new(2023,1,23,2,0,0) + ]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE;BYSETPOS=1,1") + schedule.start_time = Time.new(2023, 1, 2, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([ + Time.new(2023,1,2,9,0,0), + Time.new(2023,1,9,9,0,0), + Time.new(2023,1,16,9,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=MO,WE;BYSETPOS=3") + schedule.start_time = Time.new(2023, 1, 2, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the interval. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 2, 9, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.weekly.day(:monday, :wednesday).by_set_pos(-1).until(Time.new(2023, 1, 3, 9, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 02, 01))). + to eq([]) + 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, 01, 01), Time.new(2017, 01, 01))). + 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, 01, 01), Time.new(2017, 01, 01))). + 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 + + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=-1" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,6,24,12,0,0), + Time.new(2015,7,29,12,0,0), + Time.new(2015,8,26,12,0,0), + Time.new(2015,9,30,12,0,0) + ]) + end + + it "should support multiple positions with monthday expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=3;BYMONTHDAY=1,15,30;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 12, 01))). + to eq([ + Time.new(2015,5,15,12,0,0), + Time.new(2015,6,15,12,0,0), + Time.new(2015,7,15,12,0,0) + ]) + end + + it "should support multiple positions within the month" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMONTHDAY=1,15,30;BYSETPOS=1,2" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 8, 1))). + to eq([ + Time.new(2015,5,1,12,0,0), + Time.new(2015,5,15,12,0,0), + Time.new(2015,6,1,12,0,0), + Time.new(2015,6,15,12,0,0) + ]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=3;BYMONTHDAY=10,20;BYSETPOS=1,1" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([ + Time.new(2015,5,10,12,0,0), + Time.new(2015,6,10,12,0,0), + Time.new(2015,7,10,12,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=2;BYMONTHDAY=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2015, 5, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the month. + schedule = IceCube::Schedule.new(Time.new(2015, 5, 1, 12, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.monthly.day_of_month(10, 20).by_set_pos(2).until(Time.new(2015, 5, 15, 12, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 10, 01))). + to eq([]) + end + + it "should apply after multiple BYxxx filters" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=MO,TU,WE,TH,FR;BYMONTHDAY=13,14,15;BYSETPOS=-1" + schedule.start_time = Time.new(2019, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2019, 01, 01), Time.new(2019, 05, 01))). + to eq([ + Time.new(2019,1,15,9,0,0), + Time.new(2019,2,15,9,0,0), + Time.new(2019,3,15,9,0,0), + Time.new(2019,4,15,9,0,0) + ]) + end + + it "should work with byminute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYMINUTE=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,5,28,12,2,0), + Time.new(2015,6,28,12,2,0), + Time.new(2015,7,28,12,2,0), + Time.new(2015,8,28,12,2,0) + ]) + end + + it "should work with bysecond expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYSECOND=1,2,3,4;BYSETPOS=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,5,28,12,0,2), + Time.new(2015,6,28,12,0,2), + Time.new(2015,7,28,12,0,2), + Time.new(2015,8,28,12,0,2) + ]) + end + + it "should preserve implicit minute anchor with bysecond expansions" do + # BYSECOND should not reset the minute inherited from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=2;BYSECOND=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2015, 5, 28, 12, 30, 5) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2015, 12, 01))). + to eq([ + Time.new(2015,5,28,12,30,10), + Time.new(2015,6,28,12,30,10) + ]) + end + + it "should not consume counts across multiple rules" do + start_time = Time.new(2019, 1, 1) + rule_a = "FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" + rule_b = "FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU,WE,TH,FR;BYMONTHDAY=13,14,15;BYSETPOS=-1" + range_end = Time.new(2021, 1, 1) + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.from_ical(rule_a)) + schedule.add_recurrence_rule(IceCube::Rule.from_ical(rule_b)) + + expected_a = IceCube::Schedule.new(start_time) + expected_a.add_recurrence_rule(IceCube::Rule.from_ical(rule_a)) + expected_b = IceCube::Schedule.new(start_time) + expected_b.add_recurrence_rule(IceCube::Rule.from_ical(rule_b)) + + occurrences = schedule.occurrences_between(start_time, range_end) + expected_occurrences = (expected_a.occurrences_between(start_time, range_end) + + expected_b.occurrences_between(start_time, range_end)).sort + expect(occurrences).to eq(expected_occurrences) + expect(occurrences.size).to eq(24) + 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, 01, 01), Time.new(2017, 01, 01))). + 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, 01, 01), Time.new(2023, 01, 01))). + 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, 01, 01), Time.new(2050, 01, 01))). + 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, 01, 01), Time.new(2050, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + ]) + end + + it "should support multiple positive positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=4;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=1,2" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 12, 31))). + to eq([ + Time.new(2015, 7, 1), + Time.new(2015, 7, 2), + Time.new(2016, 7, 1), + Time.new(2016, 7, 2) + ]) + end + + it "should support multiple negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=4;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1,-2" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 12, 31))). + to eq([ + Time.new(2015, 7, 30), + Time.new(2015, 7, 31), + Time.new(2016, 7, 30), + Time.new(2016, 7, 31) + ]) + end + + it "should work with byhour expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYHOUR=1,2;BYSETPOS=1" + schedule.start_time = Time.new(2016, 7, 5, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2018, 01, 01))). + to eq([ + Time.new(2016, 7, 5, 1, 0, 0), + Time.new(2017, 7, 5, 1, 0, 0) + ]) + end + + it "should preserve implicit minute/second anchor with byhour expansions" do + # BYHOUR should not reset the minute/second inherited from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2016, 7, 5, 0, 45, 30) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2018, 01, 01))). + to eq([ + Time.new(2016, 7, 5, 2, 45, 30), + Time.new(2017, 7, 5, 2, 45, 30) + ]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYMONTHDAY=1,2;BYSETPOS=1,1" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015, 7, 1), + Time.new(2016, 7, 1) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYMONTH=7;BYMONTHDAY=1,2;BYSETPOS=3" + schedule.start_time = Time.new(2015, 1, 1) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the year. + schedule = IceCube::Schedule.new(Time.new(2015, 1, 1)) + schedule.add_recurrence_rule( + IceCube::Rule.yearly.month_of_year(7).day_of_month(1, 2).by_set_pos(2).until(Time.new(2015, 7, 1)) + ) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2016, 01, 01))). + to eq([]) + end + + it "should select positive positions within a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=2" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([ + Time.new(2015, 1, 10, 9, 0, 0), + Time.new(2016, 1, 10, 9, 0, 0) + ]) + end + + it "should select negative positions within a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=-1" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([ + Time.new(2015, 1, 20, 9, 0, 0), + Time.new(2016, 1, 20, 9, 0, 0) + ]) + end + + it "should return empty when BYSETPOS exceeds a BYYEARDAY set" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;COUNT=2;BYYEARDAY=1,10,20;BYSETPOS=4" + schedule.start_time = Time.new(2015, 1, 1, 9, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([]) + end + + it "should apply BYSETPOS before COUNT and UNTIL for BYYEARDAY" do + schedule = IceCube::Schedule.new(Time.new(2015, 1, 1, 9, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.yearly.day_of_year(1, 10, 20).by_set_pos(2).count(2).until(Time.new(2015, 1, 5)) + ) + expect(schedule.occurrences_between(Time.new(2015, 1, 1), Time.new(2017, 1, 1))). + to eq([]) + end + end + + describe DailyRule, "BYSETPOS" do + it "should work with hour expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=4;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,2,0,0), + Time.new(2023,1,2,2,0,0), + Time.new(2023,1,3,2,0,0), + Time.new(2023,1,4,2,0,0) + ]) + end + + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=3;BYHOUR=1,2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 8))). + to eq([ + Time.new(2023,1,1,2,0,0), + Time.new(2023,1,3,2,0,0), + Time.new(2023,1,5,2,0,0) + ]) + end + + it "should respect day boundaries when starting late" do + # Ensures BYSETPOS grouping resets per day while preserving the start_time minute anchor. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=2;BYHOUR=1,2;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 23, 30, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,2,1,30,0), + Time.new(2023,1,3,1,30,0) + ]) + end + + it "should ignore nonexistent local times in the BYSETPOS set", system_time_zone: "America/New_York" do + # DST spring-forward skips 2:00 AM; the invalid time must not be counted. + start_time = Time.local(2019, 3, 10, 0, 0, 0) + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule( + IceCube::Rule.daily.count(1).hour_of_day(1, 2, 3).by_set_pos(2) + ) + occurrences = schedule.occurrences_between( + Time.local(2019, 3, 10, 0, 0, 0), + Time.local(2019, 3, 11, 0, 0, 0) + ) + expect(occurrences).to eq([ + Time.local(2019,3,10,3,0,0) + ]) + end + + it "should apply after multiple BYxxx expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=9,10;BYMINUTE=15,45;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,1,10,15,0), + Time.new(2023,1,2,10,15,0), + Time.new(2023,1,3,10,15,0) + ]) + end + + it "should apply BYSETPOS before count" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=1;BYHOUR=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 05))). + to eq([ + Time.new(2023,1,1,3,0,0) + ]) + end + + it "should apply BYSETPOS before until" do + # If UNTIL were applied before BYSETPOS, the 02:00 occurrence would be selected. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.daily.hour_of_day(1, 2, 3).by_set_pos(-1).until(Time.new(2023, 1, 1, 2, 0, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 02))). + to eq([]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2;BYSETPOS=1,1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,1,0,0), + Time.new(2023,1,2,1,0,0), + Time.new(2023,1,3,1,0,0) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=2;BYHOUR=1,2;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([]) + end + + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;COUNT=3;BYHOUR=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 06))). + to eq([ + Time.new(2023,1,1,3,0,0), + Time.new(2023,1,2,3,0,0), + Time.new(2023,1,3,3,0,0) + ]) + end + + it "should support multiple positions with minute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=DAILY;BYMINUTE=10,20,30;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 03))). + to eq([ + Time.new(2023,1,1,12,10,0), + Time.new(2023,1,1,12,30,0), + Time.new(2023,1,2,12,10,0), + Time.new(2023,1,2,12,30,0) + ]) + end + end + + describe HourlyRule, "BYSETPOS" do + it "should work with minute expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=4;BYMINUTE=10,20;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 5, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0), + Time.new(2023,1,1,1,20,0), + Time.new(2023,1,1,2,20,0), + Time.new(2023,1,1,3,20,0) + ]) + end + + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;INTERVAL=2;COUNT=3;BYMINUTE=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 6, 0, 0))). + to eq([ + Time.new(2023,1,1,0,10,0), + Time.new(2023,1,1,2,10,0), + Time.new(2023,1,1,4,10,0) + ]) + end + + it "should respect hour boundaries when starting late" do + # Ensures BYSETPOS grouping resets per hour, not from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 45, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 5, 0, 0))). + to eq([ + Time.new(2023,1,1,1,10,0), + Time.new(2023,1,1,2,10,0), + Time.new(2023,1,1,3,10,0) + ]) + end + + it "should apply after multiple BYxxx expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYMINUTE=10,20;BYSECOND=5,50;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 4, 0, 0))). + to eq([ + Time.new(2023,1,1,0,10,50), + Time.new(2023,1,1,1,10,50), + Time.new(2023,1,1,2,10,50) + ]) + end + + it "should return no occurrences when BYSETPOS exceeds the set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=2;BYMINUTE=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=2;BYMINUTE=10,20;BYSETPOS=2,2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 3, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0), + Time.new(2023,1,1,1,20,0) + ]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the hour. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.hourly.minute_of_hour(10, 20).by_set_pos(2).until(Time.new(2023, 1, 1, 0, 25, 0)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([ + Time.new(2023,1,1,0,20,0) + ]) + end + + it "should support negative positions with second expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;COUNT=3;BYSECOND=5,10,15;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 34, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 4, 0, 0))). + to eq([ + Time.new(2023,1,1,0,34,15), + Time.new(2023,1,1,1,34,15), + Time.new(2023,1,1,2,34,15) + ]) + end + + it "should support multiple positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=HOURLY;BYMINUTE=5,10,15;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 2, 0, 0))). + to eq([ + Time.new(2023,1,1,0,5,0), + Time.new(2023,1,1,0,15,0), + Time.new(2023,1,1,1,5,0), + Time.new(2023,1,1,1,15,0) + ]) + end + end + + describe MinutelyRule, "BYSETPOS" do + it "should work with second expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=4;BYSECOND=5,10;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,0,10), + Time.new(2023,1,1,0,1,10), + Time.new(2023,1,1,0,2,10), + Time.new(2023,1,1,0,3,10) + ]) + end + + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;INTERVAL=5;COUNT=3;BYSECOND=10,20;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 20, 0))). + to eq([ + Time.new(2023,1,1,0,0,20), + Time.new(2023,1,1,0,5,20), + Time.new(2023,1,1,0,10,20) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=2;BYSECOND=10,20;BYSETPOS=3" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([]) + end + + it "should ignore repeated positions" do + # Duplicated BYSETPOS values should not duplicate occurrences. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=2;BYSECOND=10,20;BYSETPOS=1,1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,0,10), + Time.new(2023,1,1,0,1,10) + ]) + end + + it "should respect until limits" do + # UNTIL should be applied after BYSETPOS selection within the minute. + schedule = IceCube::Schedule.new(Time.new(2023, 1, 1, 0, 0, 0)) + schedule.add_recurrence_rule( + IceCube::Rule.minutely.second_of_minute(10, 20).by_set_pos(2).until(Time.new(2023, 1, 1, 0, 0, 25)) + ) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 2, 0))). + to eq([ + Time.new(2023,1,1,0,0,20) + ]) + end + + it "should respect minute boundaries when starting late" do + # Ensures BYSETPOS grouping resets per minute, not from the schedule start_time. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=10,20;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 45) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 5, 0))). + to eq([ + Time.new(2023,1,1,0,1,10), + Time.new(2023,1,1,0,2,10), + Time.new(2023,1,1,0,3,10) + ]) + end + + it "should apply after BYxxx filters and expansions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYHOUR=1;BYSECOND=10,20,30;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 1, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 1, 5, 0))). + to eq([ + Time.new(2023,1,1,1,0,20), + Time.new(2023,1,1,1,1,20), + Time.new(2023,1,1,1,2,20) + ]) + end + + it "should support negative positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;COUNT=3;BYSECOND=1,2,3;BYSETPOS=-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 4, 0))). + to eq([ + Time.new(2023,1,1,0,0,3), + Time.new(2023,1,1,0,1,3), + Time.new(2023,1,1,0,2,3) + ]) + end + + it "should support multiple positions" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MINUTELY;BYSECOND=5,10,15;BYSETPOS=1,-1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 2, 0))). + to eq([ + Time.new(2023,1,1,0,0,5), + Time.new(2023,1,1,0,0,15), + Time.new(2023,1,1,0,1,5), + Time.new(2023,1,1,0,1,15) + ]) + end + end + + describe SecondlyRule, "BYSETPOS" do + it "should allow BYSETPOS without other BYxxx parts" do + # RFC requires another BYxxx, but IceCube permits this for convenience. + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;COUNT=3;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([ + Time.new(2023,1,1,0,0,0), + Time.new(2023,1,1,0,0,1), + Time.new(2023,1,1,0,0,2) + ]) + end + + it "should apply BYSETPOS per interval with INTERVAL > 1" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;INTERVAL=2;COUNT=3;BYSETPOS=1" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([ + Time.new(2023,1,1,0,0,0), + Time.new(2023,1,1,0,0,2), + Time.new(2023,1,1,0,0,4) + ]) + end + + it "should return empty when BYSETPOS exceeds set size" do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=SECONDLY;COUNT=2;BYSETPOS=2" + schedule.start_time = Time.new(2023, 1, 1, 0, 0, 0) + expect(schedule.occurrences_between(Time.new(2023, 01, 01), Time.new(2023, 01, 01, 0, 0, 10))). + to eq([]) + end + end +end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2ab66c3c..e328d0e5 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -84,6 +84,30 @@ 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 preserve by_set_pos in a to_hash/from_hash round trip" do + rule = IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1]) + expect(IceCube::Rule.from_hash(rule.to_hash)).to eq(rule) + 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 +453,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/).select {|x| x =~ /RRULE/}.first).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") + end + end end end diff --git a/spec/examples/occurrence_spec.rb b/spec/examples/occurrence_spec.rb index 943c5ebb..634a2fa2 100644 --- a/spec/examples/occurrence_spec.rb +++ b/spec/examples/occurrence_spec.rb @@ -28,12 +28,19 @@ time_now = Time.current occurrence = Occurrence.new(time_now) - # From Rails 7.1 onwards, support for format options was removed - if time_now.public_method(:to_s).arity != 0 - expect(occurrence.to_s(:short)).to eq time_now.to_s(:short) - else - expect(occurrence.to_s(:short)).to eq time_now.to_s - end + # Match ActiveSupport formatting behavior across versions. + expected = + if time_now.respond_to?(:to_fs) + time_now.to_fs(:short) + elsif time_now.respond_to?(:to_formatted_s) + time_now.to_formatted_s(:short) + elsif time_now.public_method(:to_s).arity != 0 + time_now.to_s(:short) + else + time_now.to_s + end + + expect(occurrence.to_s(:short)).to eq expected end end diff --git a/spec/examples/schedule_spec.rb b/spec/examples/schedule_spec.rb index 4696a43d..5b6052a5 100644 --- a/spec/examples/schedule_spec.rb +++ b/spec/examples/schedule_spec.rb @@ -356,6 +356,23 @@ schedule.rrule IceCube::Rule.daily.count(3) expect(schedule.all_occurrences.size).to eq(5) end + + it "should consume counts for overlapping occurrences across rules" do + start_time = Time.new(2019, 1, 1) + schedule = IceCube::Schedule.new(start_time) + schedule.rrule IceCube::Rule.daily.count(2) + schedule.rrule IceCube::Rule.daily.count(2) + expect(schedule.all_occurrences).to eq([start_time, start_time + ONE_DAY]) + end + + it "should apply count limits independently across multiple rules without overlap" do + start_time = Time.new(2019, 1, 1) + schedule = IceCube::Schedule.new(start_time) + schedule.rrule IceCube::Rule.monthly.day_of_month(1).count(12) + schedule.rrule IceCube::Rule.monthly.day_of_month(15).count(12) + occurrences = schedule.occurrences_between(start_time, Time.new(2021, 1, 1)) + expect(occurrences.size).to eq(24) + end end describe :next_occurrences do diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 39dd5209..c60d8205 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + "/../spec_helper" +require "logger" require "active_support" require "active_support/time" @@ -78,6 +79,21 @@ expect(rule.to_ical).to eq("FREQ=DAILY;BYSECOND=0,15,30,45") end + it "should be able to serialize a .by_set_pos rule to_ical" do + rule = IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos(-1, 1) + ical = rule.to_ical + expect(ical).to include("FREQ=MONTHLY") + expect(ical).to include("BYDAY=MO,WE") + expect(ical).to include("BYSETPOS=-1,1") + end + + it "should be able to serialize a secondly BYSETPOS rule to_ical" do + rule = IceCube::Rule.secondly.by_set_pos(1) + ical = rule.to_ical + expect(ical).to include("FREQ=SECONDLY") + expect(ical).to include("BYSETPOS=1") + end + it "should be able to collapse a combination day_of_week and day" do rule = IceCube::Rule.daily.day(:monday, :tuesday).day_of_week(monday: [1, -1]) expect(["FREQ=DAILY;BYDAY=TU,1MO,-1MO", "FREQ=DAILY;BYDAY=1MO,-1MO,TU"].include?(rule.to_ical)).to be_truthy diff --git a/spec/examples/to_yaml_spec.rb b/spec/examples/to_yaml_spec.rb index e7c62c59..7166110d 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 @@ -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