Merge pull request #88 from cantino/human_task_agent

Human task agent

Andrew Cantino 11 年之前
父节点
当前提交
a0da672a93

+ 28 - 2
.env.example

@@ -7,7 +7,10 @@ APP_SECRET_TOKEN=REPLACE_ME_NOW!
7 7
 # for development, but it needs to be changed when you deploy to a production environment.
8 8
 DOMAIN=localhost:3000
9 9
 
10
-# Database Setup
10
+############################
11
+#      Database Setup      #
12
+############################
13
+
11 14
 DATABASE_ADAPTER=mysql2
12 15
 DATABASE_ENCODING=utf8
13 16
 DATABASE_RECONNECT=true
@@ -24,6 +27,10 @@ DATABASE_PASSWORD=""
24 27
 # Configure Rails environment.  This should only be needed in production and may cause errors in development.
25 28
 # RAILS_ENV=production
26 29
 
30
+#############################
31
+#    Email Configuration    #
32
+#############################
33
+
27 34
 # Outgoing email settings.  To use Gmail or Google Apps, put your Google Apps domain or gmail.com
28 35
 # as the SMTP_DOMAIN and your Gmail username and password as the SMTP_USER_NAME and SMTP_PASSWORD.
29 36
 SMTP_DOMAIN=your-domain-here.com
@@ -37,9 +44,28 @@ SMTP_ENABLE_STARTTLS_AUTO=true
37 44
 # The address from which system emails will appear to be sent.
38 45
 EMAIL_FROM_ADDRESS=from_address@gmail.com
39 46
 
47
+############################
48
+#     Allowing Signups     #
49
+############################
50
+
40 51
 # This invitation code will be required for users to signup with your Huginn installation.
41 52
 # You can see its use in user.rb.
42 53
 INVITATION_CODE=try-huginn
43 54
 
55
+###########################
56
+#      Agent Logging      #
57
+###########################
58
+
44 59
 # Number of lines of log messages to keep per Agent
45
-AGENT_LOG_LENGTH=100
60
+AGENT_LOG_LENGTH=200
61
+
62
+#############################
63
+#  AWS and Mechanical Turk  #
64
+#############################
65
+
66
+# AWS Credentials for MTurk
67
+AWS_ACCESS_KEY_ID="your aws access key id"
68
+AWS_ACCESS_KEY="your aws access key"
69
+
70
+# Set AWS_SANDBOX to true if you're developing Huginn code.
71
+AWS_SANDBOX=false

+ 1 - 0
Gemfile

@@ -32,6 +32,7 @@ gem 'kramdown'
32 32
 gem "typhoeus"
33 33
 gem 'nokogiri'
34 34
 gem 'wunderground'
35
+gem 'rturk'
35 36
 
36 37
 gem "twitter"
37 38
 gem 'twitter-stream', '>=0.1.16'

+ 9 - 2
Gemfile.lock

@@ -74,6 +74,8 @@ GEM
74 74
       http_parser.rb (>= 0.5.3)
75 75
     em-socksify (0.3.0)
76 76
       eventmachine (>= 1.0.0.beta.4)
77
+    erector (0.9.0)
78
+      treetop (>= 1.2.3)
77 79
     erubis (2.7.0)
78 80
     ethon (0.5.12)
79 81
       ffi (>= 1.3.0)
@@ -118,7 +120,7 @@ GEM
118 120
       mime-types (~> 1.16)
119 121
       treetop (~> 1.4.8)
120 122
     method_source (0.8.1)
121
-    mime-types (1.23)
123
+    mime-types (1.24)
122 124
     mini_portile (0.5.1)
123 125
     multi_json (1.7.9)
124 126
     multi_xml (0.5.5)
@@ -182,6 +184,10 @@ GEM
182 184
       rspec-core (~> 2.14.0)
183 185
       rspec-expectations (~> 2.14.0)
184 186
       rspec-mocks (~> 2.14.0)
187
+    rturk (2.11.0)
188
+      erector
189
+      nokogiri
190
+      rest-client
185 191
     rufus-scheduler (2.0.22)
186 192
       tzinfo (>= 0.3.23)
187 193
     safe_yaml (0.9.5)
@@ -205,7 +211,7 @@ GEM
205 211
     system_timer (1.2.4)
206 212
     thor (0.18.1)
207 213
     tilt (1.4.1)
208
-    treetop (1.4.14)
214
+    treetop (1.4.15)
209 215
       polyglot
210 216
       polyglot (>= 0.3.1)
211 217
     twilio-ruby (3.10.0)
@@ -269,6 +275,7 @@ DEPENDENCIES
269 275
   rr
270 276
   rspec
271 277
   rspec-rails
278
+  rturk
272 279
   rufus-scheduler
273 280
   sass-rails (~> 3.2.3)
274 281
   select2-rails

+ 1 - 1
app/assets/javascripts/worker-checker.js.coffee

@@ -8,7 +8,7 @@ $ ->
8 8
 
9 9
         if json.pending? && json.pending > 0
