split 3.2.0 → 4.0.5

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.
Files changed (87) hide show
  1. checksums.yaml +5 -5
  2. data/.eslintrc +1 -1
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  5. data/.github/dependabot.yml +7 -0
  6. data/.github/workflows/ci.yml +63 -0
  7. data/.rspec +1 -0
  8. data/.rubocop.yml +67 -1043
  9. data/CHANGELOG.md +174 -0
  10. data/CODE_OF_CONDUCT.md +3 -3
  11. data/CONTRIBUTING.md +1 -1
  12. data/Gemfile +6 -1
  13. data/README.md +79 -33
  14. data/Rakefile +6 -5
  15. data/lib/split/algorithms/block_randomization.rb +7 -6
  16. data/lib/split/algorithms/weighted_sample.rb +2 -1
  17. data/lib/split/algorithms/whiplash.rb +17 -18
  18. data/lib/split/algorithms.rb +14 -0
  19. data/lib/split/alternative.rb +25 -25
  20. data/lib/split/cache.rb +27 -0
  21. data/lib/split/combined_experiments_helper.rb +6 -5
  22. data/lib/split/configuration.rb +94 -91
  23. data/lib/split/dashboard/helpers.rb +9 -9
  24. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  25. data/lib/split/dashboard/paginator.rb +17 -0
  26. data/lib/split/dashboard/public/dashboard.js +10 -0
  27. data/lib/split/dashboard/public/style.css +19 -2
  28. data/lib/split/dashboard/views/_controls.erb +13 -0
  29. data/lib/split/dashboard/views/_experiment.erb +2 -1
  30. data/lib/split/dashboard/views/index.erb +24 -5
  31. data/lib/split/dashboard/views/layout.erb +1 -1
  32. data/lib/split/dashboard.rb +47 -20
  33. data/lib/split/encapsulated_helper.rb +15 -8
  34. data/lib/split/engine.rb +7 -4
  35. data/lib/split/exceptions.rb +1 -0
  36. data/lib/split/experiment.rb +160 -122
  37. data/lib/split/experiment_catalog.rb +7 -8
  38. data/lib/split/extensions/string.rb +2 -1
  39. data/lib/split/goals_collection.rb +10 -10
  40. data/lib/split/helper.rb +56 -24
  41. data/lib/split/metric.rb +6 -6
  42. data/lib/split/persistence/cookie_adapter.rb +52 -15
  43. data/lib/split/persistence/dual_adapter.rb +53 -12
  44. data/lib/split/persistence/redis_adapter.rb +8 -4
  45. data/lib/split/persistence/session_adapter.rb +1 -2
  46. data/lib/split/persistence.rb +8 -6
  47. data/lib/split/redis_interface.rb +16 -31
  48. data/lib/split/trial.rb +48 -41
  49. data/lib/split/user.rb +30 -15
  50. data/lib/split/version.rb +2 -4
  51. data/lib/split/zscore.rb +2 -3
  52. data/lib/split.rb +39 -25
  53. data/spec/algorithms/block_randomization_spec.rb +6 -5
  54. data/spec/algorithms/weighted_sample_spec.rb +6 -5
  55. data/spec/algorithms/whiplash_spec.rb +4 -5
  56. data/spec/alternative_spec.rb +35 -36
  57. data/spec/cache_spec.rb +84 -0
  58. data/spec/combined_experiments_helper_spec.rb +18 -17
  59. data/spec/configuration_spec.rb +41 -45
  60. data/spec/dashboard/pagination_helpers_spec.rb +202 -0
  61. data/spec/dashboard/paginator_spec.rb +38 -0
  62. data/spec/dashboard_helpers_spec.rb +19 -18
  63. data/spec/dashboard_spec.rb +153 -48
  64. data/spec/encapsulated_helper_spec.rb +47 -23
  65. data/spec/experiment_catalog_spec.rb +14 -13
  66. data/spec/experiment_spec.rb +224 -111
  67. data/spec/goals_collection_spec.rb +18 -16
  68. data/spec/helper_spec.rb +539 -419
  69. data/spec/metric_spec.rb +14 -14
  70. data/spec/persistence/cookie_adapter_spec.rb +105 -27
  71. data/spec/persistence/dual_adapter_spec.rb +158 -66
  72. data/spec/persistence/redis_adapter_spec.rb +35 -27
  73. data/spec/persistence/session_adapter_spec.rb +2 -3
  74. data/spec/persistence_spec.rb +1 -2
  75. data/spec/redis_interface_spec.rb +25 -82
  76. data/spec/spec_helper.rb +38 -24
  77. data/spec/split_spec.rb +18 -18
  78. data/spec/support/cookies_mock.rb +1 -2
  79. data/spec/trial_spec.rb +117 -70
  80. data/spec/user_spec.rb +69 -27
  81. data/split.gemspec +26 -22
  82. metadata +85 -37
  83. data/.travis.yml +0 -41
  84. data/Appraisals +0 -13
  85. data/gemfiles/4.2.gemfile +0 -9
  86. data/gemfiles/5.0.gemfile +0 -10
  87. data/gemfiles/5.1.gemfile +0 -10
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
- require 'spec_helper'
3
- require 'rack/test'
4
- require 'split/dashboard'
2
+
3
+ require "spec_helper"
4
+ require "rack/test"
5
+ require "split/dashboard"
5
6
 
