abingo 1.1.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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