split 3.3.2 → 4.0.0.pre2

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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc +1 -1
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  5. data/.github/dependabot.yml +7 -0
  6. data/.github/workflows/ci.yml +61 -0
  7. data/.rspec +1 -0
  8. data/.rubocop.yml +71 -1044
  9. data/.rubocop_todo.yml +226 -0
  10. data/Appraisals +1 -1
  11. data/CHANGELOG.md +62 -0
  12. data/CODE_OF_CONDUCT.md +3 -3
  13. data/Gemfile +2 -0
  14. data/README.md +40 -18
  15. data/Rakefile +2 -0
  16. data/gemfiles/6.0.gemfile +1 -1
  17. data/lib/split/algorithms/block_randomization.rb +2 -0
  18. data/lib/split/algorithms/weighted_sample.rb +2 -1
  19. data/lib/split/algorithms/whiplash.rb +3 -2
  20. data/lib/split/alternative.rb +4 -3
  21. data/lib/split/cache.rb +28 -0
  22. data/lib/split/combined_experiments_helper.rb +2 -1
  23. data/lib/split/configuration.rb +13 -14
  24. data/lib/split/dashboard/helpers.rb +1 -0
  25. data/lib/split/dashboard/pagination_helpers.rb +3 -3
  26. data/lib/split/dashboard/paginator.rb +1 -0
  27. data/lib/split/dashboard/public/dashboard.js +10 -0
  28. data/lib/split/dashboard/public/style.css +5 -0
  29. data/lib/split/dashboard/views/_controls.erb +13 -0
  30. data/lib/split/dashboard/views/layout.erb +1 -1
  31. data/lib/split/dashboard.rb +19 -1
  32. data/lib/split/encapsulated_helper.rb +3 -2
  33. data/lib/split/engine.rb +7 -4
  34. data/lib/split/exceptions.rb +1 -0
  35. data/lib/split/experiment.rb +98 -65
  36. data/lib/split/experiment_catalog.rb +1 -3
  37. data/lib/split/extensions/string.rb +1 -0
  38. data/lib/split/goals_collection.rb +2 -0
  39. data/lib/split/helper.rb +28 -8
  40. data/lib/split/metric.rb +2 -1
  41. data/lib/split/persistence/cookie_adapter.rb +6 -1
  42. data/lib/split/persistence/dual_adapter.rb +54 -12
  43. data/lib/split/persistence/redis_adapter.rb +5 -0
  44. data/lib/split/persistence/session_adapter.rb +1 -0
  45. data/lib/split/persistence.rb +4 -2
  46. data/lib/split/redis_interface.rb +9 -28
  47. data/lib/split/trial.rb +21 -11
  48. data/lib/split/user.rb +20 -4
  49. data/lib/split/version.rb +2 -4
  50. data/lib/split/zscore.rb +1 -0
  51. data/lib/split.rb +9 -3
  52. data/spec/alternative_spec.rb +1 -1
  53. data/spec/cache_spec.rb +88 -0
  54. data/spec/configuration_spec.rb +17 -15
  55. data/spec/dashboard/pagination_helpers_spec.rb +3 -1
  56. data/spec/dashboard_helpers_spec.rb +2 -2
  57. data/spec/dashboard_spec.rb +78 -17
  58. data/spec/encapsulated_helper_spec.rb +2 -2
  59. data/spec/experiment_spec.rb +116 -12
  60. data/spec/goals_collection_spec.rb +1 -1
  61. data/spec/helper_spec.rb +186 -112
  62. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  63. data/spec/persistence/dual_adapter_spec.rb +160 -68
  64. data/spec/persistence/redis_adapter_spec.rb +9 -0
  65. data/spec/redis_interface_spec.rb +0 -69
  66. data/spec/spec_helper.rb +5 -6
  67. data/spec/trial_spec.rb +45 -19
  68. data/spec/user_spec.rb +45 -3
  69. data/split.gemspec +8 -9
  70. metadata +28 -36
  71. data/.travis.yml +0 -66
  72. data/gemfiles/4.2.gemfile +0 -9
@@ -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
@@ -6,8 +6,16 @@ require 'split/dashboard'
6
6
  describe Split::Dashboard do
7
7
  include Rack::Test::Methods
8
8
 
9
+ class TestDashboard < Split::Dashboard
10
+ include Split::Helper
11
+
12
+ get '/my_experiment' do
13
+ ab_test(params[:experiment], 'blue', 'red')
14
+ end
15
+ end
16
+
9
17
  def app