6
7
  describe Split::Dashboard do
7
8
  include Rack::Test::Methods
8
9
 
10
+ class TestDashboard < Split::Dashboard
11
+ include Split::Helper
12
+
13
+ get "/my_experiment" do
14
+ ab_test(params[:experiment], "blue", "red")
15
+ end
16
+ end
17
+
9
18
  def app
10
- @app ||= Split::Dashboard
19
+ @app ||= TestDashboard
11
20
  end
12
21
 
13
22
  def link(color)
@@ -19,18 +28,22 @@ describe Split::Dashboard do
19
28
  }
20
29
 
21
30
  let(:experiment_with_goals) {
22
- Split::ExperimentCatalog.find_or_create({"link_color" => ["goal_1", "goal_2"]}, "blue", "red")
31
+ Split::ExperimentCatalog.find_or_create({ "link_color" => ["goal_1", "goal_2"] }, "blue", "red")
23
32
  }
24
33
 
25
34
  let(:metric) {
26
- Split::Metric.find_or_create(name: 'testmetric', experiments: [experiment, experiment_with_goals])
35
+ Split::Metric.find_or_create(name: "testmetric", experiments: [experiment, experiment_with_goals])
27
36
  }
28
37
 
29
38
  let(:red_link) { link("red") }
30
39
  let(:blue_link) { link("blue") }
31
40
 
41
+ before(:each) do
42
+ Split.configuration.beta_probability_simulations = 1
43
+ end
44
+
32
45
  it "should respond to /" do
33
- get '/'
46
+ get "/"
34
47
  expect(last_response).to be_ok
35
48
  end
36
49
 
@@ -42,76 +55,108 @@ describe Split::Dashboard do
42
55
  context "experiment without goals" do
43
56
  it "should display a Start button" do
44
57
  experiment
45
- get '/'
46
- expect(last_response.body).to include('Start')
58
+ get "/"
59
+ expect(last_response.body).to include("Start")
47
60
 
48
61
  post "/start?experiment=#{experiment.name}"
49
- get '/'
50
- expect(last_response.body).to include('Reset Data')
51
- expect(last_response.body).not_to include('Metrics:')
62
+ get "/"
63
+ expect(last_response.body).to include("Reset Data")
64
+ expect(last_response.body).not_to include("Metrics:")
52
65
  end
53
66
  end
54
67
 
55
68
  context "experiment with metrics" do
56
69
  it "should display the names of associated metrics" do
57
70
  metric
58
- get '/'
59
- expect(last_response.body).to include('Metrics:testmetric')
71
+ get "/"
72
+ expect(last_response.body).to include("Metrics:testmetric")
60
73
  end
61
74
  end
62
75
 
63
76
  context "with goals" do
64
77
  it "should display a Start button" do
65
78
  experiment_with_goals
66
- get '/'
67
- expect(last_response.body).to include('Start')
79
+ get "/"
80
+ expect(last_response.body).to include("Start")
68
81
 
69
82
  post "/start?experiment=#{experiment.name}"
70
- get '/'
71
- expect(last_response.body).to include('Reset Data')
83
+ get "/"
84
+ expect(last_response.body).to include("Reset Data")
72
85
  end
73
86
  end
74
87
  end
75
88
 
76
89
  describe "force alternative" do
77
- let!(:user) do
78
- Split::User.new(@app, { experiment.name => 'a' })
79
- end
90
+ context "initial version" do
91
+ let!(:user) do
92
+ Split::User.new(@app, { experiment.name => "red" })
93
+ end
80
94
 