10 10
           tooltipOptions = {
11
-            title: "#{json.pending} pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures"
11
+            title: "#{json.pending} jobs pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures"
12 12
             delay: 0
13 13
             placement: "bottom"
14 14
             trigger: "hover"

+ 14 - 4
app/controllers/events_controller.rb

@@ -1,4 +1,6 @@
1 1
 class EventsController < ApplicationController
2
+  before_filter :load_event, :except => :index
3
+
2 4
   def index
3 5
     if params[:agent]
4 6
       @agent = current_user.agents.find(params[:agent])
@@ -14,21 +16,29 @@ class EventsController < ApplicationController
14 16
   end
15 17
 
16 18
   def show
17
-    @event = current_user.events.find(params[:id])
18
-
19 19
     respond_to do |format|
20 20
       format.html
21 21
       format.json { render json: @event }
22 22
     end
23 23
   end
24 24
 
25
+  def reemit
26
+    @event.reemit!
27
+    redirect_to :back, :notice => "Event re-emitted"
28
+  end
29
+
25 30
   def destroy
26
-    event = current_user.events.find(params[:id])
27
-    event.destroy
31
+    @event.destroy
28 32
 
29 33
     respond_to do |format|
30 34
       format.html { redirect_to events_path }
31 35
       format.json { head :no_content }
32 36
     end
33 37
   end
38
+
39
+  private
40
+
41
+  def load_event
42
+    @event = current_user.events.find(params[:id])
43
+  end
34 44
 end

+ 1 - 0
app/models/agent.rb

@@ -159,6 +159,7 @@ class Agent < ActiveRecord::Base
159 159
   end
160 160
 
161 161
   def log(message, options = {})
162
+    puts "Agent##{id}: #{message}" unless Rails.env.test?
162 163
     AgentLog.log_for_agent(self, message, options)
163 164
   end
164 165
 

+ 1 - 7
app/models/agents/event_formatting_agent.rb

@@ -66,16 +66,10 @@ module Agents
66 66
       !recent_error_logs?
67 67
     end
68 68
 
69
-    def value_constructor(value, payload)
70
-      value.gsub(/<[^>]+>/).each { |jsonpath|
71
-        Utils.values_at(payload, jsonpath[1..-2]).first.to_s
72
-      }
73
-    end
74
-
75 69
     def receive(incoming_events)
76 70
       incoming_events.each do |event|
77 71
         formatted_event = options[:mode].to_s == "merge" ? event.payload : {}
78
-        options[:instructions].each_pair {|key, value| formatted_event[key] = value_constructor value, event.payload }
72
+        options[:instructions].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) }
79 73
         formatted_event[:agent] = Agent.find(event.agent_id).type.slice!(8..-1) unless options[:skip_agent].to_s == "true"
80 74
         formatted_event[:created_at] = event.created_at unless options[:skip_created_at].to_s == "true"
81 75
         create_event :payload => formatted_event

+ 332 - 0
app/models/agents/human_task_agent.rb

