vanity 0.2.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 +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
|
+
|