81
- before do
82
- allow(Split::User).to receive(:new).and_return(user)
95
+ before do
96
+ allow(Split::User).to receive(:new).and_return(user)
97
+ end
98
+
99
+ it "should set current user's alternative" do
100
+ blue_link.participant_count = 7
101
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
102
+
103
+ get "/my_experiment?experiment=#{experiment.name}"
104
+ expect(last_response.body).to include("blue")
105
+ end
106
+
107
+ it "should not modify an existing user" do
108
+ blue_link.participant_count = 7
109
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
110
+
111
+ expect(user[experiment.key]).to eq("red")
112
+ expect(blue_link.participant_count).to eq(7)
113
+ end
83
114
  end
84
115
 
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")
116
+ context "incremented version" do
117
+ let!(:user) do
118
+ experiment.increment_version
119
+ Split::User.new(@app, { "#{experiment.name}:#{experiment.version}" => "red" })
120
+ end
121
+
122
+ before do
123
+ allow(Split::User).to receive(:new).and_return(user)
124
+ end
125
+
126
+ it "should set current user's alternative" do
127
+ blue_link.participant_count = 7
128
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
129
+
130
+ get "/my_experiment?experiment=#{experiment.name}"
131
+ expect(last_response.body).to include("blue")
132
+ end
88
133
  end
89
134
  end
90
135
 
91
136
  describe "index page" do
92
137
  context "with winner" do
93
- before { experiment.winner = 'red' }
138
+ before { experiment.winner = "red" }
94
139
 
95
140
  it "displays `Reopen Experiment` button" do
96
- get '/'
141
+ get "/"
97
142
 
98
- expect(last_response.body).to include('Reopen Experiment')
143
+ expect(last_response.body).to include("Reopen Experiment")
99
144
  end
100
145
  end
101
146
 
102
147
  context "without winner" do
103
148
  it "should not display `Reopen Experiment` button" do
104
- get '/'
149
+ get "/"
105
150
 
106
- expect(last_response.body).to_not include('Reopen Experiment')
151
+ expect(last_response.body).to_not include("Reopen Experiment")
107
152
  end
108
153
  end
109
154
  end
110
155
 
111
156
  describe "reopen experiment" do
112
- before { experiment.winner = 'red' }
157
+ before { experiment.winner = "red" }
113
158
 
114
- it 'redirects' do
159
+ it "redirects" do
115
160
  post "/reopen?experiment=#{experiment.name}"
116
161
 
117
162
  expect(last_response).to be_redirect
@@ -120,13 +165,13 @@ describe Split::Dashboard do
120
165
  it "removes winner" do
121
166
  post "/reopen?experiment=#{experiment.name}"
122
167
 
123
- expect(experiment).to_not have_winner
168
+ expect(Split::ExperimentCatalog.find(experiment.name)).to_not have_winner
124
169
  end
125
170
 
126
171
  it "keeps existing stats" do
127
172
  red_link.participant_count = 5
128
173
  blue_link.participant_count = 7
129
- experiment.winner = 'blue'
174
+ experiment.winner = "blue"
130
175
 
131
176
  post "/reopen?experiment=#{experiment.name}"
132
177
 
@@ -135,10 +180,63 @@ describe Split::Dashboard do
135
180
  end
136
181
  end
137
182
 
183
+ describe "update cohorting" do
184
+ it "calls enable of cohorting when action is enable" do
185
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "enable" }
186
+
187
+ expect(experiment.cohorting_disabled?).to eq false
188
+ end
189
+
190
+ it "calls disable of cohorting when action is disable" do
191
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "disable" }
192
+
193
+ expect(experiment.cohorting_disabled?).to eq true
194
+ end
195
+
196
+ it "calls neither enable or disable cohorting when passed invalid action" do
197
+ previous_value = experiment.cohorting_disabled?
198
+
199
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "other" }
200
+
201
+ expect(experiment.cohorting_disabled?).to eq previous_value
202
+ end
203
+ end
204
+
205
+ describe "initialize experiment" do
206
+ before do
207
+ Split.configuration.experiments = {
208
+ my_experiment: {
209
+ alternatives: [ "control", "alternative" ],
210
+ }
211
+ }
212
+ end
213
+
214
+ it "initializes the experiment when the experiment is given" do
215
+ expect(Split::ExperimentCatalog.find("my_experiment")).to be nil
216
+
217
+ post "/initialize_experiment", { experiment: "my_experiment" }
218
+
219
+ experiment = Split::ExperimentCatalog.find("my_experiment")
220
+ expect(experiment).to be_a(Split::Experiment)
221
+ end
222
+
223
+ it "does not attempt to intialize the experiment when empty experiment is given" do
224
+ post "/initialize_experiment", { experiment: "" }
225
+
226
+ expect(Split::ExperimentCatalog).to_not receive(:find_or_create)
227
+ end
228
+
229
+ it "does not attempt to intialize the experiment when no experiment is given" do
230
+ post "/initialize_experiment"
231
+
232
+ expect(Split::ExperimentCatalog).to_not receive(:find_or_create)
233
+ end
234
+ end
235
+
138
236
  it "should reset an experiment" do
