abingo 1.1.0 → 2.0.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/.gitignore CHANGED
@@ -1 +1,3 @@
1
1
  .svn
2
+ abingo-test
3
+ pkg
data/Gemfile CHANGED
@@ -2,3 +2,6 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in abingo.gemspec
4
4
  gemspec
5
+ gem "contest", :group => :test
6
+ gem "ruby-debug19", :group => :test
7
+ gem "pg", :group => :test
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- abingo (1.1.0)
4
+ abingo (2.0.0)
5
5
  rails (~> 3.0)
6
6
 
7
7
  GEM
@@ -34,19 +34,25 @@ GEM
34
34
  activesupport (3.2.9)
35
35
  i18n (~> 0.6)
36
36
  multi_json (~> 1.0)
37
+ archive-tar-minitar (0.5.2)
37
38
  arel (3.0.2)
38
39
  builder (3.0.4)
40
+ columnize (0.3.6)
41
+ contest (0.1.3)
39
42
  erubis (2.7.0)
40
43
  hike (1.2.1)
41
44
  i18n (0.6.1)
42
45
  journey (1.0.4)
43
46
  json (1.7.5)
47
+ linecache19 (0.5.12)
48
+ ruby_core_source (>= 0.1.4)
44
49
  mail (2.4.4)
45
50
  i18n (>= 0.4.0)
46
51
  mime-types (~> 1.16)
47
52
  treetop (~> 1.4.8)
48
53
  mime-types (1.19)
49
54
  multi_json (1.3.7)
55
+ pg (0.14.1)
50
56
  polyglot (0.3.3)
51
57
  rack (1.4.1)
52
58
  rack-cache (1.2)
@@ -73,6 +79,16 @@ GEM
73
79
  rake (10.0.2)
74
80
  rdoc (3.12)
75
81
  json (~> 1.4)
82
+ ruby-debug-base19 (0.11.25)
83
+ columnize (>= 0.3.1)
84
+ linecache19 (>= 0.5.11)
85
+ ruby_core_source (>= 0.1.4)
86
+ ruby-debug19 (0.11.6)
87
+ columnize (>= 0.3.1)
88
+ linecache19 (>= 0.5.11)
89
+ ruby-debug-base19 (>= 0.11.19)
90
+ ruby_core_source (0.1.5)
91
+ archive-tar-minitar (>= 0.5.2)
76
92
  sprockets (2.2.1)
77
93
  hike (~> 1.2)
78
94
  multi_json (~> 1.0)
@@ -90,3 +106,6 @@ PLATFORMS
90
106
 
91
107
  DEPENDENCIES
92
108
  abingo!
109
+ contest
110
+ pg
111
+ ruby-debug19
data/README CHANGED
@@ -19,7 +19,7 @@ Key default features:
19
19
 
20
20
  Example: View
21
21
 
22
- <% ab_test("login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file| %>
22
+ <% ab_test(@abingo_identity, "login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file| %>
23
23
  <%= img_tag(button_file, :alt => "Login!") %>
24
24
  <% end %>
25
25
 
@@ -27,13 +27,13 @@ Example: Controller
27
27
 
28
28
  def register_new_user
29
29
  #See what level of free points maximizes users' decision to buy replacement points.
30
- @starter_points = ab_test("new_user_free_points", [100, 200, 300])
30
+ @starter_points = ab_test(@abingo_identity, "new_user_free_points", [100, 200, 300])
31
31
  end
32
32
 
33
33
  Example: Controller
34
34
 
35
35
  def registration
36
- if (ab_test("send_welcome_email"), :conversion => "purchase")
36
+ if (ab_test(@abingo_identity, "send_welcome_email"), :conversion => "purchase")
37
37
  #send the email, track to see if it later increases conversion to full version
38
38
  end
39
39
  end
@@ -42,12 +42,12 @@ Example: Conversion tracking (in a controller!)
42
42
 
43
43
  def buy_new_points
44
44
  #some business logic
45
- bingo!("buy_new_points") #Either a conversion named with :conversion or a test name.
45
+ @abingo_identity.bingo!("buy_new_points") #Either a conversion named with :conversion or a test name.
46
46
  end
47
47
 
48
48
  Example: Conversion tracking (in a view)
49
49
 
50
- Thanks for signing up, dude! <% bingo!("signup_page_redesign") >
50
+ Thanks for signing up, dude! <% @abingo_identity.bingo!("signup_page_redesign") >
51
51
 
52
52
  Example: Statistical Significance Testing
53
53
 
@@ -62,7 +62,7 @@ Abingo::Experiment.last.describe_result_in_words
62
62
  Installation
63
63
  =======
64
64
 
65
- 1) REQUIRED: You'll need to generate a DB migration to prepare two tables,
65
+ 1) REQUIRED: You'll need to generate a DB migration to prepare two tables,
66
66
  then migrate your database. (Note: slight edits required if you use the table names
67
67
  "experiments" or "alternatives" at present.) Note: if you are upgrading to A/Bingo 1.0.0, you'll
68
68
  want to do this again.
@@ -80,9 +80,10 @@ before_filter :set_abingo_identity
80
80
 
81
81
  def set_abingo_identity
82
82
  if (session[:abingo_identity])
83
- Abingo.identity = session[:abingo_identity]
83
+ @abingo_identity = Abingo.identify(session[:abingo_identity])
84
84
  else
85
- session[:abingo_identity] = Abingo.identity = rand(10 ** 10).to_i
85
+ @abingo_identity = Abingo.identify
86
+ session[:abingo_identity] = @abingo_identity.identity
86
87
  end
