split 0.2.4 → 0.3.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.
- data/CHANGELOG.mdown +13 -0
- data/README.mdown +39 -4
- data/lib/split/alternative.rb +23 -4
- data/lib/split/dashboard.rb +1 -1
- data/lib/split/dashboard/public/dashboard.js +1 -1
- data/lib/split/dashboard/public/reset.css +17 -17
- data/lib/split/dashboard/public/style.css +137 -33
- data/lib/split/dashboard/views/_experiment.erb +12 -10
- data/lib/split/dashboard/views/index.erb +0 -1
- data/lib/split/dashboard/views/layout.erb +3 -1
- data/lib/split/experiment.rb +40 -5
- data/lib/split/helper.rb +1 -1
- data/lib/split/version.rb +1 -1
- data/spec/alternative_spec.rb +10 -0
- data/spec/experiment_spec.rb +1 -10
- data/spec/helper_spec.rb +14 -3
- metadata +69 -119
data/CHANGELOG.mdown
CHANGED
@@ -1,3 +1,16 @@
|
|
1
|
+
## 0.3.0 (October 9, 2011)
|
2
|
+
|
3
|
+
Features:
|
4
|
+
|
5
|
+
- Redesigned dashboard (@mrappleton, #17)
|
6
|
+
- Use atomic increments in redis for better concurrency (@lautis, #18)
|
7
|
+
- Weighted alternatives
|
8
|
+
|
9
|
+
Bugfixes:
|
10
|
+
|
11
|
+
- Fix to allow overriding of experiments that aren't on version 1
|
12
|
+
|
13
|
+
|
1
14
|
## 0.2.4 (July 18, 2011)
|
2
15
|
|
3
16
|
Features:
|
data/README.mdown
CHANGED
@@ -80,15 +80,15 @@ Example: View
|
|
80
80
|
Example: Controller
|
81
81
|
|
82
82
|
def register_new_user
|
83
|
-
#See what level of free points maximizes users' decision to buy replacement points.
|
83
|
+
# See what level of free points maximizes users' decision to buy replacement points.
|
84
84
|
@starter_points = ab_test("new_user_free_points", 100, 200, 300)
|
85
85
|
end
|
86
86
|
|
87
87
|
Example: Conversion tracking (in a controller!)
|
88
88
|
|
89
89
|
def buy_new_points
|
90
|
-
#some business logic
|
91
|
-
finished("buy_new_points")
|
90
|
+
# some business logic
|
91
|
+
finished("buy_new_points")
|
92
92
|
end
|
93
93
|
|
94
94
|
Example: Conversion tracking (in a view)
|
@@ -97,6 +97,22 @@ Example: Conversion tracking (in a view)
|
|
97
97
|
|
98
98
|
You can find more examples, tutorials and guides on the [wiki](https://github.com/andrew/split/wiki).
|
99
99
|
|
100
|
+
## Extras
|
101
|
+
|
102
|
+
### Weighted alternatives
|
103
|
+
|
104
|
+
Perhaps you only want to show an alternative to 10% of your visitors because it is very experimental or not yet fully load tested.
|
105
|
+
|
106
|
+
To do this you can pass a weight with each alternative in the following ways:
|
107
|
+
|
108
|
+
ab_test('homepage design', 'Old' => 20, 'New' => 2)
|
109
|
+
|
110
|
+
ab_test('homepage design', 'Old', {'New' => 0.1})
|
111
|
+
|
112
|
+
ab_test('homepage design', {'Old' => 10}, 'New')
|
113
|
+
|
114
|
+
This will only show the new alternative to visitors 1 in 10 times, the default weight for an alternative is 1.
|
115
|
+
|
100
116
|
### Overriding alternatives
|
101
117
|
|
102
118
|
For development and testing, you may wish to force your app to always return an alternative.
|
@@ -108,11 +124,21 @@ If you have an experiment called `button_color` with alternatives called `red` a
|
|
108
124
|
|
109
125
|
will always have red buttons. This won't be stored in your session or count towards to results.
|
110
126
|
|
127
|
+
### Reset after completion
|
128
|
+
|
129
|
+
When a user completes a test their session is reset so that they may start the test again in the future.
|
130
|
+
|
131
|
+
To stop this behaviour you can pass the following option to the `finished` method:
|
132
|
+
|
133
|
+
finished('experiment_name', :reset => false)
|
134
|
+
|
135
|
+
The user will then always see the alternative they started with.
|
136
|
+
|
111
137
|
## Web Interface
|
112
138
|
|
113
139
|
Split comes with a Sinatra-based front end to get an overview of how your experiments are doing.
|
114
140
|
|
115
|
-
You can mount this inside your app using Rack::URLMap in your `config.ru`
|
141
|
+
If you are running Rails 2: You can mount this inside your app using Rack::URLMap in your `config.ru`
|
116
142
|
|
117
143
|
require 'split/dashboard'
|
118
144
|
|
@@ -120,6 +146,14 @@ You can mount this inside your app using Rack::URLMap in your `config.ru`
|
|
120
146
|
"/" => Your::App.new,
|
121
147
|
"/split" => Split::Dashboard.new
|
122
148
|
|
149
|
+
However, if you are using Rails 3: You can mount this inside your app routes by first adding this to the Gemfile:
|
150
|
+
|
151
|
+
gem 'split', :require => 'split/dashboard'
|
152
|
+
|
153
|
+
Then adding this to config/routes.rb
|
154
|
+
|
155
|
+
mount Split::Dashboard, :at => 'split'
|
156
|
+
|
123
157
|
You may want to password protect that page, you can do so with `Rack::Auth::Basic`
|
124
158
|
|
125
159
|
Split::Dashboard.use Rack::Auth::Basic do |username, password|
|
@@ -196,6 +230,7 @@ Special thanks to the following people for submitting patches:
|
|
196
230
|
|
197
231
|
* Lloyd Pick
|
198
232
|
* Jeffery Chupp
|
233
|
+
* Andrew Appleton
|
199
234
|
|
200
235
|
## Development
|
201
236
|
|
data/lib/split/alternative.rb
CHANGED
@@ -4,12 +4,19 @@ module Split
|
|
4
4
|
attr_accessor :participant_count
|
5
5
|
attr_accessor :completed_count
|
6
6
|
attr_accessor :experiment_name
|
7
|
+
attr_accessor :weight
|
7
8
|
|
8
9
|
def initialize(name, experiment_name, counters = {})
|
9
10
|
@experiment_name = experiment_name
|
10
|
-
@name = name
|
11
11
|
@participant_count = counters['participant_count'].to_i
|
12
12
|
@completed_count = counters['completed_count'].to_i
|
13
|
+
if name.class == Hash
|
14
|
+
@name = name.keys.first
|
15
|
+
@weight = name.values.first
|
16
|
+
else
|
17
|
+
@name = name
|
18
|
+
@weight = 1
|
19
|
+
end
|
13
20
|
end
|
14
21
|
|
15
22
|
def to_s
|
@@ -18,12 +25,12 @@ module Split
|
|
18
25
|
|
19
26
|
def increment_participation
|
20
27
|
@participant_count +=1
|
21
|
-
|
28
|
+
Split.redis.hincrby "#{experiment_name}:#{name}", 'participant_count', 1
|
22
29
|
end
|
23
30
|
|
24
31
|
def increment_completion
|
25
32
|
@completed_count +=1
|
26
|
-
|
33
|
+
Split.redis.hincrby "#{experiment_name}:#{name}", 'completed_count', 1
|
27
34
|
end
|
28
35
|
|
29
36
|
def control?
|
@@ -39,7 +46,7 @@ module Split
|
|
39
46
|
Split::Experiment.find(experiment_name)
|
40
47
|
end
|
41
48
|
|
42
|
-
def z_score
|
49
|
+
def z_score
|
43
50
|
# CTR_E = the CTR within the experiment split
|
44
51
|
# CTR_C = the CTR within the control split
|
45
52
|
# E = the number of impressions within the experiment split
|
@@ -97,5 +104,17 @@ module Split
|
|
97
104
|
alt.save
|
98
105
|
alt
|
99
106
|
end
|
107
|
+
|
108
|
+
def self.valid?(name)
|
109
|
+
string?(name) or hash_with_correct_values?(name)
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.string?(name)
|
113
|
+
name.class == String
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.hash_with_correct_values?(name)
|
117
|
+
name.class == Hash && name.keys.first.class == String && Float(name.values.first) rescue false
|
118
|
+
end
|
100
119
|
end
|
101
120
|
end
|
data/lib/split/dashboard.rb
CHANGED
@@ -7,7 +7,7 @@ function confirmReset() {
|
|
7
7
|
}
|
8
8
|
|
9
9
|
function confirmDelete() {
|
10
|
-
var agree=confirm("Are you sure you want to delete this experiment and all
|
10
|
+
var agree=confirm("Are you sure you want to delete this experiment and all its data?");
|
11
11
|
if (agree)
|
12
12
|
return true;
|
13
13
|
else
|
@@ -6,43 +6,43 @@ small, strike, strong, sub, sup, tt, var,
|
|
6
6
|
dl, dt, dd, ul, li,
|
7
7
|
form, label, legend,
|
8
8
|
table, caption, tbody, tfoot, thead, tr, th, td {
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
9
|
+
margin: 0;
|
10
|
+
padding: 0;
|
11
|
+
border: 0;
|
12
|
+
outline: 0;
|
13
|
+
font-weight: inherit;
|
14
|
+
font-style: normal;
|
15
|
+
font-size: 100%;
|
16
|
+
font-family: inherit;
|
17
17
|
}
|
18
18
|
|
19
19
|
:focus {
|
20
|
-
|
20
|
+
outline: 0;
|
21
21
|
}
|
22
22
|
|
23
23
|
body {
|
24
|
-
|
24
|
+
line-height: 1;
|
25
25
|
}
|
26
26
|
|
27
27
|
ul {
|
28
|
-
|
28
|
+
list-style: none;
|
29
29
|
}
|
30
30
|
|
31
31
|
table {
|
32
|
-
|
33
|
-
|
32
|
+
border-collapse: collapse;
|
33
|
+
border-spacing: 0;
|
34
34
|
}
|
35
35
|
|
36
36
|
caption, th, td {
|
37
|
-
|
38
|
-
|
37
|
+
text-align: left;
|
38
|
+
font-weight: normal;
|
39
39
|
}
|
40
40
|
|
41
41
|
blockquote:before, blockquote:after,
|
42
42
|
q:before, q:after {
|
43
|
-
|
43
|
+
content: "";
|
44
44
|
}
|
45
45
|
|
46
46
|
blockquote, q {
|
47
|
-
|
47
|
+
quotes: "" "";
|
48
48
|
}
|
@@ -1,26 +1,44 @@
|
|
1
1
|
html {
|
2
2
|
background: #efefef;
|
3
|
-
font-family:
|
3
|
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
4
4
|
font-size: 13px;
|
5
5
|
}
|
6
6
|
|
7
7
|
body {
|
8
|
-
padding: 0;
|
9
|
-
margin: 0;
|
8
|
+
padding: 0 10px;
|
9
|
+
margin: 10px auto 0;
|
10
|
+
max-width:800px;
|
10
11
|
}
|
11
12
|
|
12
13
|
.header {
|
13
|
-
background: #
|
14
|
-
|
15
|
-
|
16
|
-
|
14
|
+
background: #ededed;
|
15
|
+
background: -webkit-gradient(linear, left top, left bottom,
|
16
|
+
color-stop(0%,#576a76),
|
17
|
+
color-stop(100%,#4d5256));
|
18
|
+
background: -moz-linear-gradient (top, #576a76 0%, #414e58 100%);
|
19
|
+
background: -webkit-linear-gradient(top, #576a76 0%, #414e58 100%);
|
20
|
+
background: -o-linear-gradient (top, #576a76 0%, #414e58 100%);
|
21
|
+
background: -ms-linear-gradient (top, #576a76 0%, #414e58 100%);
|
22
|
+
background: linear-gradient (top, #576a76 0%, #414e58 100%);
|
23
|
+
border-bottom: 1px solid #fff;
|
24
|
+
-moz-border-radius-topleft: 5px;
|
25
|
+
-webkit-border-top-left-radius: 5px;
|
26
|
+
border-top-left-radius: 5px;
|
27
|
+
-moz-border-radius-topright: 5px;
|
28
|
+
-webkit-border-top-right-radius:5px;
|
29
|
+
border-top-right-radius: 5px;
|
30
|
+
|
31
|
+
overflow:hidden;
|
32
|
+
padding: 10px 5%;
|
33
|
+
text-shadow:0 1px 0 #000;
|
17
34
|
}
|
18
35
|
|
19
36
|
.header h1 {
|
20
|
-
color: #
|
21
|
-
|
22
|
-
font-
|
23
|
-
|
37
|
+
color: #eee;
|
38
|
+
float:left;
|
39
|
+
font-size:1.2em;
|
40
|
+
font-weight:normal;
|
41
|
+
margin:2px 30px 0 0;
|
24
42
|
}
|
25
43
|
|
26
44
|
.header ul li {
|
@@ -28,30 +46,43 @@ body {
|
|
28
46
|
}
|
29
47
|
|
30
48
|
.header ul li a {
|
31
|
-
color: #
|
49
|
+
color: #eee;
|
32
50
|
text-decoration: none;
|
33
51
|
margin-right: 10px;
|
34
52
|
display: inline-block;
|
35
|
-
padding: 8px;
|
36
|
-
-
|
37
|
-
-webkit-border-
|
38
|
-
|
39
|
-
|
53
|
+
padding: 4px 8px;
|
54
|
+
-moz-border-radius: 10px;
|
55
|
+
-webkit-border-radius:10px;
|
56
|
+
border-radius: 10px;
|
57
|
+
|
40
58
|
}
|
41
59
|
|
42
60
|
.header ul li a:hover {
|
43
|
-
background:
|
61
|
+
background: rgba(255,255,255,0.1);
|
62
|
+
}
|
63
|
+
|
64
|
+
.header ul li a:active {
|
65
|
+
-moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2);
|
66
|
+
-webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2);
|
67
|
+
box-shadow: inset 0 1px 0 rgba(0,0,0,0.2);
|
44
68
|
}
|
45
69
|
|
46
70
|
.header ul li.current a {
|
47
|
-
background:
|
48
|
-
|
71
|
+
background: rgba(255,255,255,0.1);
|
72
|
+
-moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2);
|
73
|
+
-webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2);
|
74
|
+
box-shadow: inset 0 1px 0 rgba(0,0,0,0.2);
|
49
75
|
color: #fff;
|
50
76
|
}
|
51
77
|
|
52
78
|
#main {
|
53
79
|
padding: 10px 5%;
|
54
|
-
background: #
|
80
|
+
background: #f9f9f9;
|
81
|
+
border:1px solid #ccc;
|
82
|
+
border-top:none;
|
83
|
+
-moz-box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
84
|
+
-webkit-box-shadow:0 3px 10px rgba(0,0,0,0.2);
|
85
|
+
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
55
86
|
overflow: hidden;
|
56
87
|
}
|
57
88
|
|
@@ -74,11 +105,11 @@ body {
|
|
74
105
|
|
75
106
|
#main table {
|
76
107
|
width: 100%;
|
77
|
-
margin: 10px
|
108
|
+
margin:0 0 10px;
|
78
109
|
}
|
79
110
|
|
80
111
|
#main table tr td, #main table tr th {
|
81
|
-
border: 1px solid #ccc;
|
112
|
+
border-bottom: 1px solid #ccc;
|
82
113
|
padding: 6px;
|
83
114
|
}
|
84
115
|
|
@@ -86,7 +117,7 @@ body {
|
|
86
117
|
background: #efefef;
|
87
118
|
color: #888;
|
88
119
|
font-size: 80%;
|
89
|
-
|
120
|
+
text-transform:uppercase;
|
90
121
|
}
|
91
122
|
|
92
123
|
#main table tr td.no-data {
|
@@ -123,16 +154,34 @@ body {
|
|
123
154
|
}
|
124
155
|
|
125
156
|
.experiment {
|
126
|
-
|
127
|
-
border
|
128
|
-
|
157
|
+
background:#fff;
|
158
|
+
border: 1px solid #eee;
|
159
|
+
border-bottom:none;
|
160
|
+
margin:30px 0;
|
161
|
+
}
|
162
|
+
|
163
|
+
.experiment .experiment-header {
|
164
|
+
background: #f4f4f4;
|
165
|
+
background: -webkit-gradient(linear, left top, left bottom,
|
166
|
+
color-stop(0%,#f4f4f4),
|
167
|
+
color-stop(100%,#e0e0e0));
|
168
|
+
background: -moz-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%);
|
169
|
+
background: -webkit-linear-gradient(top, #f4f4f4 0%, #e0e0e0 100%);
|
170
|
+
background: -o-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%);
|
171
|
+
background: -ms-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%);
|
172
|
+
background: linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%);
|
173
|
+
border-top:1px solid #fff;
|
174
|
+
overflow:hidden;
|
175
|
+
padding:0 10px;
|
129
176
|
}
|
130
177
|
|
131
178
|
.experiment h2 {
|
132
|
-
|
133
|
-
|
179
|
+
color:#888;
|
180
|
+
margin: 12px 0 0;
|
181
|
+
font-size: 1em;
|
134
182
|
font-weight:bold;
|
135
183
|
float:left;
|
184
|
+
text-shadow:0 1px 0 rgba(255,255,255,0.8);
|
136
185
|
}
|
137
186
|
|
138
187
|
.experiment h2 .version{
|
@@ -155,11 +204,9 @@ body {
|
|
155
204
|
|
156
205
|
#footer {
|
157
206
|
padding: 10px 5%;
|
158
|
-
background: #efefef;
|
159
207
|
color: #999;
|
160
208
|
font-size: 85%;
|
161
209
|
line-height: 1.5;
|
162
|
-
border-top: 5px solid #ccc;
|
163
210
|
padding-top: 10px;
|
164
211
|
}
|
165
212
|
|
@@ -182,10 +229,67 @@ body {
|
|
182
229
|
}
|
183
230
|
|
184
231
|
.worse, .better {
|
185
|
-
color: #
|
232
|
+
color: #773F3F;
|
186
233
|
font-size: 10px;
|
234
|
+
font-weight:bold;
|
187
235
|
}
|
188
236
|
|
189
237
|
.better {
|
190
|
-
color: #
|
238
|
+
color: #408C48;
|
191
239
|
}
|
240
|
+
|
241
|
+
a.button, button, input[type="submit"] {
|
242
|
+
padding: 4px 10px;
|
243
|
+
overflow: hidden;
|
244
|
+
background: #d8dae0;
|
245
|
+
-moz-box-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
246
|
+
-webkit-box-shadow:0 1px 0 rgba(0,0,0,0.5);
|
247
|
+
box-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
248
|
+
border:none;
|
249
|
+
-moz-border-radius: 30px;
|
250
|
+
-webkit-border-radius:30px;
|
251
|
+
border-radius: 30px;
|
252
|
+
color:#2e3035;
|
253
|
+
cursor: pointer;
|
254
|
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
255
|
+
text-decoration: none;
|
256
|
+
text-shadow:0 1px 0 rgba(255,255,255,0.8);
|
257
|
+
-moz-user-select: none;
|
258
|
+
-webkit-user-select:none;
|
259
|
+
user-select: none;
|
260
|
+
white-space: nowrap;
|
261
|
+
}
|
262
|
+
a.button:hover, button:hover, input[type="submit"]:hover,
|
263
|
+
a.button:focus, button:focus, input[type="submit"]:focus{
|
264
|
+
background:#bbbfc7;
|
265
|
+
}
|
266
|
+
a.button:active, button:active, input[type="submit"]:active{
|
267
|
+
-moz-box-shadow: inset 0 0 4px #484d57;
|
268
|
+
-webkit-box-shadow:inset 0 0 4px #484d57;
|
269
|
+
box-shadow: inset 0 0 4px #484d57;
|
270
|
+
position:relative;
|
271
|
+
top:1px;
|
272
|
+
}
|
273
|
+
|
274
|
+
a.button.red, button.red, input[type="submit"].red,
|
275
|
+
a.button.green, button.green, input[type="submit"].green {
|
276
|
+
color:#fff;
|
277
|
+
text-shadow:0 1px 0 rgba(0,0,0,0.4);
|
278
|
+
}
|
279
|
+
|
280
|
+
a.button.red, button.red, input[type="submit"].red {
|
281
|
+
background:#a56d6d;
|
282
|
+
}
|
283
|
+
a.button.red:hover, button.red:hover, input[type="submit"].red:hover,
|
284
|
+
a.button.red:focus, button.red:focus, input[type="submit"].red:focus {
|
285
|
+
background:#895C5C;
|
286
|
+
}
|
287
|
+
a.button.green, button.green, input[type="submit"].green {
|
288
|
+
background:#8daa92;
|
289
|
+
}
|
290
|
+
a.button.green:hover, button.green:hover, input[type="submit"].green:hover,
|
291
|
+
a.button.green:focus, button.green:focus, input[type="submit"].green:focus {
|
292
|
+
background:#768E7A;
|
293
|
+
}
|
294
|
+
|
295
|
+
|
@@ -1,13 +1,15 @@
|
|
1
1
|
<div class="experiment">
|
2
|
-
<
|
3
|
-
|
4
|
-
<
|
5
|
-
<
|
6
|
-
|
7
|
-
|
8
|
-
<
|
9
|
-
|
10
|
-
|
2
|
+
<div class="experiment-header">
|
3
|
+
<h2>Experiment: <%= experiment.name %> <% if experiment.version > 1 %><span class='version'>v<%= experiment.version %></span><% end %></h2>
|
4
|
+
<div class='inline-controls'>
|
5
|
+
<form action="<%= url "/reset/#{experiment.name}" %>" method='post' onclick="return confirmReset()">
|
6
|
+
<input type="submit" value="Reset Data">
|
7
|
+
</form>
|
8
|
+
<form action="<%= url "/#{experiment.name}" %>" method='post' onclick="return confirmDelete()">
|
9
|
+
<input type="hidden" name="_method" value="delete"/>
|
10
|
+
<input type="submit" value="Delete" class="red">
|
11
|
+
</form>
|
12
|
+
</div>
|
11
13
|
</div>
|
12
14
|
<table>
|
13
15
|
<tr>
|
@@ -59,7 +61,7 @@
|
|
59
61
|
<% else %>
|
60
62
|
<form action="<%= url experiment.name %>" method='post' onclick="return confirmWinner()">
|
61
63
|
<input type='hidden' name='alternative' value='<%= alternative.name %>'>
|
62
|
-
<input type="submit" value="Use this">
|
64
|
+
<input type="submit" value="Use this" class="green">
|
63
65
|
</form>
|
64
66
|
<% end %>
|
65
67
|
</td>
|
data/lib/split/experiment.rb
CHANGED
@@ -7,7 +7,10 @@ module Split
|
|
7
7
|
|
8
8
|
def initialize(name, *alternative_names)
|
9
9
|
@name = name.to_s
|
10
|
-
@alternative_names = alternative_names
|
10
|
+
@alternative_names = alternative_names.map do |alternative|
|
11
|
+
Split::Alternative.new(alternative, name)
|
12
|
+
end.map(&:name)
|
13
|
+
|
11
14
|
@version = (Split.redis.get("#{name.to_s}:version").to_i || 0)
|
12
15
|
end
|
13
16
|
|
@@ -36,7 +39,19 @@ module Split
|
|
36
39
|
end
|
37
40
|
|
38
41
|
def next_alternative
|
39
|
-
winner ||
|
42
|
+
winner || random_alternative
|
43
|
+
end
|
44
|
+
|
45
|
+
def random_alternative
|
46
|
+
weights = alternatives.map(&:weight)
|
47
|
+
|
48
|
+
total = weights.inject(0.0) {|t,w| t+w}
|
49
|
+
point = rand * total
|
50
|
+
|
51
|
+
alternatives.zip(weights).each do |n,w|
|
52
|
+
return n if w >= point
|
53
|
+
point -= w
|
54
|
+
end
|
40
55
|
end
|
41
56
|
|
42
57
|
def version
|
@@ -61,7 +76,7 @@ module Split
|
|
61
76
|
reset_winner
|
62
77
|
increment_version
|
63
78
|
end
|
64
|
-
|
79
|
+
|
65
80
|
def delete
|
66
81
|
alternatives.each(&:delete)
|
67
82
|
reset_winner
|
@@ -108,10 +123,18 @@ module Split
|
|
108
123
|
def self.find_or_create(key, *alternatives)
|
109
124
|
name = key.to_s.split(':')[0]
|
110
125
|
|
111
|
-
|
126
|
+
if alternatives.length == 1
|
127
|
+
if alternatives[0].is_a? Hash
|
128
|
+
alternatives = alternatives[0].map{|k,v| {k => v} }
|
129
|
+
else
|
130
|
+
raise InvalidArgument, 'You must declare at least 2 alternatives'
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
alts = initialize_alternatives(alternatives, name)
|
112
135
|
|
113
136
|
if Split.redis.exists(name)
|
114
|
-
if load_alternatives_for(name) ==
|
137
|
+
if load_alternatives_for(name) == alts.map(&:name)
|
115
138
|
experiment = self.new(name, *load_alternatives_for(name))
|
116
139
|
else
|
117
140
|
exp = self.new(name, *load_alternatives_for(name))
|
@@ -125,6 +148,18 @@ module Split
|
|
125
148
|
experiment.save
|
126
149
|
end
|
127
150
|
return experiment
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
def self.initialize_alternatives(alternatives, name)
|
155
|
+
|
156
|
+
if alternatives.reject {|a| Split::Alternative.valid? a}.any?
|
157
|
+
raise InvalidArgument, 'Alternatives must be strings'
|
158
|
+
end
|
159
|
+
|
160
|
+
alternatives.map do |alternative|
|
161
|
+
Split::Alternative.new(alternative, name)
|
162
|
+
end
|
128
163
|
end
|
129
164
|
end
|
130
165
|
end
|
data/lib/split/helper.rb
CHANGED
@@ -5,7 +5,7 @@ module Split
|
|
5
5
|
if experiment.winner
|
6
6
|
ret = experiment.winner.name
|
7
7
|
else
|
8
|
-
if forced_alternative = override(experiment.
|
8
|
+
if forced_alternative = override(experiment.name, alternatives)
|
9
9
|
ret = forced_alternative
|
10
10
|
else
|
11
11
|
begin_experiment(experiment, experiment.control.name) if exclude_visitor?
|
data/lib/split/version.rb
CHANGED
data/spec/alternative_spec.rb
CHANGED
@@ -9,6 +9,12 @@ describe Split::Alternative do
|
|
9
9
|
alternative = Split::Alternative.new('Basket', 'basket_text')
|
10
10
|
alternative.name.should eql('Basket')
|
11
11
|
end
|
12
|
+
|
13
|
+
it "return only the name" do
|
14
|
+
experiment = Split::Experiment.new('basket_text', {'Basket' => 0.6}, {"Cart" => 0.4})
|
15
|
+
alternative = Split::Alternative.new('Basket', 'basket_text')
|
16
|
+
alternative.name.should eql('Basket')
|
17
|
+
end
|
12
18
|
|
13
19
|
it "should have a default participation count of 0" do
|
14
20
|
alternative = Split::Alternative.new('Basket', 'basket_text')
|
@@ -40,6 +46,8 @@ describe Split::Alternative do
|
|
40
46
|
old_participant_count = alternative.participant_count
|
41
47
|
alternative.increment_participation
|
42
48
|
alternative.participant_count.should eql(old_participant_count+1)
|
49
|
+
|
50
|
+
Split::Alternative.find('Basket', 'basket_text').participant_count.should eql(old_participant_count+1)
|
43
51
|
end
|
44
52
|
|
45
53
|
it "should increment completed count" do
|
@@ -49,6 +57,8 @@ describe Split::Alternative do
|
|
49
57
|
old_completed_count = alternative.participant_count
|
50
58
|
alternative.increment_completion
|
51
59
|
alternative.completed_count.should eql(old_completed_count+1)
|
60
|
+
|
61
|
+
Split::Alternative.find('Basket', 'basket_text').completed_count.should eql(old_completed_count+1)
|
52
62
|
end
|
53
63
|
|
54
64
|
it "can be reset" do
|
data/spec/experiment_spec.rb
CHANGED
@@ -37,7 +37,7 @@ describe Split::Experiment do
|
|
37
37
|
Split.redis.exists('basket_text').should be false
|
38
38
|
lambda { Split::Experiment.find('link_color') }.should raise_error
|
39
39
|
end
|
40
|
-
|
40
|
+
|
41
41
|
it "should increment the version" do
|
42
42
|
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green')
|
43
43
|
experiment.version.should eql(0)
|
@@ -126,15 +126,6 @@ describe Split::Experiment do
|
|
126
126
|
end
|
127
127
|
|
128
128
|
describe 'next_alternative' do
|
129
|
-
it "should return a random alternative from those with the least participants" do
|
130
|
-
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green')
|
131
|
-
|
132
|
-
Split::Alternative.find('blue', 'link_color').increment_participation
|
133
|
-
Split::Alternative.find('red', 'link_color').increment_participation
|
134
|
-
|
135
|
-
experiment.next_alternative.name.should eql('green')
|
136
|
-
end
|
137
|
-
|
138
129
|
it "should always return the winner if one exists" do
|
139
130
|
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green')
|
140
131
|
green = Split::Alternative.find('green', 'link_color')
|
data/spec/helper_spec.rb
CHANGED
@@ -57,6 +57,17 @@ describe Split::Helper do
|
|
57
57
|
ret = ab_test('link_color', 'blue', 'red') { |alternative| "shared/#{alternative}" }
|
58
58
|
ret.should eql("shared/#{alt}")
|
59
59
|
end
|
60
|
+
|
61
|
+
it "should allow the share of visitors see an alternative to be specificed" do
|
62
|
+
ab_test('link_color', {'blue' => 0.8}, {'red' => 20})
|
63
|
+
['red', 'blue'].should include(ab_user['link_color'])
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should allow alternative weighting interface as a single hash" do
|
67
|
+
ab_test('link_color', 'blue' => 0.01, 'red' => 0.2)
|
68
|
+
experiment = Split::Experiment.find('link_color')
|
69
|
+
experiment.alternative_names.should eql(['blue', 'red'])
|
70
|
+
end
|
60
71
|
end
|
61
72
|
|
62
73
|
describe 'finished' do
|
@@ -95,17 +106,17 @@ describe Split::Helper do
|
|
95
106
|
session[:split].should eql("link_color" => alternative_name)
|
96
107
|
end
|
97
108
|
end
|
98
|
-
|
109
|
+
|
99
110
|
describe 'conversions' do
|
100
111
|
it 'should return a conversion rate for an alternative' do
|
101
112
|
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
|
102
113
|
alternative_name = ab_test('link_color', 'blue', 'red')
|
103
|
-
|
114
|
+
|
104
115
|
previous_convertion_rate = Split::Alternative.find(alternative_name, 'link_color').conversion_rate
|
105
116
|
previous_convertion_rate.should eql(0.0)
|
106
117
|
|
107
118
|
finished('link_color')
|
108
|
-
|
119
|
+
|
109
120
|
new_convertion_rate = Split::Alternative.find(alternative_name, 'link_color').conversion_rate
|
110
121
|
new_convertion_rate.should eql(1.0)
|
111
122
|
end
|
metadata
CHANGED
@@ -1,140 +1,100 @@
|
|
1
|
-
--- !ruby/object:Gem::Specification
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
2
|
name: split
|
3
|
-
version: !ruby/object:Gem::Version
|
4
|
-
|
5
|
-
prerelease:
|
6
|
-
segments:
|
7
|
-
- 0
|
8
|
-
- 2
|
9
|
-
- 4
|
10
|
-
version: 0.2.4
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
prerelease:
|
11
6
|
platform: ruby
|
12
|
-
authors:
|
7
|
+
authors:
|
13
8
|
- Andrew Nesbitt
|
14
9
|
autorequire:
|
15
10
|
bindir: bin
|
16
11
|
cert_chain: []
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
dependencies:
|
21
|
-
- !ruby/object:Gem::Dependency
|
12
|
+
date: 2011-10-09 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
22
15
|
name: redis
|
23
|
-
|
24
|
-
requirement: &id001 !ruby/object:Gem::Requirement
|
16
|
+
requirement: &70155561576500 !ruby/object:Gem::Requirement
|
25
17
|
none: false
|
26
|
-
requirements:
|
18
|
+
requirements:
|
27
19
|
- - ~>
|
28
|
-
- !ruby/object:Gem::Version
|
29
|
-
|
30
|
-
segments:
|
31
|
-
- 2
|
32
|
-
- 1
|
33
|
-
version: "2.1"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '2.1'
|
34
22
|
type: :runtime
|
35
|
-
version_requirements: *id001
|
36
|
-
- !ruby/object:Gem::Dependency
|
37
|
-
name: redis-namespace
|
38
23
|
prerelease: false
|
39
|
-
|
24
|
+
version_requirements: *70155561576500
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: redis-namespace
|
27
|
+
requirement: &70155561575180 !ruby/object:Gem::Requirement
|
40
28
|
none: false
|
41
|
-
requirements:
|
29
|
+
requirements:
|
42
30
|
- - ~>
|
43
|
-
- !ruby/object:Gem::Version
|
44
|
-
hash: 17
|
45
|
-
segments:
|
46
|
-
- 1
|
47
|
-
- 0
|
48
|
-
- 3
|
31
|
+
- !ruby/object:Gem::Version
|
49
32
|
version: 1.0.3
|
50
33
|
type: :runtime
|
51
|
-
version_requirements: *id002
|
52
|
-
- !ruby/object:Gem::Dependency
|
53
|
-
name: sinatra
|
54
34
|
prerelease: false
|
55
|
-
|
35
|
+
version_requirements: *70155561575180
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: sinatra
|
38
|
+
requirement: &70155561564480 !ruby/object:Gem::Requirement
|
56
39
|
none: false
|
57
|
-
requirements:
|
40
|
+
requirements:
|
58
41
|
- - ~>
|
59
|
-
- !ruby/object:Gem::Version
|
60
|
-
hash: 19
|
61
|
-
segments:
|
62
|
-
- 1
|
63
|
-
- 2
|
64
|
-
- 6
|
42
|
+
- !ruby/object:Gem::Version
|
65
43
|
version: 1.2.6
|
66
44
|
type: :runtime
|
67
|
-
version_requirements: *id003
|
68
|
-
- !ruby/object:Gem::Dependency
|
69
|
-
name: bundler
|
70
45
|
prerelease: false
|
71
|
-
|
46
|
+
version_requirements: *70155561564480
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: bundler
|
49
|
+
requirement: &70155561563360 !ruby/object:Gem::Requirement
|
72
50
|
none: false
|
73
|
-
requirements:
|
51
|
+
requirements:
|
74
52
|
- - ~>
|
75
|
-
- !ruby/object:Gem::Version
|
76
|
-
|
77
|
-
segments:
|
78
|
-
- 1
|
79
|
-
- 0
|
80
|
-
version: "1.0"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
81
55
|
type: :development
|
82
|
-
version_requirements: *id004
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: rspec
|
85
56
|
prerelease: false
|
86
|
-
|
57
|
+
version_requirements: *70155561563360
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: rspec
|
60
|
+
requirement: &70155561562780 !ruby/object:Gem::Requirement
|
87
61
|
none: false
|
88
|
-
requirements:
|
62
|
+
requirements:
|
89
63
|
- - ~>
|
90
|
-
- !ruby/object:Gem::Version
|
91
|
-
|
92
|
-
segments:
|
93
|
-
- 2
|
94
|
-
- 6
|
95
|
-
version: "2.6"
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '2.6'
|
96
66
|
type: :development
|
97
|
-
version_requirements: *id005
|
98
|
-
- !ruby/object:Gem::Dependency
|
99
|
-
name: rack-test
|
100
67
|
prerelease: false
|
101
|
-
|
68
|
+
version_requirements: *70155561562780
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rack-test
|
71
|
+
requirement: &70155561562200 !ruby/object:Gem::Requirement
|
102
72
|
none: false
|
103
|
-
requirements:
|
73
|
+
requirements:
|
104
74
|
- - ~>
|
105
|
-
- !ruby/object:Gem::Version
|
106
|
-
|
107
|
-
segments:
|
108
|
-
- 0
|
109
|
-
- 6
|
110
|
-
version: "0.6"
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0.6'
|
111
77
|
type: :development
|
112
|
-
version_requirements: *id006
|
113
|
-
- !ruby/object:Gem::Dependency
|
114
|
-
name: guard-rspec
|
115
78
|
prerelease: false
|
116
|
-
|
79
|
+
version_requirements: *70155561562200
|
80
|
+
- !ruby/object:Gem::Dependency
|
81
|
+
name: guard-rspec
|
82
|
+
requirement: &70155561561580 !ruby/object:Gem::Requirement
|
117
83
|
none: false
|
118
|
-
requirements:
|
84
|
+
requirements:
|
119
85
|
- - ~>
|
120
|
-
- !ruby/object:Gem::Version
|
121
|
-
|
122
|
-
segments:
|
123
|
-
- 0
|
124
|
-
- 4
|
125
|
-
version: "0.4"
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0.4'
|
126
88
|
type: :development
|
127
|
-
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: *70155561561580
|
128
91
|
description:
|
129
|
-
email:
|
92
|
+
email:
|
130
93
|
- andrewnez@gmail.com
|
131
94
|
executables: []
|
132
|
-
|
133
95
|
extensions: []
|
134
|
-
|
135
96
|
extra_rdoc_files: []
|
136
|
-
|
137
|
-
files:
|
97
|
+
files:
|
138
98
|
- .gitignore
|
139
99
|
- CHANGELOG.mdown
|
140
100
|
- Gemfile
|
@@ -162,41 +122,31 @@ files:
|
|
162
122
|
- spec/helper_spec.rb
|
163
123
|
- spec/spec_helper.rb
|
164
124
|
- split.gemspec
|
165
|
-
has_rdoc: true
|
166
125
|
homepage: https://github.com/andrew/split
|
167
126
|
licenses: []
|
168
|
-
|
169
127
|
post_install_message:
|
170
128
|
rdoc_options: []
|
171
|
-
|
172
|
-
require_paths:
|
129
|
+
require_paths:
|
173
130
|
- lib
|
174
|
-
required_ruby_version: !ruby/object:Gem::Requirement
|
131
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
175
132
|
none: false
|
176
|
-
requirements:
|
177
|
-
- -
|
178
|
-
- !ruby/object:Gem::Version
|
179
|
-
|
180
|
-
|
181
|
-
- 0
|
182
|
-
version: "0"
|
183
|
-
required_rubygems_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ! '>='
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
184
138
|
none: false
|
185
|
-
requirements:
|
186
|
-
- -
|
187
|
-
- !ruby/object:Gem::Version
|
188
|
-
|
189
|
-
segments:
|
190
|
-
- 0
|
191
|
-
version: "0"
|
139
|
+
requirements:
|
140
|
+
- - ! '>='
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '0'
|
192
143
|
requirements: []
|
193
|
-
|
194
144
|
rubyforge_project: split
|
195
|
-
rubygems_version: 1.
|
145
|
+
rubygems_version: 1.8.6
|
196
146
|
signing_key:
|
197
147
|
specification_version: 3
|
198
148
|
summary: Rack based split testing framework
|
199
|
-
test_files:
|
149
|
+
test_files:
|
200
150
|
- spec/alternative_spec.rb
|
201
151
|
- spec/configuration_spec.rb
|
202
152
|
- spec/dashboard_spec.rb
|