139
237
  red_link.participant_count = 5
140
238
  blue_link.participant_count = 7
141
- experiment.winner = 'blue'
239
+ experiment.winner = "blue"
142
240
 
143
241
  post "/reset?experiment=#{experiment.name}"
144
242
 
@@ -160,30 +258,37 @@ describe Split::Dashboard do
160
258
 
161
259
  it "should mark an alternative as the winner" do
162
260
  expect(experiment.winner).to be_nil
163
- post "/experiment?experiment=#{experiment.name}", :alternative => 'red'
261
+ post "/experiment?experiment=#{experiment.name}", alternative: "red"
164
262
 
165
263
  expect(last_response).to be_redirect
166
- expect(experiment.winner.name).to eq('red')
264
+ expect(experiment.winner.name).to eq("red")
167
265
  end
168
266
 
169
267
  it "should display the start date" do
170
- experiment_start_time = Time.parse('2011-07-07')
171
- expect(Time).to receive(:now).at_least(:once).and_return(experiment_start_time)
172
- experiment
268
+ experiment.start
173
269
 
174
- get '/'
270
+ get "/"
175
271
 
176
- expect(last_response.body).to include('<small>2011-07-07</small>')
272
+ expect(last_response.body).to include("<small>#{experiment.start_time.strftime('%Y-%m-%d')}</small>")
177
273
  end
178
274
 
179
275
  it "should handle experiments without a start date" do
180
- experiment_start_time = Time.parse('2011-07-07')
181
- expect(Time).to receive(:now).at_least(:once).and_return(experiment_start_time)
182
-
183
276
  Split.redis.hdel(:experiment_start_times, experiment.name)
184
277
 
185
- get '/'
278
+ get "/"
186
279
 
187
- expect(last_response.body).to include('<small>Unknown</small>')
280
+ expect(last_response.body).to include("<small>Unknown</small>")
281
+ end
282
+
283
+ it "should be explode with experiments with invalid data" do
284
+ red_link.participant_count = 1
285
+ red_link.set_completed_count(10)
286
+
287
+ blue_link.participant_count = 3
288
+ blue_link.set_completed_count(2)
289
+
290
+ get "/"
291
+
292
+ expect(last_response).to be_ok
188
293
  end
189
294
  end
@@ -1,13 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require 'spec_helper'
3
-
4
- describe Split::EncapsulatedHelper do
5
- include Split::EncapsulatedHelper
6
2
 
3
+ require "spec_helper"
7
4
 
8
- def params
9
- raise NoMethodError, 'This method is not really defined'
10
- end
5
+ describe Split::EncapsulatedHelper do
6
+ let(:context_shim) { Split::EncapsulatedHelper::ContextShim.new(double(request: request)) }
11
7
 
12
8
  describe "ab_test" do
13
9
  before do
@@ -15,38 +11,66 @@ describe Split::EncapsulatedHelper do
15
11
  .and_return(mock_user)
16
12
  end
17
13
 
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
14
  it "calls the block with selected alternative" do
24
- expect{|block| ab_test('link_color', 'red', 'red', &block) }.to yield_with_args('red', nil)
15
+ expect { |block| context_shim.ab_test("link_color", "red", "red", &block) }.to yield_with_args("red", {})
25
16
  end
26
17
 
27
18
  context "inside a view" do
28
-
29
19
  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| %>
20
+ require "erb"
21
+ template = ERB.new(<<-ERB.split(/\s+/s).map(&:strip).join(" "), nil, "%")
22
+ foo <% context_shim.ab_test(:foo, '1', '2') do |alt, meta| %>
33
23
  static <%= alt %>
34
24
  <% end %>
35
25
  ERB
36
- expect(template.result(binding)).to match /foo static \d/
26
+ expect(template.result(binding)).to match(/foo static \d/)
37
27
  end
