ab-split 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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