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.
- 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
|