vanity 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +8 -0
- data/README.rdoc +153 -0
- data/lib/vanity.rb +23 -0
- data/lib/vanity/experiment/ab_test.rb +170 -0
- data/lib/vanity/experiment/base.rb +98 -0
- data/lib/vanity/playground.rb +127 -0
- data/lib/vanity/rails.rb +13 -0
- data/lib/vanity/rails/helpers.rb +121 -0
- data/lib/vanity/rails/testing.rb +9 -0
- data/lib/vanity/report.erb +22 -0
- data/test/ab_test_template.erb +3 -0
- data/test/ab_test_test.rb +190 -0
- data/test/experiment_test.rb +45 -0
- data/test/playground_test.rb +67 -0
- data/test/rails_test.rb +76 -0
- data/test/test_helper.rb +35 -0
- data/vanity.gemspec +19 -0
- metadata +86 -0
data/CHANGELOG
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
0.1.1
|
2
|
+
* Added: experiment method on object, used to define and access experiments.
|
3
|
+
* Added: playground configuration (Vanity.playground.namespace = , etc).
|
4
|
+
* Added: use_vanity now accepts block instead of symbol.
|
5
|
+
* Changed: Vanity::Helpers becomes Vanity::Rails.
|
6
|
+
* Changed: A/B test experiments alternatives now handled using Alternatives
|
7
|
+
object.
|
8
|
+
* Removed: A/B test measure method no longer in use.
|
data/README.rdoc
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
Vanity is an Experience Driven-Development framework for Rails.
|
2
|
+
|
3
|
+
Requires Ruby 1.9 and Redis 1.0 or later.
|
4
|
+
|
5
|
+
|
6
|
+
== A/B Testing with Rails (in 5 easy steps)
|
7
|
+
|
8
|
+
Add Vanity to your Rails app:
|
9
|
+
|
10
|
+
class ApplicationController < ActionController::Base
|
11
|
+
use_vanity :current_user
|
12
|
+
end
|
13
|
+
|
14
|
+
Define an A/B test. This test compares three pricing options:
|
15
|
+
|
16
|
+
experiment "Price options" do
|
17
|
+
description "Mirror, mirror on the wall, who's the better price of them all?"
|
18
|
+
alternatives 19, 25, 29
|
19
|
+
end
|
20
|
+
|
21
|
+
Present different options to the user:
|
22
|
+
|
23
|
+
<h2>Get started for only $<%= ab_test :pricing_options %> a month!</h2>
|
24
|
+
|
25
|
+
Measure conversion:
|
26
|
+
|
27
|
+
class SignupController < ApplicationController
|
28
|
+
def signup
|
29
|
+
@account = Account.new(params[:account])
|
30
|
+
if @account.save
|
31
|
+
ab_goal! :pricing_options # <- conversion
|
32
|
+
redirect_to @acccount
|
33
|
+
else
|
34
|
+
render action: :offer
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
Check the report:
|
40
|
+
|
41
|
+
vanity
|
42
|
+
|
43
|
+
|
44
|
+
== A/B Tests
|
45
|
+
|
46
|
+
Each A/B experiment represents several (two or more) alternatives. Use the
|
47
|
+
ab_test method to choose an alternative. Call ab_test without a block to return
|
48
|
+
the value of the chosen alternative. Call ab_test with a block to yield with
|
49
|
+
the value.
|
50
|
+
|
51
|
+
Here are some examples:
|
52
|
+
|
53
|
+
def index
|
54
|
+
if ab_test(:new_page) # classic true/false test
|
55
|
+
render action: "new_page"
|
56
|
+
else
|
57
|
+
render action: "index"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def index
|
62
|
+
# alternatives are names of templates
|
63
|
+
render template: ab_test(:new_page)
|
64
|
+
end
|
65
|
+
|
66
|
+
<%= if ab_test(:banner) %>100% less complexity!<% end %>
|
67
|
+
|
68
|
+
<%= ab_test(:greeting) %> <%= current_user.name %>
|
69
|
+
|
70
|
+
<% ab_test :features do |count| %>
|
71
|
+
<%= count %> features to choose from!
|
72
|
+
<% end %>
|
73
|
+
|
74
|
+
To measure conversion, call ab_goal! with the experiment's name. Typically,
|
75
|
+
you would do that from a controller action, for example:
|
76
|
+
|
77
|
+
def create
|
78
|
+
ab_goal! :new_page
|
79
|
+
...
|
80
|
+
end
|
81
|
+
|
82
|
+
To measure conversion, simply call ab_goal! with the experiment name. From the
|
83
|
+
Vanity identity set by the filter we know which alternative was presented by
|
84
|
+
ab_test, and can correlate conversions to alternative. It's that simple!
|
85
|
+
|
86
|
+
|
87
|
+
== Managing Identity
|
88
|
+
|
89
|
+
For effective A/B tests, you want to:
|
90
|
+
- Randomly show different alternatives to different people
|
91
|
+
- Consistently show the same alternatives to the same person
|
92
|
+
- Know which alternative caused a conversion
|
93
|
+
- When running multiple tests at once, keep them independent
|
94
|
+
|
95
|
+
If you don't use any other mechanism, Vanity will assign a random value to a
|
96
|
+
persistent cookie and use it to track the same visitor on subsequent visits.
|
97
|
+
Cookie tracking is enabled by use_vanity.
|
98
|
+
|
99
|
+
If you keep track of users, you would want to use the user's identity instead.
|
100
|
+
Using user identity is more reliable than a cookie tied to a single Web
|
101
|
+
browser.
|
102
|
+
|
103
|
+
To do that, call use_vanity with the name of a method which returns an object
|
104
|
+
with the desired id attribute. Alternatively, you can use a proc. These two
|
105
|
+
examples are equivalent:
|
106
|
+
|
107
|
+
use_vanity :current_user
|
108
|
+
use_vanity { |controller| controller.current_user.id }
|
109
|
+
|
110
|
+
There are times when you would want to use a different identity to distinguish
|
111
|
+
test alternatives. For example, your application may have groups and you may
|
112
|
+
want to A/B test an option that will be available (or not) to all people in the
|
113
|
+
same group.
|
114
|
+
|
115
|
+
You can tell Vanity to use a different identity on a particular controller
|
116
|
+
using use_vanity. Alternatively, you can configure the experiment to extract
|
117
|
+
the identity. The following example will apply to all controllers that have a
|
118
|
+
project attribute (without affecting other experiments):
|
119
|
+
|
120
|
+
example "New feature" do
|
121
|
+
description "New feature only available to some groups"
|
122
|
+
identify { |controller| controller.project.id }
|
123
|
+
end
|
124
|
+
|
125
|
+
|
126
|
+
== Configuring Vanity
|
127
|
+
|
128
|
+
Vanity will work out of the box on a default configuration. Assuming you're
|
129
|
+
using Redis on localhost, post 6379, there's nothing special to do.
|
130
|
+
|
131
|
+
If you run a different setup, use the playground object to configure Vanity.
|
132
|
+
For example:
|
133
|
+
|
134
|
+
Vanity.playground.host = "redis.local"
|
135
|
+
Vanity.playground.password = "supersecret"
|
136
|
+
|
137
|
+
|
138
|
+
== Credits
|
139
|
+
|
140
|
+
EDD was all Nathaniel Talbott's idea, I had experience tests to finish for
|
141
|
+
Apartly, there was coffee involved and out came the idea for Vanity.
|
142
|
+
|
143
|
+
First experiment, A/B tests, heavily influenced by Patrick McKenzie's awesome
|
144
|
+
A/Bingo (http://www.bingocardcreator.com/abingo)
|
145
|
+
|
146
|
+
Pain points courtesy of Google Analytics's stylish graphs and too-many-clicks
|
147
|
+
goal tracking process.
|
148
|
+
|
149
|
+
|
150
|
+
|
151
|
+
== License
|
152
|
+
|
153
|
+
Vanity, copyright (C) 2009 Assaf Arkin, released under the "Use for good, not evil" license (www.json.org/license.html)
|
data/lib/vanity.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require "redis"
|
2
|
+
require "openssl"
|
3
|
+
|
4
|
+
# All the cool stuff happens in other places:
|
5
|
+
# - Vanity::Helpers
|
6
|
+
# - Vanity::Playground
|
7
|
+
# - Experiment::AbTest
|
8
|
+
module Vanity
|
9
|
+
# Version number.
|
10
|
+
module Version
|
11
|
+
version = Gem::Specification.load(File.expand_path("../vanity.gemspec", File.dirname(__FILE__))).version.to_s.split(".").map { |i| i.to_i }
|
12
|
+
MAJOR = version[0]
|
13
|
+
MINOR = version[1]
|
14
|
+
PATCH = version[2]
|
15
|
+
STRING = "#{MAJOR}.#{MINOR}.#{PATCH}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
require File.join(File.dirname(__FILE__), "vanity/playground")
|
21
|
+
require File.join(File.dirname(__FILE__), "vanity/experiment/base")
|
22
|
+
require File.join(File.dirname(__FILE__), "vanity/experiment/ab_test")
|
23
|
+
require File.join(File.dirname(__FILE__), "vanity/rails") if defined?(Rails)
|
@@ -0,0 +1,170 @@
|
|
1
|
+
module Vanity
|
2
|
+
module Experiment
|
3
|
+
|
4
|
+
# Experiment alternative. See AbTest#alternatives.
|
5
|
+
class Alternative
|
6
|
+
|
7
|
+
def initialize(experiment, id, value) #:nodoc:
|
8
|
+
@experiment = experiment
|
9
|
+
@id = id
|
10
|
+
@value = value
|
11
|
+
end
|
12
|
+
|
13
|
+
# Alternative id, only unique for this experiment.
|
14
|
+
attr_reader :id
|
15
|
+
|
16
|
+
# Alternative value.
|
17
|
+
attr_reader :value
|
18
|
+
|
19
|
+
# Number of participants who viewed this alternative.
|
20
|
+
def participants
|
21
|
+
redis.scard(key("participants")).to_i
|
22
|
+
end
|
23
|
+
|
24
|
+
# Number of participants who converted on this alternative.
|
25
|
+
def converted
|
26
|
+
redis.scard(key("converted")).to_i
|
27
|
+
end
|
28
|
+
|
29
|
+
# Number of conversions for this alternative (same participant may be counted more than once).
|
30
|
+
def conversions
|
31
|
+
redis.get(key("conversions")).to_i
|
32
|
+
end
|
33
|
+
|
34
|
+
def participating!(identity)
|
35
|
+
redis.sadd key("participants"), identity
|
36
|
+
end
|
37
|
+
|
38
|
+
def conversion!(identity)
|
39
|
+
if redis.sismember(key("participants"), identity)
|
40
|
+
redis.sadd key("converted"), identity
|
41
|
+
redis.incr key("conversions")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
protected
|
46
|
+
|
47
|
+
def key(name)
|
48
|
+
@experiment.key("alts:#{id}:#{name}")
|
49
|
+
end
|
50
|
+
|
51
|
+
def redis
|
52
|
+
@experiment.redis
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
# The meat.
|
58
|
+
class AbTest < Base
|
59
|
+
def initialize(*args) #:nodoc:
|
60
|
+
super
|
61
|
+
end
|
62
|
+
|
63
|
+
# Chooses a value for this experiment.
|
64
|
+
#
|
65
|
+
# This method returns different values for different identity (see
|
66
|
+
# #identify), and consistenly the same value for the same
|
67
|
+
# expriment/identity pair.
|
68
|
+
#
|
69
|
+
# For example:
|
70
|
+
# color = experiment(:which_blue).choose
|
71
|
+
def choose
|
72
|
+
identity = identify
|
73
|
+
alt = alternative_for(identity)
|
74
|
+
alt.participating! identity
|
75
|
+
alt.value
|
76
|
+
end
|
77
|
+
|
78
|
+
# Records a conversion.
|
79
|
+
#
|
80
|
+
# For example:
|
81
|
+
# experiment(:which_blue).conversion!
|
82
|
+
def conversion!
|
83
|
+
identity = identify
|
84
|
+
alt = alternative_for(identity)
|
85
|
+
alt.conversion! identity
|
86
|
+
alt.id
|
87
|
+
end
|
88
|
+
|
89
|
+
# Call this method once to specify values for the A/B test. At least two
|
90
|
+
# values are required.
|
91
|
+
#
|
92
|
+
# Call without argument to previously defined alternatives (see Alternative).
|
93
|
+
#
|
94
|
+
# For example:
|
95
|
+
# experiment "Background color" do
|
96
|
+
# alternatives "red", "blue", "orange"
|
97
|
+
# end
|
98
|
+
#
|
99
|
+
# alts = experiment(:background_color).alternatives
|
100
|
+
# puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
|
101
|
+
def alternatives(*args)
|
102
|
+
args = [true, false] if args.empty?
|
103
|
+
@alternatives = []
|
104
|
+
args.each_with_index do |arg, i|
|
105
|
+
@alternatives << Alternative.new(self, i, arg)
|
106
|
+
end
|
107
|
+
class << self ; self ; end.send(:define_method, :alternatives) { @alternatives }
|
108
|
+
alternatives
|
109
|
+
end
|
110
|
+
|
111
|
+
# Sets this test to two alternatives: true and false.
|
112
|
+
def true_false
|
113
|
+
alternatives true, false
|
114
|
+
end
|
115
|
+
|
116
|
+
def report
|
117
|
+
alts = alternatives.map { |alt|
|
118
|
+
"<dt>Option #{(65 + alt.id).chr}</dt><dd><code>#{CGI.escape_html alt.value.inspect}</code> viewed #{alt.participants} times, converted #{alt.conversions}<dd>"
|
119
|
+
}
|
120
|
+
%{<dl class="data">#{alts.join}</dl>}
|
121
|
+
end
|
122
|
+
|
123
|
+
# Forces this experiment to use a particular alternative. Useful for
|
124
|
+
# tests, e.g.
|
125
|
+
#
|
126
|
+
# setup do
|
127
|
+
# experiment(:green_button).select(true)
|
128
|
+
# end
|
129
|
+
#
|
130
|
+
# def test_shows_green_button
|
131
|
+
# . . .
|
132
|
+
# end
|
133
|
+
#
|
134
|
+
# Use nil to clear out selection:
|
135
|
+
# teardown do
|
136
|
+
# experiment(:green_button).select(nil)
|
137
|
+
# end
|
138
|
+
def chooses(value)
|
139
|
+
alternative = alternatives.find { |alt| alt.value == value }
|
140
|
+
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless alternative
|
141
|
+
Vanity.context.session[:vanity] ||= {}
|
142
|
+
Vanity.context.session[:vanity][id] = alternative.id
|
143
|
+
end
|
144
|
+
|
145
|
+
def humanize
|
146
|
+
"A/B Test"
|
147
|
+
end
|
148
|
+
|
149
|
+
def save #:nodoc:
|
150
|
+
fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
|
151
|
+
super
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
# Chooses an alternative for the identity and returns its index. This
|
157
|
+
# method always returns the same alternative for a given experiment and
|
158
|
+
# identity, and randomly distributed alternatives for each identity (in the
|
159
|
+
# same experiment).
|
160
|
+
def alternative_for(identity)
|
161
|
+
session = Vanity.context.session[:vanity]
|
162
|
+
index = session && session[id]
|
163
|
+
index ||= Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % alternatives.count
|
164
|
+
alternatives[index]
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Vanity
|
2
|
+
module Experiment
|
3
|
+
|
4
|
+
# Base class that all experiment types are derived from.
|
5
|
+
class Base
|
6
|
+
|
7
|
+
class << self
|
8
|
+
|
9
|
+
# Returns the type of this class as a symbol (e.g. AbTest becomes
|
10
|
+
# ab_test).
|
11
|
+
def type
|
12
|
+
name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{$1}_#{$2}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{$1}_#{$2}" }.downcase
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(playground, id, name, &block)
|
18
|
+
@playground = playground
|
19
|
+
@id, @name = id.to_sym, name
|
20
|
+
@namespace = "#{@playground.namespace}:#{@id}"
|
21
|
+
created = redis.get(key(:created_at)) || (redis.setnx(key(:created_at), Time.now.to_i) ; redis.get(key(:created_at)))
|
22
|
+
@created_at = Time.at(created.to_i)
|
23
|
+
@identify_block = ->(context){ context.vanity_identity }
|
24
|
+
end
|
25
|
+
|
26
|
+
# Human readable experiment name, supplied during creation.
|
27
|
+
attr_reader :name
|
28
|
+
|
29
|
+
# Unique identifier, derived from name, e.g. "Green Button" become :green_button.
|
30
|
+
attr_reader :id
|
31
|
+
|
32
|
+
# Experiment creation timestamp.
|
33
|
+
attr_reader :created_at
|
34
|
+
|
35
|
+
# Sets or returns description. For example
|
36
|
+
# experiment :simple do
|
37
|
+
# description "Simple experiment"
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# puts "Just defined: " + experiment(:simple).description
|
41
|
+
def description(text = nil)
|
42
|
+
@description = text if text
|
43
|
+
@description
|
44
|
+
end
|
45
|
+
|
46
|
+
def report
|
47
|
+
fail "Implement me"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Called to save the experiment definition.
|
51
|
+
def save #:nodoc:
|
52
|
+
end
|
53
|
+
|
54
|
+
# Call this method with no argument or block to return an identity. Call
|
55
|
+
# this method with a block to define how to obtain an identity for the
|
56
|
+
# current experiment.
|
57
|
+
#
|
58
|
+
# For example, this experiment use the identity of the project associated
|
59
|
+
# with the controller:
|
60
|
+
#
|
61
|
+
# class ProjectController < ApplicationController
|
62
|
+
# before_filter :set_project
|
63
|
+
# attr_reader :project
|
64
|
+
#
|
65
|
+
# . . .
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
# experiment "Project widget" do
|
69
|
+
# alternatives :small, :medium, :large
|
70
|
+
# identify do |controller|
|
71
|
+
# controller.project.id
|
72
|
+
# end
|
73
|
+
# end
|
74
|
+
def identify(&block)
|
75
|
+
if block_given?
|
76
|
+
@identify_block = block
|
77
|
+
self
|
78
|
+
else
|
79
|
+
@identify_block.call(Vanity.context) or fail "No identity found"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Returns key for this experiment, or with an argument, return a key
|
84
|
+
# using the experiment as the namespace. Examples:
|
85
|
+
# key => "vanity:experiments:green_button"
|
86
|
+
# key("participants") => "vanity:experiments:green_button:participants"
|
87
|
+
def key(name = nil) #:nodoc:
|
88
|
+
name ? "#{@namespace}:#{name}" : @namespace
|
89
|
+
end
|
90
|
+
|
91
|
+
# Shortcut for Vanity.playground.redis
|
92
|
+
def redis #:nodoc:
|
93
|
+
@playground.redis
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
@@ -0,0 +1,127 @@
|
|
1
|
+
require "active_support"
|
2
|
+
|
3
|
+
module Vanity
|
4
|
+
|
5
|
+
# Vanity.playground.configuration
|
6
|
+
class Configuration
|
7
|
+
end
|
8
|
+
|
9
|
+
# Playground catalogs all your experiments, holds the Vanity configuration.
|
10
|
+
# For example:
|
11
|
+
# Vanity.playground.logger = my_logger
|
12
|
+
# puts Vanity.playground.map(&:name)
|
13
|
+
class Playground
|
14
|
+
|
15
|
+
# Created new Playground. Unless you need to, use the global Vanity.playground.
|
16
|
+
def initialize
|
17
|
+
@experiments = {}
|
18
|
+
@namespace = "vanity:#{Vanity::Version::MAJOR}"
|
19
|
+
@load_path = "experiments"
|
20
|
+
end
|
21
|
+
|
22
|
+
# Redis host name. Default is 127.0.0.1
|
23
|
+
attr_accessor :host
|
24
|
+
|
25
|
+
# Redis port number. Default is 6379.
|
26
|
+
attr_accessor :port
|
27
|
+
|
28
|
+
# Redis database number. Default is 0.
|
29
|
+
attr_accessor :db
|
30
|
+
|
31
|
+
# Redis database password.
|
32
|
+
attr_accessor :password
|
33
|
+
|
34
|
+
# Namespace for database keys. Default is vanity:n, where n is the major release number, e.g. vanity:1 for 1.0.3.
|
35
|
+
attr_accessor :namespace
|
36
|
+
|
37
|
+
# Path to load experiment files from.
|
38
|
+
attr_accessor :load_path
|
39
|
+
|
40
|
+
# Logger.
|
41
|
+
attr_accessor :logger
|
42
|
+
|
43
|
+
# Defines a new experiment. Generally, do not call this directly,
|
44
|
+
# use #experiment instead.
|
45
|
+
def define(name, options = nil, &block)
|
46
|
+
id = name.to_s.downcase.gsub(/\W/, "_")
|
47
|
+
raise "Experiment #{id} already defined once" if @experiments[id]
|
48
|
+
options ||= {}
|
49
|
+
type = options[:type] || :ab_test
|
50
|
+
klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
|
51
|
+
experiment = klass.new(self, id, name)
|
52
|
+
experiment.instance_eval &block
|
53
|
+
experiment.save
|
54
|
+
@experiments[id] = experiment
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns the named experiment. You may not have guessed, but this method
|
58
|
+
# raises an exception if it cannot load the experiment's definition.
|
59
|
+
#
|
60
|
+
# Experiment names are always mapped by downcasing them and replacing
|
61
|
+
# non-word characters with underscores, so "Green call to action" becomes
|
62
|
+
# "green_call_to_action". You can also use a symbol if you feel like it.
|
63
|
+
def experiment(name)
|
64
|
+
id = name.to_s.downcase.gsub(/\W/, "_")
|
65
|
+
unless @experiments.has_key?(id)
|
66
|
+
require File.join(load_path, id)
|
67
|
+
end
|
68
|
+
@experiments[id] or fail LoadError, "Expected experiments/#{id}.rb to define experiment #{name}"
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns list of all loaded experiments.
|
72
|
+
def experiments
|
73
|
+
Dir[File.join(load_path, "*.rb")].each do |file|
|
74
|
+
require file
|
75
|
+
end
|
76
|
+
@experiments.values
|
77
|
+
end
|
78
|
+
|
79
|
+
# Use this instance to access the Redis database.
|
80
|
+
def redis
|
81
|
+
redis = Redis.new(host: self.host, port: self.port, db: self.db,
|
82
|
+
password: self.password, logger: self.logger)
|
83
|
+
class << self ; self ; end.send(:define_method, :redis) { redis }
|
84
|
+
redis
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
@playground = Playground.new
|
90
|
+
# Returns the playground instance.
|
91
|
+
def self.playground
|
92
|
+
@playground
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns the Vanity context. For example, when using Rails this would be
|
96
|
+
# the current controller, which can be used to get/set the vanity identity.
|
97
|
+
def self.context
|
98
|
+
Thread.current[:vanity_context]
|
99
|
+
end
|
100
|
+
|
101
|
+
# Sets the Vanity context. For example, when using Rails this would be
|
102
|
+
# set by the set_vanity_context before filter (via use_vanity).
|
103
|
+
def self.context=(context)
|
104
|
+
Thread.current[:vanity_context] = context
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
class Object
|
110
|
+
|
111
|
+
# Use this method to define or access an experiment.
|
112
|
+
#
|
113
|
+
# To define an experiment, call with a name, options and a block. For
|
114
|
+
# example:
|
115
|
+
# experiment "Text size" do
|
116
|
+
# alternatives :small, :medium, :large
|
117
|
+
# end
|
118
|
+
#
|
119
|
+
# puts experiment(:text_size).alternatives
|
120
|
+
def experiment(name, options = nil, &block)
|
121
|
+
if block
|
122
|
+
Vanity.playground.define(name, options, &block)
|
123
|
+
else
|
124
|
+
Vanity.playground.experiment(name)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
data/lib/vanity/rails.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), "../vanity")
|
2
|
+
require File.join(File.dirname(__FILE__), "rails/helpers")
|
3
|
+
require File.join(File.dirname(__FILE__), "rails/testing")
|
4
|
+
|
5
|
+
# Use Rails logger by default.
|
6
|
+
Vanity.playground.logger ||= ActionController::Base.logger
|
7
|
+
Vanity.playground.load_path = "#{RAILS_ROOT}/experiments"
|
8
|
+
|
9
|
+
# Include in controller, add view helper methods.
|
10
|
+
ActionController::Base.class_eval do
|
11
|
+
include Vanity::Rails
|
12
|
+
helper Vanity::Rails
|
13
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Vanity
|
2
|
+
# Helper methods for use in your controllers.
|
3
|
+
#
|
4
|
+
# 1) Use Vanity from within your controller:
|
5
|
+
#
|
6
|
+
# class ApplicationController < ActionController::Base
|
7
|
+
# use_vanity :current_user end
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# 2) Present different options for an A/B test:
|
11
|
+
#
|
12
|
+
# Get started for only $<%= ab_test :pricing %> a month!
|
13
|
+
#
|
14
|
+
# 3) Measure conversion:
|
15
|
+
#
|
16
|
+
# def signup
|
17
|
+
# ab_goal! :pricing
|
18
|
+
# . . .
|
19
|
+
# end
|
20
|
+
module Rails
|
21
|
+
module ClassMethods
|
22
|
+
|
23
|
+
# Defines the vanity_identity method, and the set_identity_context before
|
24
|
+
# filter.
|
25
|
+
#
|
26
|
+
# Single argument names a method that returns an object whose identity is
|
27
|
+
# the vanity identity. Identity is used to present an experiment
|
28
|
+
# consistently to the same person or people. It can be the user's
|
29
|
+
# identity, group, project. The object must provide its identity in
|
30
|
+
# response to the method +id+.
|
31
|
+
#
|
32
|
+
# For example, if +current_user+ returns a +User+ object, then to use the
|
33
|
+
# user's id:
|
34
|
+
# class ApplicationController < ActionController::Base
|
35
|
+
# use_vanity :current_user
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# If that method returns nil (e.g. the user has not signed in), a random
|
39
|
+
# value will be used, instead. That random value is maintained using a
|
40
|
+
# cookie.
|
41
|
+
#
|
42
|
+
# Alternatively, if you call use_vanity with a block, it will yield to the
|
43
|
+
# block with controller.
|
44
|
+
#
|
45
|
+
# If there is no identity you can use, call use_vanity with the value +nil+.
|
46
|
+
#
|
47
|
+
# For example:
|
48
|
+
# class ApplicationController < ActionController::Base
|
49
|
+
# use_vanity :current_user
|
50
|
+
# end
|
51
|
+
#
|
52
|
+
# class ProjectController < ApplicationController
|
53
|
+
# use_vanity { |controller| controller.project_id }
|
54
|
+
# end
|
55
|
+
def use_vanity(symbol = nil, &block)
|
56
|
+
define_method :vanity_identity do
|
57
|
+
return @vanity_identity if @vanity_identity
|
58
|
+
if block
|
59
|
+
@vanity_identity = block.call(self)
|
60
|
+
elsif symbol && object = send(symbol)
|
61
|
+
@vanity_identity = object.id
|
62
|
+
else
|
63
|
+
@vanity_identity = cookies["vanity_id"] || OpenSSL::Random.random_bytes(16).unpack("H*")[0]
|
64
|
+
cookies["vanity_id"] = { value: @vanity_identity, expires: 1.month.from_now }
|
65
|
+
@vanity_identity
|
66
|
+
end
|
67
|
+
end
|
68
|
+
define_method :set_vanity_context do
|
69
|
+
Vanity.context = self
|
70
|
+
end
|
71
|
+
before_filter :set_vanity_context
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.included(base) #:nodoc:
|
76
|
+
base.extend ClassMethods
|
77
|
+
end
|
78
|
+
|
79
|
+
# This method returns one of the alternative values in the named A/B test.
|
80
|
+
#
|
81
|
+
# Examples using ab_test inside controller:
|
82
|
+
# def index
|
83
|
+
# if ab_test(:new_page) # true/false test
|
84
|
+
# render action: "new_page"
|
85
|
+
# else
|
86
|
+
# render action: "index"
|
87
|
+
# end
|
88
|
+
# end
|
89
|
+
#
|
90
|
+
# def index
|
91
|
+
# render action: ab_test(:new_page) # alternatives are page names
|
92
|
+
# end
|
93
|
+
#
|
94
|
+
# Examples using ab_test inside view:
|
95
|
+
# <%= if ab_test(:banner) %>100% less complexity!<% end %>
|
96
|
+
#
|
97
|
+
# <%= ab_test(:greeting) %> <%= current_user.name %>
|
98
|
+
#
|
99
|
+
# <% ab_test :features do |count| %>
|
100
|
+
# <%= count %> features to choose from!
|
101
|
+
# <% end %>
|
102
|
+
def ab_test(name, &block)
|
103
|
+
value = Vanity.playground.experiment(name).choose
|
104
|
+
if block
|
105
|
+
content = capture(value, &block)
|
106
|
+
block_called_from_erb?(block) ? concat(content) : content
|
107
|
+
else
|
108
|
+
value
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# This method records conversion on the named A/B test. For example:
|
113
|
+
# def create
|
114
|
+
# ab_goal! :call_to_action
|
115
|
+
# Acccount.create! params[:account]
|
116
|
+
# end
|
117
|
+
def ab_goal!(name)
|
118
|
+
Vanity.playground.experiment(name).conversion!
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module ActionController #:nodoc:
|
2
|
+
class TestCase
|
3
|
+
alias :setup_controller_request_and_response_without_vanity :setup_controller_request_and_response
|
4
|
+
def setup_controller_request_and_response
|
5
|
+
setup_controller_request_and_response_without_vanity
|
6
|
+
Vanity.context = @request
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
<style>
|
2
|
+
ol.experiments { list-style: none; margin: 1em 0; padding: 0; border-bottom: 1px solid #ccc }
|
3
|
+
ol.experiments .hide { display: none }
|
4
|
+
li.experiment h3 { margin: 0 }
|
5
|
+
li.experiment blockquote { margin: 0 }
|
6
|
+
li.experiment .meta { color: #444 }
|
7
|
+
li.experiment .data dt { float: left }
|
8
|
+
li.experiment .green { color: green }
|
9
|
+
li.experiment .red { color: red }
|
10
|
+
li.experiment .data dd { margin: 0 0 .3em 6em }
|
11
|
+
</style>
|
12
|
+
<h2>Experiments</h2>
|
13
|
+
<ol class="experiments">
|
14
|
+
<% experiments.sort_by(&:created_at).each do |exp| %>
|
15
|
+
<li class="experiment" id="experiment_<%= CGI.escape exp.id.to_s %>">
|
16
|
+
<h3><%= CGI.escape_html exp.name %></h3>
|
17
|
+
<blockquote><%= CGI.escape_html exp.description.to_s %></blockquote>
|
18
|
+
<%= exp.report %>
|
19
|
+
<p class="meta"><%= exp.humanize %> started <%= exp.created_at.strftime("%a, %b %-d %Y") %></p>
|
20
|
+
</li>
|
21
|
+
<% end %>
|
22
|
+
</ol>
|
@@ -0,0 +1,190 @@
|
|
1
|
+
require "test/test_helper"
|
2
|
+
|
3
|
+
class AbTestController < ActionController::Base
|
4
|
+
use_vanity :current_user
|
5
|
+
attr_accessor :current_user
|
6
|
+
|
7
|
+
def test_render
|
8
|
+
render text: ab_test(:simple_ab)
|
9
|
+
end
|
10
|
+
|
11
|
+
def test_view
|
12
|
+
render inline: "<%= ab_test(:simple_ab) %>"
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_capture
|
16
|
+
render file: File.join(File.dirname(__FILE__), "ab_test_template.erb")
|
17
|
+
end
|
18
|
+
|
19
|
+
def goal
|
20
|
+
ab_goal! :simple_ab
|
21
|
+
render text: ""
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
class AbTestTest < ActionController::TestCase
|
27
|
+
tests AbTestController
|
28
|
+
def setup
|
29
|
+
experiment(:simple_ab) { }
|
30
|
+
end
|
31
|
+
|
32
|
+
# Experiment definition
|
33
|
+
|
34
|
+
def uses_ab_test_when_type_is_ab_test
|
35
|
+
experiment(:ab, type: :ab_test) { }
|
36
|
+
assert_instance_of Vanity::Experiment::AbTest, experiment(:ab)
|
37
|
+
end
|
38
|
+
|
39
|
+
def requires_at_least_two_alternatives_per_experiment
|
40
|
+
assert_raises RuntimeError do
|
41
|
+
experiment :none, type: :ab_test do
|
42
|
+
alternatives []
|
43
|
+
end
|
44
|
+
end
|
45
|
+
assert_raises RuntimeError do
|
46
|
+
experiment :one, type: :ab_test do
|
47
|
+
alternatives "foo"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
experiment :two, type: :ab_test do
|
51
|
+
alternatives "foo", "bar"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Running experiment
|
56
|
+
|
57
|
+
def returns_the_same_alternative_consistently
|
58
|
+
experiment :foobar do
|
59
|
+
alternatives "foo", "bar"
|
60
|
+
identify { "6e98ec" }
|
61
|
+
end
|
62
|
+
assert value = experiment(:foobar).choose
|
63
|
+
assert_match /foo|bar/, value
|
64
|
+
1000.times do
|
65
|
+
assert_equal value, experiment(:foobar).choose
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def returns_different_alternatives_for_each_participant
|
70
|
+
experiment :foobar do
|
71
|
+
alternatives "foo", "bar"
|
72
|
+
identify { rand(1000).to_s }
|
73
|
+
end
|
74
|
+
alts = Array.new(1000) { experiment(:foobar).choose }
|
75
|
+
assert_equal %w{bar foo}, alts.uniq.sort
|
76
|
+
assert_in_delta alts.select { |a| a == "foo" }.count, 500, 100 # this may fail, such is propability
|
77
|
+
end
|
78
|
+
|
79
|
+
def records_all_participants_in_each_alternative
|
80
|
+
ids = (Array.new(200) { |i| i.to_s } * 5).shuffle
|
81
|
+
experiment :foobar do
|
82
|
+
alternatives "foo", "bar"
|
83
|
+
identify { ids.pop }
|
84
|
+
end
|
85
|
+
1000.times { experiment(:foobar).choose }
|
86
|
+
alts = experiment(:foobar).alternatives
|
87
|
+
assert_equal 200, alts.inject(0) { |total,alt| total + alt.participants }
|
88
|
+
assert_in_delta alts.first.participants, 100, 20
|
89
|
+
end
|
90
|
+
|
91
|
+
def records_each_converted_participant_only_once
|
92
|
+
ids = (Array.new(100) { |i| i.to_s } * 5).shuffle
|
93
|
+
test = self
|
94
|
+
experiment :foobar do
|
95
|
+
alternatives "foo", "bar"
|
96
|
+
identify { test.identity ||= ids.pop }
|
97
|
+
end
|
98
|
+
500.times do
|
99
|
+
test.identity = nil
|
100
|
+
experiment(:foobar).choose
|
101
|
+
experiment(:foobar).conversion!
|
102
|
+
end
|
103
|
+
alts = experiment(:foobar).alternatives
|
104
|
+
assert_equal 100, alts.inject(0) { |total,alt| total + alt.converted }
|
105
|
+
end
|
106
|
+
|
107
|
+
def test_records_conversion_only_for_participants
|
108
|
+
test = self
|
109
|
+
experiment :foobar do
|
110
|
+
alternatives "foo", "bar"
|
111
|
+
identify { test.identity ||= rand(100).to_s }
|
112
|
+
end
|
113
|
+
1000.times do
|
114
|
+
test.identity = nil
|
115
|
+
experiment(:foobar).choose
|
116
|
+
experiment(:foobar).conversion!
|
117
|
+
test.identity << "!"
|
118
|
+
experiment(:foobar).conversion!
|
119
|
+
end
|
120
|
+
alts = experiment(:foobar).alternatives
|
121
|
+
assert_equal 100, alts.inject(0) { |t,a| t + a.converted }
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
# A/B helper methods
|
126
|
+
|
127
|
+
def test_fail_if_no_experiment
|
128
|
+
new_playground
|
129
|
+
assert_raise MissingSourceFile do
|
130
|
+
get :test_render
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def test_ab_test_chooses_in_render
|
135
|
+
responses = Array.new(100) do
|
136
|
+
@controller = nil ; setup_controller_request_and_response
|
137
|
+
get :test_render
|
138
|
+
@response.body
|
139
|
+
end
|
140
|
+
assert_equal %w{false true}, responses.uniq.sort
|
141
|
+
end
|
142
|
+
|
143
|
+
def test_ab_test_chooses_view_helper
|
144
|
+
responses = Array.new(100) do
|
145
|
+
@controller = nil ; setup_controller_request_and_response
|
146
|
+
get :test_view
|
147
|
+
@response.body
|
148
|
+
end
|
149
|
+
assert_equal %w{false true}, responses.uniq.sort
|
150
|
+
end
|
151
|
+
|
152
|
+
def test_ab_test_with_capture
|
153
|
+
responses = Array.new(100) do
|
154
|
+
@controller = nil ; setup_controller_request_and_response
|
155
|
+
get :test_capture
|
156
|
+
@response.body
|
157
|
+
end
|
158
|
+
assert_equal %w{false true}, responses.map(&:strip).uniq.sort
|
159
|
+
end
|
160
|
+
|
161
|
+
def test_ab_test_goal
|
162
|
+
responses = Array.new(100) do
|
163
|
+
@controller.send(:cookies).clear
|
164
|
+
get :goal
|
165
|
+
@response.body
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
|
170
|
+
# Testing with tests
|
171
|
+
|
172
|
+
def test_with_given_choice
|
173
|
+
100.times do
|
174
|
+
@controller = nil ; setup_controller_request_and_response
|
175
|
+
experiment(:simple_ab).chooses(true)
|
176
|
+
get :test_render
|
177
|
+
post :goal
|
178
|
+
end
|
179
|
+
alts = experiment(:simple_ab).alternatives
|
180
|
+
assert_equal [100,0], alts.map { |alt| alt.participants }
|
181
|
+
assert_equal [100,0], alts.map { |alt| alt.conversions }
|
182
|
+
end
|
183
|
+
|
184
|
+
def test_which_chooses_non_existent_alternative
|
185
|
+
assert_raises ArgumentError do
|
186
|
+
experiment(:simple_ab).chooses(404)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "test/test_helper"
|
2
|
+
|
3
|
+
class ExperimentTest < MiniTest::Spec
|
4
|
+
it "creates ID from name" do
|
5
|
+
exp = experiment("Green Button/Alert") { }
|
6
|
+
assert_equal "Green Button/Alert", exp.name
|
7
|
+
assert_equal :green_button_alert, exp.id
|
8
|
+
end
|
9
|
+
|
10
|
+
it "evalutes definition block at creation" do
|
11
|
+
experiment :green_button do
|
12
|
+
expects(:xmts).returns("x")
|
13
|
+
end
|
14
|
+
assert_equal "x", experiment(:green_button).xmts
|
15
|
+
end
|
16
|
+
|
17
|
+
it "saves experiments after defining it" do
|
18
|
+
experiment :green_button do
|
19
|
+
expects(:save)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it "stores when experiment created" do
|
24
|
+
experiment(:simple) { }
|
25
|
+
assert_instance_of Time, experiment(:simple).created_at
|
26
|
+
assert_in_delta experiment(:simple).created_at.to_i, Time.now.to_i, 1
|
27
|
+
end
|
28
|
+
|
29
|
+
it "keeps creation timestamp across definitions" do
|
30
|
+
early = Time.now - 1.day
|
31
|
+
Time.expects(:now).once.returns(early)
|
32
|
+
experiment(:simple) { }
|
33
|
+
assert_equal early.to_i, experiment(:simple).created_at.to_i
|
34
|
+
new_playground
|
35
|
+
experiment(:simple) { }
|
36
|
+
assert_equal early.to_i, experiment(:simple).created_at.to_i
|
37
|
+
end
|
38
|
+
|
39
|
+
it "has description" do
|
40
|
+
experiment :simple do
|
41
|
+
description "Simple experiment"
|
42
|
+
end
|
43
|
+
assert_equal "Simple experiment", experiment(:simple).description
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require "test/test_helper"
|
2
|
+
|
3
|
+
class PlaygroundTest < MiniTest::Spec
|
4
|
+
before do
|
5
|
+
@namespace = "vanity:0"
|
6
|
+
end
|
7
|
+
|
8
|
+
it "has one global instance" do
|
9
|
+
assert instance = Vanity.playground
|
10
|
+
assert_equal instance, Vanity.playground
|
11
|
+
end
|
12
|
+
|
13
|
+
it "use vanity-{major} as default namespace" do
|
14
|
+
assert @namespace, Vanity.playground.namespace
|
15
|
+
end
|
16
|
+
|
17
|
+
it "fails if it cannot load named experiment from file" do
|
18
|
+
assert_raises MissingSourceFile do
|
19
|
+
experiment("Green button")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it "loads named experiment from experiments directory" do
|
24
|
+
Vanity.playground.expects(:require).with("experiments/green_button")
|
25
|
+
begin
|
26
|
+
experiment("Green button")
|
27
|
+
rescue LoadError=>ex
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it "complains if experiment not defined in expected filed" do
|
32
|
+
Vanity.playground.expects(:require).with("experiments/green_button")
|
33
|
+
assert_raises LoadError do
|
34
|
+
experiment("Green button")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
it "returns experiment defined in file" do
|
39
|
+
playground = class << Vanity.playground ; self ; end
|
40
|
+
playground.send :define_method, :require do |file|
|
41
|
+
Vanity.playground.define "Green Button" do
|
42
|
+
def xmts ; "x" ; end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
assert_equal "x", experiment("Green button").xmts
|
46
|
+
end
|
47
|
+
|
48
|
+
it "can define and access experiment using symbol" do
|
49
|
+
assert green = experiment("Green Button") { }
|
50
|
+
assert_equal green, experiment(:green_button)
|
51
|
+
assert red = experiment(:red_button) { }
|
52
|
+
assert_equal red, experiment("Red Button")
|
53
|
+
end
|
54
|
+
|
55
|
+
it "detect and fail when defining the same experiment twice" do
|
56
|
+
experiment("Green Button") { }
|
57
|
+
assert_raises RuntimeError do
|
58
|
+
experiment(:green_button) { }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
it "uses playground namespace in experiment" do
|
63
|
+
experiment(:green_button) { }
|
64
|
+
assert_equal "#{@namespace}:green_button", experiment(:green_button).send(:key)
|
65
|
+
assert_equal "#{@namespace}:green_button:participants", experiment(:green_button).send(:key, "participants")
|
66
|
+
end
|
67
|
+
end
|
data/test/rails_test.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
require "test/test_helper"
|
2
|
+
|
3
|
+
class UseVanityController < ActionController::Base
|
4
|
+
attr_accessor :current_user
|
5
|
+
|
6
|
+
def index
|
7
|
+
render text: ab_test(:simple)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
# Pages accessible to everyone, e.g. sign in, community search.
|
12
|
+
class UseVanityTest < ActionController::TestCase
|
13
|
+
tests UseVanityController
|
14
|
+
|
15
|
+
def setup
|
16
|
+
experiment :simple do
|
17
|
+
end
|
18
|
+
UseVanityController.class_eval do
|
19
|
+
use_vanity :current_user
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_vanity_cookie_is_persistent
|
24
|
+
get :index
|
25
|
+
assert cookie = @response["Set-Cookie"].find { |c| c[/^vanity_id=/] }
|
26
|
+
assert expires = cookie[/vanity_id=[a-f0-9]{32}; path=\/; expires=(.*)(;|$)/, 1]
|
27
|
+
assert_in_delta Time.parse(expires), Time.now + 1.month, 1.minute
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_vanity_cookie_default_id
|
31
|
+
get :index
|
32
|
+
assert_match cookies['vanity_id'], /^[a-f0-9]{32}$/
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_vanity_cookie_retains_id
|
36
|
+
@request.cookies['vanity_id'] = "from_last_time"
|
37
|
+
get :index
|
38
|
+
assert_equal "from_last_time", cookies['vanity_id']
|
39
|
+
end
|
40
|
+
|
41
|
+
def test_vanity_identity_set_from_cookie
|
42
|
+
@request.cookies['vanity_id'] = "from_last_time"
|
43
|
+
get :index
|
44
|
+
assert_equal "from_last_time", @controller.send(:vanity_identity)
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_vanity_identity_set_from_user
|
48
|
+
@controller.current_user = mock("user", id: "user_id")
|
49
|
+
get :index
|
50
|
+
assert_equal "user_id", @controller.send(:vanity_identity)
|
51
|
+
end
|
52
|
+
|
53
|
+
def test_vanity_identity_with_no_user_model
|
54
|
+
UseVanityController.class_eval do
|
55
|
+
use_vanity nil
|
56
|
+
end
|
57
|
+
@controller.current_user = Object.new
|
58
|
+
get :index
|
59
|
+
assert_match cookies['vanity_id'], /^[a-f0-9]{32}$/
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_vanity_identity_set_with_block
|
63
|
+
UseVanityController.class_eval do
|
64
|
+
attr_accessor :project_id
|
65
|
+
use_vanity { |controller| controller.project_id }
|
66
|
+
end
|
67
|
+
@controller.project_id = "576"
|
68
|
+
get :index
|
69
|
+
assert_equal "576", @controller.send(:vanity_identity)
|
70
|
+
end
|
71
|
+
|
72
|
+
def teardown
|
73
|
+
UseVanityController.send(:filter_chain).clear
|
74
|
+
nuke_playground
|
75
|
+
end
|
76
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
$LOAD_PATH.delete_if { |path| path[/gems\/vanity-\d/] }
|
2
|
+
$LOAD_PATH.unshift File.expand_path("../lib", File.dirname(__FILE__))
|
3
|
+
RAILS_ROOT = File.expand_path("..")
|
4
|
+
require "minitest/spec"
|
5
|
+
require "mocha"
|
6
|
+
require "action_controller"
|
7
|
+
require "action_controller/test_case"
|
8
|
+
require "lib/vanity/rails"
|
9
|
+
MiniTest::Unit.autorun
|
10
|
+
|
11
|
+
class MiniTest::Unit::TestCase
|
12
|
+
# Call this on teardown. It wipes put the playground and any state held in it
|
13
|
+
# (mostly experiments), resets vanity ID, and clears Redis of all experiments.
|
14
|
+
def nuke_playground
|
15
|
+
Vanity.playground.redis.flushdb
|
16
|
+
new_playground
|
17
|
+
self.identity = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
# Call this if you need a new playground, e.g. to re-define the same experiment,
|
21
|
+
# or reload an experiment (saved by the previous playground).
|
22
|
+
def new_playground
|
23
|
+
Vanity.instance_variable_set :@playground, Vanity::Playground.new
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_accessor :identity # pass identity to/from experiment/test case
|
27
|
+
|
28
|
+
def teardown
|
29
|
+
nuke_playground
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
ActionController::Routing::Routes.draw do |map|
|
34
|
+
map.connect ':controller/:action/:id'
|
35
|
+
end
|
data/vanity.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = "vanity"
|
3
|
+
spec.version = "0.2.0"
|
4
|
+
spec.author = "Assaf Arkin"
|
5
|
+
spec.email = "assaf@labnotes.org"
|
6
|
+
spec.homepage = "http://github.com/assaf/vanity"
|
7
|
+
spec.summary = "Experience Driven Development framework for Rails"
|
8
|
+
spec.description = ""
|
9
|
+
#spec.post_install_message = "To get started run vanity --help"
|
10
|
+
|
11
|
+
spec.files = Dir["{bin,lib,rails,test}/**/*", "CHANGELOG", "README.rdoc", "vanity.gemspec"]
|
12
|
+
|
13
|
+
spec.has_rdoc = true
|
14
|
+
spec.extra_rdoc_files = "README.rdoc", "CHANGELOG"
|
15
|
+
spec.rdoc_options = "--title", "Vanity #{spec.version}", "--main", "README.rdoc",
|
16
|
+
"--webcvs", "http://github.com/assaf/#{spec.name}"
|
17
|
+
|
18
|
+
spec.add_dependency "redis", "0.1"
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: vanity
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Assaf Arkin
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-10 00:00:00 -08:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: redis
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - "="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0.1"
|
24
|
+
version:
|
25
|
+
description: ""
|
26
|
+
email: assaf@labnotes.org
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files:
|
32
|
+
- README.rdoc
|
33
|
+
- CHANGELOG
|
34
|
+
files:
|
35
|
+
- lib/vanity/experiment/ab_test.rb
|
36
|
+
- lib/vanity/experiment/base.rb
|
37
|
+
- lib/vanity/playground.rb
|
38
|
+
- lib/vanity/rails/helpers.rb
|
39
|
+
- lib/vanity/rails/testing.rb
|
40
|
+
- lib/vanity/rails.rb
|
41
|
+
- lib/vanity/report.erb
|
42
|
+
- lib/vanity.rb
|
43
|
+
- test/ab_test_template.erb
|
44
|
+
- test/ab_test_test.rb
|
45
|
+
- test/experiment_test.rb
|
46
|
+
- test/playground_test.rb
|
47
|
+
- test/rails_test.rb
|
48
|
+
- test/test_helper.rb
|
49
|
+
- CHANGELOG
|
50
|
+
- README.rdoc
|
51
|
+
- vanity.gemspec
|
52
|
+
has_rdoc: true
|
53
|
+
homepage: http://github.com/assaf/vanity
|
54
|
+
licenses: []
|
55
|
+
|
56
|
+
post_install_message:
|
57
|
+
rdoc_options:
|
58
|
+
- --title
|
59
|
+
- Vanity 0.2.0
|
60
|
+
- --main
|
61
|
+
- README.rdoc
|
62
|
+
- --webcvs
|
63
|
+
- http://github.com/assaf/vanity
|
64
|
+
require_paths:
|
65
|
+
- lib
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: "0"
|
71
|
+
version:
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: "0"
|
77
|
+
version:
|
78
|
+
requirements: []
|
79
|
+
|
80
|
+
rubyforge_project:
|
81
|
+
rubygems_version: 1.3.5
|
82
|
+
signing_key:
|
83
|
+
specification_version: 3
|
84
|
+
summary: Experience Driven Development framework for Rails
|
85
|
+
test_files: []
|
86
|
+
|