10
- @app ||= Split::Dashboard
18
+ @app ||= TestDashboard
11
19
  end
12
20
 
13
21
  def link(color)
@@ -29,6 +37,10 @@ describe Split::Dashboard do
29
37
  let(:red_link) { link("red") }
30
38
  let(:blue_link) { link("blue") }
31
39
 
40
+ before(:each) do
41
+ Split.configuration.beta_probability_simulations = 1
42
+ end
43
+
32
44
  it "should respond to /" do
33
45
  get '/'
34
46
  expect(last_response).to be_ok
@@ -74,17 +86,49 @@ describe Split::Dashboard do
74
86
  end
75
87
 
76
88
  describe "force alternative" do
77
- let!(:user) do
78
- Split::User.new(@app, { experiment.name => 'a' })
79
- end
89
+ context "initial version" do
90
+ let!(:user) do
91
+ Split::User.new(@app, { experiment.name => 'red' })
92
+ end
80
93
 
81
- before do
82
- allow(Split::User).to receive(:new).and_return(user)
94
+ before do
95
+ allow(Split::User).to receive(:new).and_return(user)
96
+ end
97
+
98
+ it "should set current user's alternative" do
99
+ blue_link.participant_count = 7
100
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
101
+
102
+ get "/my_experiment?experiment=#{experiment.name}"
103
+ expect(last_response.body).to include("blue")
104
+ end
105
+
106
+ it "should not modify an existing user" do
107
+ blue_link.participant_count = 7
108
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
109
+
110
+ expect(user[experiment.key]).to eq("red")
111
+ expect(blue_link.participant_count).to eq(7)
112
+ end
83
113
  end
84
114
 
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")
115
+ context "incremented version" do
116
+ let!(:user) do
117
+ experiment.increment_version
118
+ Split::User.new(@app, { "#{experiment.name}:#{experiment.version}" => 'red' })
119
+ end
120
+
121
+ before do
122
+ allow(Split::User).to receive(:new).and_return(user)
123
+ end
124
+
125
+ it "should set current user's alternative" do
126
+ blue_link.participant_count = 7
127
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
128
+
129
+ get "/my_experiment?experiment=#{experiment.name}"
130
+ expect(last_response.body).to include("blue")
131
+ end
88
132
  end
89
133
  end
90
134
 
@@ -120,7 +164,7 @@ describe Split::Dashboard do
120
164
  it "removes winner" do
121
165
  post "/reopen?experiment=#{experiment.name}"
122
166
 
123
- expect(experiment).to_not have_winner
167
+ expect(Split::ExperimentCatalog.find(experiment.name)).to_not have_winner
124
168
  end
125
169
 
126
170
  it "keeps existing stats" do
@@ -135,6 +179,28 @@ describe Split::Dashboard do
135
179
  end
136
180
  end
137
181
 
182
+ describe "update cohorting" do
183
+ it "calls enable of cohorting when action is enable" do
184
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "enable" }
185
+
186
+ expect(experiment.cohorting_disabled?).to eq false
187
+ end
188
+
189
+ it "calls disable of cohorting when action is disable" do
190
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "disable" }
191
+
192
+ expect(experiment.cohorting_disabled?).to eq true
193
+ end
194
+
195
+ it "calls neither enable or disable cohorting when passed invalid action" do
196
+ previous_value = experiment.cohorting_disabled?
197
+
198
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "other" }
199
+
200
+ expect(experiment.cohorting_disabled?).to eq previous_value
201
+ end
202
+ end
203
+
138
204
  it "should reset an experiment" do
139
205
  red_link.participant_count = 5
140
206
  blue_link.participant_count = 7
@@ -167,19 +233,14 @@ describe Split::Dashboard do
167
233
  end
168
234
 
169
235
  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
236
+ experiment.start
173
237
 
174
238
  get '/'
175
239
 
176
- expect(last_response.body).to include('<small>2011-07-07</small>')
240
+ expect(last_response.body).to include("<small>#{experiment.start_time.strftime('%Y-%m-%d')}</small>")
177
241
  end
178
242
 
179
243
  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
244
  Split.redis.hdel(:experiment_start_times, experiment.name)
184
245
 
185
246
  get '/'
@@ -21,7 +21,7 @@ describe Split::EncapsulatedHelper do
21
21
  end
22
22
 
23
23
  it "calls the block with selected alternative" do