@@ -0,0 +1,332 @@
1
+require 'rturk'
2
+
3
+module Agents
4
+  class HumanTaskAgent < Agent
5
+    default_schedule "every_10m"
6
+
7
+    description <<-MD
8
+      You can use a HumanTaskAgent to create Human Intelligence Tasks (HITs) on Mechanical Turk.
9
+
10
+      HITs can be created in response to events, or on a schedule.  Set `trigger_on` to either `schedule` or `event`.
11
+
12
+      The schedule of this Agent is how often it should check for completed HITs, __NOT__ how often to submit one.  To configure how often a new HIT
13
+      should be submitted when in `schedule` mode, set `submission_period` to a number of hours.
14
+
15
+      If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters.
16
+      For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this:
17
+
18
+          {
19
+            "expected_receive_period_in_days": 2,
20
+            "trigger_on": "event",
21
+            "hit": {
22
+              "assignments": 1,
23
+              "title": "Sentiment evaluation",
24
+              "description": "Please rate the sentiment of this message: '<$.message>'",
25
+              "reward": 0.05,
26
+              "questions": [
27
+                {
28
+                  "type": "selection",
29
+                  "key": "sentiment",
30
+                  "name": "Sentiment",
31
+                  "required": "true",
32
+                  "question": "Please select the best sentiment value:",
33
+                  "selections": [
34
+                    { "key": "happy", "text": "Happy" },
35
+                    { "key": "sad", "text": "Sad" },
36
+                    { "key": "neutral", "text": "Neutral" }
37
+                  ]
38
+                },
39
+                {
40
+                  "type": "free_text",
41
+                  "key": "feedback",
42
+                  "name": "Have any feedback for us?",
43
+                  "required": "false",
44
+                  "question": "Feedback",
45
+                  "default": "Type here...",
46
+                  "min_length": "2",
47
+                  "max_length": "2000"
48
+                }
49
+              ]
50
+            }
51
+          }
52
+
53
+      As you can see, you configure the created HIT with the `hit` option.  Required fields are `title`, which is the
54
+      title of the created HIT, `description`, which is the description of the HIT, and `questions` which is an array of
55
+      questions.  Questions can be of `type` _selection_ or _free\\_text_.  Both types require the `key`, `name`, `required`,
56
+      `type`, and `question` configuration options.  Additionally, _selection_ requires a `selections` array of options, each of
57
+      which contain `key` and `text`.  For _free\\_text_, the special configuration options are all optional, and are
58
+      `default`, `min_length`, and `max_length`.
59
+
60
+      If all of the `questions` are of `type` _selection_, you can set `take_majority` to _true_ at the top level to
61
+      automatically select the majority vote for each question across all `assignments`.  If all selections are numeric, an `average_answer` will also be generated.
62
+
63
+      As with most Agents, `expected_receive_period_in_days` is required if `trigger_on` is set to `event`.
64
+    MD
65
+
66
+    event_description <<-MD
67
+      Events look like:
68
+
69
+          {
70
+          }
71
+    MD
72
+
73
+    def validate_options
74
+      options[:hit] ||= {}
75
+      options[:hit][:questions] ||= []
76
+
77
+      errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options[:trigger_on])
78
+      errors.add(:base, "'hit.assignments' should specify the number of HIT assignments to create") unless options[:hit][:assignments].present? && options[:hit][:assignments].to_i > 0
79
+      errors.add(:base, "'hit.title' must be provided") unless options[:hit][:title].present?
80
+      errors.add(:base, "'hit.description' must be provided") unless options[:hit][:description].present?
81
+      errors.add(:base, "'hit.questions' must be provided") unless options[:hit][:questions].present? && options[:hit][:questions].length > 0
82
+
83
+      if options[:trigger_on] == "event"
84
+        errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options[:expected_receive_period_in_days].present?
85
+      elsif options[:trigger_on] == "schedule"
86
+        errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options[:submission_period].present? && options[:submission_period].to_i > 0
87
+      end
88
+
89
+      if options[:hit][:questions].any? { |question| [:key, :name, :required, :type, :question].any? {|k| !question[k].present? } }
90
+        errors.add(:base, "all questions must set 'key', 'name', 'required', 'type', and 'question'")
91
+      end
92
+
93
+      if options[:hit][:questions].any? { |question| question[:type] == "selection" && (!question[:selections].present? || question[:selections].length == 0 || !question[:selections].all? {|s| s[:key].present? } || !question[:selections].all? { |s| s[:text].present? })}
94
+        errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
95
+      end
96
+
97
+      if options[:take_majority] == "true" && options[:hit][:questions].any? { |question| question[:type] != "selection" }
98
+        errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option")
99
+      end
100
+    end
101
+
102
+    def default_options
103
+      {
104
+        :expected_receive_period_in_days => 2,
105
+        :trigger_on => "event",
106
+        :hit =>
107
+          {
108
+            :assignments => 1,
109
+            :title => "Sentiment evaluation",
110
+            :description => "Please rate the sentiment of this message: '<$.message>'",
111
+            :reward => 0.05,
112
+            :questions =>
113
+              [
114
+                {
115
+                  :type => "selection",
116
+                  :key => "sentiment",
117
+                  :name => "Sentiment",
118
+                  :required => "true",
119
+                  :question => "Please select the best sentiment value:",
120
+                  :selections =>
121
+                    [
122
+                      { :key => "happy", :text => "Happy" },
123
+                      { :key => "sad", :text => "Sad" },
124
+                      { :key => "neutral", :text => "Neutral" }
125
+                    ]
126
+                },
127
+                {
128
+                  :type => "free_text",
129
+                  :key => "feedback",
130
+                  :name => "Have any feedback for us?",
131
+                  :required => "false",
132
+                  :question => "Feedback",
133
+                  :default => "Type here...",
134
+                  :min_length => "2",
135
+                  :max_length => "2000"
136
+                }
137
+              ]
138
+          }
139
+      }
140
+    end
141
+
142
+    def working?
143
+      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
144
+    end
145
+
146
+    def check
147
+      setup!
148
+      review_hits
149
+
150
+      if options[:trigger_on] == "schedule" && (memory[:last_schedule] || 0) <= Time.now.to_i - options[:submission_period].to_i * 60 * 60
151
+        memory[:last_schedule] = Time.now.to_i
152
+        create_hit
153
+      end
154
+    end
155
+
156
+    def receive(incoming_events)
157
+      if options[:trigger_on] == "event"
158
+        setup!
159
+
160
+        incoming_events.each do |event|
161
+          create_hit event
162
+        end
163
+      end
164
+    end
165
+
166
+    # To be moved either into an initilizer or a per-agent setting.
167
+    def setup!
168
+      RTurk::logger.level = Logger::DEBUG
169
+      RTurk.setup(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_ACCESS_KEY'], :sandbox => ENV['AWS_SANDBOX'] == "true") unless Rails.env.test?
170
+    end
171
+
172
+    protected
173
+
174
+    def review_hits
175
+      reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
176
+      my_reviewed_hit_ids = reviewable_hit_ids & (memory[:hits] || {}).keys.map(&:to_s)
177
+      log "MTurk reports the following HITs [#{reviewable_hit_ids.to_sentence}], of which I own [#{my_reviewed_hit_ids.to_sentence}]"
178
+      my_reviewed_hit_ids.each do |hit_id|
179
+        hit = RTurk::Hit.new(hit_id)
180
+        assignments = hit.assignments
181
+
182
+        log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
183
+        if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
184
+          payload = { :answers => assignments.map(&:answers) }
185
+
186
+          if options[:take_majority] == "true"
187
+            counts = {}
188
+            options[:hit][:questions].each do |question|
189
+              question_counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo }
190
+              assignments.each do |assignment|
191
+                answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
192
+                answer = answers[question[:key]]
193
+                question_counts[answer] += 1
194
+              end
195
+              counts[question[:key]] = question_counts
196
+            end
197
+            payload[:counts] = counts
198
+
199
+            majority_answer = counts.inject({}) do |memo, (key, question_counts)|
200
+              memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
201
+              memo
202
+            end
203
+            payload[:majority_answer] = majority_answer
204
+
205
+            if all_questions_are_numeric?
206
+              average_answer = counts.inject({}) do |memo, (key, question_counts)|
207
+                sum = divisor = 0
208
+                question_counts.to_a.each do |num, count|
209
+                  sum += num.to_s.to_f * count
210
+                  divisor += count
211
+                end
212
+                memo[key] = sum / divisor.to_f
213
+                memo
214
+              end
215
+              payload[:average_answer] = average_answer
216
+            end
217
+          end
218
+
219
+          event = create_event :payload => payload
220
+          log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => Event.find_by_id(memory[:hits][hit_id.to_sym])
221
+
222
+          assignments.each(&:approve!)
223
+
224
+          memory[:hits].delete(hit_id.to_sym)
225
+        end
226
+      end
227
+    end
228
+
229
+    def all_questions_are_numeric?
230
+      options[:hit][:questions].all? do |question|
231
+        question[:selections].all? do |selection|
232
+          selection[:key] == selection[:key].to_f.to_s || selection[:key] == selection[:key].to_i.to_s
233
+        end
234
+      end
235
+    end
236
+
237
+    def create_hit(event = nil)
238
+      payload = event ? event.payload : {}
239
+      title = Utils.interpolate_jsonpaths(options[:hit][:title], payload).strip
240
+      description = Utils.interpolate_jsonpaths(options[:hit][:description], payload).strip
241
+      questions = Utils.recursively_interpolate_jsonpaths(options[:hit][:questions], payload)
242
+      hit = RTurk::Hit.create(:title => title) do |hit|
243
+        hit.max_assignments = (options[:hit][:assignments] || 1).to_i
244
+        hit.description = description
245
+        hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
246
+        hit.reward = (options[:hit][:reward] || 0.05).to_f
247
+        #hit.qualifications.add :approval_rate, { :gt => 80 }
248
+      end
249
+      memory[:hits] ||= {}
250
+      memory[:hits][hit.id] = event && event.id
251
+      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
252
+    end
253
+
254
+    # RTurk Question Form
255
+
256
+    class AgentQuestionForm < RTurk::QuestionForm
257
+      needs :title, :description, :questions
258
+
259
+      def question_form_content
260
+        Overview do
261
+          Title do
262
+            text @title
263
+          end
264
+          Text do
265
+            text @description
266
+          end
267
+        end
268
+
269
+        @questions.each.with_index do |question, index|
270
+          Question do
271
+            QuestionIdentifier do
272
+              text question[:key] || "question_#{index}"
273
+            end
274
+            DisplayName do
275
+              text question[:name] || "Question ##{index}"
276
+            end
277
+            IsRequired do
278
+              text question[:required] || 'true'
279
+            end
280
+            QuestionContent do
281
+              Text do
282
+                text question[:question]
283
+              end
284
+            end
285
+            AnswerSpecification do
286
+              if question[:type] == "selection"
287
+
288
+                SelectionAnswer do
289
+                  StyleSuggestion do
290
+                    text 'radiobutton'
291
+                  end
292
+                  Selections do
293
+                    question[:selections].each do |selection|
294
+                      Selection do
295
+                        SelectionIdentifier do
296
+                          text selection[:key]
297
+                        end
298
+                        Text do
299
+                          text selection[:text]
300
+                        end
301
+                      end
302
+                    end
303
+                  end
304
+                end
305
+
306
+              else
307
+
308
+                FreeTextAnswer do
309
+                  if question[:min_length].present? || question[:max_length].present?
310
+                    Constraints do
311
+                      lengths = {}
312
+                      lengths[:minLength] = question[:min_length].to_s if question[:min_length].present?
313
+                      lengths[:maxLength] = question[:max_length].to_s if question[:max_length].present?
314
+                      Length lengths
315
+                    end
316
+                  end
317
+
318
+                  if question[:default].present?
319
+                    DefaultText do
320
+                      text question[:default]
321
+                    end
322
+                  end
323
+                end
324
+
325
+              end
326
+            end
327
+          end
328
+        end
329
+      end
330
+    end
331
+  end
332
+end

