split 3.3.0 → 3.4.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.
@@ -2,13 +2,13 @@
2
2
  module Split
3
3
  class Experiment
4
4
  attr_accessor :name
5
- attr_writer :algorithm
6
- attr_accessor :resettable
7
5
  attr_accessor :goals
8
- attr_accessor :alternatives
9
6
  attr_accessor :alternative_probabilities
10
7
  attr_accessor :metadata
11
8
 
9
+ attr_reader :alternatives
10
+ attr_reader :resettable
11
+
12
12
  DEFAULT_OPTIONS = {
13
13
  :resettable => true
14
14
  }
@@ -25,7 +25,7 @@ module Split
25
25
  alternatives: load_alternatives_from_configuration,
26
26
  goals: Split::GoalsCollection.new(@name).load_from_configuration,
27
27
  metadata: load_metadata_from_configuration,
28
- resettable: exp_config[:resettable],
28
+ resettable: exp_config.fetch(:resettable, true),
29
29
  algorithm: exp_config[:algorithm]
30
30
  }
31
31
  else
@@ -62,7 +62,7 @@ module Split
62
62
  alts = load_alternatives_from_configuration
63
63
  options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
64
64
  options[:metadata] = load_metadata_from_configuration
65
- options[:resettable] = exp_config[:resettable]
65
+ options[:resettable] = exp_config.fetch(:resettable, true)
66
66
  options[:algorithm] = exp_config[:algorithm]
67
67
  end
68
68
  end
@@ -81,12 +81,12 @@ module Split
81
81
 
82
82
  if new_record?
83
83
  start unless Split.configuration.start_manually
84
+ persist_experiment_configuration
84
85
  elsif experiment_configuration_has_changed?
85
86
  reset unless Split.configuration.reset_manually
87
+ persist_experiment_configuration
86
88
  end
87
89
 
88
- persist_experiment_configuration if new_record? || experiment_configuration_has_changed?
89
-
90
90
  redis.hset(experiment_config_key, :resettable, resettable)
91
91
  redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
92
92
  self
@@ -144,11 +144,13 @@ module Split
144
144
  end
145
145
 
146
146
  def has_winner?
147
- !winner.nil?
147
+ return @has_winner if defined? @has_winner
148
+ @has_winner = !winner.nil?
148
149
  end
149
150
 
150
151
  def winner=(winner_name)
151
152
  redis.hset(:experiment_winner, name, winner_name.to_s)
153
+ @has_winner = true
152
154
  end
153
155
 
154
156
  def participant_count
@@ -161,6 +163,7 @@ module Split
161
163
 
162
164
  def reset_winner
163
165
  redis.hdel(:experiment_winner, name)
166
+ @has_winner = false
164
167
  end
165
168
 
166
169
  def start
@@ -420,14 +423,22 @@ module Split
420
423
  end
421
424
 
422
425
  def load_alternatives_from_redis
423
- case redis.type(@name)
424
- when 'set' # convert legacy sets to lists
425
- alts = redis.smembers(@name)
426
- redis.del(@name)
427
- alts.reverse.each {|a| redis.lpush(@name, a) }
428
- redis.lrange(@name, 0, -1)
429
- else
430
- redis.lrange(@name, 0, -1)
426
+ alternatives = case redis.type(@name)
427
+ when 'set' # convert legacy sets to lists
428
+ alts = redis.smembers(@name)
429
+ redis.del(@name)
430
+ alts.reverse.each {|a| redis.lpush(@name, a) }
431
+ redis.lrange(@name, 0, -1)
432
+ else
433
+ redis.lrange(@name, 0, -1)
434
+ end
435
+ alternatives.map do |alt|
436
+ alt = begin
437
+ JSON.parse(alt)
438
+ rescue
439
+ alt
440
+ end
441
+ Split::Alternative.new(alt, @name)
431
442
  end
432
443
  end
433
444
 
@@ -443,7 +454,7 @@ module Split
443
454
 
444
455
  def persist_experiment_configuration
445
456
  redis_interface.add_to_set(:experiments, name)
446
- redis_interface.persist_list(name, @alternatives.map(&:name))
457
+ redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
447
458
  goals_collection.save
448
459
  redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
449
460
  end
