hiccup 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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