ab-split 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +30 -0
- data/.csslintrc +2 -0
- data/.eslintignore +1 -0
- data/.eslintrc +213 -0
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +7 -0
- data/.rubocop_todo.yml +679 -0
- data/.travis.yml +60 -0
- data/Appraisals +19 -0
- data/CHANGELOG.md +696 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +62 -0
- data/Gemfile +7 -0
- data/LICENSE +22 -0
- data/README.md +955 -0
- data/Rakefile +9 -0
- data/ab-split.gemspec +44 -0
- data/gemfiles/4.2.gemfile +9 -0
- data/gemfiles/5.0.gemfile +9 -0
- data/gemfiles/5.1.gemfile +9 -0
- data/gemfiles/5.2.gemfile +9 -0
- data/gemfiles/6.0.gemfile +9 -0
- data/lib/split.rb +76 -0
- data/lib/split/algorithms/block_randomization.rb +23 -0
- data/lib/split/algorithms/weighted_sample.rb +18 -0
- data/lib/split/algorithms/whiplash.rb +38 -0
- data/lib/split/alternative.rb +191 -0
- data/lib/split/combined_experiments_helper.rb +37 -0
- data/lib/split/configuration.rb +255 -0
- data/lib/split/dashboard.rb +74 -0
- data/lib/split/dashboard/helpers.rb +45 -0
- data/lib/split/dashboard/pagination_helpers.rb +86 -0
- data/lib/split/dashboard/paginator.rb +16 -0
- data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
- data/lib/split/dashboard/public/dashboard.js +24 -0
- data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
- data/lib/split/dashboard/public/reset.css +48 -0
- data/lib/split/dashboard/public/style.css +328 -0
- data/lib/split/dashboard/views/_controls.erb +18 -0
- data/lib/split/dashboard/views/_experiment.erb +155 -0
- data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
- data/lib/split/dashboard/views/index.erb +26 -0
- data/lib/split/dashboard/views/layout.erb +27 -0
- data/lib/split/encapsulated_helper.rb +42 -0
- data/lib/split/engine.rb +15 -0
- data/lib/split/exceptions.rb +6 -0
- data/lib/split/experiment.rb +486 -0
- data/lib/split/experiment_catalog.rb +51 -0
- data/lib/split/extensions/string.rb +16 -0
- data/lib/split/goals_collection.rb +45 -0
- data/lib/split/helper.rb +165 -0
- data/lib/split/metric.rb +101 -0
- data/lib/split/persistence.rb +28 -0
- data/lib/split/persistence/cookie_adapter.rb +94 -0
- data/lib/split/persistence/dual_adapter.rb +85 -0
- data/lib/split/persistence/redis_adapter.rb +57 -0
- data/lib/split/persistence/session_adapter.rb +29 -0
- data/lib/split/redis_interface.rb +50 -0
- data/lib/split/trial.rb +117 -0
- data/lib/split/user.rb +69 -0
- data/lib/split/version.rb +7 -0
- data/lib/split/zscore.rb +57 -0
- data/spec/algorithms/block_randomization_spec.rb +32 -0
- data/spec/algorithms/weighted_sample_spec.rb +19 -0
- data/spec/algorithms/whiplash_spec.rb +24 -0
- data/spec/alternative_spec.rb +320 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/configuration_spec.rb +258 -0
- data/spec/dashboard/pagination_helpers_spec.rb +200 -0
- data/spec/dashboard/paginator_spec.rb +37 -0
- data/spec/dashboard_helpers_spec.rb +42 -0
- data/spec/dashboard_spec.rb +210 -0
- data/spec/encapsulated_helper_spec.rb +52 -0
- data/spec/experiment_catalog_spec.rb +53 -0
- data/spec/experiment_spec.rb +533 -0
- data/spec/goals_collection_spec.rb +80 -0
- data/spec/helper_spec.rb +1111 -0
- data/spec/metric_spec.rb +31 -0
- data/spec/persistence/cookie_adapter_spec.rb +106 -0
- data/spec/persistence/dual_adapter_spec.rb +194 -0
- data/spec/persistence/redis_adapter_spec.rb +90 -0
- data/spec/persistence/session_adapter_spec.rb +32 -0
- data/spec/persistence_spec.rb +34 -0
- data/spec/redis_interface_spec.rb +111 -0
- data/spec/spec_helper.rb +52 -0
- data/spec/split_spec.rb +43 -0
- data/spec/support/cookies_mock.rb +20 -0
- data/spec/trial_spec.rb +299 -0
- data/spec/user_spec.rb +87 -0
- 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
|