+ 4 - 0
app/models/event.rb

@@ -17,4 +17,8 @@ class Event < ActiveRecord::Base
17 17
   def symbolize_payload
18 18
     self.payload = payload.recursively_symbolize_keys if payload.is_a?(Hash)
19 19
   end
20
+
21
+  def reemit!
22
+    agent.create_event :payload => payload, :lat => lat, :lng => lng
23
+  end
20 24
 end

+ 1 - 0
app/views/events/index.html.erb

@@ -24,6 +24,7 @@
24 24
           <td>
25 25
             <div class="btn-group">
26 26
               <%= link_to 'Show', event_path(event), class: "btn btn-mini" %>
27
+              <%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-mini" %>
27 28
               <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-mini" %>
28 29
             </div>
29 30
           </td>

+ 7 - 1
config/routes.rb

@@ -19,7 +19,13 @@ Huginn::Application.routes.draw do
19 19
       end
20 20
     end
21 21
   end
22
-  resources :events, :only => [:index, :show, :destroy]
22
+
23
+  resources :events, :only => [:index, :show, :destroy] do
24
+    member do
25
+      post :reemit
26
+    end
27
+  end
28
+
23 29
   match "/worker_status" => "worker_status#show"
24 30
 
25 31
   post "/users/:user_id/update_location/:secret" => "user_location_updates#create"

+ 19 - 0
lib/utils.rb

@@ -32,6 +32,25 @@ module Utils
32 32
     end
33 33
   end
34 34
 
35
+  def self.interpolate_jsonpaths(value, data)
36
+    value.gsub(/<[^>]+>/).each { |jsonpath|
37
+      Utils.values_at(data, jsonpath[1..-2]).first.to_s
38
+    }
39
+  end
40
+
41
+  def self.recursively_interpolate_jsonpaths(struct, data)
42
+    case struct
43
+      when Hash
44
+        struct.inject({}) {|memo, (key, value)| memo[key] = recursively_interpolate_jsonpaths(value, data); memo }
45
+      when Array
46
+        struct.map {|elem| recursively_interpolate_jsonpaths(elem, data) }
47
+      when String
48
+        interpolate_jsonpaths(struct, data)
49
+      else
50
+        struct
51
+    end
52
+  end
53
+
35 54
   def self.value_at(data, path)
36 55
     values_at(data, path).first
37 56
   end

+ 23 - 1
spec/controllers/events_controller_spec.rb

@@ -7,7 +7,7 @@ describe EventsController do
7 7
   end
8 8
 
9 9
   describe "GET index" do
10
-    it "only returns Agents for the current user" do
10
+    it "only returns Events created by Agents of the current user" do
11 11
       sign_in users(:bob)
12 12
       get :index
13 13
       assigns(:events).all? {|i| i.user.should == users(:bob) }.should be_true
@@ -37,6 +37,28 @@ describe EventsController do
37 37
     end
38 38
   end
39 39
 
40
+  describe "POST reemit" do
41
+    before do
42
+      request.env["HTTP_REFERER"] = "/events"
43
+      sign_in users(:bob)
44
+    end
45
+
46
+    it "clones and re-emits events" do
47
+      lambda {
48
+        post :reemit, :id => events(:bob_website_agent_event).to_param
49
+      }.should change { Event.count }.by(1)
50
+      Event.last.payload.should == events(:bob_website_agent_event).payload
51
+      Event.last.agent.should == events(:bob_website_agent_event).agent
52
+      Event.last.created_at.should be_within(1).of(Time.now)
53
+    end
54
+
55
+    it "can only re-emit Events for the current user" do
56
+      lambda {
57
+        post :reemit, :id => events(:jane_website_agent_event).to_param
58
+      }.should raise_error(ActiveRecord::RecordNotFound)
59
+    end
60
+  end
61
+
40 62
   describe "DELETE destroy" do