24
- expect{|block| ab_test('link_color', 'red', 'red', &block) }.to yield_with_args('red', nil)
24
+ expect{|block| ab_test('link_color', 'red', 'red', &block) }.to yield_with_args('red', {})
25
25
  end
26
26
 
27
27
  context "inside a view" do
@@ -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
@@ -37,7 +37,7 @@ describe Split::Experiment do
37
37
 
38
38
  it "should save to redis" do
39
39
  experiment.save
40
- expect(Split.redis.exists('basket_text')).to be true
40
+ expect(Split.redis.exists?('basket_text')).to be true
41
41
  end
42
42
 
43
43
  it "should save the start time to redis" do
@@ -85,8 +85,8 @@ describe Split::Experiment do
85
85
  it "should not create duplicates when saving multiple times" do
86
86
  experiment.save
87
87
  experiment.save
88
- expect(Split.redis.exists('basket_text')).to be true
89
- expect(Split.redis.lrange('basket_text', 0, -1)).to eq(['Basket', "Cart"])
88
+ expect(Split.redis.exists?('basket_text')).to be true
89
+ expect(Split.redis.lrange('basket_text', 0, -1)).to eq(['{"Basket":1}', '{"Cart":1}'])
90
90
  end
91
91
 
92
92
  describe 'new record?' do
@@ -118,6 +118,23 @@ describe Split::Experiment do
118
118
  experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :resettable => false)
119
119
  expect(experiment.resettable).to be_falsey
120
120
  end
121
+
122
+ context 'from configuration' do
123
+ let(:experiment_name) { :my_experiment }
124
+ let(:experiments) do
125
+ {
126
+ experiment_name => {
127
+ :alternatives => ['Control Opt', 'Alt one']
128
+ }
129
+ }
130
+ end
131
+
132
+ before { Split.configuration.experiments = experiments }
133
+
134
+ it 'assigns default values to the experiment' do
135
+ expect(Split::Experiment.new(experiment_name).resettable).to eq(true)
136
+ end
137
+ end
121
138
  end
122
139
 
123
140
  describe 'persistent configuration' do
@@ -134,10 +151,23 @@ describe Split::Experiment do
134
151
 
135
152
  describe '#metadata' do
136
153
  let(:experiment) { Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash, :metadata => meta) }
154
+ let(:meta) { { a: 'b' }}
155
+
156
+ before do
157
+ experiment.save
158
+ end
159
+
160
+ it "should delete the key when metadata is removed" do
161
+ experiment.metadata = nil
162
+ experiment.save
163
+
164
+ expect(Split.redis.exists?(experiment.metadata_key)).to be_falsey
165
+ end
166
+
137
167
  context 'simple hash' do
138
168
  let(:meta) { { 'basket' => 'a', 'cart' => 'b' } }
169
+
139
170
  it "should persist metadata in redis" do
140
- experiment.save
141
171
  e = Split::ExperimentCatalog.find('basket_text')
142
172
  expect(e).to eq(experiment)
143
173
  expect(e.metadata).to eq(meta)
@@ -147,7 +177,6 @@ describe Split::Experiment do
147
177
  context 'nested hash' do
148
178
  let(:meta) { { 'basket' => { 'one' => 'two' }, 'cart' => 'b' } }
149
179
  it "should persist metadata in redis" do
150
- experiment.save
151
180
  e = Split::ExperimentCatalog.find('basket_text')
152
181
  expect(e).to eq(experiment)
153
182
  expect(e.metadata).to eq(meta)
@@ -180,7 +209,7 @@ describe Split::Experiment do
180
209
  experiment.save
181
210
 
182
211
  experiment.delete
183
- expect(Split.redis.exists('link_color')).to be false
212
+ expect(Split.redis.exists?('link_color')).to be false
184
213
  expect(Split::ExperimentCatalog.find('link_color')).to be_nil
185
214
  end
186
215
 
@@ -206,19 +235,59 @@ describe Split::Experiment do
206
235
  experiment.delete
207
236
  expect(experiment.start_time).to be_nil
208
237
  end
209
- end
210
238
 
239
+ it "should default cohorting back to false" do
240
+ experiment.disable_cohorting
241
+ expect(experiment.cohorting_disabled?).to eq(true)
242
+ experiment.delete
243
+ expect(experiment.cohorting_disabled?).to eq(false)
244
+ end
245
+ end
211
246
 
212
247
  describe 'winner' do
213
248
  it "should have no winner initially" do
214
249
  expect(experiment.winner).to be_nil
215
250
  end
