split 2.1.0 → 2.2.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 +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
|