split 2.1.0 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.codeclimate.yml +30 -0
- data/.csslintrc +2 -0
- data/.eslintignore +1 -0
- data/.eslintrc +213 -0
- data/.rubocop.yml +1156 -0
- data/.travis.yml +4 -0
- data/Appraisals +5 -0
- data/CHANGELOG.md +23 -1
- data/Gemfile +1 -0
- data/README.md +43 -21
- data/gemfiles/4.1.gemfile +1 -0
- data/gemfiles/4.2.gemfile +1 -0
- data/gemfiles/5.0.gemfile +10 -0
- data/lib/split.rb +15 -23
- data/lib/split/alternative.rb +0 -1
- data/lib/split/configuration.rb +13 -2
- data/lib/split/dashboard.rb +5 -0
- data/lib/split/dashboard/public/dashboard-filtering.js +3 -3
- data/lib/split/dashboard/views/_experiment.erb +4 -0
- data/lib/split/encapsulated_helper.rb +2 -15
- data/lib/split/experiment.rb +63 -54
- data/lib/split/extensions.rb +1 -1
- data/lib/split/goals_collection.rb +1 -1
- data/lib/split/redis_interface.rb +51 -0
- data/lib/split/user.rb +1 -1
- data/lib/split/version.rb +1 -1
- data/spec/configuration_spec.rb +19 -5
- data/spec/dashboard_spec.rb +15 -0
- data/spec/encapsulated_helper_spec.rb +35 -4
- data/spec/experiment_spec.rb +38 -5
- data/spec/helper_spec.rb +59 -16
- data/spec/persistence/dual_adapter_spec.rb +102 -0
- data/spec/redis_interface_spec.rb +111 -0
- data/spec/spec_helper.rb +11 -10
- data/spec/split_spec.rb +43 -0
- data/split.gemspec +2 -3
- metadata +20 -23
- data/lib/split/extensions/array.rb +0 -5
data/lib/split/extensions.rb
CHANGED
@@ -0,0 +1,51 @@
|
|
1
|
+
module Split
|
2
|
+
# Simplifies the interface to Redis.
|
3
|
+
class RedisInterface
|
4
|
+
def initialize
|
5
|
+
self.redis = Split.redis
|
6
|
+
end
|
7
|
+
|
8
|
+
def persist_list(list_name, list_values)
|
9
|
+
max_index = list_length(list_name) - 1
|
10
|
+
list_values.each_with_index do |value, index|
|
11
|
+
if index > max_index
|
12
|
+
add_to_list(list_name, value)
|
13
|
+
else
|
14
|
+
set_list_index(list_name, index, value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
make_list_length(list_name, list_values.length)
|
18
|
+
list_values
|
19
|
+
end
|
20
|
+
|
21
|
+
def add_to_list(list_name, value)
|
22
|
+
redis.rpush(list_name, value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def set_list_index(list_name, index, value)
|
26
|
+
redis.lset(list_name, index, value)
|
27
|
+
end
|
28
|
+
|
29
|
+
def list_length(list_name)
|
30
|
+
redis.llen(list_name)
|
31
|
+
end
|
32
|
+
|
33
|
+
def remove_last_item_from_list(list_name)
|
34
|
+
redis.rpop(list_name)
|
35
|
+
end
|
36
|
+
|
37
|
+
def make_list_length(list_name, new_length)
|
38
|
+
while list_length(list_name) > new_length
|
39
|
+
remove_last_item_from_list(list_name)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def add_to_set(set_name, value)
|
44
|
+
redis.sadd(set_name, value) unless redis.sismember(set_name, value)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
attr_accessor :redis
|
50
|
+
end
|
51
|
+
end
|
data/lib/split/user.rb
CHANGED
@@ -21,7 +21,7 @@ module Split
|
|
21
21
|
def max_experiments_reached?(experiment_key)
|
22
22
|
if Split.configuration.allow_multiple_experiments == 'control'
|
23
23
|
experiments = active_experiments
|
24
|
-
count_control = experiments.
|
24
|
+
count_control = experiments.count {|k,v| k == experiment_key || v == 'control'}
|
25
25
|
experiments.size > count_control
|
26
26
|
else
|
27
27
|
!Split.configuration.allow_multiple_experiments &&
|
data/lib/split/version.rb
CHANGED
data/spec/configuration_spec.rb
CHANGED
@@ -212,20 +212,34 @@ describe Split::Configuration do
|
|
212
212
|
expect(@config.normalized_experiments).to eq({:my_experiment=>{:alternatives=>[{"control_opt"=>0.67}, [{"second_opt"=>0.1}, {"third_opt"=>0.23}]]}})
|
213
213
|
end
|
214
214
|
|
215
|
-
context
|
215
|
+
context 'redis_url configuration [DEPRECATED]' do
|
216
|
+
it 'should warn on set and assign to #redis' do
|
217
|
+
expect(@config).to receive(:warn).with(/\[DEPRECATED\]/) { nil }
|
218
|
+
@config.redis_url = 'example_url'
|
219
|
+
expect(@config.redis).to eq('example_url')
|
220
|
+
end
|
221
|
+
|
222
|
+
it 'should warn on get and return #redis' do
|
223
|
+
expect(@config).to receive(:warn).with(/\[DEPRECATED\]/) { nil }
|
224
|
+
@config.redis = 'example_url'
|
225
|
+
expect(@config.redis_url).to eq('example_url')
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
context "redis configuration" do
|
216
230
|
it "should default to local redis server" do
|
217
|
-
expect(@config.
|
231
|
+
expect(@config.redis).to eq("redis://localhost:6379")
|
218
232
|
end
|
219
233
|
|
220
234
|
it "should allow for redis url to be configured" do
|
221
|
-
@config.
|
222
|
-
expect(@config.
|
235
|
+
@config.redis = "custom_redis_url"
|
236
|
+
expect(@config.redis).to eq("custom_redis_url")
|
223
237
|
end
|
224
238
|
|
225
239
|
context "provided REDIS_URL environment variable" do
|
226
240
|
it "should use the ENV variable" do
|
227
241
|
ENV['REDIS_URL'] = "env_redis_url"
|
228
|
-
expect(Split::Configuration.new.
|
242
|
+
expect(Split::Configuration.new.redis).to eq("env_redis_url")
|
229
243
|
end
|
230
244
|
end
|
231
245
|
end
|
data/spec/dashboard_spec.rb
CHANGED
@@ -73,6 +73,21 @@ describe Split::Dashboard do
|
|
73
73
|
end
|
74
74
|
end
|
75
75
|
|
76
|
+
describe "force alternative" do
|
77
|
+
let!(:user) do
|
78
|
+
Split::User.new(@app, { experiment.name => 'a' })
|
79
|
+
end
|
80
|
+
|
81
|
+
before do
|
82
|
+
allow(Split::User).to receive(:new).and_return(user)
|
83
|
+
end
|
84
|
+
|
85
|
+
it "should set current user's alternative" do
|
86
|
+
post "/force_alternative?experiment=#{experiment.name}", alternative: "b"
|
87
|
+
expect(user[experiment.name]).to eq("b")
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
76
91
|
describe "index page" do
|
77
92
|
context "with winner" do
|
78
93
|
before { experiment.winner = 'red' }
|
@@ -4,18 +4,49 @@ require 'spec_helper'
|
|
4
4
|
describe Split::EncapsulatedHelper do
|
5
5
|
include Split::EncapsulatedHelper
|
6
6
|
|
7
|
-
before do
|
8
|
-
allow_any_instance_of(Split::EncapsulatedHelper::ContextShim).to receive(:ab_user)
|
9
|
-
.and_return(mock_user)
|
10
|
-
end
|
11
7
|
|
12
8
|
def params
|
13
9
|
raise NoMethodError, 'This method is not really defined'
|
14
10
|
end
|
15
11
|
|
16
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
|
+
|
17
18
|
it "should not raise an error when params raises an error" do
|
19
|
+
expect{ params }.to raise_error(NoMethodError)
|
18
20
|
expect(lambda { ab_test('link_color', 'blue', 'red') }).not_to raise_error
|
19
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
|
20
51
|
end
|
21
52
|
end
|
data/spec/experiment_spec.rb
CHANGED
@@ -13,9 +13,7 @@ describe Split::Experiment do
|
|
13
13
|
Split::Alternative.new(color, 'link_color')
|
14
14
|
end
|
15
15
|
|
16
|
-
let(:experiment) {
|
17
|
-
new_experiment
|
18
|
-
}
|
16
|
+
let(:experiment) { new_experiment }
|
19
17
|
|
20
18
|
let(:blue) { alternative("blue") }
|
21
19
|
let(:green) { alternative("green") }
|
@@ -242,7 +240,15 @@ describe Split::Experiment do
|
|
242
240
|
end
|
243
241
|
|
244
242
|
describe 'reset' do
|
245
|
-
|
243
|
+
let(:reset_manually) { false }
|
244
|
+
|
245
|
+
before do
|
246
|
+
allow(Split.configuration).to receive(:reset_manually).and_return(reset_manually)
|
247
|
+
experiment.save
|
248
|
+
green.increment_participation
|
249
|
+
green.increment_participation
|
250
|
+
end
|
251
|
+
|
246
252
|
it 'should reset all alternatives' do
|
247
253
|
experiment.winner = 'green'
|
248
254
|
|
@@ -352,6 +358,34 @@ describe Split::Experiment do
|
|
352
358
|
same_experiment_again = same_but_different_alternative
|
353
359
|
expect(same_experiment_again.version).to eq(1)
|
354
360
|
end
|
361
|
+
|
362
|
+
context 'when experiment configuration is changed' do
|
363
|
+
let(:reset_manually) { false }
|
364
|
+
|
365
|
+
before do
|
366
|
+
experiment.save
|
367
|
+
allow(Split.configuration).to receive(:reset_manually).and_return(reset_manually)
|
368
|
+
green.increment_participation
|
369
|
+
green.increment_participation
|
370
|
+
experiment.set_alternatives_and_options(alternatives: %w(blue red green zip),
|
371
|
+
goals: %w(purchase))
|
372
|
+
experiment.save
|
373
|
+
end
|
374
|
+
|
375
|
+
it 'resets all alternatives' do
|
376
|
+
expect(green.participant_count).to eq(0)
|
377
|
+
expect(green.completed_count).to eq(0)
|
378
|
+
end
|
379
|
+
|
380
|
+
context 'when reset_manually is set' do
|
381
|
+
let(:reset_manually) { true }
|
382
|
+
|
383
|
+
it 'does not reset alternatives' do
|
384
|
+
expect(green.participant_count).to eq(2)
|
385
|
+
expect(green.completed_count).to eq(0)
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
355
389
|
end
|
356
390
|
|
357
391
|
describe 'alternatives passed as non-strings' do
|
@@ -448,5 +482,4 @@ describe Split::Experiment do
|
|
448
482
|
expect(experiment.calc_winning_alternatives).to be nil
|
449
483
|
end
|
450
484
|
end
|
451
|
-
|
452
485
|
end
|
data/spec/helper_spec.rb
CHANGED
@@ -197,24 +197,51 @@ describe Split::Helper do
|
|
197
197
|
expect(button_size_alt.participant_count).to eq(1)
|
198
198
|
end
|
199
199
|
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
200
|
+
context "with allow_multiple_experiments = 'control'" do
|
201
|
+
it "should let a user participate in many experiment with one non-'control' alternative" do
|
202
|
+
Split.configure do |config|
|
203
|
+
config.allow_multiple_experiments = 'control'
|
204
|
+
end
|
205
|
+
groups = 100.times.map do |n|
|
206
|
+
ab_test("test#{n}".to_sym, {'control' => (100 - n)}, {"test#{n}-alt" => n})
|
207
|
+
end
|
208
|
+
|
209
|
+
experiments = ab_user.active_experiments
|
210
|
+
expect(experiments.size).to be > 1
|
211
|
+
|
212
|
+
count_control = experiments.values.count { |g| g == 'control' }
|
213
|
+
expect(count_control).to eq(experiments.size - 1)
|
214
|
+
|
215
|
+
count_alts = groups.count { |g| g != 'control' }
|
216
|
+
expect(count_alts).to eq(1)
|
208
217
|
end
|
209
218
|
|
210
|
-
|
211
|
-
|
219
|
+
context "when user already has experiment" do
|
220
|
+
let(:mock_user){ Split::User.new(self, {'test_0' => 'test-alt'}) }
|
221
|
+
before{
|
222
|
+
Split.configure do |config|
|
223
|
+
config.allow_multiple_experiments = 'control'
|
224
|
+
end
|
225
|
+
Split::ExperimentCatalog.find_or_initialize('test_0', 'control', 'test-alt').save
|
226
|
+
Split::ExperimentCatalog.find_or_initialize('test_1', 'control', 'test-alt').save
|
227
|
+
}
|
212
228
|
|
213
|
-
|
214
|
-
|
229
|
+
it "should restore previously selected alternative" do
|
230
|
+
expect(ab_user.active_experiments.size).to eq 1
|
231
|
+
expect(ab_test(:test_0, {'control' => 100}, {"test-alt" => 1})).to eq 'test-alt'
|
232
|
+
expect(ab_test(:test_0, {'control' => 1}, {"test-alt" => 100})).to eq 'test-alt'
|
233
|
+
end
|
234
|
+
|
235
|
+
it "lets override existing choice" do
|
236
|
+
pending "this requires user store reset on first call not depending on whelther it is current trial"
|
237
|
+
@params = { 'ab_test' => { 'test_1' => 'test-alt' } }
|
238
|
+
|
239
|
+
expect(ab_test(:test_0, {'control' => 0}, {"test-alt" => 100})).to eq 'control'
|
240
|
+
expect(ab_test(:test_1, {'control' => 100}, {"test-alt" => 1})).to eq 'test-alt'
|
241
|
+
end
|
242
|
+
|
243
|
+
end
|
215
244
|
|
216
|
-
count_alts = groups.count { |g| g != 'control' }
|
217
|
-
expect(count_alts).to eq(1)
|
218
245
|
end
|
219
246
|
|
220
247
|
it "should not over-write a finished key when an experiment is on a later version" do
|
@@ -642,6 +669,22 @@ describe Split::Helper do
|
|
642
669
|
|
643
670
|
it_behaves_like "a disabled test"
|
644
671
|
end
|
672
|
+
|
673
|
+
context "when ignored other address" do
|
674
|
+
before do
|
675
|
+
@request = OpenStruct.new(:ip => '1.1.1.1')
|
676
|
+
Split.configure do |c|
|
677
|
+
c.ignore_ip_addresses << '81.19.48.130'
|
678
|
+
end
|
679
|
+
end
|
680
|
+
|
681
|
+
it "works as usual" do
|
682
|
+
alternative_name = ab_test('link_color', 'red', 'blue')
|
683
|
+
expect{
|
684
|
+
ab_finished('link_color')
|
685
|
+
}.to change(Split::Alternative.new(alternative_name, 'link_color'), :completed_count).by(1)
|
686
|
+
end
|
687
|
+
end
|
645
688
|
end
|
646
689
|
|
647
690
|
describe 'versioned experiments' do
|
@@ -774,7 +817,7 @@ describe Split::Helper do
|
|
774
817
|
end
|
775
818
|
end
|
776
819
|
|
777
|
-
expect(Split.configuration.db_failover_on_db_error).to receive(:call)
|
820
|
+
expect(Split.configuration.db_failover_on_db_error).to receive(:call).and_call_original
|
778
821
|
ab_test('link_color', 'blue', 'red')
|
779
822
|
end
|
780
823
|
|
@@ -833,7 +876,7 @@ describe Split::Helper do
|
|
833
876
|
end
|
834
877
|
end
|
835
878
|
|
836
|
-
expect(Split.configuration.db_failover_on_db_error).to receive(:call)
|
879
|
+
expect(Split.configuration.db_failover_on_db_error).to receive(:call).and_call_original
|
837
880
|
ab_finished('link_color')
|
838
881
|
end
|
839
882
|
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "spec_helper"
|
3
|
+
|
4
|
+
describe Split::Persistence::DualAdapter do
|
5
|
+
|
6
|
+
let(:context){ "some context" }
|
7
|
+
|
8
|
+
let(:just_adapter){ Class.new }
|
9
|
+
let(:selected_adapter_instance){ double }
|
10
|
+
let(:selected_adapter){
|
11
|
+
c = Class.new
|
12
|
+
expect(c).to receive(:new){ selected_adapter_instance }
|
13
|
+
c
|
14
|
+
}
|
15
|
+
let(:not_selected_adapter){
|
16
|
+
c = Class.new
|
17
|
+
expect(c).not_to receive(:new)
|
18
|
+
c
|
19
|
+
}
|
20
|
+
|
21
|
+
shared_examples_for "forwarding calls" do
|
22
|
+
it "#[]=" do
|
23
|
+
expect(selected_adapter_instance).to receive(:[]=).with('my_key', 'my_value')
|
24
|
+
expect_any_instance_of(not_selected_adapter).not_to receive(:[]=)
|
25
|
+
subject["my_key"] = "my_value"
|
26
|
+
end
|
27
|
+
|
28
|
+
it "#[]" do
|
29
|
+
expect(selected_adapter_instance).to receive(:[]).with('my_key'){'my_value'}
|
30
|
+
expect_any_instance_of(not_selected_adapter).not_to receive(:[])
|
31
|
+
expect(subject["my_key"]).to eq('my_value')
|
32
|
+
end
|
33
|
+
|
34
|
+
it "#delete" do
|
35
|
+
expect(selected_adapter_instance).to receive(:delete).with('my_key'){'my_value'}
|
36
|
+
expect_any_instance_of(not_selected_adapter).not_to receive(:delete)
|
37
|
+
expect(subject.delete("my_key")).to eq('my_value')
|
38
|
+
end
|
39
|
+
|
40
|
+
it "#keys" do
|
41
|
+
expect(selected_adapter_instance).to receive(:keys){'my_value'}
|
42
|
+
expect_any_instance_of(not_selected_adapter).not_to receive(:keys)
|
43
|
+
expect(subject.keys).to eq('my_value')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
context "when logged in" do
|
48
|
+
subject {
|
49
|
+
described_class.with_config(
|
50
|
+
logged_in: -> (context) { true },
|
51
|
+
logged_in_adapter: selected_adapter,
|
52
|
+
logged_out_adapter: not_selected_adapter
|
53
|
+
).new(context)
|
54
|
+
}
|
55
|
+
|
56
|
+
it_should_behave_like "forwarding calls"
|
57
|
+
end
|
58
|
+
|
59
|
+
context "when not logged in" do
|
60
|
+
subject {
|
61
|
+
described_class.with_config(
|
62
|
+
logged_in: -> (context) { false },
|
63
|
+
logged_in_adapter: not_selected_adapter,
|
64
|
+
logged_out_adapter: selected_adapter
|
65
|
+
).new(context)
|
66
|
+
}
|
67
|
+
|
68
|
+
it_should_behave_like "forwarding calls"
|
69
|
+
end
|
70
|
+
|
71
|
+
describe "when errors in config" do
|
72
|
+
before{
|
73
|
+
described_class.config.clear
|
74
|
+
}
|
75
|
+
let(:some_proc){ ->{} }
|
76
|
+
it "when no logged in adapter" do
|
77
|
+
expect{
|
78
|
+
described_class.with_config(
|
79
|
+
logged_in: some_proc,
|
80
|
+
logged_out_adapter: just_adapter
|
81
|
+
).new(context)
|
82
|
+
}.to raise_error(StandardError, /:logged_in_adapter/)
|
83
|
+
end
|
84
|
+
it "when no logged out adapter" do
|
85
|
+
expect{
|
86
|
+
described_class.with_config(
|
87
|
+
logged_in: some_proc,
|
88
|
+
logged_in_adapter: just_adapter
|
89
|
+
).new(context)
|
90
|
+
}.to raise_error(StandardError, /:logged_out_adapter/)
|
91
|
+
end
|
92
|
+
it "when no logged in detector" do
|
93
|
+
expect{
|
94
|
+
described_class.with_config(
|
95
|
+
logged_in_adapter: just_adapter,
|
96
|
+
logged_out_adapter: just_adapter
|
97
|
+
).new(context)
|
98
|
+
}.to raise_error(StandardError, /:logged_in$/)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|