38
-
39
28
  end
40
29
  end
41
30
 
42
31
  describe "context" do
43
- it 'is passed in shim' do
44
- ctx = Class.new{
32
+ it "is passed in shim" do
33
+ ctx = Class.new {
45
34
  include Split::EncapsulatedHelper
46
35
  public :session
47
36
  }.new
48
- expect(ctx).to receive(:session){{}}
49
- expect{ ctx.ab_test('link_color', 'blue', 'red') }.not_to raise_error
37
+
38
+ expect(ctx).to receive(:session) { {} }
39
+ expect { ctx.ab_test("link_color", "blue", "red") }.not_to raise_error
40
+ end
41
+
42
+ context "when request is defined in context of ContextShim" do
43
+ context "when overriding by params" do
44
+ it do
45
+ ctx = Class.new {
46
+ public :session
47
+ def request
48
+ build_request(params: {
49
+ "ab_test" => { "link_color" => "blue" }
50
+ })
51
+ end
52
+ }.new
53
+
54
+ context_shim = Split::EncapsulatedHelper::ContextShim.new(ctx)
55
+ expect(context_shim.ab_test("link_color", "blue", "red")).to be("blue")
56
+ end
57
+ end
58
+
59
+ context "when overriding by cookies" do
60
+ it do
61
+ ctx = Class.new {
62
+ public :session
63
+ def request
64
+ build_request(cookies: {
65
+ "split_override" => '{ "link_color": "red" }'
66
+ })
67
+ end
68
+ }.new
69
+
70
+ context_shim = Split::EncapsulatedHelper::ContextShim.new(ctx)
71
+ expect(context_shim.ab_test("link_color", "blue", "red")).to be("red")
72
+ end
73
+ end
50
74
  end
51
75
  end
52
76
  end
@@ -1,53 +1,54 @@
1
1
  # frozen_string_literal: true
2
- require 'spec_helper'
2
+
3
+ require "spec_helper"
3
4
 
4
5
  describe Split::ExperimentCatalog do
5
6
  subject { Split::ExperimentCatalog }
6
7
 
7
8
  describe ".find_or_create" do
8
9
  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
+ expect { subject.find_or_create("xyz", "1", "2", "3") }.not_to raise_error
10
11
  end
11
12
 
12
13
  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
+ expect { subject.find_or_create("xyz", ["1", "2", "3"]) }.not_to raise_error
14
15
  end
15
16
 
16
17
  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
+ expect { subject.find_or_create("xyz", 1, 2, 3) }.to raise_error(ArgumentError)
18
19
  end
19
20
 
20
21
  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
+ expect { subject.find_or_create("xyz", :a, :b, :c) }.to raise_error(ArgumentError)
22
23
  end
23
24
 
24
25
  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
+ expect { subject.find_or_create({ "link_color" => ["purchase", "refund"] }, "blue", "red") }
26
27
  .not_to raise_error
27
28
  end
28
29
 
29
30
  it "should not raise error when passed just one goal" do
30
- expect { subject.find_or_create({'link_color' => "purchase"}, 'blue', 'red') }
31
+ expect { subject.find_or_create({ "link_color" => "purchase" }, "blue", "red") }
31
32
  .not_to raise_error
32
33
  end
33
34
 
34
35
  it "constructs a new experiment" do
35
- expect(subject.find_or_create('my_exp', 'control me').control.to_s).to eq('control me')
36
+ expect(subject.find_or_create("my_exp", "control me").control.to_s).to eq("control me")
36
37
  end
37
38
  end
38
39
 
39
- describe '.find' do
40
+ describe ".find" do
40
41
  it "should return an existing experiment" do
41
- experiment = Split::Experiment.new('basket_text', alternatives: ['blue', 'red', 'green'])
42
+ experiment = Split::Experiment.new("basket_text", alternatives: ["blue", "red", "green"])
42
43
  experiment.save
43
44
 
44
- experiment = subject.find('basket_text')
45
+ experiment = subject.find("basket_text")
45
46
 
46
- expect(experiment.name).to eq('basket_text')
47
+ expect(experiment.name).to eq("basket_text")
47
48
  end
48
49
 
49
50
  it "should return nil if experiment not exist" do
50
- expect(subject.find('non_existent_experiment')).to be_nil
51
+ expect(subject.find("non_existent_experiment")).to be_nil
51
52
  end
52
53
  end
53
54
  end