251
+ end
216
252
 
217
- it "should allow you to specify a winner" do
253
+ describe 'winner=' do
254
+ it 'should allow you to specify a winner' do
218
255
  experiment.save
219
256
  experiment.winner = 'red'
220
257
  expect(experiment.winner.name).to eq('red')
221
258
  end
259
+
260
+ it 'should call the on_experiment_winner_choose hook' do
261
+ expect(Split.configuration.on_experiment_winner_choose).to receive(:call)
262
+ experiment.winner = 'green'
263
+ end
264
+
265
+ context 'when has_winner state is memoized' do
266
+ before { expect(experiment).to_not have_winner }
267
+
268
+ it 'should keep has_winner state consistent' do
269
+ experiment.winner = 'red'
270
+ expect(experiment).to have_winner
271
+ end
272
+ end
273
+ end
274
+
275
+ describe 'reset_winner' do
276
+ before { experiment.winner = 'green' }
277
+
278
+ it 'should reset the winner' do
279
+ experiment.reset_winner
280
+ expect(experiment.winner).to be_nil
281
+ end
282
+
283
+ context 'when has_winner state is memoized' do
284
+ before { expect(experiment).to have_winner }
285
+
286
+ it 'should keep has_winner state consistent' do
287
+ experiment.reset_winner
288
+ expect(experiment).to_not have_winner
289
+ end
290
+ end
222
291
  end
223
292
 
224
293
  describe 'has_winner?' do
@@ -235,6 +304,12 @@ describe Split::Experiment do
235
304
  expect(experiment).to_not have_winner
236
305
  end
237
306
  end
307
+
308
+ it 'memoizes has_winner state' do
309
+ expect(experiment).to receive(:winner).once
310
+ expect(experiment).to_not have_winner
311
+ expect(experiment).to_not have_winner
312
+ end
238
313
  end
239
314
 
240
315
  describe 'reset' do
@@ -335,6 +410,22 @@ describe Split::Experiment do
335
410
  end
336
411
  end
337
412
 
413
+ describe "#cohorting_disabled?" do
414
+ it "returns false when nothing has been configured" do
415
+ expect(experiment.cohorting_disabled?).to eq false
416
+ end
417
+
418
+ it "returns true when enable_cohorting is performed" do
419
+ experiment.enable_cohorting
420
+ expect(experiment.cohorting_disabled?).to eq false
421
+ end
422
+
423
+ it "returns false when nothing has been configured" do
424
+ experiment.disable_cohorting
425
+ expect(experiment.cohorting_disabled?).to eq true
426
+ end
427
+ end
428
+
338
429
  describe 'changing an existing experiment' do
339
430
  def same_but_different_alternative
340
431
  Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'yellow', 'orange')
@@ -357,6 +448,21 @@ describe Split::Experiment do
357
448
  expect(same_experiment_again.version).to eq(1)
358
449
  end
359
450
 
451
+ context "when metadata is changed" do
452
+ it "should increase version" do
453
+ experiment.save
454
+ experiment.metadata = { 'foo' => 'bar' }
455
+
456
+ expect { experiment.save }.to change { experiment.version }.by(1)
457
+ end
458
+
459
+ it "does not increase version" do
460
+ experiment.metadata = nil
461
+ experiment.save
462
+ expect { experiment.save }.to change { experiment.version }.by(0)
463
+ end
464
+ end
465
+
360
466
  context 'when experiment configuration is changed' do
361
467
  let(:reset_manually) { false }
362
468
 
@@ -414,9 +520,7 @@ describe Split::Experiment do
414
520
  }
415
521
 
416
522
  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
523
+ let(:same_but_different_goals) { Split::ExperimentCatalog.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green') }
420
524
 
421
525
  before { experiment.save }
422
526
 
@@ -425,7 +529,7 @@ describe Split::Experiment do
425
529
  end
426
530
 
427
531
  it "should reset an experiment if it is loaded with different goals" do
428
- same_experiment = same_but_different_goals
532
+ same_but_different_goals
429
533
  expect(Split::ExperimentCatalog.find("link_color").goals).to eq(["purchase", "refund"])
430
534
  end
431
535
 
@@ -48,7 +48,7 @@ describe Split::GoalsCollection do
48
48
  goals_collection.save
49
49
 
50
50
  goals_collection.delete
51
- expect(Split.redis.exists(goals_key)).to be false
51
+ expect(Split.redis.exists?(goals_key)).to be false
52
52
  end
53
53
  end
54
54