87
88
  end
88
89
 
@@ -106,4 +107,4 @@ A/Bingo defaults to using the same cache store as Rails. If you want to change
106
107
  Abingo.cache = ActiveSupport::Cache::MemCacheStore.new("cache.example.com:12345") #best if really memcacheDB
107
108
 
108
109
 
109
- Copyright (c) 2009-2010 Patrick McKenzie, released under the MIT license
110
+ Copyright (c) 2009-2010 Patrick McKenzie, released under the MIT license
@@ -14,25 +14,20 @@ ActionView::Base.send :include, AbingoViewHelper
14
14
  #Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
15
15
 
16
16
  class Abingo
17
-
18
- @@VERSION = "1.1.0"
19
- @@MAJOR_VERSION = "1.1"
20
- cattr_reader :VERSION
21
- cattr_reader :MAJOR_VERSION
22
-
23
- #Not strictly necessary, but eh, as long as I'm here.
24
17
  cattr_accessor :salt
25
18
  @@salt = "Not really necessary."
26
19
 
27
20
  @@options ||= {}
28
21
  cattr_accessor :options
29
22
 
23
+ attr_accessor :identity
24
+
30
25
  #Defined options:
31
26
  # :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
32
27
  # :enable_override_in_session => if true, allows session[test_name] to override the calculated value for a test.
33
28
  # :expires_in => if not nil, passes expire_in to creation of per-user cache keys. Useful for Redis, to prevent expired sessions
34
29
  # from running wild and consuming all of your memory.
35
- # :count_humans_only => Count only participation and conversions from humans. Humans can be identified by calling Abingo.mark_human!
30
+ # :count_humans_only => Count only participation and conversions from humans. Humans can be identified by calling @abingo.mark_human!
36
31
  # This can be done in e.g. Javascript code, which bots will typically not execute. See FAQ for details.
37
32
  # :expires_in_for_bots => if not nil, passes expire_in to creation of per-user cache keys, but only for bots.
38
33
  # Only matters if :count_humans_only is on.
@@ -47,30 +42,40 @@ class Abingo
47
42
  #
48
43
  #You can overwrite Abingo's cache instance, if you would like it to not share
49
44
  #your generic Rails cache.
50
- cattr_writer :cache
51
45
 
52
46
  def self.cache
53
- @@cache || Rails.cache
47
+ @cache || Rails.cache
54
48
  end
55
49
 
56
- #This method gives a unique identity to a user. It can be absolutely anything
57
- #you want, as long as it is consistent.
58
- #
59
- #We use the identity to determine, deterministically, which alternative a user sees.
60
- #This means that if you use Abingo.identify_user on someone at login, they will
50
+ def self.cache=(cache)
51
+ @cache = cache
52
+ end
53
+
54
+ def self.identity=(new_identity)
55
+ raise RuntimeError.new("Setting identity on the class level has been deprecated. Please create an instance via: @abingo = Abingo.identify('user-id')")
56
+ end
57
+
58
+ def self.generate_identity
59
+ rand(10 ** 10).to_i.to_s
60
+ end
61
+
62
+ #This method identifies a user and ensures they consistently see the same alternative.
63
+ #This means that if you use Abingo.identify on someone at login, they will
61
64
  #always see the same alternative for a particular test which is past the login
62
65
  #screen. For details and usage notes, see the docs.
63
- def self.identity=(new_identity)
64
- @@identity = new_identity.to_s
66
+ def self.identify(identity = nil)
67
+ identity ||= generate_identity
68
+ new(identity)
65
69
  end
66
70
 
67
- def self.identity
68
- @@identity ||= rand(10 ** 10).to_i.to_s
71
+ def initialize(identity)
72
+ @identity = identity
73
+ super
69
74
  end
70
75
 
71
76
  #A simple convenience method for doing an A/B test. Returns true or false.
72
77
  #If you pass it a block, it will bind the choice to the variable given to the block.
73
- def self.flip(test_name)
78
+ def flip(test_name)
74
79
  if block_given?
75
80
  yield(self.test(test_name, [true, false]))
76
81
  else
@@ -82,7 +87,7 @@ class Abingo
82
87
  #options accepts
83
88
  # :multiple_participation (true or false)
84
89
  # :conversion name of conversion to listen for (alias: conversion_name)
85
- def self.test(test_name, alternatives, options = {})
90
+ def test(test_name, alternatives, options = {})
86
91
 
87
92
  short_circuit = Abingo.cache.read("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
88
93
  unless short_circuit.nil?
@@ -91,42 +96,39 @@ class Abingo
91
96
 
92
97
  unless Abingo::Experiment.exists?(test_name)
93
98
  lock_key = "Abingo::lock_for_creation(#{test_name.gsub(" ", "_")})"
94
- creation_required = true
95
-
99
+ lock_id = SecureRandom.hex
96
100
  #this prevents (most) repeated creations of experiments in high concurrency environments.
97
101
  if Abingo.cache.exist?(lock_key)
98
- creation_required = false
99
- while Abingo.cache.exist?(lock_key)
100
- sleep(0.1)
102
+ wait_for_lock_release(lock_key)
103
+ else
104
+ Abingo.cache.write(lock_key, lock_id, :expires_in => 5.seconds)
105
+ sleep(0.1)
106
+ if Abingo.cache.read(lock_key) == lock_id
107
+ conversion_name = options[:conversion] || options[:conversion_name]
108
+ Abingo::Experiment.start_experiment!(test_name, Abingo.parse_alternatives(alternatives), conversion_name)
109
+ else
110
+ wait_for_lock_release(lock_key)
101
111
  end
102
- creation_required = Abingo::Experiment.exists?(test_name)
103
- end
104
-
105
- if creation_required
106
- Abingo.cache.write(lock_key, 1, :expires_in => 5.seconds)
107
- conversion_name = options[:conversion] || options[:conversion_name]
108
- Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives), conversion_name)
109
- Abingo.cache.delete(lock_key)
110
112
  end
