experimental 0.1.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 +15 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +337 -0
- data/Rakefile +19 -0
- data/app/models/experimental/cache.rb +69 -0
- data/app/models/experimental/experiment.rb +114 -0
- data/app/models/experimental/loader.rb +64 -0
- data/app/models/experimental/population/default.rb +12 -0
- data/app/models/experimental/population/filter.rb +33 -0
- data/app/models/experimental/population/new_users.rb +9 -0
- data/app/models/experimental/subject.rb +19 -0
- data/app/views/experimental/_experiment.html.erb +16 -0
- data/app/views/experimental/_index.html.erb +50 -0
- data/app/views/experimental/_links.html.erb +7 -0
- data/app/views/experimental/_new.html.erb +37 -0
- data/config/routes.rb +2 -0
- data/lib/experimental/controller_actions.rb +89 -0
- data/lib/experimental/engine.rb +4 -0
- data/lib/experimental/rspec_helpers.rb +56 -0
- data/lib/experimental/version.rb +11 -0
- data/lib/experimental.rb +11 -0
- data/lib/generators/experimental/experimental_generator.rb +29 -0
- data/lib/generators/experimental/templates/create_experiments_table.rb +24 -0
- data/lib/generators/experimental/templates/experimental.rb +6 -0
- data/lib/generators/experimental/templates/experiments.yml +33 -0
- data/lib/tasks/experiment.rake +6 -0
- metadata +170 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
Zjc0ZjhjZGU1OTRjYzA0YjY2MjM2NDVhOTQ2NjY1MGJjNjFiZWUyYg==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
N2Q1ZDM5OTk4NWYyMzNmYjY5OWNlOWVjNzFlMTUxYzZjMjRlZTUyNg==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
ZGVmMGFjYWNkZWExMGJmNWRiNmVmMjRiZWQzMTUyNGFhZGRmNThiODI2MTg3
|
10
|
+
NTQzMjI4NzBmZGJkNmFlNDI5YTM2MThmMmJkY2JiZWVkODBhZTcyZmM0OWVm
|
11
|
+
NzQzNGUwOGMwMGVjZjhiM2QzM2NhNmQxYTAyMDJhNTI3NzIyNjE=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
N2Q3ZjM1OWRjN2NiMzA4ZWExYWU5ODY0ZDcxMjA5NTgzOWI4OWE2OWIzNmJk
|
14
|
+
MjM1Njk1NTk4Mjc1NWRmNzM4N2YwM2M1YjViZmRlODFlMDIxOWRkM2I1NmU3
|
15
|
+
MWNiODNhMmEzOGZhMDBkM2JlMDY1MjRkN2Q5MTNjYWEyZDQ1ZmU=
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2013 This Life, Inc.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,337 @@
|
|
1
|
+
# Experimental
|
2
|
+
Experimental is an Split testing framework for Rails.
|
3
|
+
It was written with a few goals in mind:
|
4
|
+
* Split the users in a non-predictable pattern (i.e. half of the users won't always
|
5
|
+
be in all experiments)
|
6
|
+
* Keep experiments and their start and end dates in the database
|
7
|
+
* Have a clear developer workflow, so that tests in the code are
|
8
|
+
started in the database when the code goes out and tests that should
|
9
|
+
be removed make the site explode
|
10
|
+
* Allow admins to end experiments and set a winner
|
11
|
+
* Cache the experiments
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
```rails g experimental```
|
16
|
+
|
17
|
+
### Routes
|
18
|
+
|
19
|
+
````
|
20
|
+
resources :experiments, :only => [:index, :new, :create] do
|
21
|
+
collection do
|
22
|
+
get :inactive
|
23
|
+
post :set_winner
|
24
|
+
end
|
25
|
+
end
|
26
|
+
````
|
27
|
+
|
28
|
+
````
|
29
|
+
namespace :singles_admin do
|
30
|
+
resources :experiments, :only => [:index, :new, :create] do
|
31
|
+
collection do
|
32
|
+
get :inactive
|
33
|
+
post :set_winner
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
````
|
38
|
+
|
39
|
+
### Admin Frontend
|
40
|
+
|
41
|
+
#### Create your own admin controller:
|
42
|
+
````
|
43
|
+
class Admin::ExperimentsController < ApplicationController
|
44
|
+
include Experimental::ControllerActions
|
45
|
+
|
46
|
+
alias_method :index, :experiments_index
|
47
|
+
alias_method :new, :experiments_new
|
48
|
+
alias_method :set_winner, :experiments_set_winner
|
49
|
+
|
50
|
+
def create
|
51
|
+
if experiments_create
|
52
|
+
redirect_to admin_experiments_path
|
53
|
+
else
|
54
|
+
render :new
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def base_resource_name
|
59
|
+
"singles_admin_experiment"
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
````
|
64
|
+
|
65
|
+
#### Using ActiveAdmin:
|
66
|
+
|
67
|
+
``` rails g active_admin:resource Experiment```
|
68
|
+
|
69
|
+
````
|
70
|
+
require 'experimental/controller_actions'
|
71
|
+
|
72
|
+
ActiveAdmin.register Experimental::Experiment, as: "Experiment" do
|
73
|
+
actions :index, :new, :create
|
74
|
+
filter :name
|
75
|
+
|
76
|
+
controller do
|
77
|
+
class_eval do
|
78
|
+
include Experimental::ControllerActions
|
79
|
+
end
|
80
|
+
|
81
|
+
def base_resource_name
|
82
|
+
"admin_experiment"
|
83
|
+
end
|
84
|
+
|
85
|
+
def create
|
86
|
+
if experiments_create
|
87
|
+
redirect_to admin_experiments_path
|
88
|
+
else
|
89
|
+
render :new
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def new
|
94
|
+
experiments_new
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# collection_actions force active_admin to create a route
|
99
|
+
collection_action :set_winner, method: :post do
|
100
|
+
experiments_set_winner
|
101
|
+
end
|
102
|
+
|
103
|
+
# can do this instead of the ended_or_removed scope below
|
104
|
+
# you will need to add a link to inactive_admins_experiments_path
|
105
|
+
# in your view
|
106
|
+
collection_action :inactive do
|
107
|
+
experiments_inactive
|
108
|
+
render template: 'admin/experiments/index'
|
109
|
+
end
|
110
|
+
|
111
|
+
scope :in_progress, :default => true do |experiments|
|
112
|
+
experiments.in_progress
|
113
|
+
end
|
114
|
+
|
115
|
+
scope :ended_or_removed do |experiments|
|
116
|
+
@include_inactive = true
|
117
|
+
experiments.ended_or_removed
|
118
|
+
end
|
119
|
+
|
120
|
+
index do
|
121
|
+
render template: 'admin/experiments/index'
|
122
|
+
end
|
123
|
+
|
124
|
+
form :partial => 'new'
|
125
|
+
end
|
126
|
+
|
127
|
+
````
|
128
|
+
|
129
|
+
#### Views
|
130
|
+
|
131
|
+
create an index and new view in appropriate view folder, i.e.
|
132
|
+
|
133
|
+
``` app/views/admin/experiments/index.html.erb ```
|
134
|
+
````
|
135
|
+
<%= render partial: 'experimental/links' %>
|
136
|
+
<%= render partial: 'experimental/index' %>
|
137
|
+
````
|
138
|
+
|
139
|
+
``` app/views/admin/experiments/new.html.erb ```
|
140
|
+
````
|
141
|
+
<%= render partial: 'experimental/links' %>
|
142
|
+
<%= render partial: 'experimental/new' %>
|
143
|
+
````
|
144
|
+
|
145
|
+
*Note: ActiveAdmin users will not need to include the links
|
146
|
+
partials*
|
147
|
+
|
148
|
+
### Subject
|
149
|
+
|
150
|
+
For the class you'd like to be the subject of experiments, include the
|
151
|
+
Experimental::Subject module in a model with an id and timestamps
|
152
|
+
````
|
153
|
+
class User < ActiveRecord::Base
|
154
|
+
include Experimental::Subject
|
155
|
+
...
|
156
|
+
````
|
157
|
+
|
158
|
+
|
159
|
+
## Usage
|
160
|
+
|
161
|
+
### Create an experiment
|
162
|
+
|
163
|
+
In `config/experiments.yml`, add the name, num_buckets, and notes of the
|
164
|
+
experiment under in_code:
|
165
|
+
````
|
166
|
+
in_code:
|
167
|
+
-
|
168
|
+
name: :price_experiment
|
169
|
+
num_buckets: 2
|
170
|
+
notes: |
|
171
|
+
0: $22
|
172
|
+
1: $19.99
|
173
|
+
|
174
|
+
````
|
175
|
+
Then run `rake experiments:sync`
|
176
|
+
|
177
|
+
### Using the experiment
|
178
|
+
|
179
|
+
To see if a user is in the experiment population AND in a bucket:
|
180
|
+
````
|
181
|
+
#checks if the user is in the my_experiment population
|
182
|
+
# and if they are in bucket 0
|
183
|
+
user.in_bucket?(:my_experiment, 0)
|
184
|
+
````
|
185
|
+
|
186
|
+
To see if a user is in the experiment population **ONLY**
|
187
|
+
````
|
188
|
+
user.in_experiment?(:my_experiment)
|
189
|
+
````
|
190
|
+
|
191
|
+
### Ending an experiment
|
192
|
+
|
193
|
+
You can end an experiment by setting the end_date. In the admin
|
194
|
+
interface, there is a dropdown to set the end date. When ending an
|
195
|
+
experiment *you must set a winning bucket*
|
196
|
+
|
197
|
+
*Ending an experiment means that all users will be given the winning
|
198
|
+
bucket*
|
199
|
+
|
200
|
+
### Removing an experiment
|
201
|
+
|
202
|
+
A removed experiment is an experiment that is not referenced
|
203
|
+
anywhere in code. In fact, the framework will throw an exception
|
204
|
+
if you reference an experiment that is not in code.
|
205
|
+
|
206
|
+
Moving an experiment from the in_code to the removed section of
|
207
|
+
`config/experiments.yml` and running `rake experiments:sync` will
|
208
|
+
remove the experiment and expire the cache.
|
209
|
+
|
210
|
+
````
|
211
|
+
removed:
|
212
|
+
-
|
213
|
+
name: :price_experiment
|
214
|
+
````
|
215
|
+
Then run `rake experiments:sync`
|
216
|
+
|
217
|
+
|
218
|
+
|
219
|
+
## Testing
|
220
|
+
|
221
|
+
### Setup
|
222
|
+
in ```spec_helper.rb``` (after inclusion of ActiveSupport)
|
223
|
+
|
224
|
+
````
|
225
|
+
require 'experimental/rspec_helpers'
|
226
|
+
````
|
227
|
+
|
228
|
+
*You may want to force experiments off for all tests by default*
|
229
|
+
````
|
230
|
+
config.before(:each) do
|
231
|
+
User.any_instance.stub(:in_experiment?).and_return(false)
|
232
|
+
end
|
233
|
+
````
|
234
|
+
|
235
|
+
### Testing experiments
|
236
|
+
|
237
|
+
Include the Rspec helpers in your spec class or spec_helper
|
238
|
+
````
|
239
|
+
include Experimental::RspecHelpers
|
240
|
+
````
|
241
|
+
|
242
|
+
Shared contexts are available for in_experiment? and in_bucket?
|
243
|
+
````
|
244
|
+
include_context "in experiment"
|
245
|
+
include_context "not in experiment"
|
246
|
+
|
247
|
+
include_context "in experiment bucket 0"
|
248
|
+
include_context "in experiment bucket 1"
|
249
|
+
```
|
250
|
+
|
251
|
+
Helper methods are also available:
|
252
|
+
|
253
|
+
**is_in_experiment**
|
254
|
+
````
|
255
|
+
# first param is true for in experiment, false for not in
|
256
|
+
experiment
|
257
|
+
# second param is the experiment name
|
258
|
+
# third param is the subject object
|
259
|
+
is_in_experiment(true, :my_experiment, my_subject)
|
260
|
+
|
261
|
+
# if user and experiment_name are defined, you can do
|
262
|
+
let(:experiment_name) { :my_experiment }
|
263
|
+
let(:user) { User.new }
|
264
|
+
is_in_experiment # true if in experiment
|
265
|
+
is_in_experiment(false) # true if NOT in experiment
|
266
|
+
````
|
267
|
+
|
268
|
+
**is_not_in_experiment**
|
269
|
+
````
|
270
|
+
# first param is name of experiment
|
271
|
+
# second param is subject object
|
272
|
+
is_not_in_experiment(:my_experiment, my_subject)
|
273
|
+
|
274
|
+
# if user and experiment_name are defined, you can do
|
275
|
+
let(:experiment_name) { :my_experiment }
|
276
|
+
let(:user) { User.new }
|
277
|
+
is_not_in_experiment
|
278
|
+
````
|
279
|
+
|
280
|
+
**has_experiment_bucket**
|
281
|
+
````
|
282
|
+
has_experiment_bucket(1, :my_experiment, my_subject)
|
283
|
+
|
284
|
+
# if user and experiment_name are defined, you can do
|
285
|
+
let(:experiment_name) { :my_experiment }
|
286
|
+
let(:user) { User.new }
|
287
|
+
has_experiment_bucket(1)
|
288
|
+
|
289
|
+
````
|
290
|
+
|
291
|
+
|
292
|
+
|
293
|
+
## Developer Workflow
|
294
|
+
|
295
|
+
Experiments *can* be defined in config/experiments.yml
|
296
|
+
Running the rake task `rake experiments:sync` will load those
|
297
|
+
experiments under 'in_code' into the database and set removed_at
|
298
|
+
timestamp for those under 'removed'
|
299
|
+
|
300
|
+
You will likely want to automate the running of `rake
|
301
|
+
experiments:sync` by adding to your deploy file.
|
302
|
+
|
303
|
+
### Capistrano
|
304
|
+
In `config/deploy.rb`:
|
305
|
+
|
306
|
+
Create a namespace to run the task:
|
307
|
+
````
|
308
|
+
namespace :database do
|
309
|
+
desc "Sync experiments"
|
310
|
+
task :sync_from_app, roles: :db, only: { primary: true } do
|
311
|
+
run "cd #{current_path} && RAILS_ENV=#{rails_env} bundle exec rake experiments:sync"
|
312
|
+
end
|
313
|
+
end
|
314
|
+
````
|
315
|
+
|
316
|
+
Include that in the deploy:default task:
|
317
|
+
````
|
318
|
+
namespace :deploy do
|
319
|
+
...
|
320
|
+
task :default do
|
321
|
+
begin
|
322
|
+
update_code
|
323
|
+
migrate
|
324
|
+
database.sync_from_app
|
325
|
+
restart
|
326
|
+
...
|
327
|
+
````
|
328
|
+
|
329
|
+
### Admin created experiments
|
330
|
+
|
331
|
+
The purpose of Admin created experiments are for experiments
|
332
|
+
that will flow through to another system, such as an email provider.
|
333
|
+
They likely start with a known string and are dynamically sent in
|
334
|
+
code.
|
335
|
+
Otherwise, Admin created experiments will do nothing as their is no
|
336
|
+
code attached to them.
|
337
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
Bundler::GemHelper.install_tasks
|
8
|
+
|
9
|
+
require 'rake/testtask'
|
10
|
+
|
11
|
+
Rake::TestTask.new(:test) do |t|
|
12
|
+
t.libs << 'lib'
|
13
|
+
t.libs << 'spec'
|
14
|
+
t.pattern = 'spec/**/*_spec.rb'
|
15
|
+
t.verbose = false
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
task :default => :test
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module Experimental
|
2
|
+
class Cache
|
3
|
+
cattr_accessor :interval
|
4
|
+
cattr_reader :last_check
|
5
|
+
cattr_reader :last_update
|
6
|
+
cattr_reader :experiments
|
7
|
+
cattr_accessor :cache_race_condition_ttl
|
8
|
+
@@cache_race_condition_ttl = 10
|
9
|
+
cattr_accessor :cache_key
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def get(name)
|
13
|
+
unless within_interval?
|
14
|
+
if need_update?(last_cached_update)
|
15
|
+
@@experiments = experiments_to_hash(Experiment.in_code)
|
16
|
+
@@last_update = Time.now
|
17
|
+
end
|
18
|
+
end
|
19
|
+
experiments[name.to_sym]
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](name)
|
23
|
+
get(name)
|
24
|
+
end
|
25
|
+
|
26
|
+
def interval
|
27
|
+
@@interval ||= 5.minutes
|
28
|
+
end
|
29
|
+
|
30
|
+
def last_check
|
31
|
+
@@last_check ||=
|
32
|
+
Time.now - interval - 1.minute
|
33
|
+
end
|
34
|
+
|
35
|
+
def last_update
|
36
|
+
@@last_update ||= last_cached_update
|
37
|
+
end
|
38
|
+
|
39
|
+
def cache_key
|
40
|
+
@@cache_key ||= "experiments_last_update"
|
41
|
+
end
|
42
|
+
|
43
|
+
def within_interval?
|
44
|
+
(Time.now - last_check) < interval
|
45
|
+
end
|
46
|
+
|
47
|
+
def need_update?(last_cached_update_time)
|
48
|
+
experiments.nil? || last_update < last_cached_update_time
|
49
|
+
end
|
50
|
+
|
51
|
+
def experiments_to_hash(experiments)
|
52
|
+
HashWithIndifferentAccess.new.tap do |h|
|
53
|
+
experiments.each { |e| h[e.name.to_sym] = e }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def last_cached_update
|
58
|
+
# setting a default to 1 minute ago will force all servers to update
|
59
|
+
Rails.cache.fetch(cache_key, :race_condition_ttl => cache_race_condition_ttl ) do
|
60
|
+
Experiment.last_updated_at || 1.minute.ago
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def expire_last_updated
|
65
|
+
Rails.cache.delete(cache_key)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Experimental
|
2
|
+
class Experiment < ActiveRecord::Base
|
3
|
+
extend Population::Filter
|
4
|
+
|
5
|
+
cattr_accessor :use_cache
|
6
|
+
@@use_cache = true
|
7
|
+
|
8
|
+
attr_accessible :name, :num_buckets, :notes
|
9
|
+
|
10
|
+
validates_presence_of :name, :num_buckets
|
11
|
+
validates_numericality_of :num_buckets, :greater_than_or_equal_to => 2
|
12
|
+
validates_numericality_of :winning_bucket,
|
13
|
+
:greater_than_or_equal_to => 0,
|
14
|
+
:less_than => :num_buckets,
|
15
|
+
:if => :ended?
|
16
|
+
|
17
|
+
after_create :expire_cache
|
18
|
+
|
19
|
+
def self.in_code
|
20
|
+
where(:removed_at => nil)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.in_progress
|
24
|
+
where('removed_at is null and end_date is null').
|
25
|
+
order('start_date desc').
|
26
|
+
order(:name)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.ended_or_removed
|
30
|
+
where('removed_at is not null or end_date is not null').
|
31
|
+
order(:removed_at).
|
32
|
+
order('end_date desc')
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.last_updated_at
|
36
|
+
maximum(:updated_at)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.[](experiment_name)
|
40
|
+
if use_cache
|
41
|
+
Cache.get(experiment_name)
|
42
|
+
else
|
43
|
+
find_by_name(experiment_name.to_s)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.expire_cache
|
48
|
+
Cache.expire_last_updated if use_cache
|
49
|
+
end
|
50
|
+
|
51
|
+
def expire_cache
|
52
|
+
Experiment.expire_cache
|
53
|
+
end
|
54
|
+
|
55
|
+
def bucket(subject)
|
56
|
+
(ended? || removed?) ? winning_bucket : bucket_number(subject)
|
57
|
+
end
|
58
|
+
|
59
|
+
def in?(subject)
|
60
|
+
return false if removed?
|
61
|
+
population_filter.in?(subject, self)
|
62
|
+
end
|
63
|
+
|
64
|
+
def end(winning_num)
|
65
|
+
self.winning_bucket = winning_num
|
66
|
+
self.end_date = Time.now
|
67
|
+
result = save
|
68
|
+
|
69
|
+
Experiment.expire_cache if result
|
70
|
+
|
71
|
+
result
|
72
|
+
end
|
73
|
+
|
74
|
+
def remove
|
75
|
+
result = false
|
76
|
+
|
77
|
+
unless removed?
|
78
|
+
result = update_attributes(
|
79
|
+
{ removed_at: Time.now }, without_protection: true
|
80
|
+
)
|
81
|
+
expire_cache if result
|
82
|
+
end
|
83
|
+
|
84
|
+
result
|
85
|
+
end
|
86
|
+
|
87
|
+
def removed?
|
88
|
+
!removed_at.nil?
|
89
|
+
end
|
90
|
+
|
91
|
+
def ended?
|
92
|
+
!end_date.nil? && Time.now > end_date
|
93
|
+
end
|
94
|
+
|
95
|
+
def active?
|
96
|
+
!removed? && !ended?
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_sql_formula(subject_table = "users")
|
100
|
+
"CONV(SUBSTR(SHA1(CONCAT(\"#{name}\",#{subject_table}.id)),1,8),16,10) % #{num_buckets}"
|
101
|
+
end
|
102
|
+
|
103
|
+
def bucket_number(subject)
|
104
|
+
top_8 = Digest::SHA1.hexdigest("#{name}#{subject.id}")[0..7]
|
105
|
+
top_8.to_i(16) % num_buckets
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def population_filter
|
111
|
+
@population_filter ||= self.class.find_population(population)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Experimental
|
2
|
+
class Loader
|
3
|
+
cattr_accessor :file_path
|
4
|
+
@@file_path = 'config/experiments.yml'
|
5
|
+
|
6
|
+
@@whitelisted_attributes = [:name, :num_buckets, :notes, :population]
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def sync(verbose = false)
|
10
|
+
puts "Loading experiments ..." if verbose
|
11
|
+
experiments = load_experiments
|
12
|
+
|
13
|
+
#new/active
|
14
|
+
experiments.in_code.each do |exp|
|
15
|
+
name = exp.with_indifferent_access[:name]
|
16
|
+
puts "\tUpdating #{name} ..." if verbose
|
17
|
+
|
18
|
+
exp = whitelisted_attrs(exp)
|
19
|
+
e = Experimental::Experiment.find_by_name(name) || Experimental::Experiment.new
|
20
|
+
|
21
|
+
puts "\t\tcreating ..." if verbose && e.id.nil?
|
22
|
+
puts "\t\tupdating ..." if verbose && !e.id.nil?
|
23
|
+
|
24
|
+
exp.merge!({ start_date: Time.now }) if e.start_date.nil?
|
25
|
+
e.update_attributes!(exp, without_protection: true)
|
26
|
+
end unless experiments.in_code.nil?
|
27
|
+
|
28
|
+
#removed
|
29
|
+
experiments.removed.each do |exp|
|
30
|
+
name = exp.with_indifferent_access[:name]
|
31
|
+
puts "\tRemoving #{name} ..." if verbose
|
32
|
+
|
33
|
+
exp = whitelisted_attrs(exp)
|
34
|
+
e = Experimental::Experiment.find_by_name(name)
|
35
|
+
|
36
|
+
if e && e.removed_at.nil?
|
37
|
+
puts "\t\t#{name} exists, removing ..." if verbose
|
38
|
+
result = e.remove
|
39
|
+
puts "\t\t\t#{result}" if verbose
|
40
|
+
else
|
41
|
+
puts "\t\t#{name} doesn't exist!" if verbose
|
42
|
+
end
|
43
|
+
end unless experiments.removed.nil?
|
44
|
+
|
45
|
+
puts "Expiring cache ..." if verbose
|
46
|
+
Experimental::Experiment.expire_cache
|
47
|
+
|
48
|
+
puts "Done syncing experiments!" if verbose
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def load_experiments
|
54
|
+
OpenStruct.new YAML.load_file(File.join(Rails.root, file_path))
|
55
|
+
end
|
56
|
+
|
57
|
+
def whitelisted_attrs(exp)
|
58
|
+
exp.
|
59
|
+
select { |k, v| @@whitelisted_attributes.include?(k.to_sym) }.
|
60
|
+
with_indifferent_access
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Experimental
|
2
|
+
module Population
|
3
|
+
module Filter
|
4
|
+
def self.extended(base)
|
5
|
+
base.reset_population_filters
|
6
|
+
end
|
7
|
+
|
8
|
+
def find_population(name)
|
9
|
+
if name.blank?
|
10
|
+
Experimental::Population::Default
|
11
|
+
else
|
12
|
+
filter_classes[name.to_s]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def register_population_filter(name, filter_class)
|
17
|
+
filter_classes[name.to_s] = filter_class
|
18
|
+
end
|
19
|
+
|
20
|
+
def reset_population_filters
|
21
|
+
filter_classes.clear
|
22
|
+
register_population_filter(:new_users, NewUsers)
|
23
|
+
register_population_filter(:default, Default)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def filter_classes
|
29
|
+
@filter_classes ||= {}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Experimental
|
2
|
+
module Subject
|
3
|
+
def in_experiment?(name)
|
4
|
+
Experiment[name].try { |e| e.in?(self) }
|
5
|
+
end
|
6
|
+
|
7
|
+
def not_in_experiment?(name)
|
8
|
+
!in_experiment?(name)
|
9
|
+
end
|
10
|
+
|
11
|
+
def experiment_bucket(name)
|
12
|
+
Experiment[name].try { |e| e.bucket(self) }
|
13
|
+
end
|
14
|
+
|
15
|
+
def in_bucket?(name, bucket)
|
16
|
+
in_experiment?(name) && experiment_bucket(name) == bucket
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
<tr>
|
2
|
+
<td><%= experiment.name.humanize %></td>
|
3
|
+
<td><%= "#{experiment.num_buckets} (0-#{experiment.num_buckets-1})" %></td>
|
4
|
+
<td><%= experiment.population || "default" %></td>
|
5
|
+
<td><%= experiment.notes.try(:gsub, "\n", '<br>').try(:html_safe) %></td>
|
6
|
+
<td><%= experiment.start_date.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
|
7
|
+
<% if @include_inactive %>
|
8
|
+
<td><%= experiment.winning_bucket %></td>
|
9
|
+
<td><%= experiment.end_date.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
|
10
|
+
<td><%= experiment.removed_at.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
|
11
|
+
<% else %>
|
12
|
+
<td>
|
13
|
+
<%= hidden_field_tag "exp-#{experiment.id}", experiment.id %>
|
14
|
+
<%= select_tag "exp-#{experiment.id}-buckets", options_for_select((0..experiment.num_buckets-1).to_a.insert(0, ['-- select --', -1])), class: 'set_winner' %>
|
15
|
+
</td>
|
16
|
+
<% end %>
|
@@ -0,0 +1,50 @@
|
|
1
|
+
<h1><%= @h1 %></h1>
|
2
|
+
|
3
|
+
<table>
|
4
|
+
<thead>
|
5
|
+
<tr>
|
6
|
+
<th scope="col"> Name </th>
|
7
|
+
<th scope="col"> Buckets </th>
|
8
|
+
<th scope="col"> Population </th>
|
9
|
+
<th scope="col"> Notes </th>
|
10
|
+
<th scope="col"> Start </th>
|
11
|
+
<th scope="col"> Winner </th>
|
12
|
+
<% if @include_inactive %>
|
13
|
+
<th scope="col"> End </th>
|
14
|
+
<th scope="col"> Removed </th>
|
15
|
+
<% end %>
|
16
|
+
</tr>
|
17
|
+
</thead>
|
18
|
+
<tbody>
|
19
|
+
<%= render partial: 'experimental/experiment', collection: @experiments %>
|
20
|
+
</tbody>
|
21
|
+
</table>
|
22
|
+
|
23
|
+
<script type="text/javascript">
|
24
|
+
$(function(){
|
25
|
+
$(".set_winner").change(function(){
|
26
|
+
var bucket_id = $(this).val();
|
27
|
+
var msg = "Are you sure you want to end this experiment and set the winning bucket to " + bucket_id;
|
28
|
+
|
29
|
+
if( bucket_id > -1 && confirm(msg) ){
|
30
|
+
var exp_id = $(this).siblings().val();
|
31
|
+
|
32
|
+
$.ajax({
|
33
|
+
type: "POST",
|
34
|
+
data: {id:exp_id, bucket_id:bucket_id},
|
35
|
+
url: '<%= @experimental_path_names.set_winner %>',
|
36
|
+
success: function(resp){
|
37
|
+
window.location.reload();
|
38
|
+
},
|
39
|
+
error: function(){
|
40
|
+
alert("WE BLEW UP!");
|
41
|
+
$(this).val(-1);
|
42
|
+
}
|
43
|
+
});
|
44
|
+
}
|
45
|
+
else{
|
46
|
+
$(this).val(-1);
|
47
|
+
}
|
48
|
+
});
|
49
|
+
});
|
50
|
+
</script>
|
@@ -0,0 +1,37 @@
|
|
1
|
+
<h1> New Experiment</h1>
|
2
|
+
<br />
|
3
|
+
<%= form_for(@experiment, url: @experimental_path_names.index) do |f| %>
|
4
|
+
<% if @experiment.errors.any? %>
|
5
|
+
<div id="error_explanation">
|
6
|
+
<h2>
|
7
|
+
<%= pluralize(@experiment.errors.count, "error") %>
|
8
|
+
probited this experiment from being saved:
|
9
|
+
</h2>
|
10
|
+
<ul>
|
11
|
+
<% @experiment.errors.full_messages.each do |msg| %>
|
12
|
+
<li><%= msg %></li>
|
13
|
+
<% end %>
|
14
|
+
</ul>
|
15
|
+
</div>
|
16
|
+
<% end %>
|
17
|
+
|
18
|
+
<p class="field">
|
19
|
+
<%= f.label :name %>
|
20
|
+
<br />
|
21
|
+
<%= f.text_field :name %>
|
22
|
+
</p>
|
23
|
+
<p class="field">
|
24
|
+
<%= f.label :num_buckets %>
|
25
|
+
<br />
|
26
|
+
<%= f.text_field :num_buckets %>
|
27
|
+
</p>
|
28
|
+
<p class="field">
|
29
|
+
<%= f.label :notes %>
|
30
|
+
<br />
|
31
|
+
<%= f.text_area :notes, cols: "30", rows: "5" %>
|
32
|
+
</p>
|
33
|
+
|
34
|
+
<p class="actions" >
|
35
|
+
<%= f.submit %>
|
36
|
+
</p>
|
37
|
+
<% end %>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module Experimental
|
4
|
+
module ControllerActions
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
class_eval do
|
9
|
+
attr_writer :base_resource_name
|
10
|
+
|
11
|
+
respond_to :json, :only => [:set_winner]
|
12
|
+
|
13
|
+
before_filter :set_experimental_path_names
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def base_resource_name
|
18
|
+
@base_resource_name ||= "experiment"
|
19
|
+
end
|
20
|
+
|
21
|
+
def set_experimental_path_names
|
22
|
+
@experimental_path_names = OpenStruct.new
|
23
|
+
plural_path = "#{base_resource_name.pluralize}_path"
|
24
|
+
|
25
|
+
@experimental_path_names.index = self.send(plural_path.to_sym)
|
26
|
+
if self.respond_to?("inactive_#{plural_path}".to_sym)
|
27
|
+
@experimental_path_names.inactive = self.send("inactive_#{plural_path}".to_sym)
|
28
|
+
end
|
29
|
+
@experimental_path_names.new = self.send("new_#{base_resource_name}_path".to_sym)
|
30
|
+
@experimental_path_names.set_winner = self.send("set_winner_#{plural_path}".to_sym)
|
31
|
+
end
|
32
|
+
|
33
|
+
def experimental_path_names
|
34
|
+
set_experimental_path_names if @experimental_path_names.nil?
|
35
|
+
@experimental_path_names
|
36
|
+
end
|
37
|
+
|
38
|
+
def experiments_index
|
39
|
+
@h1 = "In-progress Experiments"
|
40
|
+
@include_inactive = false
|
41
|
+
@experiments = Experiment.in_progress
|
42
|
+
end
|
43
|
+
|
44
|
+
def experiments_new
|
45
|
+
@experiment = Experiment.new
|
46
|
+
end
|
47
|
+
|
48
|
+
def experiments_create
|
49
|
+
@experiment = Experiment.new(params[:experimental_experiment])
|
50
|
+
@experiment.start_date = Time.now
|
51
|
+
|
52
|
+
if @experiment.save
|
53
|
+
flash[:notice] = "Experiment was successfully created."
|
54
|
+
return true
|
55
|
+
else
|
56
|
+
flash.now[:error] = "There was an error!"
|
57
|
+
return false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def experiments_inactive
|
62
|
+
@h1 = "Ended or Removed Experiments"
|
63
|
+
@include_inactive = true
|
64
|
+
@experiments = Experiment.ended_or_removed
|
65
|
+
end
|
66
|
+
|
67
|
+
def create
|
68
|
+
if experiments_create
|
69
|
+
redirect_to experimental_path_names.index
|
70
|
+
else
|
71
|
+
render :new
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def inactive
|
76
|
+
experiments_inactive
|
77
|
+
render :index
|
78
|
+
end
|
79
|
+
|
80
|
+
def experiments_set_winner
|
81
|
+
exp = Experiment.find params[:id]
|
82
|
+
if exp.end(params[:bucket_id])
|
83
|
+
render json: nil, status: :ok
|
84
|
+
else
|
85
|
+
render json: nil, status: :error
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Experimental
|
2
|
+
module RspecHelpers
|
3
|
+
extend ::ActiveSupport::Concern
|
4
|
+
|
5
|
+
def is_in_experiment(val = true, name = nil, obj = nil)
|
6
|
+
obj ||= user
|
7
|
+
name ||= experiment_name
|
8
|
+
|
9
|
+
obj.should_receive(:in_experiment?).any_number_of_times.
|
10
|
+
with(name).and_return(val)
|
11
|
+
end
|
12
|
+
|
13
|
+
def is_not_in_experiment(name = nil, obj = nil)
|
14
|
+
obj ||= user
|
15
|
+
name ||= experiment_name
|
16
|
+
|
17
|
+
is_in_experiment(false, name, obj)
|
18
|
+
end
|
19
|
+
|
20
|
+
def has_experiment_bucket(bucket, name = nil, obj = nil)
|
21
|
+
obj ||= user
|
22
|
+
name ||= experiment_name
|
23
|
+
|
24
|
+
obj.should_receive(:experiment_bucket).any_number_of_times.
|
25
|
+
with(name).and_return(bucket)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
module ClassMethods
|
30
|
+
shared_context "in experiment" do
|
31
|
+
before do
|
32
|
+
is_in_experiment
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
shared_context "not in experiment" do
|
37
|
+
before do
|
38
|
+
is_not_in_experiment
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
shared_context "in experiment bucket 1" do
|
43
|
+
include_context "in experiment"
|
44
|
+
before do
|
45
|
+
has_experiment_bucket(1)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
shared_context "in experiment bucket 0" do
|
50
|
+
include_context "in experiment"
|
51
|
+
before do
|
52
|
+
has_experiment_bucket(0)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/experimental.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'rails'
|
2
|
+
require 'experimental/engine'
|
3
|
+
|
4
|
+
module Experimental
|
5
|
+
autoload :VERSION, 'experimental/version'
|
6
|
+
autoload :ControllerActions, 'experimental/controller_actions'
|
7
|
+
|
8
|
+
def self.register_population_filter(name, filter_class)
|
9
|
+
Experiment.register_population_filter(name, filter_class)
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class ExperimentalGenerator < Rails::Generators::Base
|
2
|
+
include Rails::Generators::Migration
|
3
|
+
source_root File.expand_path('../templates', __FILE__)
|
4
|
+
|
5
|
+
desc "copy experiments yaml file"
|
6
|
+
def copy_experiments_yaml
|
7
|
+
copy_file "experiments.yml", "config/experiments.yml"
|
8
|
+
end
|
9
|
+
|
10
|
+
desc "copy initializer"
|
11
|
+
def copy_experiments_initialize
|
12
|
+
copy_file "experimental.rb", "config/initializers/experimental.rb"
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
desc "add migrations"
|
17
|
+
def self.next_migration_number(path)
|
18
|
+
if ActiveRecord::Base.timestamped_migrations
|
19
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
20
|
+
else
|
21
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def copy_migrations
|
26
|
+
migration_template 'create_experiments_table.rb',
|
27
|
+
'db/migrate/create_experiments_table.rb'
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class CreateExperimentsTable < ActiveRecord::Migration
|
2
|
+
def up
|
3
|
+
create_table :experiments do |t|
|
4
|
+
t.string :name, null: false, limit: 128
|
5
|
+
t.datetime :start_date
|
6
|
+
t.datetime :end_date
|
7
|
+
t.integer :num_buckets, null: false, default: 1
|
8
|
+
t.integer :winning_bucket
|
9
|
+
t.text :notes, limit: 256
|
10
|
+
t.string :population
|
11
|
+
t.datetime :removed_at
|
12
|
+
t.timestamps
|
13
|
+
end
|
14
|
+
|
15
|
+
add_index :experiments, :name, :unique => true
|
16
|
+
add_index :experiments, :start_date
|
17
|
+
add_index :experiments, :population
|
18
|
+
add_index :experiments, :updated_at
|
19
|
+
end
|
20
|
+
|
21
|
+
def down
|
22
|
+
drop_table :experiments
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,6 @@
|
|
1
|
+
#Load the population classes
|
2
|
+
#Rails lazy loads and since they are never explicitly referenced, they don't get loaded
|
3
|
+
#Deleting this file causes failures when running individual specs
|
4
|
+
Dir[File.join(Rails.root, 'app/models/experimental/population/*.rb')].each do |f|
|
5
|
+
require f
|
6
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# run rake experiments:sync to get these into the database
|
2
|
+
# sets start_date to Time.now for experiments in_code
|
3
|
+
# sets removed_at to Time.now for experiments in removed
|
4
|
+
|
5
|
+
# these are the experiments with the path still in code
|
6
|
+
# note: these could be ended via the Admin
|
7
|
+
# Format:
|
8
|
+
# -
|
9
|
+
# name: #name of the experiment as symbol
|
10
|
+
# num_buckets: #number of possible buckets (standard a/b test would be 2)
|
11
|
+
# population: #optional
|
12
|
+
# #possible values:
|
13
|
+
# :default or nil = all users
|
14
|
+
# :new_users = users created after start date
|
15
|
+
# notes: #optional. free form notes to see in Admin
|
16
|
+
# # preface this with | in order to have multi-line text
|
17
|
+
# Example
|
18
|
+
# -
|
19
|
+
# name: :test_experiment
|
20
|
+
# num_buckets: 2
|
21
|
+
# notes: |
|
22
|
+
# 0: sees experiment
|
23
|
+
# 1: does not see experiment
|
24
|
+
in_code:
|
25
|
+
|
26
|
+
# copy an experiment to this section when all code paths have been removed
|
27
|
+
# removed at date will be set in rake task
|
28
|
+
# only in_code experiments are loaded, but just in case ...
|
29
|
+
# experiment code throws an exception if you reference a removed experiment
|
30
|
+
# Example:
|
31
|
+
# -
|
32
|
+
# name: :removed_experiment
|
33
|
+
removed:
|
metadata
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: experimental
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- HowAboutWe.com
|
8
|
+
- Rebecca Miller-Webster
|
9
|
+
- Bryan Woods
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2013-05-17 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rails
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 3.2.6
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ~>
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: 3.2.6
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: jquery-rails
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - ! '>='
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: '0'
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ! '>='
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: sqlite3
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ! '>='
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :development
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ! '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
- !ruby/object:Gem::Dependency
|
58
|
+
name: rspec-rails
|
59
|
+
requirement: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
type: :development
|
65
|
+
prerelease: false
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ! '>='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
- !ruby/object:Gem::Dependency
|
72
|
+
name: activeadmin
|
73
|
+
requirement: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - ! '>='
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '0'
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: sass-rails
|
87
|
+
requirement: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - ! '>='
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0'
|
92
|
+
type: :development
|
93
|
+
prerelease: false
|
94
|
+
version_requirements: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ! '>='
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
- !ruby/object:Gem::Dependency
|
100
|
+
name: coffee-rails
|
101
|
+
requirement: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ! '>='
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0'
|
106
|
+
type: :development
|
107
|
+
prerelease: false
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ! '>='
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0'
|
113
|
+
description: AB Test framework for Rails
|
114
|
+
email:
|
115
|
+
- dev@howaboutwe.com
|
116
|
+
executables: []
|
117
|
+
extensions: []
|
118
|
+
extra_rdoc_files: []
|
119
|
+
files:
|
120
|
+
- app/models/experimental/cache.rb
|
121
|
+
- app/models/experimental/experiment.rb
|
122
|
+
- app/models/experimental/loader.rb
|
123
|
+
- app/models/experimental/population/default.rb
|
124
|
+
- app/models/experimental/population/filter.rb
|
125
|
+
- app/models/experimental/population/new_users.rb
|
126
|
+
- app/models/experimental/subject.rb
|
127
|
+
- app/views/experimental/_experiment.html.erb
|
128
|
+
- app/views/experimental/_index.html.erb
|
129
|
+
- app/views/experimental/_links.html.erb
|
130
|
+
- app/views/experimental/_new.html.erb
|
131
|
+
- config/routes.rb
|
132
|
+
- lib/experimental/controller_actions.rb
|
133
|
+
- lib/experimental/engine.rb
|
134
|
+
- lib/experimental/rspec_helpers.rb
|
135
|
+
- lib/experimental/version.rb
|
136
|
+
- lib/experimental.rb
|
137
|
+
- lib/generators/experimental/experimental_generator.rb
|
138
|
+
- lib/generators/experimental/templates/create_experiments_table.rb
|
139
|
+
- lib/generators/experimental/templates/experimental.rb
|
140
|
+
- lib/generators/experimental/templates/experiments.yml
|
141
|
+
- lib/tasks/experiment.rake
|
142
|
+
- MIT-LICENSE
|
143
|
+
- Rakefile
|
144
|
+
- README.markdown
|
145
|
+
homepage: http://wwww.github.com/howaboutwe/experimental
|
146
|
+
licenses:
|
147
|
+
- MIT
|
148
|
+
metadata: {}
|
149
|
+
post_install_message:
|
150
|
+
rdoc_options: []
|
151
|
+
require_paths:
|
152
|
+
- lib
|
153
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - ! '>='
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
159
|
+
requirements:
|
160
|
+
- - ! '>='
|
161
|
+
- !ruby/object:Gem::Version
|
162
|
+
version: '0'
|
163
|
+
requirements: []
|
164
|
+
rubyforge_project:
|
165
|
+
rubygems_version: 2.0.3
|
166
|
+
signing_key:
|
167
|
+
specification_version: 4
|
168
|
+
summary: Adds support for database-backed AB tests in Rails apps
|
169
|
+
test_files: []
|
170
|
+
has_rdoc:
|