41 63
     it "only deletes events for the current user" do
42 64
       sign_in users(:bob)

+ 30 - 0
spec/lib/utils_spec.rb

@@ -27,6 +27,36 @@ describe Utils do
27 27
     end
28 28
   end
29 29
 
30
+  describe "#interpolate_jsonpaths" do
31
+    it "interpolates jsonpath expressions between matching <>'s" do
32
+      Utils.interpolate_jsonpaths("hello <$.there.world> this <escape works>", { :there => { :world => "WORLD" }, :works => "should work" }).should == "hello WORLD this should+work"
33
+    end
34
+  end
35
+
36
+  describe "#recursively_interpolate_jsonpaths" do
37
+    it "interpolates all string values in a structure" do
38
+      struct = {
39
+        :int => 5,
40
+        :string => "this <escape $.works>",
41
+        :array => ["<works>", "now", "<$.there.world>"],
42
+        :deep => {
43
+          :string => "hello <there.world>",
44
+          :hello => :world
45
+        }
46
+      }
47
+      data = { :there => { :world => "WORLD" }, :works => "should work" }
48
+      Utils.recursively_interpolate_jsonpaths(struct, data).should == {
49
+        :int => 5,
50
+        :string => "this should+work",
51
+        :array => ["should work", "now", "WORLD"],
52
+        :deep => {
53
+          :string => "hello WORLD",
54
+          :hello => :world
55
+        }
56
+      }
57
+    end
58
+  end
59
+
30 60
   describe "#value_at" do
31 61
     it "returns the value at a JSON path" do
32 62
       Utils.value_at({ :foo => { :bar => :baz }}.to_json, "foo.bar").should == "baz"

+ 429 - 0
spec/models/agents/human_task_agent_spec.rb