113
+ Abingo.cache.delete(lock_key)
111
114
  end
112
115
 
113
116
  choice = self.find_alternative_for_user(test_name, alternatives)
114
- participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
117
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{self.identity}") || []
115
118
 
116
119
  #Set this user to participate in this experiment, and increment participants count.
117
120
  if options[:multiple_participation] || !(participating_tests.include?(test_name))
118
121
  unless participating_tests.include?(test_name)
119
122
  participating_tests = participating_tests + [test_name]
120
- expires_in = Abingo.expires_in
121
- if expires_in
122
- Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => expires_in})
123
+ if self.expires_in
124
+ Abingo.cache.write("Abingo::participating_tests::#{self.identity}", participating_tests, {:expires_in => self.expires_in})
123
125
  else
124
- Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
126
+ Abingo.cache.write("Abingo::participating_tests::#{self.identity}", participating_tests)
125
127
  end
126
128
  end
127
129
  #If we're only counting known humans, then postpone scoring participation until after we know the user is human.
128
- if (!@@options[:count_humans_only] || Abingo.is_human?)
129
- Abingo::Alternative.score_participation(test_name)
130
+ if (!@@options[:count_humans_only] || self.is_human?)
131
+ Abingo::Alternative.score_participation(test_name, choice)
130
132
  end
131
133
  end
132
134
 
@@ -137,6 +139,11 @@ class Abingo
137
139
  end
138
140
  end
139
141
 
142
+ def wait_for_lock_release(lock_key)
143
+ while Abingo.cache.exist?(lock_key)
144
+ sleep(0.1)
145
+ end
146
+ end
140
147
 
141
148
  #Scores conversions for tests.
142
149
  #test_name_or_array supports three types of input:
@@ -149,7 +156,7 @@ class Abingo
149
156
  #An array of either of the above: for each element of the array, process as above.
150
157
  #
151
158
  #nil: score a conversion for every test the u
152
- def Abingo.bingo!(name = nil, options = {})
159
+ def bingo!(name = nil, options = {})
153
160
  if name.kind_of? Array
154
161
  name.map do |single_test|
155
162
  self.bingo!(single_test, options)
@@ -157,7 +164,7 @@ class Abingo
157
164
  else
158
165
  if name.nil?
159
166
  #Score all participating tests
160
- participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
167
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{self.identity}") || []
161
168
  participating_tests.each do |participating_test|
162
169
  self.bingo!(participating_test, options)
163
170
  end
@@ -182,52 +189,53 @@ class Abingo
182
189
  end
183
190
  end
184
191
 
185
- def self.participating_tests(only_current = true)
186
- identity = Abingo.identity
192
+ def participating_tests(only_current = true)
187
193
  participating_tests = Abingo.cache.read("Abingo::participating_tests::#{identity}") || []
188
194
  tests_and_alternatives = participating_tests.inject({}) do |acc, test_name|
189
195
  alternatives_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
190
196
  alternatives = Abingo.cache.read(alternatives_key)
191
- acc[test_name] = Abingo.find_alternative_for_user(test_name, alternatives)
197
+ acc[test_name] = find_alternative_for_user(test_name, alternatives)
192
198
  acc
193
199
  end
194
200
  if (only_current)
195
201
  tests_and_alternatives.reject! do |key, value|
196
- self.cache.read("Abingo::Experiment::short_circuit(#{key})")
202
+ Abingo.cache.read("Abingo::Experiment::short_circuit(#{key})")
197
203
  end
198
204
  end
199
205
  tests_and_alternatives
200
206
  end
201
207
 
202
208
  #Marks that this user is human.
203
- def self.human!
204
- Abingo.cache.fetch("Abingo::is_human(#{Abingo.identity})", {:expires_in => Abingo.expires_in(true)}) do
209
+ def human!
210
+ Abingo.cache.fetch("Abingo::is_human(#{self.identity})", {:expires_in => self.expires_in(true)}) do
205
211
  #Now that we know the user is human, score participation for all their tests. (Further participation will *not* be lazy evaluated.)
206
212
 
207
213
  #Score all tests which have been deferred.
208
- participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
214
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{self.identity}") || []
209
215
 
210
216
  #Refresh cache expiry for this user to match that of known humans.
211
217
  if (@@options[:expires_in_for_bots] && !participating_tests.blank?)
212
- Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => Abingo.expires_in(true)})
218
+ Abingo.cache.write("Abingo::participating_tests::#{self.identity}", participating_tests, {:expires_in => self.expires_in(true)})
213
219
  end
214
220
 
215
221
  participating_tests.each do |test_name|
216
- Alternative.score_participation(test_name)
217
- if conversions = Abingo.cache.read("Abingo::conversions(#{Abingo.identity},#{test_name}")
218
- conversions.times { Alternative.score_conversion(test_name) }
222
+ viewed_alternative = find_alternative_for_user(test_name,
223
+ Abingo::Experiment.alternatives_for_test(test_name))
224
+ Alternative.score_participation(test_name, viewed_alternative)
225
+ if conversions = Abingo.cache.read("Abingo::conversions(#{self.identity},#{test_name}")
226
+ conversions.times { Alternative.score_conversion(test_name, viewed_alternative) }
219
227
  end