@@ -459,7 +470,7 @@ module Split
459
470
  existing_alternatives = load_alternatives_from_redis
460
471
  existing_goals = Split::GoalsCollection.new(@name).load_from_redis
461
472
  existing_metadata = load_metadata_from_redis
462
- existing_alternatives != @alternatives.map(&:name) ||
473
+ existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
463
474
  existing_goals != @goals ||
464
475
  existing_metadata != @metadata
465
476
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Split
2
3
  class GoalsCollection
3
4
 
data/lib/split/helper.rb CHANGED
@@ -8,7 +8,7 @@ module Split
8
8
  def ab_test(metric_descriptor, control = nil, *alternatives)
9
9
  begin
10
10
  experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
11
- alternative = if Split.configuration.enabled
11
+ alternative = if Split.configuration.enabled && !exclude_visitor?
12
12
  experiment.save
13
13
  raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
14
14
  trial = Trial.new(:user => ab_user, :experiment => experiment,
@@ -44,6 +44,7 @@ module Split
44
44
  end
45
45
 
46
46
  def finish_experiment(experiment, options = {:reset => true})
47
+ return false if active_experiments[experiment.name].nil?
47
48
  return true if experiment.has_winner?
48
49
  should_reset = experiment.resettable? && options[:reset]
49
50
  if ab_user[experiment.finished_key] && !should_reset
@@ -79,7 +80,7 @@ module Split
79
80
 
80
81
  def ab_record_extra_info(metric_descriptor, key, value = 1)
81
82
  return if exclude_visitor? || Split.configuration.disabled?
82
- metric_descriptor, goals = normalize_metric(metric_descriptor)
83
+ metric_descriptor, _ = normalize_metric(metric_descriptor)
83
84
  experiments = Metric.possible_experiments(metric_descriptor)
84
85
 
85
86
  if experiments.any?
@@ -122,7 +123,7 @@ module Split
122
123
  end
123
124
 
124
125
  def exclude_visitor?
125
- instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?
126
+ defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
126
127
  end
127
128
 
128
129
  def is_robot?
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
4
-
5
3
  module Split
6
4
  module Persistence
7
5
  class DualAdapter
8
- extend Forwardable
9
- def_delegators :@adapter, :keys, :[], :[]=, :delete
6
+ def self.with_config(options={})
7
+ self.config.merge!(options)
8
+ self
9
+ end
10
+
11
+ def self.config
12
+ @config ||= {}
13
+ end
10
14
 
11
15
  def initialize(context)
12
16
  if logged_in = self.class.config[:logged_in]
@@ -22,22 +26,60 @@ module Split
22
26
  raise "Please configure :logged_out_adapter"
23
27
  end
24
28
 
25
- if logged_in.call(context)
26
- @adapter = logged_in_adapter.new(context)
29
+ @fallback_to_logged_out_adapter =
30
+ self.class.config[:fallback_to_logged_out_adapter] || false
31
+ @logged_in = logged_in.call(context)
32
+ @logged_in_adapter = logged_in_adapter.new(context)
33
+ @logged_out_adapter = logged_out_adapter.new(context)
34
+ @active_adapter = @logged_in ? @logged_in_adapter : @logged_out_adapter
35
+ end
36
+
37
+ def keys
38
+ if @fallback_to_logged_out_adapter
39
+ (@logged_in_adapter.keys + @logged_out_adapter.keys).uniq
27
40
  else
28
- @adapter = logged_out_adapter.new(context)
41
+ @active_adapter.keys
29
42
  end
30
43
  end
31
44
 
32
- def self.with_config(options={})
33
- self.config.merge!(options)
34
- self
45
+ def [](key)
46
+ if @fallback_to_logged_out_adapter
47
+ @logged_in && @logged_in_adapter[key] || @logged_out_adapter[key]
48
+ else
49
+ @active_adapter[key]
50
+ end
35
51
  end
36
52
 
37
- def self.config
38
- @config ||= {}
53
+ def []=(key, value)
54
+ if @fallback_to_logged_out_adapter
55
+ @logged_in_adapter[key] = value if @logged_in
56
+ old_value = @logged_out_adapter[key]
57
+ @logged_out_adapter[key] = value
58
+
59
+ decrement_participation(key, old_value) if decrement_participation?(old_value, value)
60
+ else
61
+ @active_adapter[key] = value
62
+ end
63
+ end
64
+
65
+ def delete(key)
66
+ if @fallback_to_logged_out_adapter
67
+ @logged_in_adapter.delete(key)
68
+ @logged_out_adapter.delete(key)
69
+ else
70
+ @active_adapter.delete(key)
71
+ end
39
72
  end
40
73
 
74
+ private
75
+
76
+ def decrement_participation?(old_value, value)
77
+ !old_value.nil? && !value.nil? && old_value != value
78
+ end
79
+
80
+ def decrement_participation(key, value)
81
+ Split.redis.hincrby("#{key}:#{value}", 'participant_count', -1)
82
+ end
41
83
  end
42
84
  end
43
85
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Split
2
3
  # Simplifies the interface to Redis.
3
4
  class RedisInterface
data/lib/split/trial.rb CHANGED
@@ -2,7 +2,7 @@
2
2
  module Split
3
3
  class Trial
4
4
  attr_accessor :experiment
5
- attr_accessor :metadata
5
+ attr_writer :metadata
6
6
 
7
7
  def initialize(attrs = {})
8
8
  self.experiment = attrs.delete(:experiment)
@@ -68,10 +68,8 @@ module Split
68
68
  if exclude_user?
69
69
  self.alternative = @experiment.control
70
70
  else
71
- value = @user[@experiment.key]
72
- if value
73
- self.alternative = value
74
- else
71
+ self.alternative = @user[@experiment.key]
72
+ if alternative.nil?
75
73
  self.alternative = @experiment.next_alternative
76
74
 
77
75
  # Increment the number of participants since we are actually choosing a new alternative
@@ -82,7 +80,7 @@ module Split
82
80
  end
83
81
  end
84
82
 
85
- @user[@experiment.key] = alternative.name if should_store_alternative?
83
+ @user[@experiment.key] = alternative.name if !@experiment.has_winner? && should_store_alternative?
86
84
  @alternative_choosen = true
87
85
  run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled?
88
86
  alternative
data/lib/split/user.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'forwardable'
2
3
 
3
4
  module Split
@@ -8,9 +9,11 @@ module Split
8
9
 
9
10
  def initialize(context, adapter=nil)
10
11
  @user = adapter || Split::Persistence.adapter.new(context)
12
+ @cleaned_up = false
11
13
  end
12
14
 
13
15
  def cleanup_old_experiments!
16
+ return if @cleaned_up
14
17
  keys_without_finished(user.keys).each do |key|
15
18
  experiment = ExperimentCatalog.find key_without_version(key)
16
19
  if experiment.nil? || experiment.has_winner? || experiment.start_time.nil?
@@ -18,6 +21,7 @@ module Split
18
21
  user.delete Experiment.finished_key(key)
19
22
  end
20
23
  end
24
+ @cleaned_up = true
21
25
  end
22
26
 
23
27
  def max_experiments_reached?(experiment_key)
@@ -38,7 +42,7 @@ module Split
38
42
 
39
43
  def active_experiments
40
44
  experiment_pairs = {}
41
- user.keys.each do |key|
45
+ keys_without_finished(user.keys).each do |key|
42
46
  Metric.possible_experiments(key_without_version(key)).each do |experiment|
43
47
  if !experiment.has_winner?
44
48
  experiment_pairs[key_without_version(key)] = user[key]
data/lib/split/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  module Split
3
3
  MAJOR = 3
4
- MINOR = 3
4
+ MINOR = 4
5
5
  PATCH = 0
6
6
  VERSION = [MAJOR, MINOR, PATCH].join('.')
7
7
  end
data/lib/split.rb CHANGED
@@ -66,4 +66,11 @@ module Split
66
66
  end
67
67
  end
68
68
 
69
- Split.configure {}
69
+ # Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first.
70
+ if defined?(::Rails)
71
+ class Railtie < Rails::Railtie
72
+ config.before_initialize { Split.configure {} }
73
+ end
74
+ else
75
+ Split.configure {}
76
+ end
@@ -10,7 +10,9 @@ describe Split::DashboardPaginationHelpers do
10
10
  context 'when params empty' do
11
11
  let(:params) { Hash[] }
12
12
 
13
- it 'returns 10' do
13
+ it 'returns the default (10)' do
14
+ default_per_page = Split.configuration.dashboard_pagination_default_per_page
15
+ expect(pagination_per).to eql default_per_page
14
16
  expect(pagination_per).to eql 10
15
17
  end
16
18
  end
@@ -27,11 +27,11 @@ describe Split::DashboardHelpers do
27
27
 
28
28
  describe '#round' do
29
29
  it 'can round number strings' do
30
- expect(round('3.1415')).to eq BigDecimal.new('3.14')
30
+ expect(round('3.1415')).to eq BigDecimal('3.14')
31
31
  end
32
32
 
33
33
  it 'can round number strings for precsion' do
34
- expect(round('3.1415', 1)).to eq BigDecimal.new('3.1')
34
+ expect(round('3.1415', 1)).to eq BigDecimal('3.1')
35
35
  end
36
36
 
37
37
  it 'can handle invalid number strings' do
@@ -29,6 +29,10 @@ describe Split::Dashboard do
29
29
  let(:red_link) { link("red") }
30
30
  let(:blue_link) { link("blue") }
31
31
 
32
+ before(:each) do
33
+ Split.configuration.beta_probability_simulations = 1
34
+ end
35
+
32
36
  it "should respond to /" do
33
37
  get '/'
34
38
  expect(last_response).to be_ok
@@ -74,17 +78,39 @@ describe Split::Dashboard do
74
78
  end
75
79
 
76
80
  describe "force alternative" do
77
- let!(:user) do
78
- Split::User.new(@app, { experiment.name => 'a' })
79
- end
81
+ context "initial version" do
82
+ let!(:user) do
83
+ Split::User.new(@app, { experiment.name => 'red' })
84
+ end
80
85
 
81
- before do
82
- allow(Split::User).to receive(:new).and_return(user)
86
+ before do
87
+ allow(Split::User).to receive(:new).and_return(user)
88
+ end
89
+
90
+ it "should set current user's alternative" do
91
+ blue_link.participant_count = 7
92
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
93
+ expect(user[experiment.key]).to eq("blue")
94
+ expect(blue_link.participant_count).to eq(8)
95
+ end
83
96
  end
84
97
 
85
- it "should set current user's alternative" do
86
- post "/force_alternative?experiment=#{experiment.name}", alternative: "b"
87
- expect(user[experiment.name]).to eq("b")
98
+ context "incremented version" do
99
+ let!(:user) do
100
+ experiment.increment_version
101
+ Split::User.new(@app, { "#{experiment.name}:#{experiment.version}" => 'red' })
102
+ end
103
+
104
+ before do
105
+ allow(Split::User).to receive(:new).and_return(user)
106
+ end
107
+
108
+ it "should set current user's alternative" do
109
+ blue_link.participant_count = 7
110
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
111
+ expect(user[experiment.key]).to eq("blue")
112
+ expect(blue_link.participant_count).to eq(8)
113
+ end
88
114
  end
89
115
  end
90
116
 
@@ -120,7 +146,7 @@ describe Split::Dashboard do
120
146
  it "removes winner" do
121
147
  post "/reopen?experiment=#{experiment.name}"
122
148
 
123
- expect(experiment).to_not have_winner
149
+ expect(Split::ExperimentCatalog.find(experiment.name)).to_not have_winner
124
150
  end
125
151
 
126
152
  it "keeps existing stats" do
@@ -167,19 +193,14 @@ describe Split::Dashboard do
167
193
  end
168
194
 
169
195
  it "should display the start date" do
170
- experiment_start_time = Time.parse('2011-07-07')
171
- expect(Time).to receive(:now).at_least(:once).and_return(experiment_start_time)
172
- experiment
196
+ experiment.start
173
197
 
174
198
  get '/'
175
199
 
176
- expect(last_response.body).to include('<small>2011-07-07</small>')
200
+ expect(last_response.body).to include("<small>#{experiment.start_time.strftime('%Y-%m-%d')}</small>")
177
201
  end
178
202
 
179
203
  it "should handle experiments without a start date" do
180
- experiment_start_time = Time.parse('2011-07-07')
181
- expect(Time).to receive(:now).at_least(:once).and_return(experiment_start_time)
182
-
183
204
  Split.redis.hdel(:experiment_start_times, experiment.name)
184
205
 
185
206
  get '/'
@@ -33,7 +33,7 @@ describe Split::EncapsulatedHelper do
33
33
  static <%= alt %>
34
34
  <% end %>
35
35
  ERB
36
- expect(template.result(binding)).to match /foo static \d/
36
+ expect(template.result(binding)).to match(/foo static \d/)
37
37
  end
38
38
 
39
39
  end
@@ -35,6 +35,12 @@ describe Split::Experiment do
35
35
  expect(experiment.resettable).to be_truthy
36
36
  end
37
37
 
38
+ it "should be resettable when loading from configuration" do
39
+ allow(Split.configuration).to receive(:experiment_for).with('some_experiment') { { alternatives: %w(a b) } }
40
+
41
+ expect(Split::Experiment.new('some_experiment')).to be_resettable
42
+ end
43
+
38
44
  it "should save to redis" do
39
45
  experiment.save
40
46
  expect(Split.redis.exists('basket_text')).to be true
@@ -86,7 +92,7 @@ describe Split::Experiment do
86
92
  experiment.save
87
93
  experiment.save
88
94
  expect(Split.redis.exists('basket_text')).to be true
89
- expect(Split.redis.lrange('basket_text', 0, -1)).to eq(['Basket', "Cart"])
95
+ expect(Split.redis.lrange('basket_text', 0, -1)).to eq(['{"Basket":1}', '{"Cart":1}'])
90
96
  end
91
97
 
92
98
  describe 'new record?' do
@@ -213,12 +219,41 @@ describe Split::Experiment do
213
219
  it "should have no winner initially" do
214
220
  expect(experiment.winner).to be_nil
215
221
  end
222
+ end
216
223
 
224
+ describe 'winner=' do
217
225
  it "should allow you to specify a winner" do
218
226
  experiment.save
219
227
  experiment.winner = 'red'
220
228
  expect(experiment.winner.name).to eq('red')
221
229
  end
230
+
231
+ context 'when has_winner state is memoized' do
232
+ before { expect(experiment).to_not have_winner }
233
+
234
+ it 'should keep has_winner state consistent' do
235
+ experiment.winner = 'red'
236
+ expect(experiment).to have_winner
237
+ end
238
+ end
239
+ end
240
+
241
+ describe 'reset_winner' do
242
+ before { experiment.winner = 'green' }
243
+
244
+ it 'should reset the winner' do
245
+ experiment.reset_winner
246
+ expect(experiment.winner).to be_nil
247
+ end
248
+
249
+ context 'when has_winner state is memoized' do
250
+ before { expect(experiment).to have_winner }
251
+
252
+ it 'should keep has_winner state consistent' do
253
+ experiment.reset_winner
254
+ expect(experiment).to_not have_winner
255
+ end
256
+ end
222
257
  end
223
258
 
224
259
  describe 'has_winner?' do
@@ -235,6 +270,12 @@ describe Split::Experiment do
235
270
  expect(experiment).to_not have_winner
236
271
  end
237
272
  end
273
+
274
+ it 'memoizes has_winner state' do
275
+ expect(experiment).to receive(:winner).once
276
+ expect(experiment).to_not have_winner
277
+ expect(experiment).to_not have_winner
278
+ end
238
279
  end
239
280
 
240
281
  describe 'reset' do
@@ -414,9 +455,7 @@ describe Split::Experiment do
414
455
  }
415
456
 
416
457
  context "saving experiment" do
417
- def same_but_different_goals
418
- Split::ExperimentCatalog.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green')
419
- end
458
+ let(:same_but_different_goals) { Split::ExperimentCatalog.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green') }
420
459
 
421
460
  before { experiment.save }
422
461
 
@@ -425,7 +464,7 @@ describe Split::Experiment do
425
464
  end
426
465
 
427
466
  it "should reset an experiment if it is loaded with different goals" do
428
- same_experiment = same_but_different_goals
467
+ same_but_different_goals
429
468
  expect(Split::ExperimentCatalog.find("link_color").goals).to eq(["purchase", "refund"])
430
469
  end
431
470