@@ -0,0 +1,429 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::HumanTaskAgent do
4
+  before do
5
+    @checker = Agents::HumanTaskAgent.new(:name => "my human task agent")
6
+    @checker.options = @checker.default_options
7
+    @checker.user = users(:bob)
8
+    @checker.save!
9
+
10
+    @event = Event.new
11
+    @event.agent = agents(:bob_rain_notifier_agent)
12
+    @event.payload = { :foo => { "bar" => { :baz => "a2b" } },
13
+                       :name => "Joe" }
14
+    @event.id = 345
15
+
16
+    @checker.should be_valid
17
+  end
18
+
19
+  describe "validations" do
20
+    it "validates that trigger_on is 'schedule' or 'event'" do
21
+      @checker.options[:trigger_on] = "foo"
22
+      @checker.should_not be_valid
23
+    end
24
+
25
+    it "requires expected_receive_period_in_days when trigger_on is set to 'event'" do
26
+      @checker.options[:trigger_on] = "event"
27
+      @checker.options[:expected_receive_period_in_days] = nil
28
+      @checker.should_not be_valid
29
+      @checker.options[:expected_receive_period_in_days] = 2
30
+      @checker.should be_valid
31
+    end
32
+
33
+    it "requires a positive submission_period when trigger_on is set to 'schedule'" do
34
+      @checker.options[:trigger_on] = "schedule"
35
+      @checker.options[:submission_period] = nil
36
+      @checker.should_not be_valid
37
+      @checker.options[:submission_period] = 2
38
+      @checker.should be_valid
39
+    end
40
+
41
+    it "requires a hit.title" do
42
+      @checker.options[:hit][:title] = ""
43
+      @checker.should_not be_valid
44
+    end
45
+
46
+    it "requires a hit.description" do
47
+      @checker.options[:hit][:description] = ""
48
+      @checker.should_not be_valid
49
+    end
50
+
51
+    it "requires hit.assignments" do
52
+      @checker.options[:hit][:assignments] = ""
53
+      @checker.should_not be_valid
54
+      @checker.options[:hit][:assignments] = 0
55
+      @checker.should_not be_valid
56
+      @checker.options[:hit][:assignments] = "moose"
57
+      @checker.should_not be_valid
58
+      @checker.options[:hit][:assignments] = "2"
59
+      @checker.should be_valid
60
+    end
61
+
62
+    it "requires hit.questions" do
63
+      old_questions = @checker.options[:hit][:questions]
64
+      @checker.options[:hit][:questions] = nil
65
+      @checker.should_not be_valid
66
+      @checker.options[:hit][:questions] = []
67
+      @checker.should_not be_valid
68
+      @checker.options[:hit][:questions] = [old_questions[0]]
69
+      @checker.should be_valid
70
+    end
71
+
72
+    it "requires that all questions have key, name, required, type, and question" do
73
+      old_questions = @checker.options[:hit][:questions]
74
+      @checker.options[:hit][:questions].first[:key] = ""
75
+      @checker.should_not be_valid
76
+
77
+      @checker.options[:hit][:questions] = old_questions
78
+      @checker.options[:hit][:questions].first[:name] = ""
79
+      @checker.should_not be_valid
80
+
81
+      @checker.options[:hit][:questions] = old_questions
82
+      @checker.options[:hit][:questions].first[:required] = nil
83
+      @checker.should_not be_valid
84
+
85
+      @checker.options[:hit][:questions] = old_questions
86
+      @checker.options[:hit][:questions].first[:type] = ""
87
+      @checker.should_not be_valid
88
+
89
+      @checker.options[:hit][:questions] = old_questions
90
+      @checker.options[:hit][:questions].first[:question] = ""
91
+      @checker.should_not be_valid
92
+    end
93
+
94
+    it "requires that all questions of type 'selection' have a selections array with keys and text" do
95
+      @checker.options[:hit][:questions][0][:selections] = []
96
+      @checker.should_not be_valid
97
+      @checker.options[:hit][:questions][0][:selections] = [{}]
98
+      @checker.should_not be_valid
99
+      @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "" }]
100
+      @checker.should_not be_valid
101
+      @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "hi" }]
102
+      @checker.should_not be_valid
103
+      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "" }]
104
+      @checker.should_not be_valid
105
+      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }]
106
+      @checker.should be_valid
107
+      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }, {}]
108
+      @checker.should_not be_valid
109
+    end
110
+
111
+    it "requires that all questions be of type 'selection' when `take_majority` is `true`" do
112
+      @checker.options[:take_majority] = "true"
113
+      @checker.should_not be_valid
114
+      @checker.options[:hit][:questions][1][:type] = "selection"
115
+      @checker.options[:hit][:questions][1][:selections] = @checker.options[:hit][:questions][0][:selections]
116
+      @checker.should be_valid
117
+    end
118
+  end
119
+
120
+  describe "when 'trigger_on' is set to 'schedule'" do
121
+    before do
122
+      @checker.options[:trigger_on] = "schedule"
123
+      @checker.options[:submission_period] = "2"
124
+      @checker.options.delete(:expected_receive_period_in_days)
125
+    end
126
+
127
+    it "should check for reviewable HITs frequently" do
128
+      mock(@checker).review_hits.twice
129
+      mock(@checker).create_hit.once
130
+      @checker.check
131
+      @checker.check
132
+    end
133
+
134
+    it "should create HITs every 'submission_period' hours" do
135
+      now = Time.now
136
+      stub(Time).now { now }
137
+      mock(@checker).review_hits.times(3)
138
+      mock(@checker).create_hit.twice
139
+      @checker.check
140
+      now += 1 * 60 * 60
141
+      @checker.check
142
+      now += 1 * 60 * 60
143
+      @checker.check
144
+    end
145
+
146
+    it "should ignore events" do
147
+      mock(@checker).create_hit(anything).times(0)
148
+      @checker.receive([events(:bob_website_agent_event)])
149
+    end
150
+  end
151
+
152
+  describe "when 'trigger_on' is set to 'event'" do
153
+    it "should not create HITs during check but should check for reviewable HITs" do
154
+      @checker.options[:submission_period] = "2"
155
+      now = Time.now
156
+      stub(Time).now { now }
157
+      mock(@checker).review_hits.times(3)
158
+      mock(@checker).create_hit.times(0)
159
+      @checker.check
160
+      now += 1 * 60 * 60
161
+      @checker.check
162
+      now += 1 * 60 * 60
163
+      @checker.check
164
+    end
165
+
166
+    it "should create HITs based on events" do
167
+      mock(@checker).create_hit(events(:bob_website_agent_event)).times(1)
168
+      @checker.receive([events(:bob_website_agent_event)])
169
+    end
170
+  end
171
+
172
+  describe "creating hits" do
173
+    it "can create HITs based on events, interpolating their values" do
174
+      @checker.options[:hit][:title] = "Hi <.name>"
175
+      @checker.options[:hit][:description] = "Make something for <.name>"
176
+      @checker.options[:hit][:questions][0][:name] = "<.name> Question 1"
177
+
178
+      question_form = nil
179
+      hitInterface = OpenStruct.new
180
+      hitInterface.id = 123
181
+      mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
182
+      mock(RTurk::Hit).create(:title => "Hi Joe").yields(hitInterface) { hitInterface }
183
+
184
+      @checker.send :create_hit, @event
185
+
186
+      hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
187
+      hitInterface.reward.should == @checker.options[:hit][:reward]
188
+      hitInterface.description.should == "Make something for Joe"
189
+
190
+      xml = question_form.to_xml
191
+      xml.should include("<Title>Hi Joe</Title>")
192
+      xml.should include("<Text>Make something for Joe</Text>")
193
+      xml.should include("<DisplayName>Joe Question 1</DisplayName>")
194
+
195
+      @checker.memory[:hits][123].should == @event.id
196
+    end
197
+
198
+    it "works without an event too" do
199
+      @checker.options[:hit][:title] = "Hi <.name>"
200
+      hitInterface = OpenStruct.new
201
+      hitInterface.id = 123
202
+      mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm)
203
+      mock(RTurk::Hit).create(:title => "Hi").yields(hitInterface) { hitInterface }
204
+      @checker.send :create_hit
205
+      hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
206
+      hitInterface.reward.should == @checker.options[:hit][:reward]
207
+    end
208
+  end
209
+
210
+  describe "reviewing HITs" do
211
+    class FakeHit
212
+      def initialize(options = {})
213
+        @options = options
214
+      end
215
+
216
+      def assignments
217
+        @options[:assignments] || []
218
+      end
219
+
220
+      def max_assignments
221
+        @options[:max_assignments] || 1
222
+      end
223
+    end
224
+
225
+    class FakeAssignment
226
+      attr_accessor :approved
227
+
228
+      def initialize(options = {})
229
+        @options = options
230
+      end
231
+
232
+      def answers
233
+        @options[:answers] || {}
234
+      end
235
+
236
+      def status
237
+        @options[:status] || ""
238
+      end
239
+
240
+      def approve!
241
+        @approved = true
242
+      end
243
+    end
244
+
245
+    it "should work on multiple HITs" do
246
+      event2 = Event.new
247
+      event2.agent = agents(:bob_rain_notifier_agent)
248
+      event2.payload = { :foo2 => { "bar2" => { :baz2 => "a2b2" } },
249
+                          :name2 => "Joe2" }
250
+      event2.id = 3452
251
+
252
+      # It knows about two HITs from two different events.
253
+      @checker.memory[:hits] = {}
254
+      @checker.memory[:hits][:"JH3132836336DHG"] = @event.id
255
+      @checker.memory[:hits][:"JH39AA63836DHG"] = event2.id
256
+
257
+      hit_ids = %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345]
258
+      mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { hit_ids } } # It sees 3 HITs.
259
+
260
+      # It looksup the two HITs that it owns.  Neither are ready yet.
261
+      mock(RTurk::Hit).new("JH3132836336DHG") { FakeHit.new }
262
+      mock(RTurk::Hit).new("JH39AA63836DHG") { FakeHit.new }
263
+
264
+      @checker.send :review_hits
265
+    end
266
+
267
+    it "shouldn't do anything if an assignment isn't ready" do
268
+      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
269
+      mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
270
+      assignments = [
271
+        FakeAssignment.new(:status => "Accepted", :answers => {}),
272
+        FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
273
+      ]
274
+      hit = FakeHit.new(:max_assignments => 2, :assignments => assignments)
275
+      mock(RTurk::Hit).new("JH3132836336DHG") { hit }
276
+
277
+      # One of the assignments isn't set to "Submitted", so this should get skipped for now.
278
+      mock.any_instance_of(FakeAssignment).answers.times(0)
279
+
280
+      @checker.send :review_hits
281
+
282
+      assignments.all? {|a| a.approved == true }.should be_false
283
+      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
284
+    end
285
+
286
+    it "shouldn't do anything if an assignment is missing" do
287
+      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
288
+      mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
289
+      assignments = [
290
+        FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
291
+      ]
292
+      hit = FakeHit.new(:max_assignments => 2, :assignments => assignments)
293
+      mock(RTurk::Hit).new("JH3132836336DHG") { hit }
294
+
295
+      # One of the assignments hasn't shown up yet, so this should get skipped for now.
296
+      mock.any_instance_of(FakeAssignment).answers.times(0)
297
+
298
+      @checker.send :review_hits
299
+
300
+      assignments.all? {|a| a.approved == true }.should be_false
301
+      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
302
+    end
303
+
304
+    it "should create events when all assignments are ready" do
305
+      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
306
+      mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
307
+      assignments = [
308
+        FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>""}),
309
+        FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
310
+      ]
311
+      hit = FakeHit.new(:max_assignments => 2, :assignments => assignments)
312
+      mock(RTurk::Hit).new("JH3132836336DHG") { hit }
313
+
314
+      lambda {
315
+        @checker.send :review_hits
316
+      }.should change { Event.count }.by(1)
317
+
318
+      assignments.all? {|a| a.approved == true }.should be_true
319
+
320
+      @checker.events.last.payload[:answers].should == [
321
+        {:sentiment => "neutral", :feedback => ""},
322
+        {:sentiment => "happy", :feedback => "Take 2"}
323
+      ]
324
+
325
+      @checker.memory[:hits].should == {}
326
+    end
327
+
328
+    describe "taking majority votes" do
329
+      before do
330
+        @checker.options[:take_majority] = "true"
331
+        @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
332
+        mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
333
+      end
334
+
335
+      it "should take the majority votes of all questions" do
336
+        @checker.options[:hit][:questions][1] = {
337
+          :type => "selection",
338
+          :key => "age_range",
339
+          :name => "Age Range",
340
+          :required => "true",
341
+          :question => "Please select your age range:",
342
+          :selections =>
343
+            [
344
+              { :key => "<50", :text => "50 years old or younger" },
345
+              { :key => ">50", :text => "Over 50 years old" }
346
+            ]
347
+        }
348
+
349
+        assignments = [
350
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"sad", "age_range"=>"<50"}),
351
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "age_range"=>">50"}),
352
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "age_range"=>">50"}),
353
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "age_range"=>">50"})
354
+        ]
355
+        hit = FakeHit.new(:max_assignments => 4, :assignments => assignments)
356
+        mock(RTurk::Hit).new("JH3132836336DHG") { hit }
357
+
358
+        lambda {
359
+          @checker.send :review_hits
360
+        }.should change { Event.count }.by(1)
361
+
362
+        assignments.all? {|a| a.approved == true }.should be_true
363
+
364
+        @checker.events.last.payload[:answers].should == [
365
+          { :sentiment => "sad", :age_range => "<50" },
366
+          { :sentiment => "neutral", :age_range => ">50" },
367
+          { :sentiment => "happy", :age_range => ">50" },
368
+          { :sentiment => "happy", :age_range => ">50" }
369
+        ]
370
+
371
+        @checker.events.last.payload[:counts].should == { :sentiment => { :happy => 2, :sad => 1, :neutral => 1 }, :age_range => { :">50" => 3, :"<50" => 1 } }
372
+        @checker.events.last.payload[:majority_answer].should == { :sentiment => "happy", :age_range => ">50" }
373
+        @checker.events.last.payload.should_not have_key(:average_answer)
374
+
375
+        @checker.memory[:hits].should == {}
376
+      end
377
+
378
+      it "should also provide an average answer when all questions are numeric" do
379
+        @checker.options[:hit][:questions] = [
380
+          {
381
+            :type => "selection",
382
+            :key => "rating",
383
+            :name => "Rating",
384
+            :required => "true",
385
+            :question => "Please select a rating:",
386
+            :selections =>
387
+              [
388
+                { :key => "1", :text => "One" },
389
+                { :key => "2", :text => "Two" },
390
+                { :key => "3", :text => "Three" },
391
+                { :key => "4", :text => "Four" },
392
+                { :key => "5.1", :text => "Five Point One" }
393
+              ]
394
+          }
395
+        ]
396
+
397
+        assignments = [
398
+          FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"1" }),
399
+          FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"3" }),
400
+          FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"5.1" }),
401
+          FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"2" }),
402
+          FakeAssignment.new(:status => "Submitted", :answers => { "rating"=>"2" })
403
+        ]
404
+        hit = FakeHit.new(:max_assignments => 5, :assignments => assignments)
405
+        mock(RTurk::Hit).new("JH3132836336DHG") { hit }
406
+
407
+        lambda {
408
+          @checker.send :review_hits
409
+        }.should change { Event.count }.by(1)
410
+
411
+        assignments.all? {|a| a.approved == true }.should be_true
412
+
413
+        @checker.events.last.payload[:answers].should == [
414
+          { :rating => "1" },
415
+          { :rating => "3" },
416
+          { :rating => "5.1" },
417
+          { :rating => "2" },
418
+          { :rating => "2" }
419
+        ]
420
+
421
+        @checker.events.last.payload[:counts].should == { :rating => { :"1" => 1, :"2" => 2, :"3" => 1, :"4" => 0, :"5.1" => 1 } }
422
+        @checker.events.last.payload[:majority_answer].should == { :rating => "2" }
423
+        @checker.events.last.payload[:average_answer].should == { :rating => (1 + 2 + 2 + 3 + 5.1) / 5.0 }
424
+
425
+        @checker.memory[:hits].should == {}
426
+      end
427
+    end
428
+  end
429
+end