220
228
  end
221
229
  true #Marks this user as human in the cache.
222
230
  end
223
231
  end
224
232
 
225
- protected
226
-
227
- def self.is_human?
228
- !!Abingo.cache.read("Abingo::is_human(#{Abingo.identity})")
233
+ def is_human?
234
+ !!Abingo.cache.read("Abingo::is_human(#{self.identity})")
229
235
  end
230
236
 
237
+ protected
238
+
231
239
  #For programmer convenience, we allow you to specify what the alternatives for
232
240
  #an experiment are in a few ways. Thus, we need to actually be able to handle
233
241
  #all of them. We fire this parser very infrequently (once per test, typically)
@@ -264,32 +272,34 @@ class Abingo
264
272
 
265
273
  def self.retrieve_alternatives(test_name, alternatives)
266
274
  cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
267
- alternative_array = self.cache.fetch(cache_key) do
268
- self.parse_alternatives(alternatives)
275
+ alternative_array = Abingo.cache.fetch(cache_key) do
276
+ Abingo.parse_alternatives(alternatives)
269
277
  end
270
278
  alternative_array
271
279
  end
272
280
 
273
- def self.find_alternative_for_user(test_name, alternatives)
274
- alternatives_array = retrieve_alternatives(test_name, alternatives)
281
+ def find_alternative_for_user(test_name, alternatives)
282
+ alternatives_array = Abingo.retrieve_alternatives(test_name, alternatives)
275
283
  alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
276
284
  end
277
285
 
278
286
  #Quickly determines what alternative to show a given user. Given a test name
279
287
  #and their identity, we hash them together (which, for MD5, provably introduces
280
288
  #enough entropy that we don't care) otherwise
281
- def self.modulo_choice(test_name, choices_count)
289
+ def modulo_choice(test_name, choices_count)
282
290
  Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
283
291
  end
284
292
 
285
- def self.score_conversion!(test_name)
293
+ def score_conversion!(test_name)
286
294
  test_name.gsub!(" ", "_")
287
- participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
295
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{self.identity}") || []
288
296
  if options[:assume_participation] || participating_tests.include?(test_name)
289
- cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name}"
297
+ cache_key = "Abingo::conversions(#{self.identity},#{test_name}"
290
298
  if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
291
- if !options[:count_humans_only] || Abingo.is_human?
292
- Abingo::Alternative.score_conversion(test_name)
299
+ if !options[:count_humans_only] || is_human?
300
+ viewed_alternative = find_alternative_for_user(test_name,
301
+ Abingo::Experiment.alternatives_for_test(test_name))
302
+ Abingo::Alternative.score_conversion(test_name, viewed_alternative)
293
303
  end
294
304
 
295
305
  if Abingo.cache.exist?(cache_key)
@@ -301,12 +311,12 @@ class Abingo
301
311
  end
302
312
  end
303
313
 
304
- def self.expires_in(known_human = false)
314
+ def expires_in(known_human = false)
305
315
  expires_in = nil
306
316
  if (@@options[:expires_in])
307
317
  expires_in = @@options[:expires_in]
308
318
  end
309
- if (@@options[:count_humans_only] && @@options[:expires_in_for_bots] && !(known_human || Abingo.is_human?))
319
+ if (@@options[:count_humans_only] && @@options[:expires_in_for_bots] && !(known_human || is_human?))
310
320
  expires_in = @@options[:expires_in_for_bots]
311
321
  end
312
322
  expires_in
@@ -1,3 +1,4 @@
1
+ require "active_record"
1
2
  require "abingo/conversion_rate"
2
3
  class Abingo::Alternative < ActiveRecord::Base
3
4
  include Abingo::ConversionRate
@@ -10,15 +11,11 @@ class Abingo::Alternative < ActiveRecord::Base
10
11
  Digest::MD5.hexdigest(Abingo.salt + test_name + alternative_name.to_s)
11
12
  end
12
13
 
