split 2.2.0 → 3.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/.rubocop.yml +2 -2
- data/.travis.yml +27 -3
- data/Appraisals +6 -5
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +54 -5
- data/LICENSE +1 -1
- data/README.md +190 -111
- data/gemfiles/4.2.gemfile +1 -1
- data/gemfiles/5.0.gemfile +2 -2
- data/gemfiles/{4.1.gemfile → 5.1.gemfile} +3 -2
- data/lib/split/algorithms/block_randomization.rb +22 -0
- data/lib/split/alternative.rb +32 -7
- data/lib/split/combined_experiments_helper.rb +37 -0
- data/lib/split/configuration.rb +7 -0
- data/lib/split/dashboard/helpers.rb +5 -1
- data/lib/split/dashboard/views/_experiment.erb +31 -1
- data/lib/split/encapsulated_helper.rb +2 -0
- data/lib/split/engine.rb +2 -0
- data/lib/split/experiment.rb +5 -4
- data/lib/split/helper.rb +29 -0
- data/lib/split/persistence/cookie_adapter.rb +19 -15
- data/lib/split/persistence/dual_adapter.rb +3 -0
- data/lib/split/persistence.rb +5 -3
- data/lib/split/user.rb +2 -0
- data/lib/split/version.rb +1 -1
- data/lib/split/zscore.rb +1 -1
- data/lib/split.rb +21 -19
- data/spec/algorithms/block_randomization_spec.rb +32 -0
- data/spec/alternative_spec.rb +43 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/dashboard_helpers_spec.rb +14 -0
- data/spec/experiment_spec.rb +1 -3
- data/spec/helper_spec.rb +12 -0
- data/spec/persistence/cookie_adapter_spec.rb +6 -2
- data/spec/persistence/dual_adapter_spec.rb +2 -2
- data/split.gemspec +13 -3
- metadata +22 -12
- data/lib/split/algorithms.rb +0 -4
- data/lib/split/extensions.rb +0 -4
data/lib/split/configuration.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Split
|
|
|
25
25
|
attr_accessor :on_before_experiment_delete
|
|
26
26
|
attr_accessor :include_rails_helper
|
|
27
27
|
attr_accessor :beta_probability_simulations
|
|
28
|
+
attr_accessor :winning_alternative_recalculation_interval
|
|
28
29
|
attr_accessor :redis
|
|
29
30
|
|
|
30
31
|
attr_reader :experiments
|
|
@@ -48,7 +49,9 @@ module Split
|
|
|
48
49
|
'spider' => 'generic web spider',
|
|
49
50
|
'UnwindFetchor' => 'Gnip crawler',
|
|
50
51
|
'WordPress' => 'WordPress spider',
|
|
52
|
+
'YandexAccessibilityBot' => 'Yandex accessibility spider',
|
|
51
53
|
'YandexBot' => 'Yandex spider',
|
|
54
|
+
'YandexMobileBot' => 'Yandex mobile spider',
|
|
52
55
|
'ZIBB' => 'ZIBB spider',
|
|
53
56
|
|
|
54
57
|
# HTTP libraries
|
|
@@ -73,10 +76,13 @@ module Split
|
|
|
73
76
|
'facebookexternalhit' => 'facebook bot',
|
|
74
77
|
'Feedfetcher-Google' => 'Google Feedfetcher',
|
|
75
78
|
'https://developers.google.com/+/web/snippet' => 'Google+ Snippet Fetcher',
|
|
79
|
+
'LinkedInBot' => 'LinkedIn bot',
|
|
76
80
|
'LongURL' => 'URL expander service',
|
|
77
81
|
'NING' => 'NING - Yet Another Twitter Swarmer',
|
|
82
|
+
'Pinterest' => 'Pinterest Bot',
|
|
78
83
|
'redditbot' => 'Reddit Bot',
|
|
79
84
|
'ShortLinkTranslate' => 'Link shortener',
|
|
85
|
+
'Slackbot' => 'Slackbot link expander',
|
|
80
86
|
'TweetmemeBot' => 'TweetMeMe Crawler',
|
|
81
87
|
'Twitterbot' => 'Twitter URL expander',
|
|
82
88
|
'UnwindFetch' => 'Gnip URL expander',
|
|
@@ -212,6 +218,7 @@ module Split
|
|
|
212
218
|
@algorithm = Split::Algorithms::WeightedSample
|
|
213
219
|
@include_rails_helper = true
|
|
214
220
|
@beta_probability_simulations = 10000
|
|
221
|
+
@winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
|
|
215
222
|
@redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
|
|
216
223
|
end
|
|
217
224
|
|
|
@@ -18,7 +18,11 @@ module Split
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def round(number, precision = 2)
|
|
21
|
-
|
|
21
|
+
begin
|
|
22
|
+
BigDecimal.new(number.to_s)
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
BigDecimal.new(0)
|
|
25
|
+
end.round(precision).to_f
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def confidence_level(z_score)
|
|
@@ -5,6 +5,25 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<% experiment.calc_winning_alternatives %>
|
|
8
|
+
<%
|
|
9
|
+
extra_columns = []
|
|
10
|
+
experiment.alternatives.each do |alternative|
|
|
11
|
+
extra_info = alternative.extra_info || {}
|
|
12
|
+
extra_columns += extra_info.keys
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
extra_columns.uniq!
|
|
16
|
+
summary_texts = {}
|
|
17
|
+
extra_columns.each do |column|
|
|
18
|
+
extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] }
|
|
19
|
+
if extra_infos[0][column].kind_of?(Numeric)
|
|
20
|
+
summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]}
|
|
21
|
+
else
|
|
22
|
+
summary_texts[column] = "N/A"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
%>
|
|
26
|
+
|
|
8
27
|
|
|
9
28
|
<div class="<%= experiment_class %>" data-name="<%= experiment.name %>" data-complete="<%= experiment.has_winner? %>">
|
|
10
29
|
<div class="experiment-header">
|
|
@@ -32,6 +51,9 @@
|
|
|
32
51
|
<th>Non-finished</th>
|
|
33
52
|
<th>Completed</th>
|
|
34
53
|
<th>Conversion Rate</th>
|
|
54
|
+
<% extra_columns.each do |column| %>
|
|
55
|
+
<th><%= column %></th>
|
|
56
|
+
<% end %>
|
|
35
57
|
<th>
|
|
36
58
|
<form>
|
|
37
59
|
<select id="dropdown-<%=experiment.jstring(goal)%>" name="dropdown-<%=experiment.jstring(goal)%>">
|
|
@@ -82,6 +104,9 @@
|
|
|
82
104
|
});
|
|
83
105
|
});
|
|
84
106
|
</script>
|
|
107
|
+
<% extra_columns.each do |column| %>
|
|
108
|
+
<td><%= alternative.extra_info && alternative.extra_info[column] %></td>
|
|
109
|
+
<% end %>
|
|
85
110
|
<td>
|
|
86
111
|
<div class="box-<%=experiment.jstring(goal)%> confidence-<%=experiment.jstring(goal)%>">
|
|
87
112
|
<span title='z-score: <%= round(alternative.z_score(goal), 3) %>'><%= confidence_level(alternative.z_score(goal)) %></span>
|
|
@@ -90,7 +115,7 @@
|
|
|
90
115
|
<div class="box-<%=experiment.jstring(goal)%> probability-<%=experiment.jstring(goal)%>">
|
|
91
116
|
<span title="p_winner: <%= round(alternative.p_winner(goal), 3) %>"><%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>%</span>
|
|
92
117
|
</div>
|
|
93
|
-
</td>
|
|
118
|
+
</td>
|
|
94
119
|
<td>
|
|
95
120
|
<% if experiment.has_winner? %>
|
|
96
121
|
<% if experiment.winner.name == alternative.name %>
|
|
@@ -118,6 +143,11 @@
|
|
|
118
143
|
<td><%= total_unfinished %></td>
|
|
119
144
|
<td><%= total_completed %></td>
|
|
120
145
|
<td>N/A</td>
|
|
146
|
+
<% extra_columns.each do |column| %>
|
|
147
|
+
<td>
|
|
148
|
+
<%= summary_texts[column] %>
|
|
149
|
+
</td>
|
|
150
|
+
<% end %>
|
|
121
151
|
<td>N/A</td>
|
|
122
152
|
<td>N/A</td>
|
|
123
153
|
</tr>
|
data/lib/split/engine.rb
CHANGED
|
@@ -5,6 +5,8 @@ module Split
|
|
|
5
5
|
if Split.configuration.include_rails_helper
|
|
6
6
|
ActionController::Base.send :include, Split::Helper
|
|
7
7
|
ActionController::Base.helper Split::Helper
|
|
8
|
+
ActionController::Base.send :include, Split::CombinedExperimentsHelper
|
|
9
|
+
ActionController::Base.helper Split::CombinedExperimentsHelper
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/split/experiment.rb
CHANGED
|
@@ -262,10 +262,11 @@ module Split
|
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
def calc_winning_alternatives
|
|
265
|
-
#
|
|
266
|
-
|
|
265
|
+
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
266
|
+
intervals_since_epoch =
|
|
267
|
+
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
267
268
|
|
|
268
|
-
if self.calc_time !=
|
|
269
|
+
if self.calc_time != intervals_since_epoch
|
|
269
270
|
if goals.empty?
|
|
270
271
|
self.estimate_winning_alternative
|
|
271
272
|
else
|
|
@@ -274,7 +275,7 @@ module Split
|
|
|
274
275
|
end
|
|
275
276
|
end
|
|
276
277
|
|
|
277
|
-
self.calc_time =
|
|
278
|
+
self.calc_time = intervals_since_epoch
|
|
278
279
|
|
|
279
280
|
self.save
|
|
280
281
|
end
|
data/lib/split/helper.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Split
|
|
|
10
10
|
experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
|
|
11
11
|
alternative = if Split.configuration.enabled
|
|
12
12
|
experiment.save
|
|
13
|
+
raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
|
|
13
14
|
trial = Trial.new(:user => ab_user, :experiment => experiment,
|
|
14
15
|
:override => override_alternative(experiment.name), :exclude => exclude_visitor?,
|
|
15
16
|
:disabled => split_generically_disabled?)
|
|
@@ -76,6 +77,34 @@ module Split
|
|
|
76
77
|
Split.configuration.db_failover_on_db_error.call(e)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
def ab_record_extra_info(metric_descriptor, key, value = 1)
|
|
81
|
+
return if exclude_visitor? || Split.configuration.disabled?
|
|
82
|
+
metric_descriptor, goals = normalize_metric(metric_descriptor)
|
|
83
|
+
experiments = Metric.possible_experiments(metric_descriptor)
|
|
84
|
+
|
|
85
|
+
if experiments.any?
|
|
86
|
+
experiments.each do |experiment|
|
|
87
|
+
alternative_name = ab_user[experiment.key]
|
|
88
|
+
|
|
89
|
+
if alternative_name
|
|
90
|
+
alternative = experiment.alternatives.find{|alt| alt.name == alternative_name}
|
|
91
|
+
alternative.record_extra_info(key, value) if alternative
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
rescue => e
|
|
96
|
+
raise unless Split.configuration.db_failover
|
|
97
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ab_active_experiments()
|
|
101
|
+
ab_user.active_experiments
|
|
102
|
+
rescue => e
|
|
103
|
+
raise unless Split.configuration.db_failover
|
|
104
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
|
|
79
108
|
def override_present?(experiment_name)
|
|
80
109
|
override_alternative(experiment_name)
|
|
81
110
|
end
|
|
@@ -6,20 +6,21 @@ module Split
|
|
|
6
6
|
class CookieAdapter
|
|
7
7
|
|
|
8
8
|
def initialize(context)
|
|
9
|
-
@
|
|
9
|
+
@request, @response = context.request, context.response
|
|
10
|
+
@cookies = @request.cookies
|
|
10
11
|
@expires = Time.now + cookie_length_config
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def [](key)
|
|
14
|
-
hash[key]
|
|
15
|
+
hash[key.to_s]
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def []=(key, value)
|
|
18
|
-
set_cookie(hash.merge(key => value))
|
|
19
|
+
set_cookie(hash.merge!(key.to_s => value))
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def delete(key)
|
|
22
|
-
set_cookie(hash.tap { |h| h.delete(key) })
|
|
23
|
+
set_cookie(hash.tap { |h| h.delete(key.to_s) })
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def keys
|
|
@@ -28,22 +29,25 @@ module Split
|
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
31
|
-
def set_cookie(value)
|
|
32
|
-
@
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
def set_cookie(value = {})
|
|
33
|
+
@response.set_cookie :split.to_s, default_options.merge(value: JSON.generate(value))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def default_options
|
|
37
|
+
{ expires: @expires, path: '/' }
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def hash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
@hash ||= begin
|
|
42
|
+
if cookies = @cookies[:split.to_s]
|
|
43
|
+
begin
|
|
44
|
+
JSON.parse(cookies)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
else
|
|
43
49
|
{}
|
|
44
50
|
end
|
|
45
|
-
else
|
|
46
|
-
{}
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
data/lib/split/persistence.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
%w[session_adapter cookie_adapter redis_adapter dual_adapter].each do |f|
|
|
3
|
-
require "split/persistence/#{f}"
|
|
4
|
-
end
|
|
5
2
|
|
|
6
3
|
module Split
|
|
7
4
|
module Persistence
|
|
5
|
+
require 'split/persistence/cookie_adapter'
|
|
6
|
+
require 'split/persistence/dual_adapter'
|
|
7
|
+
require 'split/persistence/redis_adapter'
|
|
8
|
+
require 'split/persistence/session_adapter'
|
|
9
|
+
|
|
8
10
|
ADAPTERS = {
|
|
9
11
|
:cookie => Split::Persistence::CookieAdapter,
|
|
10
12
|
:session => Split::Persistence::SessionAdapter
|
data/lib/split/user.rb
CHANGED
data/lib/split/version.rb
CHANGED
data/lib/split/zscore.rb
CHANGED
data/lib/split.rb
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
alternative
|
|
4
|
-
configuration
|
|
5
|
-
exceptions
|
|
6
|
-
experiment
|
|
7
|
-
experiment_catalog
|
|
8
|
-
extensions
|
|
9
|
-
goals_collection
|
|
10
|
-
helper
|
|
11
|
-
metric
|
|
12
|
-
persistence
|
|
13
|
-
encapsulated_helper
|
|
14
|
-
redis_interface
|
|
15
|
-
trial
|
|
16
|
-
user
|
|
17
|
-
version
|
|
18
|
-
zscore].each do |f|
|
|
19
|
-
require "split/#{f}"
|
|
20
|
-
end
|
|
2
|
+
require 'redis'
|
|
21
3
|
|
|
4
|
+
require 'split/algorithms/block_randomization'
|
|
5
|
+
require 'split/algorithms/weighted_sample'
|
|
6
|
+
require 'split/algorithms/whiplash'
|
|
7
|
+
require 'split/alternative'
|
|
8
|
+
require 'split/configuration'
|
|
9
|
+
require 'split/encapsulated_helper'
|
|
10
|
+
require 'split/exceptions'
|
|
11
|
+
require 'split/experiment'
|
|
12
|
+
require 'split/experiment_catalog'
|
|
13
|
+
require 'split/extensions/string'
|
|
14
|
+
require 'split/goals_collection'
|
|
15
|
+
require 'split/helper'
|
|
16
|
+
require 'split/combined_experiments_helper'
|
|
17
|
+
require 'split/metric'
|
|
18
|
+
require 'split/persistence'
|
|
19
|
+
require 'split/redis_interface'
|
|
20
|
+
require 'split/trial'
|
|
21
|
+
require 'split/user'
|
|
22
|
+
require 'split/version'
|
|
23
|
+
require 'split/zscore'
|
|
22
24
|
require 'split/engine' if defined?(Rails)
|
|
23
25
|
|
|
24
26
|
module Split
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
describe Split::Algorithms::BlockRandomization do
|
|
4
|
+
|
|
5
|
+
let(:experiment) { Split::Experiment.new 'experiment' }
|
|
6
|
+
let(:alternative_A) { Split::Alternative.new 'A', 'experiment' }
|
|
7
|
+
let(:alternative_B) { Split::Alternative.new 'B', 'experiment' }
|
|
8
|
+
let(:alternative_C) { Split::Alternative.new 'C', 'experiment' }
|
|
9
|
+
|
|
10
|
+
before :each do
|
|
11
|
+
allow(experiment).to receive(:alternatives) { [alternative_A, alternative_B, alternative_C] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "should return an alternative" do
|
|
15
|
+
expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment).class).to eq(Split::Alternative)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "should always return the minimum participation option" do
|
|
19
|
+
allow(alternative_A).to receive(:participant_count) { 1 }
|
|
20
|
+
allow(alternative_B).to receive(:participant_count) { 1 }
|
|
21
|
+
allow(alternative_C).to receive(:participant_count) { 0 }
|
|
22
|
+
expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment)).to eq(alternative_C)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "should return one of the minimum participation options when multiple" do
|
|
26
|
+
allow(alternative_A).to receive(:participant_count) { 0 }
|
|
27
|
+
allow(alternative_B).to receive(:participant_count) { 0 }
|
|
28
|
+
allow(alternative_C).to receive(:participant_count) { 0 }
|
|
29
|
+
alternative = Split::Algorithms::BlockRandomization.choose_alternative(experiment)
|
|
30
|
+
expect([alternative_A, alternative_B, alternative_C].include?(alternative)).to be(true)
|
|
31
|
+
end
|
|
32
|
+
end
|
data/spec/alternative_spec.rb
CHANGED
|
@@ -273,5 +273,48 @@ describe Split::Alternative do
|
|
|
273
273
|
expect(control.z_score(goal1)).to eq('N/A')
|
|
274
274
|
expect(control.z_score(goal2)).to eq('N/A')
|
|
275
275
|
end
|
|
276
|
+
|
|
277
|
+
it "should not blow up for Conversion Rates > 1" do
|
|
278
|
+
control = experiment.control
|
|
279
|
+
control.participant_count = 3474
|
|
280
|
+
control.set_completed_count(4244)
|
|
281
|
+
|
|
282
|
+
alternative2.participant_count = 3434
|
|
283
|
+
alternative2.set_completed_count(4358)
|
|
284
|
+
|
|
285
|
+
expect { control.z_score }.not_to raise_error
|
|
286
|
+
expect { alternative2.z_score }.not_to raise_error
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe "extra_info" do
|
|
291
|
+
it "reads saved value of recorded_info in redis" do
|
|
292
|
+
saved_recorded_info = {"key_1" => 1, "key_2" => "2"}
|
|
293
|
+
Split.redis.hset "#{alternative.experiment_name}:#{alternative.name}", 'recorded_info', saved_recorded_info.to_json
|
|
294
|
+
extra_info = alternative.extra_info
|
|
295
|
+
|
|
296
|
+
expect(extra_info).to eql(saved_recorded_info)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe "record_extra_info" do
|
|
301
|
+
it "saves key" do
|
|
302
|
+
alternative.record_extra_info("signup", 1)
|
|
303
|
+
expect(alternative.extra_info["signup"]).to eql(1)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "adds value to saved key's value second argument is number" do
|
|
307
|
+
alternative.record_extra_info("signup", 1)
|
|
308
|
+
alternative.record_extra_info("signup", 2)
|
|
309
|
+
expect(alternative.extra_info["signup"]).to eql(3)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "sets saved's key value to the second argument if it's a string" do
|
|
313
|
+
alternative.record_extra_info("signup", "Value 1")
|
|
314
|
+
expect(alternative.extra_info["signup"]).to eql("Value 1")
|
|
315
|
+
|
|
316
|
+
alternative.record_extra_info("signup", "Value 2")
|
|
317
|
+
expect(alternative.extra_info["signup"]).to eql("Value 2")
|
|
318
|
+
end
|
|
276
319
|
end
|
|
277
320
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'spec_helper'
|
|
3
|
+
require 'split/combined_experiments_helper'
|
|
4
|
+
|
|
5
|
+
describe Split::CombinedExperimentsHelper do
|
|
6
|
+
include Split::CombinedExperimentsHelper
|
|
7
|
+
|
|
8
|
+
describe 'ab_combined_test' do
|
|
9
|
+
let!(:config_enabled) { true }
|
|
10
|
+
let!(:combined_experiments) { [:exp_1_click, :exp_1_scroll ]}
|
|
11
|
+
let!(:allow_multiple_experiments) { true }
|
|
12
|
+
|
|
13
|
+
before do
|
|
14
|
+
Split.configuration.experiments = {
|
|
15
|
+
:combined_exp_1 => {
|
|
16
|
+
:alternatives => [ {"control"=> 0.5}, {"test-alt"=> 0.5} ],
|
|
17
|
+
:metric => :my_metric,
|
|
18
|
+
:combined_experiments => combined_experiments
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
Split.configuration.enabled = config_enabled
|
|
22
|
+
Split.configuration.allow_multiple_experiments = allow_multiple_experiments
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context 'without config enabled' do
|
|
26
|
+
let!(:config_enabled) { false }
|
|
27
|
+
|
|
28
|
+
it "raises an error" do
|
|
29
|
+
expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError )
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'multiple experiments disabled' do
|
|
34
|
+
let!(:allow_multiple_experiments) { false }
|
|
35
|
+
|
|
36
|
+
it "raises an error if multiple experiments is disabled" do
|
|
37
|
+
expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context 'without combined experiments' do
|
|
42
|
+
let!(:combined_experiments) { nil }
|
|
43
|
+
|
|
44
|
+
it "raises an error" do
|
|
45
|
+
expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError )
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "uses same alternative for all sub experiments and returns the alternative" do
|
|
50
|
+
allow(self).to receive(:get_alternative) { "test-alt" }
|
|
51
|
+
expect(self).to receive(:ab_test).with(:exp_1_click, {"control"=>0.5}, {"test-alt"=>0.5}) { "test-alt" }
|
|
52
|
+
expect(self).to receive(:ab_test).with(:exp_1_scroll, [{"control" => 0, "test-alt" => 1}])
|
|
53
|
+
|
|
54
|
+
expect(ab_combined_test('combined_exp_1')).to eq('test-alt')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -24,5 +24,19 @@ describe Split::DashboardHelpers do
|
|
|
24
24
|
expect(confidence_level(2.58)).to eq('99% confidence')
|
|
25
25
|
expect(confidence_level(3.00)).to eq('99% confidence')
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
describe '#round' do
|
|
29
|
+
it 'can round number strings' do
|
|
30
|
+
expect(round('3.1415')).to eq BigDecimal.new('3.14')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'can round number strings for precsion' do
|
|
34
|
+
expect(round('3.1415', 1)).to eq BigDecimal.new('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
|
|
27
41
|
end
|
|
28
42
|
end
|
data/spec/experiment_spec.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'spec_helper'
|
|
3
|
-
require 'split/experiment'
|
|
4
|
-
require 'split/algorithms'
|
|
5
3
|
require 'time'
|
|
6
4
|
|
|
7
5
|
describe Split::Experiment do
|
|
@@ -460,7 +458,7 @@ describe Split::Experiment do
|
|
|
460
458
|
expect(experiment.alternatives[0].p_winner).to be_within(0.04).of(0.50)
|
|
461
459
|
end
|
|
462
460
|
|
|
463
|
-
it "should calculate the probability of being the winning alternative separately for each goal" do
|
|
461
|
+
it "should calculate the probability of being the winning alternative separately for each goal", :skip => true do
|
|
464
462
|
experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
|
|
465
463
|
goal1 = experiment.goals[0]
|
|
466
464
|
goal2 = experiment.goals[1]
|
data/spec/helper_spec.rb
CHANGED
|
@@ -35,6 +35,18 @@ describe Split::Helper do
|
|
|
35
35
|
expect(lambda { ab_test({'link_color' => "purchase"}, 'blue', 'red') }).not_to raise_error
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
it "raises an appropriate error when processing combined expirements" do
|
|
39
|
+
Split.configuration.experiments = {
|
|
40
|
+
:combined_exp_1 => {
|
|
41
|
+
:alternatives => [ { name: "control", percent: 50 }, { name: "test-alt", percent: 50 } ],
|
|
42
|
+
:metric => :my_metric,
|
|
43
|
+
:combined_experiments => [:combined_exp_1_sub_1]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
Split::ExperimentCatalog.find_or_create('combined_exp_1')
|
|
47
|
+
expect(lambda { ab_test('combined_exp_1')}).to raise_error(Split::InvalidExperimentsFormatError )
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
it "should assign a random alternative to a new user when there are an equal number of alternatives assigned" do
|
|
39
51
|
ab_test('link_color', 'blue', 'red')
|
|
40
52
|
expect(['red', 'blue']).to include(ab_user['link_color'])
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "spec_helper"
|
|
3
|
+
require 'rack/test'
|
|
3
4
|
|
|
4
5
|
describe Split::Persistence::CookieAdapter do
|
|
5
6
|
|
|
6
|
-
let(:
|
|
7
|
+
let(:env) { Rack::MockRequest.env_for("http://example.com:8080/") }
|
|
8
|
+
let(:request) { Rack::Request.new(env) }
|
|
9
|
+
let(:response) { Rack::MockResponse.new(200, {}, "") }
|
|
10
|
+
let(:context) { double(request: request, response: response) }
|
|
7
11
|
subject { Split::Persistence::CookieAdapter.new(context) }
|
|
8
12
|
|
|
9
13
|
describe "#[] and #[]=" do
|
|
@@ -30,7 +34,7 @@ describe Split::Persistence::CookieAdapter do
|
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
it "handles invalid JSON" do
|
|
33
|
-
context.cookies[:split] = { :value => '{"foo":2,', :expires => Time.now }
|
|
37
|
+
context.request.cookies[:split] = { :value => '{"foo":2,', :expires => Time.now }
|
|
34
38
|
expect(subject["my_key"]).to be_nil
|
|
35
39
|
subject["my_key"] = "my_value"
|
|
36
40
|
expect(subject["my_key"]).to eq("my_value")
|
|
@@ -47,7 +47,7 @@ describe Split::Persistence::DualAdapter do
|
|
|
47
47
|
context "when logged in" do
|
|
48
48
|
subject {
|
|
49
49
|
described_class.with_config(
|
|
50
|
-
logged_in:
|
|
50
|
+
logged_in: lambda { |context| true },
|
|
51
51
|
logged_in_adapter: selected_adapter,
|
|
52
52
|
logged_out_adapter: not_selected_adapter
|
|
53
53
|
).new(context)
|
|
@@ -59,7 +59,7 @@ describe Split::Persistence::DualAdapter do
|
|
|
59
59
|
context "when not logged in" do
|
|
60
60
|
subject {
|
|
61
61
|
described_class.with_config(
|
|
62
|
-
logged_in:
|
|
62
|
+
logged_in: lambda { |context| false },
|
|
63
63
|
logged_in_adapter: not_selected_adapter,
|
|
64
64
|
logged_out_adapter: selected_adapter
|
|
65
65
|
).new(context)
|
data/split.gemspec
CHANGED
|
@@ -12,7 +12,17 @@ Gem::Specification.new do |s|
|
|
|
12
12
|
s.homepage = "https://github.com/splitrb/split"
|
|
13
13
|
s.summary = "Rack based split testing framework"
|
|
14
14
|
|
|
15
|
-
s.
|
|
15
|
+
s.metadata = {
|
|
16
|
+
"homepage_uri" => "https://github.com/splitrb/split",
|
|
17
|
+
"changelog_uri" => "https://github.com/splitrb/split/blob/master/CHANGELOG.md",
|
|
18
|
+
"source_code_uri" => "https://github.com/splitrb/split",
|
|
19
|
+
"bug_tracker_uri" => "https://github.com/splitrb/split/issues",
|
|
20
|
+
"wiki_uri" => "https://github.com/splitrb/split/wiki",
|
|
21
|
+
"mailing_list_uri" => "https://groups.google.com/d/forum/split-ruby"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
s.required_ruby_version = '>= 1.9.3'
|
|
25
|
+
s.required_rubygems_version = '>= 2.0.0'
|
|
16
26
|
|
|
17
27
|
s.rubyforge_project = "split"
|
|
18
28
|
|
|
@@ -24,10 +34,10 @@ Gem::Specification.new do |s|
|
|
|
24
34
|
s.add_dependency 'sinatra', '>= 1.2.6'
|
|
25
35
|
s.add_dependency 'simple-random', '>= 0.9.3'
|
|
26
36
|
|
|
27
|
-
s.add_development_dependency 'bundler', '~> 1.
|
|
37
|
+
s.add_development_dependency 'bundler', '~> 1.14'
|
|
28
38
|
s.add_development_dependency 'simplecov', '~> 0.12'
|
|
29
39
|
s.add_development_dependency 'rack-test', '~> 0.6'
|
|
30
|
-
s.add_development_dependency 'rake', '~>
|
|
40
|
+
s.add_development_dependency 'rake', '~> 12'
|
|
31
41
|
s.add_development_dependency 'rspec', '~> 3.4'
|
|
32
42
|
s.add_development_dependency 'pry', '~> 0.10'
|
|
33
43
|
s.add_development_dependency 'fakeredis', '~> 0.6.0'
|