ab-split 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.csslintrc +2 -0
  4. data/.eslintignore +1 -0
  5. data/.eslintrc +213 -0
  6. data/.github/FUNDING.yml +1 -0
  7. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +7 -0
  10. data/.rubocop_todo.yml +679 -0
  11. data/.travis.yml +60 -0
  12. data/Appraisals +19 -0
  13. data/CHANGELOG.md +696 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/CONTRIBUTING.md +62 -0
  16. data/Gemfile +7 -0
  17. data/LICENSE +22 -0
  18. data/README.md +955 -0
  19. data/Rakefile +9 -0
  20. data/ab-split.gemspec +44 -0
  21. data/gemfiles/4.2.gemfile +9 -0
  22. data/gemfiles/5.0.gemfile +9 -0
  23. data/gemfiles/5.1.gemfile +9 -0
  24. data/gemfiles/5.2.gemfile +9 -0
  25. data/gemfiles/6.0.gemfile +9 -0
  26. data/lib/split.rb +76 -0
  27. data/lib/split/algorithms/block_randomization.rb +23 -0
  28. data/lib/split/algorithms/weighted_sample.rb +18 -0
  29. data/lib/split/algorithms/whiplash.rb +38 -0
  30. data/lib/split/alternative.rb +191 -0
  31. data/lib/split/combined_experiments_helper.rb +37 -0
  32. data/lib/split/configuration.rb +255 -0
  33. data/lib/split/dashboard.rb +74 -0
  34. data/lib/split/dashboard/helpers.rb +45 -0
  35. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  36. data/lib/split/dashboard/paginator.rb +16 -0
  37. data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
  38. data/lib/split/dashboard/public/dashboard.js +24 -0
  39. data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
  40. data/lib/split/dashboard/public/reset.css +48 -0
  41. data/lib/split/dashboard/public/style.css +328 -0
  42. data/lib/split/dashboard/views/_controls.erb +18 -0
  43. data/lib/split/dashboard/views/_experiment.erb +155 -0
  44. data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
  45. data/lib/split/dashboard/views/index.erb +26 -0
  46. data/lib/split/dashboard/views/layout.erb +27 -0
  47. data/lib/split/encapsulated_helper.rb +42 -0
  48. data/lib/split/engine.rb +15 -0
  49. data/lib/split/exceptions.rb +6 -0
  50. data/lib/split/experiment.rb +486 -0
  51. data/lib/split/experiment_catalog.rb +51 -0
  52. data/lib/split/extensions/string.rb +16 -0
  53. data/lib/split/goals_collection.rb +45 -0
  54. data/lib/split/helper.rb +165 -0
  55. data/lib/split/metric.rb +101 -0
  56. data/lib/split/persistence.rb +28 -0
  57. data/lib/split/persistence/cookie_adapter.rb +94 -0
  58. data/lib/split/persistence/dual_adapter.rb +85 -0
  59. data/lib/split/persistence/redis_adapter.rb +57 -0
  60. data/lib/split/persistence/session_adapter.rb +29 -0
  61. data/lib/split/redis_interface.rb +50 -0
  62. data/lib/split/trial.rb +117 -0
  63. data/lib/split/user.rb +69 -0
  64. data/lib/split/version.rb +7 -0
  65. data/lib/split/zscore.rb +57 -0
  66. data/spec/algorithms/block_randomization_spec.rb +32 -0
  67. data/spec/algorithms/weighted_sample_spec.rb +19 -0
  68. data/spec/algorithms/whiplash_spec.rb +24 -0
  69. data/spec/alternative_spec.rb +320 -0
  70. data/spec/combined_experiments_helper_spec.rb +57 -0
  71. data/spec/configuration_spec.rb +258 -0
  72. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  73. data/spec/dashboard/paginator_spec.rb +37 -0
  74. data/spec/dashboard_helpers_spec.rb +42 -0
  75. data/spec/dashboard_spec.rb +210 -0
  76. data/spec/encapsulated_helper_spec.rb +52 -0
  77. data/spec/experiment_catalog_spec.rb +53 -0
  78. data/spec/experiment_spec.rb +533 -0
  79. data/spec/goals_collection_spec.rb +80 -0
  80. data/spec/helper_spec.rb +1111 -0
  81. data/spec/metric_spec.rb +31 -0
  82. data/spec/persistence/cookie_adapter_spec.rb +106 -0
  83. data/spec/persistence/dual_adapter_spec.rb +194 -0
  84. data/spec/persistence/redis_adapter_spec.rb +90 -0
  85. data/spec/persistence/session_adapter_spec.rb +32 -0
  86. data/spec/persistence_spec.rb +34 -0
  87. data/spec/redis_interface_spec.rb +111 -0
  88. data/spec/spec_helper.rb +52 -0
  89. data/spec/split_spec.rb +43 -0
  90. data/spec/support/cookies_mock.rb +20 -0
  91. data/spec/trial_spec.rb +299 -0
  92. data/spec/user_spec.rb +87 -0
  93. metadata +322 -0
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'split/dashboard/paginator'
4
+
5
+ describe Split::DashboardPaginator do
6
+ context 'when collection is 1..20' do
7
+ let(:collection) { (1..20).to_a }
8
+
9
+ context 'when per 5 for page' do
10
+ let(:per) { 5 }
11
+
12
+ it 'when page number is 1 result is [1, 2, 3, 4, 5]' do
13
+ result = Split::DashboardPaginator.new(collection, 1, per).paginate
14
+ expect(result).to eql [1, 2, 3, 4, 5]
15
+ end
16
+
17
+ it 'when page number is 2 result is [6, 7, 8, 9, 10]' do
18
+ result = Split::DashboardPaginator.new(collection, 2, per).paginate
19
+ expect(result).to eql [6, 7, 8, 9, 10]
20
+ end
21
+ end
22
+
23
+ context 'when per 10 for page' do
24
+ let(:per) { 10 }
25
+
26
+ it 'when page number is 1 result is [1..10]' do
27
+ result = Split::DashboardPaginator.new(collection, 1, per).paginate
28
+ expect(result).to eql [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
29
+ end
30
+
31
+ it 'when page number is 2 result is [10..20]' do
32
+ result = Split::DashboardPaginator.new(collection, 2, per).paginate
33
+ expect(result).to eql [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'split/dashboard/helpers'
4
+
5
+ include Split::DashboardHelpers
6
+
7
+ describe Split::DashboardHelpers do
8
+ describe 'confidence_level' do
9
+ it 'should handle very small numbers' do
10
+ expect(confidence_level(Complex(2e-18, -0.03))).to eq('Insufficient confidence')
11
+ end
12
+
13
+ it "should consider a z-score of 1.65 <= z < 1.96 as 90% confident" do
14
+ expect(confidence_level(1.65)).to eq('90% confidence')
15
+ expect(confidence_level(1.80)).to eq('90% confidence')
16
+ end
17
+
18
+ it "should consider a z-score of 1.96 <= z < 2.58 as 95% confident" do
19
+ expect(confidence_level(1.96)).to eq('95% confidence')
20
+ expect(confidence_level(2.00)).to eq('95% confidence')
21
+ end
22
+
23
+ it "should consider a z-score of z >= 2.58 as 99% confident" do
24
+ expect(confidence_level(2.58)).to eq('99% confidence')
25
+ expect(confidence_level(3.00)).to eq('99% confidence')
26
+ end
27
+
28
+ describe '#round' do
29
+ it 'can round number strings' do
30
+ expect(round('3.1415')).to eq BigDecimal('3.14')
31
+ end
32
+
33
+ it 'can round number strings for precsion' do
34
+ expect(round('3.1415', 1)).to eq BigDecimal('3.1')
35
+ end
36
+
37
+ it 'can handle invalid number strings' do
38
+ expect(round('N/A')).to be_zero
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'rack/test'
4
+ require 'split/dashboard'
5
+
6
+ describe Split::Dashboard do
7
+ include Rack::Test::Methods
8
+
9
+ def app
10
+ @app ||= Split::Dashboard
11
+ end
12
+
13
+ def link(color)
14
+ Split::Alternative.new(color, experiment.name)
15
+ end
16
+
17
+ let(:experiment) {
18
+ Split::ExperimentCatalog.find_or_create("link_color", "blue", "red")
19
+ }
20
+
21
+ let(:experiment_with_goals) {
22
+ Split::ExperimentCatalog.find_or_create({"link_color" => ["goal_1", "goal_2"]}, "blue", "red")
23
+ }
24
+
25
+ let(:metric) {
26
+ Split::Metric.find_or_create(name: 'testmetric', experiments: [experiment, experiment_with_goals])
27
+ }
28
+
29
+ let(:red_link) { link("red") }
30
+ let(:blue_link) { link("blue") }
31
+
32
+ before(:each) do
33
+ Split.configuration.beta_probability_simulations = 1
34
+ end
35
+
36
+ it "should respond to /" do
37
+ get '/'
38
+ expect(last_response).to be_ok
39
+ end
40
+
41
+ context "start experiment manually" do
42
+ before do
43
+ Split.configuration.start_manually = true
44
+ end
45
+
46
+ context "experiment without goals" do
47
+ it "should display a Start button" do
48
+ experiment
49
+ get '/'
50
+ expect(last_response.body).to include('Start')
51
+
52
+ post "/start?experiment=#{experiment.name}"
53
+ get '/'
54
+ expect(last_response.body).to include('Reset Data')
55
+ expect(last_response.body).not_to include('Metrics:')
56
+ end
57
+ end
58
+
59
+ context "experiment with metrics" do
60
+ it "should display the names of associated metrics" do
61
+ metric
62
+ get '/'
63
+ expect(last_response.body).to include('Metrics:testmetric')
64
+ end
65
+ end
66
+
67
+ context "with goals" do
68
+ it "should display a Start button" do
69
+ experiment_with_goals
70
+ get '/'
71
+ expect(last_response.body).to include('Start')
72
+
73
+ post "/start?experiment=#{experiment.name}"
74
+ get '/'
75
+ expect(last_response.body).to include('Reset Data')
76
+ end
77
+ end
78
+ end
79
+
80
+ describe "force alternative" do
81
+ context "initial version" do
82
+ let!(:user) do
83
+ Split::User.new(@app, { experiment.name => 'red' })
84
+ end
85
+
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
96
+ end
97
+
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
114
+ end
115
+ end
116
+
117
+ describe "index page" do
118
+ context "with winner" do
119
+ before { experiment.winner = 'red' }
120
+
121
+ it "displays `Reopen Experiment` button" do
122
+ get '/'
123
+
124
+ expect(last_response.body).to include('Reopen Experiment')
125
+ end
126
+ end
127
+
128
+ context "without winner" do
129
+ it "should not display `Reopen Experiment` button" do
130
+ get '/'
131
+
132
+ expect(last_response.body).to_not include('Reopen Experiment')
133
+ end
134
+ end
135
+ end
136
+
137
+ describe "reopen experiment" do
138
+ before { experiment.winner = 'red' }
139
+
140
+ it 'redirects' do
141
+ post "/reopen?experiment=#{experiment.name}"
142
+
143
+ expect(last_response).to be_redirect
144
+ end
145
+
146
+ it "removes winner" do
147
+ post "/reopen?experiment=#{experiment.name}"
148
+
149
+ expect(Split::ExperimentCatalog.find(experiment.name)).to_not have_winner
150
+ end
151
+
152
+ it "keeps existing stats" do
153
+ red_link.participant_count = 5
154
+ blue_link.participant_count = 7
155
+ experiment.winner = 'blue'
156
+
157
+ post "/reopen?experiment=#{experiment.name}"
158
+
159
+ expect(red_link.participant_count).to eq(5)
160
+ expect(blue_link.participant_count).to eq(7)
161
+ end
162
+ end
163
+
164
+ it "should reset an experiment" do
165
+ red_link.participant_count = 5
166
+ blue_link.participant_count = 7
167
+ experiment.winner = 'blue'
168
+
169
+ post "/reset?experiment=#{experiment.name}"
170
+
171
+ expect(last_response).to be_redirect
172
+
173
+ new_red_count = red_link.participant_count
174
+ new_blue_count = blue_link.participant_count
175
+
176
+ expect(new_blue_count).to eq(0)
177
+ expect(new_red_count).to eq(0)
178
+ expect(experiment.winner).to be_nil
179
+ end
180
+
181
+ it "should delete an experiment" do
182
+ delete "/experiment?experiment=#{experiment.name}"
183
+ expect(last_response).to be_redirect
184
+ expect(Split::ExperimentCatalog.find(experiment.name)).to be_nil
185
+ end
186
+
187
+ it "should mark an alternative as the winner" do
188
+ expect(experiment.winner).to be_nil
189
+ post "/experiment?experiment=#{experiment.name}", :alternative => 'red'
190
+
191
+ expect(last_response).to be_redirect
192
+ expect(experiment.winner.name).to eq('red')
193
+ end
194
+
195
+ it "should display the start date" do
196
+ experiment.start
197
+
198
+ get '/'
199
+
200
+ expect(last_response.body).to include("<small>#{experiment.start_time.strftime('%Y-%m-%d')}</small>")
201
+ end
202
+
203
+ it "should handle experiments without a start date" do
204
+ Split.redis.hdel(:experiment_start_times, experiment.name)
205
+
206
+ get '/'
207
+
208
+ expect(last_response.body).to include('<small>Unknown</small>')
209
+ end
210
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ describe Split::EncapsulatedHelper do
5
+ include Split::EncapsulatedHelper
6
+
7
+
8
+ def params
9
+ raise NoMethodError, 'This method is not really defined'
10
+ end
11
+
12
+ describe "ab_test" do
13
+ before do
14
+ allow_any_instance_of(Split::EncapsulatedHelper::ContextShim).to receive(:ab_user)
15
+ .and_return(mock_user)
16
+ end
17
+
18
+ it "should not raise an error when params raises an error" do
19
+ expect{ params }.to raise_error(NoMethodError)
20
+ expect(lambda { ab_test('link_color', 'blue', 'red') }).not_to raise_error
21
+ end
22
+
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)
25
+ end
26
+
27
+ context "inside a view" do
28
+
29
+ it "works inside ERB" do
30
+ require 'erb'
31
+ template = ERB.new(<<-ERB.split(/\s+/s).map(&:strip).join(' '), nil, "%")
32
+ foo <% ab_test(:foo, '1', '2') do |alt, meta| %>
33
+ static <%= alt %>
34
+ <% end %>
35
+ ERB
36
+ expect(template.result(binding)).to match(/foo static \d/)
37
+ end
38
+
39
+ end
40
+ end
41
+
42
+ describe "context" do
43
+ it 'is passed in shim' do
44
+ ctx = Class.new{
45
+ include Split::EncapsulatedHelper
46
+ public :session
47
+ }.new
48
+ expect(ctx).to receive(:session){{}}
49
+ expect{ ctx.ab_test('link_color', 'blue', 'red') }.not_to raise_error
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ describe Split::ExperimentCatalog do
5
+ subject { Split::ExperimentCatalog }
6
+
7
+ describe ".find_or_create" do
8
+ it "should not raise an error when passed strings for alternatives" do
9
+ expect { subject.find_or_create('xyz', '1', '2', '3') }.not_to raise_error
10
+ end
11
+
12
+ it "should not raise an error when passed an array for alternatives" do
13
+ expect { subject.find_or_create('xyz', ['1', '2', '3']) }.not_to raise_error
14
+ end
15
+
16
+ it "should raise the appropriate error when passed integers for alternatives" do
17
+ expect { subject.find_or_create('xyz', 1, 2, 3) }.to raise_error(ArgumentError)
18
+ end
19
+
20
+ it "should raise the appropriate error when passed symbols for alternatives" do
21
+ expect { subject.find_or_create('xyz', :a, :b, :c) }.to raise_error(ArgumentError)
22
+ end
23
+
24
+ it "should not raise error when passed an array for goals" do
25
+ expect { subject.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red') }
26
+ .not_to raise_error
27
+ end
28
+
29
+ it "should not raise error when passed just one goal" do
30
+ expect { subject.find_or_create({'link_color' => "purchase"}, 'blue', 'red') }
31
+ .not_to raise_error
32
+ end
33
+
34
+ it "constructs a new experiment" do
35
+ expect(subject.find_or_create('my_exp', 'control me').control.to_s).to eq('control me')
36
+ end
37
+ end
38
+
39
+ describe '.find' do
40
+ it "should return an existing experiment" do
41
+ experiment = Split::Experiment.new('basket_text', alternatives: ['blue', 'red', 'green'])
42
+ experiment.save
43
+
44
+ experiment = subject.find('basket_text')
45
+
46
+ expect(experiment.name).to eq('basket_text')
47
+ end
48
+
49
+ it "should return nil if experiment not exist" do
50
+ expect(subject.find('non_existent_experiment')).to be_nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,533 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'time'
4
+
5
+ describe Split::Experiment do
6
+ def new_experiment(goals=[])
7
+ Split::Experiment.new('link_color', :alternatives => ['blue', 'red', 'green'], :goals => goals)
8
+ end
9
+
10
+ def alternative(color)
11
+ Split::Alternative.new(color, 'link_color')
12
+ end
13
+
14
+ let(:experiment) { new_experiment }
15
+
16
+ let(:blue) { alternative("blue") }
17
+ let(:green) { alternative("green") }
18
+
19
+ context "with an experiment" do
20
+ let(:experiment) { Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"]) }
21
+
22
+ it "should have a name" do
23
+ expect(experiment.name).to eq('basket_text')
24
+ end
25
+
26
+ it "should have alternatives" do
27
+ expect(experiment.alternatives.length).to be 2
28
+ end
29
+
30
+ it "should have alternatives with correct names" do
31
+ expect(experiment.alternatives.collect{|a| a.name}).to eq(['Basket', 'Cart'])
32
+ end
33
+
34
+ it "should be resettable by default" do
35
+ expect(experiment.resettable).to be_truthy
36
+ end
37
+
38
+ it "should save to redis" do
39
+ experiment.save
40
+ expect(Split.redis.exists('basket_text')).to be true
41
+ end
42
+
43
+ it "should save the start time to redis" do
44
+ experiment_start_time = Time.at(1372167761)
45
+ expect(Time).to receive(:now).and_return(experiment_start_time)
46
+ experiment.save
47
+
48
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to eq(experiment_start_time)
49
+ end
50
+
51
+ it "should not save the start time to redis when start_manually is enabled" do
52
+ expect(Split.configuration).to receive(:start_manually).and_return(true)
53
+ experiment.save
54
+
55
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to be_nil
56
+ end
57
+
58
+ it "should save the selected algorithm to redis" do
59
+ experiment_algorithm = Split::Algorithms::Whiplash
60
+ experiment.algorithm = experiment_algorithm
61
+ experiment.save
62
+
63
+ expect(Split::ExperimentCatalog.find('basket_text').algorithm).to eq(experiment_algorithm)
64
+ end
65
+
66
+ it "should handle having a start time stored as a string" do
67
+ experiment_start_time = Time.parse("Sat Mar 03 14:01:03")
68
+ expect(Time).to receive(:now).twice.and_return(experiment_start_time)
69
+ experiment.save
70
+ Split.redis.hset(:experiment_start_times, experiment.name, experiment_start_time)
71
+
72
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to eq(experiment_start_time)
73
+ end
74
+
75
+ it "should handle not having a start time" do
76
+ experiment_start_time = Time.parse("Sat Mar 03 14:01:03")
77
+ expect(Time).to receive(:now).and_return(experiment_start_time)
78
+ experiment.save
79
+
80
+ Split.redis.hdel(:experiment_start_times, experiment.name)
81
+
82
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to be_nil
83
+ end
84
+
85
+ it "should not create duplicates when saving multiple times" do
86
+ experiment.save
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":1}', '{"Cart":1}'])
90
+ end
91
+
92
+ describe 'new record?' do
93
+ it "should know if it hasn't been saved yet" do
94
+ expect(experiment.new_record?).to be_truthy
95
+ end
96
+
97
+ it "should know if it has been saved yet" do
98
+ experiment.save
99
+ expect(experiment.new_record?).to be_falsey
100
+ end
101
+ end
102
+
103
+ describe 'control' do
104
+ it 'should be the first alternative' do
105
+ experiment.save
106
+ expect(experiment.control.name).to eq('Basket')
107
+ end
108
+ end
109
+ end
110
+
111
+ describe 'initialization' do
112
+ it "should set the algorithm when passed as an option to the initializer" do
113
+ experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash)
114
+ expect(experiment.algorithm).to eq(Split::Algorithms::Whiplash)
115
+ end
116
+
117
+ it "should be possible to make an experiment not resettable" do
118
+ experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :resettable => false)
119
+ expect(experiment.resettable).to be_falsey
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
138
+ end
139
+
140
+ describe 'persistent configuration' do
141
+
142
+ it "should persist resettable in redis" do
143
+ experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :resettable => false)
144
+ experiment.save
145
+
146
+ e = Split::ExperimentCatalog.find('basket_text')
147
+ expect(e).to eq(experiment)
148
+ expect(e.resettable).to be_falsey
149
+
150
+ end
151
+
152
+ describe '#metadata' do
153
+ let(:experiment) { Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash, :metadata => meta) }
154
+ context 'simple hash' do
155
+ let(:meta) { { 'basket' => 'a', 'cart' => 'b' } }
156
+ it "should persist metadata in redis" do
157
+ experiment.save
158
+ e = Split::ExperimentCatalog.find('basket_text')
159
+ expect(e).to eq(experiment)
160
+ expect(e.metadata).to eq(meta)
161
+ end
162
+ end
163
+
164
+ context 'nested hash' do
165
+ let(:meta) { { 'basket' => { 'one' => 'two' }, 'cart' => 'b' } }
166
+ it "should persist metadata in redis" do
167
+ experiment.save
168
+ e = Split::ExperimentCatalog.find('basket_text')
169
+ expect(e).to eq(experiment)
170
+ expect(e.metadata).to eq(meta)
171
+ end
172
+ end
173
+ end
174
+
175
+ it "should persist algorithm in redis" do
176
+ experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash)
177
+ experiment.save
178
+
179
+ e = Split::ExperimentCatalog.find('basket_text')
180
+ expect(e).to eq(experiment)
181
+ expect(e.algorithm).to eq(Split::Algorithms::Whiplash)
182
+ end
183
+
184
+ it "should persist a new experiment in redis, that does not exist in the configuration file" do
185
+ experiment = Split::Experiment.new('foobar', :alternatives => ['tra', 'la'], :algorithm => Split::Algorithms::Whiplash)
186
+ experiment.save
187
+
188
+ e = Split::ExperimentCatalog.find('foobar')
189
+ expect(e).to eq(experiment)
190
+ expect(e.alternatives.collect{|a| a.name}).to eq(['tra', 'la'])
191
+ end
192
+ end
193
+
194
+ describe 'deleting' do
195
+ it 'should delete itself' do
196
+ experiment = Split::Experiment.new('basket_text', :alternatives => [ 'Basket', "Cart"])
197
+ experiment.save
198
+
199
+ experiment.delete
200
+ expect(Split.redis.exists('link_color')).to be false
201
+ expect(Split::ExperimentCatalog.find('link_color')).to be_nil
202
+ end
203
+
204
+ it "should increment the version" do
205
+ expect(experiment.version).to eq(0)
206
+ experiment.delete
207
+ expect(experiment.version).to eq(1)
208
+ end
209
+
210
+ it "should call the on_experiment_delete hook" do
211
+ expect(Split.configuration.on_experiment_delete).to receive(:call)
212
+ experiment.delete
213
+ end
214
+
215
+ it "should call the on_before_experiment_delete hook" do
216
+ expect(Split.configuration.on_before_experiment_delete).to receive(:call)
217
+ experiment.delete
218
+ end
219
+
220
+ it 'should reset the start time if the experiment should be manually started' do
221
+ Split.configuration.start_manually = true
222
+ experiment.start
223
+ experiment.delete
224
+ expect(experiment.start_time).to be_nil
225
+ end
226
+ end
227
+
228
+
229
+ describe 'winner' do
230
+ it "should have no winner initially" do
231
+ expect(experiment.winner).to be_nil
232
+ end
233
+ end
234
+
235
+ describe 'winner=' do
236
+ it "should allow you to specify a winner" do
237
+ experiment.save
238
+ experiment.winner = 'red'
239
+ expect(experiment.winner.name).to eq('red')
240
+ end
241
+
242
+ context 'when has_winner state is memoized' do
243
+ before { expect(experiment).to_not have_winner }
244
+
245
+ it 'should keep has_winner state consistent' do
246
+ experiment.winner = 'red'
247
+ expect(experiment).to have_winner
248
+ end
249
+ end
250
+ end
251
+
252
+ describe 'reset_winner' do
253
+ before { experiment.winner = 'green' }
254
+
255
+ it 'should reset the winner' do
256
+ experiment.reset_winner
257
+ expect(experiment.winner).to be_nil
258
+ end
259
+
260
+ context 'when has_winner state is memoized' do
261
+ before { expect(experiment).to have_winner }
262
+
263
+ it 'should keep has_winner state consistent' do
264
+ experiment.reset_winner
265
+ expect(experiment).to_not have_winner
266
+ end
267
+ end
268
+ end
269
+
270
+ describe 'has_winner?' do
271
+ context 'with winner' do
272
+ before { experiment.winner = 'red' }
273
+
274
+ it 'returns true' do
275
+ expect(experiment).to have_winner
276
+ end
277
+ end
278
+
279
+ context 'without winner' do
280
+ it 'returns false' do
281
+ expect(experiment).to_not have_winner
282
+ end
283
+ end
284
+
285
+ it 'memoizes has_winner state' do
286
+ expect(experiment).to receive(:winner).once
287
+ expect(experiment).to_not have_winner
288
+ expect(experiment).to_not have_winner
289
+ end
290
+ end
291
+
292
+ describe 'reset' do
293
+ let(:reset_manually) { false }
294
+
295
+ before do
296
+ allow(Split.configuration).to receive(:reset_manually).and_return(reset_manually)
297
+ experiment.save
298
+ green.increment_participation
299
+ green.increment_participation
300
+ end
301
+
302
+ it 'should reset all alternatives' do
303
+ experiment.winner = 'green'
304
+
305
+ expect(experiment.next_alternative.name).to eq('green')
306
+ green.increment_participation
307
+
308
+ experiment.reset
309
+
310
+ expect(green.participant_count).to eq(0)
311
+ expect(green.completed_count).to eq(0)
312
+ end
313
+
314
+ it 'should reset the winner' do
315
+ experiment.winner = 'green'
316
+
317
+ expect(experiment.next_alternative.name).to eq('green')
318
+ green.increment_participation
319
+
320
+ experiment.reset
321
+
322
+ expect(experiment.winner).to be_nil
323
+ end
324
+
325
+ it "should increment the version" do
326
+ expect(experiment.version).to eq(0)
327
+ experiment.reset
328
+ expect(experiment.version).to eq(1)
329
+ end
330
+
331
+ it "should call the on_experiment_reset hook" do
332
+ expect(Split.configuration.on_experiment_reset).to receive(:call)
333
+ experiment.reset
334
+ end
335
+
336
+ it "should call the on_before_experiment_reset hook" do
337
+ expect(Split.configuration.on_before_experiment_reset).to receive(:call)
338
+ experiment.reset
339
+ end
340
+ end
341
+
342
+ describe 'algorithm' do
343
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'red', 'green') }
344
+
345
+ it 'should use the default algorithm if none is specified' do
346
+ expect(experiment.algorithm).to eq(Split.configuration.algorithm)
347
+ end
348
+
349
+ it 'should use the user specified algorithm for this experiment if specified' do
350
+ experiment.algorithm = Split::Algorithms::Whiplash
351
+ expect(experiment.algorithm).to eq(Split::Algorithms::Whiplash)
352
+ end
353
+ end
354
+
355
+ describe '#next_alternative' do
356
+ context 'with multiple alternatives' do
357
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'red', 'green') }
358
+
359
+ context 'with winner' do
360
+ it "should always return the winner" do
361
+ green = Split::Alternative.new('green', 'link_color')
362
+ experiment.winner = 'green'
363
+
364
+ expect(experiment.next_alternative.name).to eq('green')
365
+ green.increment_participation
366
+
367
+ expect(experiment.next_alternative.name).to eq('green')
368
+ end
369
+ end
370
+
371
+ context 'without winner' do
372
+ it "should use the specified algorithm" do
373
+ experiment.algorithm = Split::Algorithms::Whiplash
374
+ expect(experiment.algorithm).to receive(:choose_alternative).and_return(Split::Alternative.new('green', 'link_color'))
375
+ expect(experiment.next_alternative.name).to eq('green')
376
+ end
377
+ end
378
+ end
379
+
380
+ context 'with single alternative' do
381
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue') }
382
+
383
+ it "should always return the only alternative" do
384
+ expect(experiment.next_alternative.name).to eq('blue')
385
+ expect(experiment.next_alternative.name).to eq('blue')
386
+ end
387
+ end
388
+ end
389
+
390
+ describe 'changing an existing experiment' do
391
+ def same_but_different_alternative
392
+ Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'yellow', 'orange')
393
+ end
394
+
395
+ it "should reset an experiment if it is loaded with different alternatives" do
396
+ experiment.save
397
+ blue.participant_count = 5
398
+ same_experiment = same_but_different_alternative
399
+ expect(same_experiment.alternatives.map(&:name)).to eq(['blue', 'yellow', 'orange'])
400
+ expect(blue.participant_count).to eq(0)
401
+ end
402
+
403
+ it "should only reset once" do
404
+ experiment.save
405
+ expect(experiment.version).to eq(0)
406
+ same_experiment = same_but_different_alternative
407
+ expect(same_experiment.version).to eq(1)
408
+ same_experiment_again = same_but_different_alternative
409
+ expect(same_experiment_again.version).to eq(1)
410
+ end
411
+
412
+ context 'when experiment configuration is changed' do
413
+ let(:reset_manually) { false }
414
+
415
+ before do
416
+ experiment.save
417
+ allow(Split.configuration).to receive(:reset_manually).and_return(reset_manually)
418
+ green.increment_participation
419
+ green.increment_participation
420
+ experiment.set_alternatives_and_options(alternatives: %w(blue red green zip),
421
+ goals: %w(purchase))
422
+ experiment.save
423
+ end
424
+
425
+ it 'resets all alternatives' do
426
+ expect(green.participant_count).to eq(0)
427
+ expect(green.completed_count).to eq(0)
428
+ end
429
+
430
+ context 'when reset_manually is set' do
431
+ let(:reset_manually) { true }
432
+
433
+ it 'does not reset alternatives' do
434
+ expect(green.participant_count).to eq(2)
435
+ expect(green.completed_count).to eq(0)
436
+ end
437
+ end
438
+ end
439
+ end
440
+
441
+ describe 'alternatives passed as non-strings' do
442
+ it "should throw an exception if an alternative is passed that is not a string" do
443
+ expect(lambda { Split::ExperimentCatalog.find_or_create('link_color', :blue, :red) }).to raise_error(ArgumentError)
444
+ expect(lambda { Split::ExperimentCatalog.find_or_create('link_enabled', true, false) }).to raise_error(ArgumentError)
445
+ end
446
+ end
447
+
448
+ describe 'specifying weights' do
449
+ let(:experiment_with_weight) {
450
+ Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 2 })
451
+ }
452
+
453
+ it "should work for a new experiment" do
454
+ expect(experiment_with_weight.alternatives.map(&:weight)).to eq([1, 2])
455
+ end
456
+
457
+ it "should work for an existing experiment" do
458
+ experiment.save
459
+ expect(experiment_with_weight.alternatives.map(&:weight)).to eq([1, 2])
460
+ end
461
+ end
462
+
463
+ describe "specifying goals" do
464
+ let(:experiment) {
465
+ new_experiment(["purchase"])
466
+ }
467
+
468
+ context "saving experiment" do
469
+ let(:same_but_different_goals) { Split::ExperimentCatalog.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green') }
470
+
471
+ before { experiment.save }
472
+
473
+ it "can find existing experiment" do
474
+ expect(Split::ExperimentCatalog.find("link_color").name).to eq("link_color")
475
+ end
476
+
477
+ it "should reset an experiment if it is loaded with different goals" do
478
+ same_but_different_goals
479
+ expect(Split::ExperimentCatalog.find("link_color").goals).to eq(["purchase", "refund"])
480
+ end
481
+
482
+ end
483
+
484
+ it "should have goals" do
485
+ expect(experiment.goals).to eq(["purchase"])
486
+ end
487
+
488
+ context "find or create experiment" do
489
+ it "should have correct goals" do
490
+ experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
491
+ expect(experiment.goals).to eq(["purchase", "refund"])
492
+ experiment = Split::ExperimentCatalog.find_or_create('link_color3', 'blue', 'red', 'green')
493
+ expect(experiment.goals).to eq([])
494
+ end
495
+ end
496
+ end
497
+
498
+ describe "beta probability calculation" do
499
+ it "should return a hash with the probability of each alternative being the best" do
500
+ experiment = Split::ExperimentCatalog.find_or_create('mathematicians', 'bernoulli', 'poisson', 'lagrange')
501
+ experiment.calc_winning_alternatives
502
+ expect(experiment.alternative_probabilities).not_to be_nil
503
+ end
504
+
505
+ it "should return between 46% and 54% probability for an experiment with 2 alternatives and no data" do
506
+ experiment = Split::ExperimentCatalog.find_or_create('scientists', 'einstein', 'bohr')
507
+ experiment.calc_winning_alternatives
508
+ expect(experiment.alternatives[0].p_winner).to be_within(0.04).of(0.50)
509
+ end
510
+
511
+ it "should calculate the probability of being the winning alternative separately for each goal", :skip => true do
512
+ experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
513
+ goal1 = experiment.goals[0]
514
+ goal2 = experiment.goals[1]
515
+ experiment.alternatives.each do |alternative|
516
+ alternative.participant_count = 50
517
+ alternative.set_completed_count(10, goal1)
518
+ alternative.set_completed_count(15+rand(30), goal2)
519
+ end
520
+ experiment.calc_winning_alternatives
521
+ alt = experiment.alternatives[0]
522
+ p_goal1 = alt.p_winner(goal1)
523
+ p_goal2 = alt.p_winner(goal2)
524
+ expect(p_goal1).not_to be_within(0.04).of(p_goal2)
525
+ end
526
+
527
+ it "should return nil and not re-calculate probabilities if they have already been calculated today" do
528
+ experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
529
+ expect(experiment.calc_winning_alternatives).not_to be nil
530
+ expect(experiment.calc_winning_alternatives).to be nil
531
+ end
532
+ end
533
+ end