+ 47 - 47
spec/models/agents/post_agent_spec.rb

@@ -1,14 +1,14 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Agents::PostAgent do
4
-    before do
5
-        @valid_params = {
6
-            :name => "somename",
7
-            :options => {
8
-                :post_url => "http://www.example.com",
9
-                :expected_receive_period_in_days => 1
10
-            } 
11
-        }
4
+  before do
5
+    @valid_params = {
6
+      :name => "somename",
7
+      :options => {
8
+        :post_url => "http://www.example.com",
9
+        :expected_receive_period_in_days => 1
10
+      }
11
+    }
12 12
 
13 13
     @checker = Agents::PostAgent.new(@valid_params)
14 14
     @checker.user = users(:jane)
@@ -17,55 +17,55 @@ describe Agents::PostAgent do
17 17
     @event = Event.new
18 18
     @event.agent = agents(:jane_weather_agent)
19 19
     @event.payload = {
20
-        :somekey => "somevalue",
21
-        :someotherkey => {
22
-            :somekey => "value"
23
-        }
20
+      :somekey => "somevalue",
21
+      :someotherkey => {
22
+        :somekey => "value"
23
+      }
24 24
     }
25 25
 
26 26
     @sent_messages = []
27
-    stub.any_instance_of(Agents::PostAgent).post_event { |uri,event| @sent_messages << event}
28
-    end
27
+    stub.any_instance_of(Agents::PostAgent).post_event { |uri, event| @sent_messages << event }
28
+  end
29 29
 
