hiccup 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.mdown ADDED
@@ -0,0 +1,82 @@
1
+ # Hiccup
2
+
3
+ Hiccup mixes a-la-cart recurrence features into your recurring model. It doesn't dictate the data structure of your model, just the interface. It works just like Devise does for authenticatable models.
4
+
5
+ Hiccup does provide an extremely lightweight `Schedule` class that mixes in all of Hiccup's feature, but you don't have to use Hiccup's Schedul if you don't want to.
6
+
7
+ ### Usage
8
+
9
+ ```ruby
10
+ class Schedule
11
+ extend Hiccup
12
+
13
+ hiccup :enumerable,
14
+ :validatable,
15
+ :humanizable,
16
+ :inferrable,
17
+ :serializable => [:ical]
18
+
19
+ end
20
+ ```
21
+
22
+ ### Interface
23
+
24
+ Hiccup requires that a recurring object expose the following properties
25
+
26
+ - **kind**: one of `:never`, `:weekly`, `:monthly`, `:annually`
27
+ - **start_date**: the date when the recurrence should start
28
+ - **ends**: either `true` or `false`, indicating whether the recurrence has an end date
29
+ - **end_date**: the date when the recurrence should end
30
+ - **skip**: the number of instances to skip (You'd set `skip` to 2 to specify an event that occurs every _other_ week, for example)
31
+ - **weekly_pattern**: an array of weekdays on which a weekly event should recur
32
+ - **monthly_pattern**: an array of recurrence rules for a monthly event
33
+
34
+ ### Examples
35
+
36
+
37
+ ```ruby
38
+ # Every other Monday
39
+ Schedule.new(:kind => :weekly, :weekly_pattern => ["Monday"])
40
+
41
+ # Every year on June 21 (starting in 1999)
42
+ Schedule.new(:kind => :yearly, :start_date => Date.new(1999, 6, 21))
43
+
44
+ # The second and fourth Sundays of the month
45
+ Schedule.new(:kind => :monthly, :monthly_pattern => [[2, "Sunday"], [4, "Sunday"]])
46
+ ```
47
+
48
+
49
+ # Modules
50
+
51
+ ### Enumerable
52
+
53
+ Supplies methods for creating instances of a recurring pattern
54
+
55
+ ### Validatable
56
+
57
+ Mixes in ActiveModel validations for recurrence models
58
+
59
+ ### Humanizable
60
+
61
+ Represents a recurring pattern in a human-readable string
62
+
63
+ ### Inferrable
64
+
65
+ Infers a schedule from an array of dates
66
+
67
+ Examples:
68
+
69
+ ```ruby
70
+ schedule = Schedule.infer %w{2012-7-9 2012-8-13 2012-9-10}
71
+ schedule.humanize # => "The second Monday of every month"
72
+
73
+ schedule = Schedule.infer %w{2010-3-4 2011-3-4 2012-3-4}
74
+ schedule.humanize # => "Every year on March 4"
75
+
76
+ schedule = Schedule.infer %w{2012-3-6 2012-3-8 2012-3-15 2012-3-20 2012-3-27 2012-3-29}
77
+ schedule.humanize # => "Every Tuesday and Thursday"
78
+ ```
79
+
80
+ ### Serializable
81
+
82
+ Supports serializing and deserializing a recurrence pattern to an array of formats
data/hiccup.gemspec CHANGED
@@ -8,17 +8,19 @@ Gem::Specification.new do |s|
8
8
  s.authors = ["Bob Lail"]
9
9
  s.email = ["bob.lailfamily@gmail.com"]
10
10
  s.homepage = "http://boblail.github.com/hiccup/"
11
- s.summary = %q{Recurrence features a-la-cart}
12
- s.description = %q{Recurrence features a-la-cart}
11
+ s.summary = %q{A library for working with things that recur}
12
+ s.description = %q{Hiccup mixes a-la-cart recurrence features into your data structure. It doesn't dictate the data structure, just the interface.}
13
13
 
14
14
  s.rubyforge_project = "hiccup"
15
15
 
16
- s.add_dependency "activesupport"
16
+ s.add_dependency "activesupport", "~> 3.2.8"
17
+ s.add_dependency "builder"
17
18
 
18
19
  s.add_development_dependency "ri_cal"
19
- s.add_development_dependency "rails"
20
+ s.add_development_dependency "rails", "~> 3.2.8"
20
21
  s.add_development_dependency "turn"
21
22
  s.add_development_dependency "simplecov"
23
+ s.add_development_dependency "pry"
22
24
 
23
25
  s.files = `git ls-files`.split("\n")
24
26
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
@@ -21,6 +21,17 @@ module Hiccup
21
21
  end
22
22
 
23
23
 
24
+
25
+ def get_nth_wday_of_month
26
+ (day - 1) / 7 + 1
27
+ end
28
+
29
+ def get_nth_wday_string
30
+ "#{get_nth_wday_of_month} #{::Date::DAYNAMES[wday]}"
31
+ end
32
+
33
+
34
+
24
35
  end
25
36
  end
26
37
  end
@@ -0,0 +1,17 @@
1
+ module Hiccup
2
+ module CoreExt
3
+ module EnumerableExtensions
4
+
5
+ def to_histogram
6
+ self.each_with_object(Hash.new { 0 }) do |item, histogram|
7
+ pattern = block_given? ? yield(item) : item
8
+ histogram[pattern] += 1
9
+ end
10
+ end
11
+
12
+ end
13
+ end
14
+ end
15
+
16
+ Enumerable.send(:include, Hiccup::CoreExt::EnumerableExtensions)
17
+ Array.send(:include, Hiccup::CoreExt::EnumerableExtensions)
@@ -0,0 +1,16 @@
1
+ module Hiccup
2
+ module CoreExt
3
+ module HashExtensions
4
+
5
+ def group_by_value
6
+ each_with_object({}) do |(key, value), new_hash|
7
+ (new_hash[value]||=[]).push(key)
8
+ end
9
+ end
10
+ alias :flip :group_by_value
11
+
12
+ end
13
+ end
14
+ end
15
+
16
+ Hash.send(:include, Hiccup::CoreExt::HashExtensions)
@@ -66,7 +66,8 @@ module Hiccup
66
66
  next unless temp
67
67
 
68
68
  remainder = months_between(temp, start_date) % skip
69
- temp = monthly_occurrence_to_date(occurrence, (skip - remainder).months.after(temp)) if (remainder > 0)
69
+ temp = monthly_occurrence_to_date(occurrence, (skip - remainder).months.after(temp)) if (remainder > 0)
70
+ next unless temp
70
71
 
71
72
  result = temp if !result || (temp < result)
72
73
  end
@@ -135,7 +136,8 @@ module Hiccup
135
136
  next unless temp
136
137
 
137
138
  remainder = months_between(temp, start_date) % skip
138
- temp = monthly_occurrence_to_date(occurrence, remainder.months.before(temp)) if (remainder > 0)
139
+ temp = monthly_occurrence_to_date(occurrence, remainder.months.before(temp)) if (remainder > 0)
140
+ next unless temp
139
141
 
140
142
  result = temp if !result || (temp > result)
141
143
  end
@@ -178,6 +180,22 @@ module Hiccup
178
180
 
179
181
 
180
182
 
183
+ def n_occurrences_before(limit, date)
184
+ n_occurrences_on_or_before(limit, 1.day.before(date))
185
+ end
186
+
187
+ def n_occurrences_on_or_before(limit, date)
188
+ occurrences = []
189
+ occurrence = first_occurrence_on_or_before(date)
190
+ while occurrence && occurrences.length < limit
191
+ occurrences << occurrence
192
+ occurrence = first_occurrence_before(occurrence)
193
+ end
194
+ occurrences
195
+ end
196
+
197
+
198
+
181
199
  def monthly_occurrence_to_date(occurrence, date)
182
200
  year, month = date.year, date.month
183
201
 
@@ -39,7 +39,7 @@ module Hiccup
39
39
  end
40
40
 
41
41
  def yearly_humanize
42
- sentence("Every", ordinal, "year on", self.start_date.strftime('%B %d'))
42
+ sentence("Every", ordinal, "year on", self.start_date.strftime('%B'), self.start_date.strftime('%e').strip)
43
43
  end
44
44
 
45
45
 
@@ -0,0 +1,249 @@
1
+ require 'active_support/concern'
2
+ require 'active_support/core_ext/date/conversions'
3
+ require 'hiccup/core_ext/enumerable'
4
+ require 'hiccup/core_ext/hash'
5
+
6
+
7
+ module Hiccup
8
+ module Inferrable
9
+ extend ActiveSupport::Concern
10
+
11
+ module ClassMethods
12
+
13
+
14
+
15
+ def infer(dates, options={})
16
+ @verbose = options.fetch(:verbose, false)
17
+ dates = extract_array_of_dates!(dates)
18
+ guesses = generate_guesses(dates)
19
+ guess, score = pick_best_guess(guesses, dates)
20
+ guess
21
+ end
22
+
23
+
24
+
25
+ def generate_guesses(dates)
26
+ @start_date = dates.min
27
+ @end_date = dates.max
28
+ [].tap do |guesses|
29
+ guesses.concat generate_yearly_guesses(dates)
30
+ guesses.concat generate_monthly_guesses(dates)
31
+ guesses.concat generate_weekly_guesses(dates)
32
+ end
33
+ end
34
+
35
+ def generate_yearly_guesses(dates)
36
+ histogram_of_patterns = dates.to_histogram do |date|
37
+ [date.month, date.day]
38
+ end
39
+ patterns_by_popularity = histogram_of_patterns.flip # => {1 => [...], 2 => [...], 5 => [a, b]}
40
+ highest_popularity = patterns_by_popularity.keys.max # => 5
41
+ most_popular = patterns_by_popularity[highest_popularity].first # => a
42
+ start_date = Date.new(@start_date.year, *most_popular)
43
+
44
+ [].tap do |guesses|
45
+ (1...5).each do |skip|
46
+ guesses << self.new.tap do |schedule|
47
+ schedule.kind = :annually
48
+ schedule.start_date = start_date
49
+ schedule.end_date = @end_date
50
+ schedule.skip = skip
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def generate_monthly_guesses(dates)
57
+ histogram_of_patterns = dates.to_histogram do |date|
58
+ [date.get_nth_wday_of_month, Date::DAYNAMES[date.wday]]
59
+ end
60
+ patterns_by_popularity = histogram_of_patterns.flip
61
+
62
+ histogram_of_days = dates.to_histogram(&:day)
63
+ days_by_popularity = histogram_of_days.flip
64
+
65
+ if @verbose
66
+ puts "",
67
+ " monthly analysis:",
68
+ " input: #{dates.inspect}",
69
+ " histogram (weekday): #{histogram_of_patterns.inspect}",
70
+ " by_popularity (weekday): #{patterns_by_popularity.inspect}",
71
+ " histogram (day): #{histogram_of_days.inspect}",
72
+ " by_popularity (day): #{days_by_popularity.inspect}"
73
+ end
74
+
75
+ [].tap do |guesses|
76
+ (1...5).each do |skip|
77
+ enumerate_by_popularity(days_by_popularity) do |days|
78
+ guesses << self.new.tap do |schedule|
79
+ schedule.kind = :monthly
80
+ schedule.start_date = @start_date
81
+ schedule.end_date = @end_date
82
+ schedule.skip = skip
83
+ schedule.monthly_pattern = days
84
+ end
85
+ end
86
+
87
+ enumerate_by_popularity(patterns_by_popularity) do |patterns|
88
+ guesses << self.new.tap do |schedule|
89
+ schedule.kind = :monthly
90
+ schedule.start_date = @start_date
91
+ schedule.end_date = @end_date
92
+ schedule.skip = skip
93
+ schedule.monthly_pattern = patterns
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def generate_weekly_guesses(dates)
101
+ [].tap do |guesses|
102
+ histogram_of_wdays = dates.to_histogram do |date|
103
+ Date::DAYNAMES[date.wday]
104
+ end
105
+ wdays_by_popularity = histogram_of_wdays.flip
106
+
107
+ if @verbose
108
+ puts "",
109
+ " weekly analysis:",
110
+ " input: #{dates.inspect}",
111
+ " histogram: #{histogram_of_wdays.inspect}",
112
+ " by_popularity: #{wdays_by_popularity.inspect}"
113
+ end
114
+
115
+ (1...5).each do |skip|
116
+ enumerate_by_popularity(wdays_by_popularity) do |wdays|
117
+ guesses << self.new.tap do |schedule|
118
+ schedule.kind = :weekly
119
+ schedule.start_date = @start_date
120
+ schedule.end_date = @end_date
121
+ schedule.skip = skip
122
+ schedule.weekly_pattern = wdays
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+
130
+
131
+ # Expects a hash of values grouped by popularity
132
+ # Yields the most popular values first, and then
133
+ # increasingly less popular values
134
+ def enumerate_by_popularity(values_by_popularity)
135
+ popularities = values_by_popularity.keys.sort.reverse
136
+ popularities.length.times do |i|
137
+ at_popularities = popularities.take(i + 1)
138
+ yield values_by_popularity.values_at(*at_popularities).flatten(1)
139
+ end
140
+ end
141
+
142
+
143
+
144
+ def pick_best_guess(guesses, dates)
145
+ scored_guesses = guesses \
146
+ .map { |guess| [guess, score_guess(guess, dates)] } \
147
+ .sort_by { |(guess, score)| -score.to_f }
148
+
149
+ if @verbose
150
+ puts "\nGUESSES FOR #{dates}:"
151
+ scored_guesses.each do |(guess, score)|
152
+ puts " (%.3f p/%.3f b/%.3f c/%.3f) #{guess.humanize}" % [
153
+ score.to_f,
154
+ score.prediction_rate,
155
+ score.brick_penalty,
156
+ score.complexity_penalty]
157
+ end
158
+ puts ""
159
+ end
160
+
161
+ best_guess = scored_guesses.reject { |(guess, score)| score.to_f < 0.333 }.first
162
+ best_guess || Schedule.new(kind: :never)
163
+ end
164
+
165
+ def score_guess(guess, input_dates)
166
+ predicted_dates = guess.occurrences_between(guess.start_date, guess.end_date)
167
+
168
+ # prediction_rate is the percent of input dates predicted
169
+ predictions = (predicted_dates & input_dates).length
170
+ prediction_rate = Float(predictions) / Float(input_dates.length)
171
+
172
+ # bricks are dates predicted by this guess but not in the input
173
+ bricks = (predicted_dates - input_dates).length
174
+
175
+ # brick_rate is the percent of bricks to predictions
176
+ # A brick_rate >= 1 means that this guess bricks more than it predicts
177
+ brick_rate = Float(bricks) / Float(input_dates.length)
178
+
179
+ # complexity measures how many rules are necesary
180
+ # to describe the pattern
181
+ complexity = complexity_of(guess)
182
+
183
+ # complexity_rate is the number of rules per inputs
184
+ complexity_rate = Float(complexity) / Float(input_dates.length)
185
+
186
+ Score.new(prediction_rate, brick_rate, complexity_rate)
187
+ end
188
+
189
+ def complexity_of(schedule)
190
+ return schedule.weekly_pattern.length if schedule.weekly?
191
+ return schedule.monthly_pattern.length if schedule.monthly?
192
+ 1
193
+ end
194
+
195
+
196
+
197
+ def extract_array_of_dates!(dates)
198
+ raise_invalid_dates_error! unless dates.respond_to?(:each)
199
+ dates.map { |date| assert_date!(date) }.sort
200
+ end
201
+
202
+ def assert_date!(date)
203
+ return date if date.is_a?(Date)
204
+ date.to_date rescue raise_invalid_dates_error!
205
+ end
206
+
207
+ def raise_invalid_dates_error!
208
+ raise ArgumentError.new("Inferrable.infer expects to receive a collection of dates")
209
+ end
210
+
211
+
212
+
213
+ class Score < Struct.new(:prediction_rate, :brick_rate, :complexity_rate)
214
+
215
+ # as brick rate rises, our confidence in this guess drops
216
+ def brick_penalty
217
+ brick_penalty = brick_rate * 0.33
218
+ brick_penalty = 1 if brick_penalty > 1
219
+ brick_penalty
220
+ end
221
+
222
+ # as the complexity rises, our confidence in this guess drops
223
+ # this hash table is a stand-in for a proper formala
224
+ #
225
+ # A complexity of 1 means that 1 rule is required per input
226
+ # date. This means we haven't really discovered a pattern.
227
+ def complexity_penalty
228
+ complexity_rate
229
+ end
230
+
231
+ # our confidence is weakened by bricks and complexity
232
+ def confidence
233
+ confidence = 1.0
234
+ confidence *= (1 - brick_penalty)
235
+ confidence *= (1 - complexity_penalty)
236
+ confidence
237
+ end
238
+
239
+ # a number between 0 and 1
240
+ def to_f
241
+ prediction_rate * confidence
242
+ end
243
+
244
+ end
245
+
246
+
247
+ end
248
+ end
249
+ end
@@ -11,6 +11,7 @@ module Hiccup
11
11
  hiccup :enumerable,
12
12
  :validatable,
13
13
  :humanizable,
14
+ :inferrable,
14
15
  :serializable => [:ical]
15
16
 
16
17
 
@@ -37,7 +37,7 @@ module Hiccup
37
37
  component = RiCal.parse_string(component_ics).first
38
38
 
39
39
  @obj = @klass.new
40
- @obj.start_date = component.dtstart_property.to_datetime
40
+ @obj.start_date = component.dtstart_property.try(:to_datetime)
41
41
  component.rrule_property.each do |rule|
42
42
  case rule.freq
43
43
  when "WEEKLY"; parse_weekly_rule(rule)
@@ -1,3 +1,3 @@
1
1
  module Hiccup
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,32 @@
1
+ require "test_helper"
2
+
3
+
4
+ class CoreExtDateTest < ActiveSupport::TestCase
5
+
6
+
7
+ test "should correctly identify the nth weekday of the month of a date" do
8
+ assert_equal 1, Date.new(2012, 7, 1).get_nth_wday_of_month
9
+ assert_equal 1, Date.new(2012, 7, 7).get_nth_wday_of_month
10
+ assert_equal 2, Date.new(2012, 7, 8).get_nth_wday_of_month
11
+ assert_equal 2, Date.new(2012, 7, 14).get_nth_wday_of_month
12
+ assert_equal 3, Date.new(2012, 7, 15).get_nth_wday_of_month
13
+ assert_equal 3, Date.new(2012, 7, 21).get_nth_wday_of_month
14
+ assert_equal 4, Date.new(2012, 7, 22).get_nth_wday_of_month
15
+ assert_equal 4, Date.new(2012, 7, 28).get_nth_wday_of_month
16
+ assert_equal 5, Date.new(2012, 7, 29).get_nth_wday_of_month
17
+ end
18
+
19
+ test "should correctly identify the nth weekday of the month of a date as a string" do
20
+ assert_equal "1 Sunday", Date.new(2012, 7, 1).get_nth_wday_string
21
+ assert_equal "1 Saturday", Date.new(2012, 7, 7).get_nth_wday_string
22
+ assert_equal "2 Sunday", Date.new(2012, 7, 8).get_nth_wday_string
23
+ assert_equal "2 Saturday", Date.new(2012, 7, 14).get_nth_wday_string
24
+ assert_equal "3 Sunday", Date.new(2012, 7, 15).get_nth_wday_string
25
+ assert_equal "3 Saturday", Date.new(2012, 7, 21).get_nth_wday_string
26
+ assert_equal "4 Sunday", Date.new(2012, 7, 22).get_nth_wday_string
27
+ assert_equal "4 Saturday", Date.new(2012, 7, 28).get_nth_wday_string
28
+ assert_equal "5 Sunday", Date.new(2012, 7, 29).get_nth_wday_string
29
+ end
30
+
31
+
32
+ end
@@ -55,7 +55,7 @@ class EnumerableTest < ActiveSupport::TestCase
55
55
  dates = schedule.occurrences_during_month(2008,7).map {|date| date.day}
56
56
  expected_dates = []
57
57
  assert_equal expected_dates, dates, "occurrences_during_month should generate no occurrences if before start_date"
58
-
58
+
59
59
  schedule = Schedule.new({
60
60
  :kind => :weekly,
61
61
  :weekly_pattern => %w{Monday},
@@ -99,6 +99,37 @@ class EnumerableTest < ActiveSupport::TestCase
99
99
 
100
100
 
101
101
 
102
+ def test_n_occurrences_before
103
+ schedule = Schedule.new({
104
+ :kind => :weekly,
105
+ :weekly_pattern => %w{Monday Wednesday Friday},
106
+ :start_date => Date.new(2009,3,15),
107
+ :ends => true,
108
+ :end_date => Date.new(2009,11,30)})
109
+ dates = schedule.n_occurrences_before(10, Date.new(2009, 10, 31)).map { |date| date.strftime("%Y-%m-%d") }
110
+
111
+ expected_dates = ["2009-10-30", "2009-10-28", "2009-10-26",
112
+ "2009-10-23", "2009-10-21", "2009-10-19",
113
+ "2009-10-16", "2009-10-14", "2009-10-12",
114
+ "2009-10-09" ]
115
+ assert_equal expected_dates, dates
116
+ end
117
+
118
+ test "n_occurrences_before should return a shorter array if no events exist before the given date" do
119
+ schedule = Schedule.new({
120
+ :kind => :weekly,
121
+ :weekly_pattern => %w{Monday Wednesday Friday},
122
+ :start_date => Date.new(2009,3,15),
123
+ :ends => true,
124
+ :end_date => Date.new(2009,11,30)})
125
+ dates = schedule.n_occurrences_before(10, Date.new(2009, 3, 20)).map { |date| date.strftime("%Y-%m-%d") }
126
+
127
+ expected_dates = ["2009-03-18", "2009-03-16"]
128
+ assert_equal expected_dates, dates
129
+ end
130
+
131
+
132
+
102
133
  def test_how_contains_handles_parameter_types
103
134
  date = Date.new(1981,4,23)
104
135
  schedule = Schedule.new({:kind => :annually, :start_date => date})
@@ -127,6 +158,24 @@ class EnumerableTest < ActiveSupport::TestCase
127
158
 
128
159
 
129
160
 
161
+ test "should not throw an exception when calculating monthly recurrence and skip causes a guess to be discarded" do
162
+ schedule = Schedule.new({
163
+ :kind => :monthly,
164
+ :monthly_pattern => [
165
+ [1, "Tuesday"],
166
+ [2, "Thursday"],
167
+ [3, "Thursday"],
168
+ [3, "Tuesday"],
169
+ [4, "Tuesday"],
170
+ [5, "Thursday"] ],
171
+ :skip => 3,
172
+ :start_date => Date.new(2012,3,6),
173
+ :end_date => Date.new(2012,3,29)})
174
+ schedule.occurrences_between(schedule.start_date, schedule.end_date)
175
+ end
176
+
177
+
178
+
130
179
  def test_weekly_recurrence_and_skip
131
180
  schedule = Schedule.new({
132
181
  :kind => :weekly,
@@ -54,16 +54,30 @@ class HumanizableTest < ActiveSupport::TestCase
54
54
  {:kind => :monthly, :monthly_pattern => [[1, "Monday"], [3, "Monday"]], :skip => 2})
55
55
 
56
56
  test_humanize(
57
- "Every year on #{Date.today.strftime('%B %d')}",
58
- {:kind => :annually})
57
+ "Every year on October 1",
58
+ {:kind => :annually, start_date: Date.new(2012, 10, 1)})
59
59
 
60
60
  test_humanize(
61
- "Every other year on #{Date.today.strftime('%B %d')}",
62
- {:kind => :annually, :skip => 2})
61
+ "Every year on October 10",
62
+ {:kind => :annually, start_date: Date.new(2012, 10, 10)})
63
63
 
64
64
  test_humanize(
65
- "Every fourth year on #{Date.today.strftime('%B %d')}",
66
- {:kind => :annually, :skip => 4})
65
+ "Every other year on August 12",
66
+ {:kind => :annually, :skip => 2, start_date: Date.new(2012, 8, 12)})
67
+
68
+ test_humanize(
69
+ "Every fourth year on January 31",
70
+ {:kind => :annually, :skip => 4, start_date: Date.new(1888, 1, 31)})
71
+
72
+
73
+
74
+ test "should not have spaces in front of day numbers" do
75
+ independence_day = Schedule.new({
76
+ :kind => :annually,
77
+ :start_date => Date.new(2012, 7, 4)
78
+ })
79
+ assert_equal "Every year on July 4", independence_day.humanize
80
+ end
67
81
 
68
82
 
69
83
 
@@ -17,6 +17,20 @@ class IcalSerializableTest < ActiveSupport::TestCase
17
17
  end
18
18
 
19
19
 
20
+ def test_parsing_empty_recurrence
21
+ schedule = Schedule.from_ical("")
22
+ assert_equal :never, schedule.kind
23
+ end
24
+
25
+
26
+ test_roundtrip(
27
+ "No recurrence",
28
+ "DTSTART;VALUE=DATE-TIME:20090101T000000Z\n",
29
+ { :kind => :never,
30
+ :start_date => DateTime.new(2009, 1, 1)
31
+ })
32
+
33
+
20
34
  test_roundtrip(
21
35
  "Simple weekly recurrence",
22
36
  "DTSTART;VALUE=DATE-TIME:20090101T000000Z\nRRULE:FREQ=WEEKLY;BYDAY=SU\n",
@@ -26,6 +40,17 @@ class IcalSerializableTest < ActiveSupport::TestCase
26
40
  })
27
41
 
28
42
 
43
+ test_roundtrip(
44
+ "Simple weekly recurrence (with an end date)",
45
+ "DTSTART;VALUE=DATE-TIME:20090101T000000Z\nRRULE:FREQ=WEEKLY;UNTIL=20091231T000000Z;BYDAY=SU\n",
46
+ { :kind => :weekly,
47
+ :weekly_pattern => %w{Sunday},
48
+ :start_date => DateTime.new(2009, 1, 1),
49
+ :end_date => DateTime.new(2009, 12, 31),
50
+ :ends => true
51
+ })
52
+
53
+
29
54
  test_roundtrip(
30
55
  "Complex weekly recurrence (with an interval)",
31
56
  "DTSTART;VALUE=DATE-TIME:20090101T000000Z\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH\n",
@@ -0,0 +1,244 @@
1
+ require "test_helper"
2
+
3
+ # Ideas: see 3/4/10, 3/5/11, 3/4/12 as closer to
4
+ # a pattern than 3/4/10, 9/15/11, 3/4/12.
5
+
6
+ class InferrableTest < ActiveSupport::TestCase
7
+ include Hiccup
8
+
9
+
10
+
11
+ test "should raise an error if not given an array of dates" do
12
+ assert_raises ArgumentError do
13
+ Schedule.infer(["what's this?"])
14
+ end
15
+ end
16
+
17
+ test "extract_array_of_dates! should interpret date-like things as dates" do
18
+ datelike_things = [Date.today, Time.now, DateTime.new(2012, 8, 17)]
19
+ dates = Schedule.extract_array_of_dates!(datelike_things)
20
+ assert_equal datelike_things.length, dates.length
21
+ assert dates.all? { |date| date.is_a?(Date) }, "Expected these to all be dates, but they were #{dates.map(&:class)}"
22
+ end
23
+
24
+ test "extract_array_of_dates! should put dates in order" do
25
+ input_dates = %w{2014-01-01 2007-01-01 2009-01-01}
26
+ expected_dates = %w{2007-01-01 2009-01-01 2014-01-01}
27
+ dates = Schedule.extract_array_of_dates!(input_dates)
28
+ assert_equal expected_dates, dates.map(&:to_s)
29
+ end
30
+
31
+
32
+
33
+ test "should prefer guesses that predict too many results over guesses that predict too few" do
34
+
35
+ # In this stream of dates, we could guess an annual recurrence on 1/15
36
+ # or a monthly recurrence on the 15th.
37
+ #
38
+ # - The annual recurrence would have zero bricks (no unfulfilled predictions),
39
+ # but it would fail to predict 5 of the dates in this list.
40
+ #
41
+ # - The monthly recurrence would have zero failures (there's nothing in
42
+ # in the list it _wouldn't_ predict), but it would have 6 bricks.
43
+ #
44
+ dates = %w{2011-1-15 2011-2-15 2011-5-15 2011-7-15 2011-8-15 2011-11-15 2012-1-15}
45
+
46
+ # If bricks and fails were equal, the annual recurrence would be the
47
+ # preferred guess, but the monthly one makes more sense.
48
+ # It is better to brick than to fail.
49
+ schedule = Schedule.infer(dates)
50
+ assert_equal :monthly, schedule.kind
51
+ end
52
+
53
+
54
+
55
+
56
+
57
+ # Infers annual schedules
58
+
59
+ test "should infer an annual" do
60
+ dates = %w{2010-3-4 2011-3-4 2012-3-4}
61
+ schedule = Schedule.infer(dates)
62
+ assert_equal "Every year on March 4", schedule.humanize
63
+ end
64
+
65
+
66
+ # ... with skips
67
+
68
+ test "should infer a schedule that occurs every other year" do
69
+ dates = %w{2010-3-4 2012-3-4 2014-3-4}
70
+ schedule = Schedule.infer(dates)
71
+ assert_equal "Every other year on March 4", schedule.humanize
72
+ end
73
+
74
+ # ... where some of the input is wrong
75
+
76
+ test "should infer a yearly schedule when one of the dates was rescheduled" do
77
+ dates = %w{2010-3-4 2011-9-15 2012-3-4 2013-3-4}
78
+ schedule = Schedule.infer(dates)
79
+ assert_equal "Every year on March 4", schedule.humanize
80
+ end
81
+
82
+ test "should infer a yearly schedule when the first date was rescheduled" do
83
+ dates = %w{2010-3-6 2011-3-4 2012-3-4 2013-3-4}
84
+ schedule = Schedule.infer(dates)
85
+ assert_equal "Every year on March 4", schedule.humanize
86
+ end
87
+
88
+
89
+
90
+
91
+
92
+ # Infers monthly schedules
93
+
94
+ test "should infer a monthly schedule that occurs on a date" do
95
+ dates = %w{2012-2-4 2012-3-4 2012-4-4}
96
+ schedule = Schedule.infer(dates)
97
+ assert_equal "The 4th of every month", schedule.humanize
98
+
99
+ dates = %w{2012-2-17 2012-3-17 2012-4-17}
100
+ schedule = Schedule.infer(dates)
101
+ assert_equal "The 17th of every month", schedule.humanize
102
+ end
103
+
104
+ test "should infer a monthly schedule that occurs on a weekday" do
105
+ dates = %w{2012-7-9 2012-8-13 2012-9-10}
106
+ schedule = Schedule.infer(dates)
107
+ assert_equal "The second Monday of every month", schedule.humanize
108
+ end
109
+
110
+ test "should infer a schedule that occurs several times a month" do
111
+ dates = %w{2012-7-9 2012-7-23 2012-8-13 2012-8-27 2012-9-10 2012-9-24}
112
+ schedule = Schedule.infer(dates)
113
+ assert_equal "The second Monday and fourth Monday of every month", schedule.humanize
114
+ end
115
+
116
+
117
+ # ... with skips
118
+
119
+ test "should infer a schedule that occurs every third month" do
120
+ dates = %w{2012-2-4 2012-5-4 2012-8-4}
121
+ schedule = Schedule.infer(dates)
122
+ assert_equal "The 4th of every third month", schedule.humanize
123
+ end
124
+
125
+
126
+ # ... when some dates are wrong in the input group
127
+
128
+ test "should infer a monthly (by day) schedule when one day was rescheduled" do
129
+ dates = %w{2012-10-02 2012-11-02 2012-12-03}
130
+ schedule = Schedule.infer(dates)
131
+ assert_equal "The 2nd of every month", schedule.humanize
132
+ end
133
+
134
+ test "should infer a monthly (by day) schedule when the first day was rescheduled" do
135
+ dates = %w{2012-10-03 2012-11-02 2012-12-02}
136
+ schedule = Schedule.infer(dates)
137
+ assert_equal "The 2nd of every month", schedule.humanize
138
+ end
139
+
140
+
141
+ test "should infer a monthly (by weekday) schedule when one day was rescheduled" do
142
+ dates = %w{2012-10-02 2012-11-06 2012-12-05} # 1st Tuesday, 1st Tuesday, 1st Wednesday
143
+ schedule = Schedule.infer(dates)
144
+ assert_equal "The first Tuesday of every month", schedule.humanize
145
+ end
146
+
147
+ test "should infer a monthly (by weekday) schedule when the first day was rescheduled" do
148
+ dates = %w{2012-10-03 2012-11-01 2012-12-06} # 1st Wednesday, 1st Thursday, 1st Thursday
149
+ schedule = Schedule.infer(dates)
150
+ assert_equal "The first Thursday of every month", schedule.humanize
151
+ end
152
+
153
+ test "should infer a monthly (by weekday) schedule when the first day was rescheduled 2" do
154
+ dates = %w{2012-10-11 2012-11-01 2012-12-06} # 2nd Thursday, 1st Thursday, 1st Thursday
155
+ schedule = Schedule.infer(dates)
156
+ assert_equal "The first Thursday of every month", schedule.humanize
157
+ end
158
+
159
+
160
+
161
+
162
+
163
+ # Infers weekly schedules
164
+
165
+ test "should infer a weekly schedule" do
166
+ dates = %w{2012-3-4 2012-3-11 2012-3-18}
167
+ schedule = Schedule.infer(dates)
168
+ assert_equal "Every Sunday", schedule.humanize
169
+ end
170
+
171
+ test "should infer a schedule that occurs several times a week" do
172
+ dates = %w{2012-3-6 2012-3-8 2012-3-13 2012-3-15 2012-3-20 2012-3-22}
173
+ schedule = Schedule.infer(dates)
174
+ assert_equal "Every Tuesday and Thursday", schedule.humanize
175
+ end
176
+
177
+
178
+ # ... with skips
179
+
180
+ test "should infer weekly recurrence for something that occurs every other week" do
181
+ dates = %w{2012-3-6 2012-3-8 2012-3-20 2012-3-22}
182
+ schedule = Schedule.infer(dates)
183
+ assert_equal "Tuesday and Thursday of every other week", schedule.humanize
184
+ end
185
+
186
+
187
+ # ... when some dates are missing from the input array
188
+
189
+ test "should infer a weekly schedule (missing dates)" do
190
+ dates = %w{2012-3-4 2012-3-11 2012-3-25}
191
+ schedule = Schedule.infer(dates)
192
+ assert_equal "Every Sunday", schedule.humanize
193
+ end
194
+
195
+ test "should infer a schedule that occurs several times a week (missing dates)" do
196
+ dates = %w{2012-3-6 2012-3-8 2012-3-15 2012-3-20 2012-3-27 2012-3-29}
197
+ schedule = Schedule.infer(dates)
198
+ assert_equal "Every Tuesday and Thursday", schedule.humanize
199
+ end
200
+
201
+
202
+ # ... when some dates are wrong in the input group
203
+
204
+ test "should infer a weekly schedule when one day was rescheduled" do
205
+ dates = %w{2012-10-02 2012-10-09 2012-10-15} # a Tuesday, a Tuesday, and a Monday
206
+ schedule = Schedule.infer(dates)
207
+ assert_equal "Every Tuesday", schedule.humanize
208
+ end
209
+
210
+ test "should infer a weekly schedule when the first day was rescheduled" do
211
+ dates = %w{2012-10-07 2012-10-10 2012-10-17} # a Sunday, a Wednesday, and a Wednesday
212
+ schedule = Schedule.infer(dates)
213
+ assert_equal "Every Wednesday", schedule.humanize
214
+ end
215
+
216
+
217
+
218
+
219
+ # Correctly identifies scenarios where there is no pattern
220
+
221
+ test "should not try to guess a pattern for input where there is none" do
222
+ arbitrary_date_ranges = [
223
+ %w{2013-01-01 2013-03-30 2014-08-19},
224
+ %w{2012-10-01 2012-10-09 2012-10-17}, # a Monday, a Tuesday, and a Wednesday
225
+ ]
226
+
227
+ arbitrary_date_ranges.each do |dates|
228
+ schedule = Schedule.infer(dates)
229
+ fail "There should be no pattern to the dates #{dates}, but Hiccup guessed \"#{schedule.humanize}\"" unless schedule.never?
230
+ end
231
+ end
232
+
233
+
234
+
235
+ test "should diabolically complex schedules" do
236
+ dates = %w{2012-11-06 2012-11-08 2012-11-15 2012-11-20 2012-11-27 2012-11-29 2013-02-05 2013-02-14 2013-02-21 2013-02-19 2013-02-26 2013-05-07 2013-05-09 2013-05-16 2013-05-28 2013-05-21 2013-05-30}
237
+ schedule = Schedule.infer(dates)
238
+ assert_equal "The first Tuesday, second Thursday, third Thursday, third Tuesday, fourth Tuesday, and fifth Thursday of every third month", schedule.humanize
239
+ end
240
+
241
+
242
+
243
+
244
+ end
data/test/test_helper.rb CHANGED
@@ -5,3 +5,4 @@ require "rails/test_help"
5
5
  require "active_support/core_ext"
6
6
  require "turn"
7
7
  require "hiccup/schedule"
8
+ require "pry"
metadata CHANGED
@@ -1,138 +1,201 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: hiccup
3
- version: !ruby/object:Gem::Version
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
4
5
  prerelease:
5
- version: 0.2.1
6
6
  platform: ruby
7
- authors:
7
+ authors:
8
8
  - Bob Lail
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
-
13
- date: 2011-09-18 00:00:00 -05:00
14
- default_executable:
15
- dependencies:
16
- - !ruby/object:Gem::Dependency
12
+ date: 2012-11-06 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
17
15
  name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 3.2.8
22
+ type: :runtime
18
23
  prerelease: false
19
- requirement: &id001 !ruby/object:Gem::Requirement
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 3.2.8
30
+ - !ruby/object:Gem::Dependency
31
+ name: builder
32
+ requirement: !ruby/object:Gem::Requirement
20
33
  none: false
21
- requirements:
22
- - - ">="
23
- - !ruby/object:Gem::Version
24
- version: "0"
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
25
38
  type: :runtime
26
- version_requirements: *id001
27
- - !ruby/object:Gem::Dependency
28
- name: ri_cal
29
39
  prerelease: false
30
- requirement: &id002 !ruby/object:Gem::Requirement
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: ri_cal
48
+ requirement: !ruby/object:Gem::Requirement
31
49
  none: false
32
- requirements:
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: "0"
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
36
54
  type: :development
37
- version_requirements: *id002
38
- - !ruby/object:Gem::Dependency
39
- name: rails
40
55
  prerelease: false
41
- requirement: &id003 !ruby/object:Gem::Requirement
56
+ version_requirements: !ruby/object:Gem::Requirement
42
57
  none: false
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: "0"
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rails
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ~>
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.8
47
70
  type: :development
48
- version_requirements: *id003
49
- - !ruby/object:Gem::Dependency
50
- name: turn
51
71
  prerelease: false
52
- requirement: &id004 !ruby/object:Gem::Requirement
72
+ version_requirements: !ruby/object:Gem::Requirement
53
73
  none: false
54
- requirements:
55
- - - ">="
56
- - !ruby/object:Gem::Version
57
- version: "0"
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ version: 3.2.8
78
+ - !ruby/object:Gem::Dependency
79
+ name: turn
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
58
86
  type: :development
59
- version_requirements: *id004
60
- - !ruby/object:Gem::Dependency
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
61
95
  name: simplecov
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
62
103
  prerelease: false
63
- requirement: &id005 !ruby/object:Gem::Requirement
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: pry
112
+ requirement: !ruby/object:Gem::Requirement
64
113
  none: false
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: "0"
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
69
118
  type: :development
70
- version_requirements: *id005
71
- description: Recurrence features a-la-cart
72
- email:
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ description: Hiccup mixes a-la-cart recurrence features into your data structure.
127
+ It doesn't dictate the data structure, just the interface.
128
+ email:
73
129
  - bob.lailfamily@gmail.com
74
130
  executables: []
75
-
76
131
  extensions: []
77
-
78
132
  extra_rdoc_files: []
79
-
80
- files:
133
+ files:
81
134
  - .gitignore
82
135
  - .simplecov
83
136
  - Gemfile
137
+ - README.mdown
84
138
  - Rakefile
85
139
  - hiccup.gemspec
86
140
  - lib/hiccup.rb
87
141
  - lib/hiccup/convenience.rb
88
142
  - lib/hiccup/core_ext/date.rb
89
143
  - lib/hiccup/core_ext/duration.rb
144
+ - lib/hiccup/core_ext/enumerable.rb
90
145
  - lib/hiccup/core_ext/fixnum.rb
146
+ - lib/hiccup/core_ext/hash.rb
91
147
  - lib/hiccup/enumerable.rb
92
148
  - lib/hiccup/humanizable.rb
149
+ - lib/hiccup/inferrable.rb
93
150
  - lib/hiccup/schedule.rb
94
151
  - lib/hiccup/serializable/ical.rb
95
152
  - lib/hiccup/serializers/ical.rb
96
153
  - lib/hiccup/validatable.rb
97
154
  - lib/hiccup/version.rb
155
+ - test/core_ext_date_test.rb
98
156
  - test/duration_ext_test.rb
99
157
  - test/enumerable_test.rb
100
158
  - test/humanizable_test.rb
101
159
  - test/ical_serializable_test.rb
160
+ - test/inferrable_test.rb
102
161
  - test/test_helper.rb
103
162
  - test/validatable_test.rb
104
- has_rdoc: true
105
163
  homepage: http://boblail.github.com/hiccup/
106
164
  licenses: []
107
-
108
165
  post_install_message:
109
166
  rdoc_options: []
110
-
111
- require_paths:
167
+ require_paths:
112
168
  - lib
113
- required_ruby_version: !ruby/object:Gem::Requirement
169
+ required_ruby_version: !ruby/object:Gem::Requirement
114
170
  none: false
115
- requirements:
116
- - - ">="
117
- - !ruby/object:Gem::Version
118
- version: "0"
119
- required_rubygems_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ! '>='
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ segments:
176
+ - 0
177
+ hash: -955647137280487206
178
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
179
  none: false
121
- requirements:
122
- - - ">="
123
- - !ruby/object:Gem::Version
124
- version: "0"
180
+ requirements:
181
+ - - ! '>='
182
+ - !ruby/object:Gem::Version
183
+ version: '0'
184
+ segments:
185
+ - 0
186
+ hash: -955647137280487206
125
187
  requirements: []
126
-
127
188
  rubyforge_project: hiccup
128
- rubygems_version: 1.6.2
189
+ rubygems_version: 1.8.23
129
190
  signing_key:
130
191
  specification_version: 3
131
- summary: Recurrence features a-la-cart
132
- test_files:
192
+ summary: A library for working with things that recur
193
+ test_files:
194
+ - test/core_ext_date_test.rb
133
195
  - test/duration_ext_test.rb
134
196
  - test/enumerable_test.rb
135
197
  - test/humanizable_test.rb
136
198
  - test/ical_serializable_test.rb
199
+ - test/inferrable_test.rb
137
200
  - test/test_helper.rb
138
201
  - test/validatable_test.rb