13
- def self.score_conversion(test_name)
14
- viewed_alternative = Abingo.find_alternative_for_user(test_name,
15
- Abingo::Experiment.alternatives_for_test(test_name))
14
+ def self.score_conversion(test_name, viewed_alternative)
16
15
  self.update_all("conversions = conversions + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
17
16
  end
18
17
 
19
- def self.score_participation(test_name)
20
- viewed_alternative = Abingo.find_alternative_for_user(test_name,
21
- Abingo::Experiment.alternatives_for_test(test_name))
18
+ def self.score_participation(test_name, viewed_alternative)
22
19
  self.update_all("participants = participants + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
23
20
  end
24
21
 
@@ -1,12 +1,8 @@
1
+ require 'action_controller'
1
2
  class Abingo
2
3
  module Controller
3
4
  module Dashboard
4
-
5
- if Rails::VERSION::MAJOR <= 2
6
- ActionController::Base.view_paths.unshift File.join(File.dirname(__FILE__), "../views")
7
- else
8
- ActionController::Base.prepend_view_path File.join(File.dirname(__FILE__), "../views")
9
- end
5
+ ActionController::Base.prepend_view_path File.join(File.dirname(__FILE__), "../views")
10
6
 
11
7
  def index
12
8
  @experiments = Abingo::Experiment.all
@@ -76,11 +76,7 @@ class Abingo::Experiment < ActiveRecord::Base
76
76
  cloned_alternatives_array -= [alt]
77
77
  end
78
78
  experiment.status = "Live"
79
- if Rails::VERSION::MAJOR == 2
80
- experiment.save(false) #Calling the validation here causes problems b/c of transaction.
81
- else
82
- experiment.save(:validate => false)
83
- end
79
+ experiment.save(:validate => false)
84
80
  Abingo.cache.write("Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"), 1)
85
81
 
86
82
  #This might have issues in very, very high concurrency environments...
@@ -1,3 +1,3 @@
1
1
  class Abingo
2
- VERSION = "1.1.0"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -5,15 +5,15 @@
5
5
 
6
6
  module AbingoSugar
7
7
 
8
- def ab_test(test_name, alternatives = nil, options = {})
8
+ def ab_test(abingo, test_name, alternatives = nil, options = {})
9
9
  if (Abingo.options[:enable_specification] && !params[test_name].nil?)
10
10
  choice = params[test_name]
11
11
  elsif (Abingo.options[:enable_override_in_session] && !session[test_name].nil?)
12
12
  choice = session[test_name]
13
13
  elsif (alternatives.nil?)
14
- choice = Abingo.flip(test_name)
14
+ choice = abingo.flip(test_name)
15
15
  else
16
- choice = Abingo.test(test_name, alternatives, options)
16
+ choice = abingo.test(test_name, alternatives, options)
17
17
  end
18
18
 
19
19
  if block_given?
@@ -23,19 +23,19 @@ module AbingoSugar
23
23
  end
24
24
  end
25
25
 
26
- def bingo!(test_name, options = {})
27
- Abingo.bingo!(test_name, options)
26
+ def bingo!(abingo, test_name, options = {})
27
+ abingo.bingo!(test_name, options)
28
28
  end
29
29
 
30
30
  #Mark the user as a human.
31
- def abingo_mark_human
31
+ def abingo_mark_human(abingo)
32
32
  textual_result = "1"
33
33
  begin
34
34
  a = params[:a].to_i
35
35
  b = params[:b].to_i
36
36
  c = params[:c].to_i
37
37
  if (request.method == :post && (a + b == c))
38
- Abingo.human!
38
+ abingo.human!
39
39
  else
40
40
  textual_result = "0"
41
41
  end
@@ -43,7 +43,6 @@ module AbingoSugar
43
43
  textual_result = "0"
44
44
  end
45
45
  render :text => textual_result, :layout => false #Not actually used by browser
46
-
47
46
  end
48
47
 
49
- end
48
+ end
@@ -2,32 +2,28 @@
2
2
 
3
3
  module AbingoViewHelper
4
4
 
5
- def ab_test(test_name, alternatives = nil, options = {}, &block)
5
+ def ab_test(abingo, test_name, alternatives = nil, options = {}, &block)
6
6
 
7
7
  if (Abingo.options[:enable_specification] && !params[test_name].nil?)
8
8
  choice = params[test_name]
9
9
  elsif (Abingo.options[:enable_override_in_session] && !session[test_name].nil?)
10
10
  choice = session[test_name]
11
11
  elsif (alternatives.nil?)
12
- choice = Abingo.flip(test_name)
12
+ choice = abingo.flip(test_name)
13
13
  else
14
- choice = Abingo.test(test_name, alternatives, options)
14
+ choice = abingo.test(test_name, alternatives, options)
15
15
  end
16
16
 
17
17
  if block
18
18
  content_tag = capture(choice, &block)
19
- unless Rails::VERSION::MAJOR >= 3
20
- block_called_from_erb?(block) ? concat(content_tag) : content_tag
21
- else
22
- content_tag
23
- end
19
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
24
20
  else
25
21
  choice
26
22
  end
27
23
  end
28
24
 
29
- def bingo!(test_name, options = {})
30
- Abingo.bingo!(test_name, options)
25
+ def bingo!(abingo, test_name, options = {})
26
+ abingo.bingo!(test_name, options)
31
27
  end
32
28
 
33
29
  #This causes an AJAX post against the URL. That URL should call Abingo.human!
@@ -41,5 +37,5 @@ module AbingoViewHelper
41
37
  end
42
38
  script.nil? ? "" : %Q|<script type="text/javascript">#{script}</script>|
43
39
  end
44
-
45
- end
40
+
41
+ end
@@ -1,14 +1,20 @@
1
- require 'test_helper'
1
+ require_relative 'test_helper'
2
2
 
3
- class AbingoTest < ActiveSupport::TestCase
3
+ class AbingoTest < Test::Unit::TestCase
4
4
 
5
- #Wipes cache, D/B prior to doing a test run.
6
- Abingo.cache.clear
7
- Abingo::Experiment.delete_all
8
- Abingo::Alternative.delete_all
5
+ setup do
6
+ Abingo.options = {}
7
+ end
8
+
9
+ teardown do
10
+ Abingo.cache.clear
11
+ Abingo::Experiment.delete_all
12
+ Abingo::Alternative.delete_all
13
+ end
9
14
 
10
15
  test "identity automatically assigned" do
11
- assert Abingo.identity != nil
16
+ abingo = Abingo.identify
17
+ assert abingo.identity != nil
12
18
  end
13
19
 
14
20
  test "alternative parsing" do
@@ -23,31 +29,35 @@ class AbingoTest < ActiveSupport::TestCase
23
29
  assert_equal 0, Abingo::Experiment.count
24
30
  assert_equal 0, Abingo::Alternative.count
25
31
  alternatives = %w{A B}
26
- alternative_selected = Abingo.test("unit_test_sample_A", alternatives)
32
+ abingo = Abingo.identify
33
+ alternative_selected = abingo.test("unit_test_sample_A", alternatives)
27
34
  assert_equal 1, Abingo::Experiment.count
28
35
  assert_equal 2, Abingo::Alternative.count
29
36
  assert alternatives.include?(alternative_selected)
30
37
  end
31
38
 
32
39
  test "exists works right" do
33
- Abingo.test("exist works right", %w{does does_not})
40
+ abingo = Abingo.identify
41
+ abingo.test("exist works right", %w{does does_not})
34
42
  assert Abingo::Experiment.exists?("exist works right")
35
43
  end
36
44
 
37
45
  test "alternatives picked consistently" do
38
- alternative_picked = Abingo.test("consistency_test", 1..100)
46
+ abingo = Abingo.identify
47
+ alternative_picked = abingo.test("consistency_test", 1..100)
39
48
  100.times do
40
- assert_equal alternative_picked, Abingo.test("consistency_test", 1..100)
49
+ assert_equal alternative_picked, abingo.test("consistency_test", 1..100)
41
50
  end
42
51
  end
43
52
 
44
53
  test "participation works" do
45
54
  new_tests = %w{participationA participationB participationC}
55
+ abingo = Abingo.identify
46
56
  new_tests.map do |test_name|
47
- Abingo.test(test_name, 1..5)
57
+ abingo.test(test_name, 1..5)
48
58
  end
49
59
 
50
- participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
60
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{abingo.identity}") || []
51
61
 
52
62
  new_tests.map do |test_name|
53
63
  assert participating_tests.include? test_name
@@ -56,7 +66,8 @@ class AbingoTest < ActiveSupport::TestCase
56
66
 
57
67
  test "participants counted" do
58
68
  test_name = "participants_counted_test"
59
- alternative = Abingo.test(test_name, %w{a b c})
69
+ abingo = Abingo.identify
70
+ alternative = abingo.test(test_name, %w{a b c})
60
71
 
61
72
  ex = Abingo::Experiment.find_by_test_name(test_name)
62
73
  lookup = Abingo::Alternative.calculate_lookup(test_name, alternative)
@@ -67,14 +78,15 @@ class AbingoTest < ActiveSupport::TestCase
67
78
 
68
79
  test "conversion tracking by test name" do
69
80
  test_name = "conversion_test_by_name"
70
- alternative = Abingo.test(test_name, %w{a b c})
71
- Abingo.bingo!(test_name)
81
+ abingo = Abingo.identify
82
+ alternative = abingo.test(test_name, %w{a b c})
83
+ abingo.bingo!(test_name)
72
84
  ex = Abingo::Experiment.find_by_test_name(test_name)
73
85
  lookup = Abingo::Alternative.calculate_lookup(test_name, alternative)
74
86
  chosen_alt = Abingo::Alternative.find_by_lookup(lookup)
75
87
  assert_equal 1, ex.conversions
76
88
  assert_equal 1, chosen_alt.conversions
77
- Abingo.bingo!(test_name)
89
+ abingo.bingo!(test_name)
78
90
 
79
91
  #Should still only have one because this conversion should not be double counted.
80
92
  #We haven't specified that in the test options.
@@ -82,13 +94,14 @@ class AbingoTest < ActiveSupport::TestCase
82
94
  end
83
95
 
84
96
  test "conversion tracking by conversion name" do
97
+ abingo = Abingo.identify
85
98
  conversion_name = "purchase"
86
99
  tests = %w{conversionTrackingByConversionNameA conversionTrackingByConversionNameB conversionTrackingByConversionNameC}
87
100
  tests.map do |test_name|
88
- Abingo.test(test_name, %w{A B}, :conversion => conversion_name)
101
+ abingo.test(test_name, %w{A B}, :conversion => conversion_name)
89
102
  end
90
103
 
91
- Abingo.bingo!(conversion_name)
104
+ abingo.bingo!(conversion_name)
92
105
  tests.map do |test_name|
93
106
  assert_equal 1, Abingo::Experiment.find_by_test_name(test_name).conversions
94
107
  end
@@ -97,7 +110,8 @@ class AbingoTest < ActiveSupport::TestCase
97
110
  test "short circuiting works" do
98
111
  conversion_name = "purchase"
99
112
  test_name = "short circuit test"
100
- alt_picked = Abingo.test(test_name, %w{A B}, :conversion => conversion_name)
113
+ abingo = Abingo.identify
114
+ alt_picked = abingo.test(test_name, %w{A B}, :conversion => conversion_name)
101
115
  ex = Abingo::Experiment.find_by_test_name(test_name)
102
116
  alt_not_picked = (%w{A B} - [alt_picked]).first
103
117
 
@@ -105,14 +119,12 @@ class AbingoTest < ActiveSupport::TestCase
105
119
 
106
120
  ex.reload
107
121
  assert_equal "Finished", ex.status
108
-
109
- Abingo.bingo!(test_name) #Should not be counted, test is over.
122
+
123
+ abingo.bingo!(test_name) #Should not be counted, test is over.
110
124
  assert_equal 0, ex.conversions
111
125
 
112
- old_identity = Abingo.identity
113
- Abingo.identity = "shortCircuitTestNewIdentity"
114
- Abingo.test(test_name, %w{A B}, :conversion => conversion_name)
115
- Abingo.identity = old_identity
126
+ new_bingo = Abingo.identify("shortCircuitTestNewIdentity")
127
+ new_bingo.test(test_name, %w{A B}, :conversion => conversion_name)
116
128
  ex.reload
117
129
  assert_equal 1, ex.participants #Original identity counted, new identity not counted b/c test stopped
118
130
  end
@@ -125,35 +137,56 @@ class AbingoTest < ActiveSupport::TestCase
125
137
  threads = []
126
138
  5.times do
127
139
  threads << Thread.new do
128
- Abingo.test(test_name, alternatives, conversion_name)
129
- 1
140
+ abingo = Abingo.identify
141
+ abingo.test(test_name, alternatives, :conversion => conversion_name)
142
+ ActiveRecord::Base.connection.close
130
143
  end
131
144
  end
132
- sleep(10)
145
+ threads.each(&:join)
133
146
  assert_equal 1, Abingo::Experiment.count_by_sql(["select count(id) from experiments where test_name = ?", test_name])
134
147
  end
135
148
 
149
+ test "proper conversions with concurrency" do
150
+ test_name = "conversion_concurrency_test"
151
+ alternatives = %w{foo bar}
152
+ threads = []
153
+ alternatives.size.times do |i|
154
+ threads << Thread.new do
155
+ abingo = Abingo.identify(i)
156
+ abingo.test(test_name, alternatives)
157
+ abingo.bingo!(test_name)
158
+ sleep(0.3) if i == 0
159
+ ActiveRecord::Base.connection.close
160
+ end
161
+ end
162
+ threads.each(&:join)
163
+ ex = Abingo::Experiment.find_by_test_name(test_name)
164
+ ex.alternatives.each do |alternative|
165
+ assert_equal 1, alternative.conversions
166
+ end
167
+ end
168
+
136
169
  test "non-humans are ignored for participation and conversions if not explicitly counted" do
137
170
  Abingo.options[:count_humans_only] = true
138
171
  Abingo.options[:expires_in] = 1.hour
139
172
  Abingo.options[:expires_in_for_bots] = 3.seconds
140
- first_identity = Abingo.identity = "unsure_if_human#{Time.now.to_i}"
173
+ first_identity = Abingo.identify("unsure_if_human#{Time.now.to_i}")
141
174
  test_name = "are_you_a_human"
142
- Abingo.test(test_name, %w{does_not matter})
175
+ first_identity.test(test_name, %w{does_not matter})
143
176
 
144
- assert_false Abingo.is_human?, "Identity not marked as human yet."
177
+ assert !first_identity.is_human?, "Identity not marked as human yet."
145
178
 
146
179
  ex = Abingo::Experiment.find_by_test_name(test_name)
147
- Abingo.bingo!(test_name)
180
+ first_identity.bingo!(test_name)
148
181
  assert_equal 0, ex.participants, "Not human yet, so should have no participants."
149
182
  assert_equal 0, ex.conversions, "Not human yet, so should have no conversions."
150
183
 
151
- Abingo.human!
152
-
184
+ first_identity.human!
185
+
153
186
  #Setting up second participant who doesn't convert.
154
- Abingo.identity = "unsure_if_human_2_#{Time.now.to_i}"
155
- Abingo.test(test_name, %w{does_not matter})
156
- Abingo.human!
187
+ second_identity = Abingo.identify("unsure_if_human_2_#{Time.now.to_i}")
188
+ second_identity.test(test_name, %w{does_not matter})
189
+ second_identity.human!
157
190
 
158
191
  ex = Abingo::Experiment.find_by_test_name(test_name)
159
192
  assert_equal 2, ex.participants, "Now that we're human, our participation should matter."
@@ -161,27 +194,27 @@ class AbingoTest < ActiveSupport::TestCase
161
194
  end
162
195
 
163
196
  test "Participating tests for a given identity" do
164
- Abingo.identity = "test_participant"
197
+ abingo = Abingo.identify("test_participant")
165
198
  test_names = (1..3).map {|t| "participating_test_test_name #{t}"}
166
199
  test_alternatives = %w{yes no}
167
- test_names.each {|test_name| Abingo.test(test_name, test_alternatives)}
200
+ test_names.each {|test_name| abingo.test(test_name, test_alternatives)}
168
201
  ex = Abingo::Experiment.last
169
202
  ex.end_experiment!("no") #End final of 3 tests, leaving 2 presently running
170
203
 
171
- assert_equal 2, Abingo.participating_tests.size #Pairs for two tests
172
- Abingo.participating_tests.each do |key, value|
204
+ assert_equal 2, abingo.participating_tests.size #Pairs for two tests
205
+ abingo.participating_tests.each do |key, value|
173
206
  assert test_names.include? key
174
207
  assert test_alternatives.include? value
175
208
  end
176
-
177
- assert_equal 3, Abingo.participating_tests(false).size #pairs for three tests
178
- Abingo.participating_tests(false).each do |key, value|
209
+
210
+ assert_equal 3, abingo.participating_tests(false).size #pairs for three tests
211
+ abingo.participating_tests(false).each do |key, value|
179
212
  assert test_names.include? key
180
213
  assert test_alternatives.include? value
181
214
  end
182
215
 
183
- Abingo.identity = "test_nonparticipant"
184
- assert_equal({}, Abingo.participating_tests)
216
+ non_participant = Abingo.identify("test_nonparticipant")
217
+ assert_equal({}, non_participant.participating_tests)
185
218
  end
186
219
 
187
220
  end
@@ -1,34 +1,59 @@
1
- require 'rubygems'
2
- require 'rails'
1
+ #require 'rubygems'
2
+ #require 'rails'
3
+ #
4
+ #require 'rails/all'
5
+ require 'test/unit'
6
+ require 'contest'
7
+ require 'pg'
8
+ require 'active_support/cache'
9
+ require 'active_support/cache/memory_store'
10
+ require_relative '../lib/abingo'
11
+ ActiveRecord::Base.establish_connection(
12
+ :adapter => "postgresql",
13
+ :database => "abingo-test")
14
+ ActiveRecord::Schema.define do
15
+ create_table "experiments", :force => true do |t|
16
+ t.string "test_name"
17
+ t.string "status"
18
+ t.timestamps
19
+ end
3
20
 
4
- if Rails::VERSION::MAJOR.to_i >= 3
5
- require 'rails/all'
6
- require 'test/unit'
21
+ add_index "experiments", "test_name"
22
+ #add_index "experiments", "created_on"
7
23
 
8
- require 'active_support'
9
- require 'active_support/railtie'
10
- require 'active_support/core_ext'
11
- require 'active_support/test_case'
24
+ create_table "alternatives", :force => true do |t|
25
+ t.integer :experiment_id
26
+ t.string :content
27
+ t.string :lookup, :limit => 32
28
+ t.integer :weight, :default => 1
29
+ t.integer :participants, :default => 0
30
+ t.integer :conversions, :default => 0
31
+ end
12
32
 
13
- require 'action_controller'
14
- require 'action_controller/caching'
15
- require 'active_record'
16
- require 'active_record/base'
17
-
18
- require 'rails'
19
- require 'rails/application'
20
-
21
- require 'rails/railtie'
22
-
23
- #We need to load the whole Rails application to properly initialize Rails.cache and other constants. Oh boy.
24
- #We're going to parse it out of RAILS_PATH/config.ru using a little metaprogramming magic.
25
- require ::File.expand_path('../../../../../config/environment', __FILE__)
26
- lines = File.open(::File.expand_path('../../../../../config.ru', __FILE__)).readlines.select {|a| a =~ /::Application/}
27
- application_name = lines.first[/[^ ]*::/].gsub(":", "")
28
- Kernel.const_get(application_name).const_get("Application").initialize!
29
- else
30
- #Rails 2 testing
31
- require 'active_support'
32
- require 'active_support/test_case'
33
+ add_index "alternatives", "experiment_id"
34
+ add_index "alternatives", "lookup" #Critical for speed, since we'll primarily be updating by that.
33
35
  end
34
-
36
+ Abingo.cache = ActiveSupport::Cache::MemoryStore.new
37
+ #
38
+ #require 'active_support'
39
+ #require 'active_support/railtie'
40
+ #require 'active_support/core_ext'
41
+ #require 'active_support/test_case'
42
+ #
43
+ #require 'action_controller'
44
+ #require 'action_controller/caching'
45
+ #require 'active_record'
46
+ #require 'active_record/base'
47
+ #
48
+ #require 'rails'
49
+ #require 'rails/application'
50
+ #
51
+ #require 'rails/railtie'
52
+ #
53
+ ##We need to load the whole Rails application to properly initialize Rails.cache and other constants. Oh boy.
54
+ ##We're going to parse it out of RAILS_PATH/config.ru using a little metaprogramming magic.
55
+ #require ::File.expand_path('../../../../../config/environment', __FILE__)
56
+ #lines = File.open(::File.expand_path('../../../../../config.ru', __FILE__)).readlines.select {|a| a =~ /::Application/}
57
+ #application_name = lines.first[/[^ ]*::/].gsub(":", "")
58
+ #Kernel.const_get(application_name).const_get("Application").initialize!
59
+ #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abingo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -13,7 +13,7 @@ date: 2012-11-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
16
- requirement: &70225544745500 !ruby/object:Gem::Requirement
16
+ requirement: &70288958337380 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,7 +21,7 @@ dependencies:
21
21
  version: '3.0'
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *70225544745500
24
+ version_requirements: *70288958337380
25
25
  description: A split testing framework for Rails 3.x.x
26
26
  email:
27
27
  - me@glenngillen.com
@@ -50,10 +50,8 @@ files:
50
50
  - lib/abingo/views/dashboard/index.erb
51
51
  - lib/abingo_sugar.rb
52
52
  - lib/abingo_view_helper.rb
53
- - strip.rb
54
53
  - test/abingo_test.rb
55
54
  - test/test_helper.rb
56
- - uninstall.rb
57
55
  homepage: https://github.com/glenngillen/abingo
58
56
  licenses: []
59
57
  post_install_message:
@@ -68,7 +66,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
68
66
  version: '0'
69
67
  segments:
70
68
  - 0
71
- hash: -3437429555840451377
69
+ hash: -1837967124242571080
72
70
  required_rubygems_version: !ruby/object:Gem::Requirement
73
71
  none: false
74
72
  requirements:
@@ -77,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
75
  version: '0'
78
76
  segments:
79
77
  - 0
80
- hash: -3437429555840451377
78
+ hash: -1837967124242571080
81
79
  requirements: []
82
80
  rubyforge_project:
83
81
  rubygems_version: 1.8.15
data/strip.rb DELETED
@@ -1,11 +0,0 @@
1
- require 'find'
2
- require 'fileutils'
3
- def find_and_delete(path, pattern)
4
- Find.find(path) do |f|
5
- if !File.file?(f) and f[pattern]
6
- FileUtils.rm_rf(f)
7
- end
8
- end
9
- end
10
- # print all the ruby files
11
- find_and_delete(".", /\.svn$/)
@@ -1 +0,0 @@
1
- # Uninstall hook code here