30
-    describe "#receive" do
31
-        it "checks if it can handle multiple events" do
32
-            event1 = Event.new
33
-            event1.agent = agents(:bob_weather_agent)
34
-            event1.payload = {
35
-                :xyz => "value1",
36
-                :message => "value2"
37
-            }
30
+  describe "#receive" do
31
+    it "checks if it can handle multiple events" do
32
+      event1 = Event.new
33
+      event1.agent = agents(:bob_weather_agent)
34
+      event1.payload = {
35
+        :xyz => "value1",
36
+        :message => "value2"
37
+      }
38 38
 
39
-            lambda {
40
-                @checker.receive([@event,event1])
41
-            }.should change { @sent_messages.length }.by(2)
42
-        end
39
+      lambda {
40
+        @checker.receive([@event, event1])
41
+      }.should change { @sent_messages.length }.by(2)
43 42
     end
43
+  end
44 44
 
45
-    describe "#working?" do
46
-        it "checks if events have been received within expected receive period" do
47
-            @checker.should_not be_working
48
-            Agents::PostAgent.async_receive @checker.id, [@event.id]
49
-            @checker.reload.should be_working
50
-            two_days_from_now = 2.days.from_now
51
-            stub(Time).now { two_days_from_now }  
52
-            @checker.reload.should_not be_working
53
-        end
45
+  describe "#working?" do
46
+    it "checks if events have been received within expected receive period" do
47
+      @checker.should_not be_working
48
+      Agents::PostAgent.async_receive @checker.id, [@event.id]
49
+      @checker.reload.should be_working
50
+      two_days_from_now = 2.days.from_now
51
+      stub(Time).now { two_days_from_now }
52
+      @checker.reload.should_not be_working
54 53
     end
54
+  end
55 55
 
56
-    describe "validation" do
57
-        before do
58
-            @checker.should be_valid
59
-        end
56
+  describe "validation" do
57
+    before do
58
+      @checker.should be_valid
59
+    end
60 60
 
61
-        it "should validate presence of post_url" do
62
-            @checker.options[:post_url] = ""
63
-            @checker.should_not be_valid
64
-        end
61
+    it "should validate presence of post_url" do
62
+      @checker.options[:post_url] = ""
63
+      @checker.should_not be_valid
64
+    end
65 65
 
66
-        it "should validate presence of expected_receive_period_in_days" do
67
-            @checker.options[:expected_receive_period_in_days] = ""
68
-            @checker.should_not be_valid
69
-        end
66
+    it "should validate presence of expected_receive_period_in_days" do
67
+      @checker.options[:expected_receive_period_in_days] = ""
68
+      @checker.should_not be_valid
70 69
     end
70
+  end
71 71
 end

+ 15 - 0
spec/models/event_spec.rb

@@ -1,4 +1,19 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Event do
4
+  describe "#reemit" do
5
+    it "creates a new event identical to itself" do
6
+      events(:bob_website_agent_event).lat = 2
7
+      events(:bob_website_agent_event).lng = 3
8
+      events(:bob_website_agent_event).created_at = 2.weeks.ago
9
+      lambda {
10
+        events(:bob_website_agent_event).reemit!
11
+      }.should change { Event.count }.by(1)
12
+      Event.last.payload.should == events(:bob_website_agent_event).payload
13
+      Event.last.agent.should == events(:bob_website_agent_event).agent
14
+      Event.last.lat.should == 2
15
+      Event.last.lng.should == 3
16
+      Event.last.created_at.should be_within(1).of(Time.now)
17
+    end
18
+  end
4 19
 end