split 2.2.0 → 3.1.1

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.
@@ -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>
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ require "split/helper"
3
+
2
4
  # Split's helper exposes all kinds of methods we don't want to
3
5
  # mix into our model classes.
4
6
  #
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/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
- @cookies = context.send(:cookies)
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
- @cookies[:split] = {
33
- :value => JSON.generate(value),
34
- :expires => @expires
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
- if @cookies[:split]
40
- begin
41
- JSON.parse(@cookies[:split])
42
- rescue JSON::ParserError
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
 
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
2
5
  module Split
3
6
  module Persistence
4
7
  class DualAdapter
@@ -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
@@ -1,3 +1,5 @@
1
+ require 'forwardable'
2
+
1
3
  module Split
2
4
  class User
3
5
  extend Forwardable
data/lib/split/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  module Split
3
- MAJOR = 2
4
- MINOR = 2
5
- PATCH = 0
3
+ MAJOR = 3
4
+ MINOR = 1
5
+ PATCH = 1
6
6
  VERSION = [MAJOR, MINOR, PATCH].join('.')
7
7
  end
data/lib/split/zscore.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  module Split
3
- module Zscore
3
+ class Zscore
4
4
 
5
5
  include Math
6
6
 
data/lib/split.rb CHANGED
@@ -1,24 +1,26 @@
1
1
  # frozen_string_literal: true
2
- %w[algorithms
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
@@ -274,4 +274,35 @@ describe Split::Alternative do
274
274
  expect(control.z_score(goal2)).to eq('N/A')
275
275
  end
276
276
  end
277
+
278
+ describe "extra_info" do
279
+ it "reads saved value of recorded_info in redis" do
280
+ saved_recorded_info = {"key_1" => 1, "key_2" => "2"}
281
+ Split.redis.hset "#{alternative.experiment_name}:#{alternative.name}", 'recorded_info', saved_recorded_info.to_json
282
+ extra_info = alternative.extra_info
283
+
284
+ expect(extra_info).to eql(saved_recorded_info)
285
+ end
286
+ end
287
+
288
+ describe "record_extra_info" do
289
+ it "saves key" do
290
+ alternative.record_extra_info("signup", 1)
291
+ expect(alternative.extra_info["signup"]).to eql(1)
292
+ end
293
+
294
+ it "adds value to saved key's value second argument is number" do
295
+ alternative.record_extra_info("signup", 1)
296
+ alternative.record_extra_info("signup", 2)
297
+ expect(alternative.extra_info["signup"]).to eql(3)
298
+ end
299
+
300
+ it "sets saved's key value to the second argument if it's a string" do
301
+ alternative.record_extra_info("signup", "Value 1")
302
+ expect(alternative.extra_info["signup"]).to eql("Value 1")
303
+
304
+ alternative.record_extra_info("signup", "Value 2")
305
+ expect(alternative.extra_info["signup"]).to eql("Value 2")
306
+ end
307
+ end
277
308
  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 alternatives for all sub experiments " 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, [{"test-alt" => 1}] )
53
+
54
+ ab_combined_test('combined_exp_1')
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
@@ -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(:context) { double(:cookies => CookiesMock.new) }
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: -> (context) { true },
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: -> (context) { false },
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.required_ruby_version = '>= 1.9.2'
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.10'
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', '~> 11.1'
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'