absurdity 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ *.sw?
6
+ dump.rdb
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm ruby-1.9.2@absurdity
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in absurdity.gemspec
4
+ gemspec
data/README.rdoc ADDED
@@ -0,0 +1,117 @@
1
+ =Absurdity
2
+
3
+ A rails gem for absurdly simple a/b testing
4
+
5
+ http://github.com/xing/absurdity
6
+
7
+ ==Prerequisites
8
+
9
+ Redis (tested with 1.2.6, 2.2.2)
10
+ Rails (tested with 3.0.10, 3.1.0)
11
+
12
+ ==Installation
13
+
14
+ gem "absurdity"
15
+
16
+ ==Usage
17
+
18
+ ===Instantiate a Redis client
19
+
20
+ Put this in an initializer, for expample config/initializers/redis_absurdity.rb:
21
+
22
+ Absurdity.redis = Redis.new(:host => "localhost", :port => 6379)
23
+
24
+ Note: More often you will use a redis.conf to set up your redis client, the important part is to assign your
25
+ redis client instance to Absurdity.redis .
26
+ Don't forget to start your Redis server.
27
+
28
+ ===Define Experiments
29
+
30
+ mkdir "absurdity" in your rails app root directory
31
+ touch "absurdity/experiments.yml"
32
+
33
+ define your experiments in this file:
34
+ :experiments:
35
+ :wild_side:
36
+ :metrics:
37
+ - :fun
38
+ - :hangover
39
+ :variants:
40
+ - :with_music
41
+ - :without_music
42
+ :walk:
43
+ :metrics:
44
+ - :sun
45
+ - :rain
46
+ :variants:
47
+ - :slow
48
+ - :fast
49
+ :completed:
50
+ :fast
51
+
52
+ :wild_side will be the name of your experiment
53
+ [:fun, :hangover] will be the metrics you wish to track
54
+ [:with_music, :without_music] will be the variants you wish to vary
55
+
56
+ the :walk has been marked as completed.
57
+ You should identify which variant to show here (:fast).
58
+ Now everyone will only see the :fast variant.
59
+ And, no one will produce any metric tracking.
60
+
61
+ Note: You can't use the shortform for declaring an array of symbols in ruby 1.9.2 and Psych.
62
+ So instead of using :metrics: [:fun, :hangover] use the longer form shown above.
63
+
64
+ ===Usage in a controller
65
+
66
+ def create
67
+ Absurdity.track! :fun, :wild_side, current_user.id
68
+ end
69
+
70
+ ===Usage in view/helpers
71
+
72
+ def my_helper
73
+ variant = Absurdity.variant :wild_side, current_user.id
74
+ if variant == :with_music
75
+ Play.music
76
+ else
77
+ Play.nothing
78
+ end
79
+ end
80
+
81
+ ===View Metrics
82
+
83
+ /absurdities => linked list of experiments
84
+ /absurdities/experiment_name => show experiment
85
+
86
+ ==Authors
87
+
88
+ {Tim Payton}[http://github.com/dizzy42]
89
+ {Ömür Özkir}[http://github.com/oem]
90
+
91
+ Please find out more about our work in our
92
+ {tech blog}[http://blog.xing.com/category/english/tech-blog].
93
+
94
+
95
+ ==License
96
+
97
+ The MIT License
98
+
99
+ Copyright (c) 2011 {XING AG}[http://www.xing.com/]
100
+
101
+ Permission is hereby granted, free of charge, to any person obtaining a copy
102
+ of this software and associated documentation files (the "Software"), to deal
103
+ in the Software without restriction, including without limitation the rights
104
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
105
+ copies of the Software, and to permit persons to whom the Software is
106
+ furnished to do so, subject to the following conditions:
107
+
108
+ The above copyright notice and this permission notice shall be included in
109
+ all copies or substantial portions of the Software.
110
+
111
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
112
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
113
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
114
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
115
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
116
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
117
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << 'lib'
8
+ t.libs << 'test'
9
+ t.pattern = 'test/**/*_test.rb'
10
+ t.verbose = false
11
+ end
12
+
13
+ desc "Run tests"
14
+ task :default => :test
15
+
data/absurdity.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "absurdity/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "absurdity"
7
+ s.version = Absurdity::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Tim Payton", "Ömür Özkir"]
10
+ s.email = "opensource@xing.com"
11
+ s.homepage = "http://xing.github.com/absurdity/"
12
+ s.summary = %q{Absurdly simple a/b testing}
13
+ s.description = %q{See summary}
14
+
15
+ s.rubyforge_project = "absurdity"
16
+
17
+ s.add_dependency("redis", ">= 0")
18
+ s.add_dependency("rake", ">= 0.8.7")
19
+
20
+ s.add_development_dependency("mock_redis", ">= 0")
21
+ s.add_development_dependency("mocha", ">= 0")
22
+ s.add_development_dependency("minitest", ">= 0")
23
+
24
+ s.files = `git ls-files`.split("\n")
25
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
26
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
27
+ s.require_paths = ["lib"]
28
+ end
@@ -0,0 +1,18 @@
1
+ class AbsurditiesController < ApplicationController
2
+ helper :absurdity
3
+ layout "absurdities"
4
+
5
+ def index
6
+ @reports = Absurdity::Experiment.reports
7
+ end
8
+
9
+ def show
10
+ @report = Absurdity::Experiment.find(params[:id].to_sym).report
11
+ end
12
+
13
+ def create
14
+ Absurdity.track!(params[:metric], params[:experiment], params[:identity_id])
15
+ render nothing: true
16
+ end
17
+
18
+ end
@@ -0,0 +1,31 @@
1
+ module AbsurdityHelper
2
+
3
+ def metric_info(metrics, metric, count)
4
+ content_tag(:span, "#{metric.to_s.humanize}: ", class: "metric")
5
+ .concat "#{count} "
6
+ # str += metric_ratios(metrics, metric, count)
7
+ end
8
+
9
+ def completed_text(completed)
10
+ if completed == :completed
11
+ content_tag(:span, "Completed")
12
+ elsif completed
13
+ content_tag(:span, "Completed: #{completed}")
14
+ else
15
+ ""
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def metric_ratios(metrics, metric, count)
22
+ ratios = metrics.map do |other_metric, other_count|
23
+ if metric != other_metric && count != 0 && other_count != 0
24
+ str = "#{metric.to_s.humanize}/#{other_metric.to_s.humanize} :"
25
+ str += " #{number_to_percentage((count.to_f/other_count.to_f) * 100, precision: 3)}"
26
+ str
27
+ end
28
+ end.compact
29
+ ratios.present? ? "(" + ratios.join(",") + ")" : ""
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ <header>
2
+ <h1>Experiments</h1>
3
+ </header>
4
+
5
+ <ul class="reports">
6
+ <% @reports.each do |report| %>
7
+ <li>
8
+ <%= link_to report[:name].to_s.humanize, absurdity_path(report[:name]) %>
9
+ <%= completed_text(report[:completed]) %>
10
+ </li>
11
+ <% end %>
12
+ </ul>
@@ -0,0 +1,22 @@
1
+ <header>
2
+ <h1>Experiment</h1>
3
+
4
+ <h2>
5
+ <%= @report[:name].to_s.humanize %>
6
+ </h2>
7
+
8
+ <h3>
9
+ <%= completed_text(@report[:completed]) %>
10
+ </h3>
11
+ </header>
12
+
13
+ <ul>
14
+ <% @report[:data].each do |variant, metrics| %>
15
+ <li class="variant">
16
+ <h3><%= variant.to_s.humanize %></h3>
17
+ <% metrics.each do |metric, count| %>
18
+ <p><%= metric_info(metrics, metric, count) %></p>
19
+ <% end %>
20
+ </li>
21
+ <% end %>
22
+ </ul>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>">
3
+ <head>
4
+ <title>Absurdity - A/B Testing</title>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, minimum-scale=1, maximum-scale=1, user-scalable=0">
7
+ <link rel="apple-touch-icon" href="/images/iphone_favicon.png">
8
+ <link rel="prev" href="/">
9
+
10
+ <link rel="stylesheet" href="/stylesheets/absurdity.css" media="screen">
11
+ </head>
12
+
13
+ <body>
14
+ <%= yield %>
15
+ </body>
16
+
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Rails.application.routes.draw do
2
+
3
+ resources :absurdities,
4
+ :format => :html,
5
+ :only => [:index, :show, :create]
6
+
7
+ end
data/lib/absurdity.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+ require 'absurdity/railtie.rb' if defined?(Rails)
3
+ require 'absurdity/engine' if defined?(Rails)
4
+
5
+ module Absurdity
6
+ class MissingIdentityIDError < RuntimeError; end
7
+
8
+ autoload :Config, "absurdity/config"
9
+ autoload :Experiment, "absurdity/experiment"
10
+ autoload :Metric, "absurdity/metric"
11
+ autoload :Variant, "absurdity/variant"
12
+ autoload :Datastore, "absurdity/datastore"
13
+
14
+ def self.redis=(redis)
15
+ Config.instance.redis = redis
16
+ end
17
+
18
+ def self.track!(metric_slug, experiment_slug, identity_id=nil)
19
+ Experiment.find(experiment_slug).track!(metric_slug, identity_id)
20
+ end
21
+
22
+ def self.variant(experiment_slug, identity_id)
23
+ Experiment.find(experiment_slug).variant_for(identity_id)
24
+ end
25
+
26
+ end
@@ -0,0 +1,24 @@
1
+ require 'singleton'
2
+ require 'logger'
3
+
4
+ module Absurdity
5
+ class Config
6
+ include Singleton
7
+
8
+ def redis
9
+ @redis
10
+ end
11
+
12
+ def redis=(redis)
13
+ @redis = redis
14
+ end
15
+
16
+ def logger
17
+ @logger ||= ::Logger.new(STDOUT)
18
+ end
19
+
20
+ def logger=(logger)
21
+ @logger = logger
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,172 @@
1
+ module Absurdity
2
+ class Datastore
3
+ PARSE_AS_JSON = [
4
+ :metrics_list,
5
+ :variants_list,
6
+ :experiments_list
7
+ ]
8
+
9
+ def self.save(object)
10
+ klass = object.class
11
+ if klass == Absurdity::Experiment
12
+ save_experiment(object)
13
+ elsif klass == Absurdity::Metric
14
+ save_metric(object)
15
+ elsif klass == Absurdity::Variant
16
+ save_variant(object)
17
+ end
18
+ end
19
+
20
+ def self.all(klass)
21
+ if klass == Absurdity::Experiment
22
+ all_experiments
23
+ elsif klass == Absurdity::Metric
24
+ all_metrics
25
+ elsif klass == Absurdity::Variant
26
+ all_variants
27
+ end
28
+ end
29
+
30
+ def self.find(klass, options)
31
+ if klass == Absurdity::Experiment
32
+ find_experiment(options[:slug])
33
+ elsif klass == Absurdity::Metric
34
+ find_metric(options[:slug], options[:experiment_slug], options[:variant_slug])
35
+ elsif klass == Absurdity::Variant
36
+ find_variant(options[:identity_id], options[:experiment_slug])
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def self.redis
43
+ Config.instance.redis
44
+ end
45
+
46
+
47
+ # FINDERS
48
+
49
+ def self.find_experiment(experiment_slug)
50
+ return nil unless slug = experiments_list.find { |e| e == experiment_slug }
51
+ experiment = Experiment.new(slug)
52
+ experiment.attributes[:metrics_list] = get(:metrics_list, experiment: experiment)
53
+ experiment.attributes[:variants_list] = get(:variants_list, experiment: experiment)
54
+ experiment.attributes[:completed] = get(:completed, experiment: experiment)
55
+ experiment
56
+ end
57
+
58
+ def self.find_metric(metric_slug, experiment_slug, variant_slug)
59
+ experiment = find_experiment(experiment_slug)
60
+ return nil unless experiment && experiment.metrics_list.find { |sl| sl == metric_slug }
61
+ metric = Metric.new(metric_slug, experiment_slug, variant_slug)
62
+ metric.instance_variable_set(:@count, metric_count(metric))
63
+ metric
64
+ end
65
+
66
+ def self.find_variant(identity_id, experiment_slug)
67
+ experiment = find_experiment(experiment_slug)
68
+ return nil unless slug = get(variant_key(identity_id), experiment: experiment)
69
+ Variant.new(slug, experiment_slug, identity_id)
70
+ end
71
+
72
+ def self.all_experiments
73
+ experiments_list.map { |exp| find_experiment(exp) }
74
+ end
75
+
76
+
77
+ # SAVERS
78
+
79
+ def self.save_metric(metric)
80
+ set(metric_key(metric), metric.count.to_i, experiment: find_experiment(metric.experiment_slug))
81
+ end
82
+
83
+ def self.save_experiment(experiment)
84
+ unless find_experiment(experiment.slug)
85
+ add_to_experiments_list(experiment.slug)
86
+ create_variants(experiment)
87
+ create_metrics(experiment)
88
+ end
89
+ mark_completed(experiment) if experiment.completed
90
+ end
91
+
92
+ def self.save_variant(variant)
93
+ set(variant_key(variant.identity_id), variant.slug, experiment: find_experiment(variant.experiment_slug))
94
+ end
95
+
96
+ def self.get(key, options={})
97
+ if experiment = options[:experiment]
98
+ store_key = "experiments:#{experiment.slug}:#{key}"
99
+ else
100
+ store_key = "experiments:#{key}"
101
+ end
102
+ string = redis.get(store_key)
103
+ if string.to_i.to_s == string
104
+ string.to_i
105
+ elsif PARSE_AS_JSON.include?(key)
106
+ string && JSON.parse(string).map { |v| v.to_sym }
107
+ else
108
+ string && string.to_sym
109
+ end
110
+ end
111
+
112
+ def self.set(key, value, options={})
113
+ if experiment = options[:experiment]
114
+ store_key = "experiments:#{experiment.slug}:#{key}"
115
+ else
116
+ store_key = "experiments:#{key}"
117
+ end
118
+ redis.set(store_key, value)
119
+ end
120
+
121
+ def self.metric_count(metric)
122
+ experiment = find_experiment(metric.experiment_slug)
123
+ get(metric_key(metric), experiment: experiment).to_i
124
+ end
125
+
126
+ def self.metric_key(metric)
127
+ key = metric.variant_slug ? "#{metric.variant_slug}" : ""
128
+ key += ":#{metric.slug}:count"
129
+ key
130
+ end
131
+
132
+ def self.variant_key(identity_id)
133
+ "identity_id:#{identity_id}:variant"
134
+ end
135
+
136
+ def self.experiments_list
137
+ get(:experiments_list) || []
138
+ end
139
+
140
+ def self.add_to_experiments_list(slug)
141
+ set(:experiments_list, (experiments_list << slug).to_json)
142
+ end
143
+
144
+ def self.create_metrics(experiment)
145
+ set(:metrics_list, experiment.metrics_list.to_json, experiment: experiment)
146
+ experiment.metrics_list.each do |metric_slug|
147
+ if experiment.variants?
148
+ experiment.variants_list.each do |variant_slug|
149
+ Metric.create(metric_slug, experiment.slug, variant_slug)
150
+ end
151
+ else
152
+ Metric.create(metric_slug, experiment.slug)
153
+ end
154
+ end
155
+ end
156
+
157
+ def self.create_variants(experiment)
158
+ if !experiment.variants_list.nil?
159
+ set(:variants_list, experiment.variants_list.to_json, experiment: experiment)
160
+ end
161
+ end
162
+
163
+ def self.mark_completed(experiment)
164
+ set(:completed, experiment.completed, experiment: experiment)
165
+ end
166
+
167
+ def self.logger
168
+ Config.instance.logger
169
+ end
170
+
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ module Absurdity
2
+ class Redis < Datastore
3
+
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ module Absurdity
2
+ class Engine < Rails::Engine
3
+
4
+ initializer "static assets" do |app|
5
+ app.middleware.insert_before ::Rack::Lock, ::ActionDispatch::Static, "#{root}/public"
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,135 @@
1
+ module Absurdity
2
+ class Experiment
3
+ class NotFoundError < RuntimeError; end
4
+ class FoundError < RuntimeError; end
5
+
6
+ def self.create(slug, metrics_list, variants_list=nil)
7
+ raise FoundError if Datastore.find(self, slug: slug)
8
+ experiment = new(slug, metrics_list: metrics_list, variants_list: variants_list)
9
+ experiment.save
10
+ experiment
11
+ end
12
+
13
+ def self.find(slug)
14
+ raise NotFoundError unless experiment = Datastore.find(self, slug: slug)
15
+ experiment
16
+ end
17
+
18
+ def self.reports
19
+ all.map { |exp| exp.report }
20
+ end
21
+
22
+ def self.all
23
+ Datastore.all_experiments
24
+ end
25
+
26
+ attr_reader :slug, :attributes
27
+ def initialize(slug, attributes = {})
28
+ @slug = slug
29
+ @attributes = attributes
30
+ end
31
+
32
+ def report
33
+ report = { name: slug, completed: completed, data: {} }
34
+ if variants?
35
+ variants_list.each do |variant|
36
+ report[:data][variant] = {}
37
+ metrics_list.each do |metric_slug|
38
+ report[:data][variant][metric_slug] = metric(metric_slug, variant).count
39
+ end
40
+ end
41
+ else
42
+ metrics_list.each do |metric_slug|
43
+ report[:data][metric_slug] = metric(metric_slug).count
44
+ end
45
+ end
46
+ report
47
+ end
48
+
49
+ def save
50
+ Datastore.save_experiment(self)
51
+ end
52
+
53
+ def track!(metric_slug, identity_id=nil)
54
+ raise Absurdity::MissingIdentityIDError if variants? && identity_id.nil?
55
+ variant = variants? ? variant_for(identity_id) : nil
56
+ metric(metric_slug, variant).track! unless completed
57
+ end
58
+
59
+ def complete(variant_slug)
60
+ @attributes[:completed] = variant_slug
61
+ save
62
+ end
63
+
64
+ def completed
65
+ @attributes[:completed]
66
+ end
67
+
68
+ def count(metric_slug)
69
+ if variants?
70
+ count = {}
71
+ variants_list.each do |variant|
72
+ count[variant] = metric(metric_slug, variant).count
73
+ end
74
+ else
75
+ count = metric(metric_slug).count
76
+ end
77
+ count
78
+ end
79
+
80
+ def metric(metric_slug, variant=nil)
81
+ Metric.find(metric_slug, slug, variant)
82
+ end
83
+
84
+ def ==(other_experiment)
85
+ slug == other_experiment.slug &&
86
+ metrics_list == other_experiment.metrics_list &&
87
+ variants_list == other_experiment.variants_list
88
+ end
89
+
90
+ def metrics
91
+ return @metrics unless @metrics.nil?
92
+ @metrics = []
93
+ metrics_list.each do |metric_slug|
94
+ if variants?
95
+ variants_list.each { |variant| @metrics << metric(metric_slug, variant) }
96
+ else
97
+ @metrics << metric(metric_slug)
98
+ end
99
+ end
100
+ @metrics
101
+ end
102
+
103
+ def variant_for(identity_id)
104
+ if completed
105
+ variant = Variant.new(completed, slug, identity_id)
106
+ else
107
+ variant = Variant.find(identity_id, slug)
108
+ if variant.nil?
109
+ variant = Variant.new(random_variant, slug, identity_id)
110
+ variant.save
111
+ end
112
+ end
113
+ variant.slug
114
+ end
115
+
116
+ def metrics_list
117
+ attributes[:metrics_list]
118
+ end
119
+
120
+ def variants_list
121
+ attributes[:variants_list]
122
+ end
123
+
124
+ def variants?
125
+ variants_list
126
+ end
127
+
128
+ private
129
+
130
+ def random_variant
131
+ variants_list.sort_by { rand }[0]
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,46 @@
1
+ module Absurdity
2
+ class Metric
3
+ class NotFoundError < RuntimeError; end
4
+
5
+ def self.create(slug, experiment_slug, variant_slug=nil)
6
+ metric = new(slug, experiment_slug, variant_slug)
7
+ metric.save
8
+ metric
9
+ end
10
+
11
+ def self.find(slug, experiment_slug, variant_slug=nil)
12
+ raise NotFoundError unless metric = Datastore.find(self,
13
+ slug: slug,
14
+ experiment_slug: experiment_slug,
15
+ variant_slug: variant_slug)
16
+ metric
17
+ end
18
+
19
+ attr_reader :slug, :experiment_slug, :variant_slug
20
+ def initialize(slug, experiment_slug, variant_slug=nil)
21
+ @slug = slug
22
+ @experiment_slug = experiment_slug
23
+ @variant_slug = variant_slug
24
+ end
25
+
26
+ def save
27
+ Datastore.save(self)
28
+ end
29
+
30
+ def track!
31
+ @count += 1
32
+ save
33
+ end
34
+
35
+ def count
36
+ @count ||= 0
37
+ end
38
+
39
+ def ==(other_metric)
40
+ slug == other_metric.slug &&
41
+ experiment_slug == other_metric.experiment_slug &&
42
+ variant_slug == other_metric.variant_slug
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ module Absurdity
2
+ class Railtie < ::Rails::Railtie
3
+ config.after_initialize do
4
+ Config.instance.logger = ::Rails.logger
5
+ load_experiments
6
+ end
7
+
8
+ def self.load_experiments
9
+ experiments_to_create = YAML.load_file("absurdity/experiments.yml")[:experiments]
10
+ experiments_to_create.each do |experiment_slug, values|
11
+ metrics_list = values[:metrics]
12
+ variants_list = values[:variants]
13
+ experiment = new_experiment(experiment_slug, metrics_list, variants_list)
14
+ complete(experiment_slug, values[:completed]) if values[:completed] && !experiment.completed
15
+ end
16
+ end
17
+
18
+ def self.new_experiment(experiment_slug, metrics_list, variants_list=nil)
19
+ begin
20
+ experiment = Experiment.find(experiment_slug)
21
+ rescue Experiment::NotFoundError
22
+ Experiment.create(experiment_slug, metrics_list, variants_list)
23
+ end
24
+ end
25
+
26
+ def self.complete(experiment_slug, variant_slug)
27
+ experiment = Experiment.find(experiment_slug)
28
+ experiment.complete(variant_slug)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ module Absurdity
2
+ class Variant
3
+
4
+ def self.find(identity_id, experiment_slug)
5
+ Datastore.find(self, experiment_slug: experiment_slug, identity_id: identity_id)
6
+ end
7
+
8
+ attr_reader :slug, :experiment_slug, :identity_id
9
+ def initialize(slug, experiment_slug, identity_id)
10
+ @slug = slug
11
+ @experiment_slug = experiment_slug
12
+ @identity_id = identity_id
13
+ end
14
+
15
+ def save
16
+ Datastore.save(self)
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module Absurdity
2
+ VERSION = "0.2.5"
3
+ end
@@ -0,0 +1,96 @@
1
+ body {
2
+ background-color: #fff;
3
+ font: 12px Helvetica, Arial, sans-serif;
4
+ margin: 0 auto;
5
+ padding: 12px 0;
6
+ width: 1000px;
7
+ }
8
+
9
+ h1, h2, h3 {
10
+ color: #bbb;
11
+ display: inline;
12
+ font-size: 2em;
13
+ font-weight: normal;
14
+ margin: 0;
15
+ padding: 0;
16
+ text-transform: uppercase;
17
+ white-space: nowrap;
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
20
+ }
21
+
22
+ h1 {
23
+ color: #333;
24
+ font-weight: bold;
25
+ padding-right: 12px;
26
+ }
27
+
28
+ h2 {
29
+ font-size: 2em;
30
+ font-weight: normal;
31
+ }
32
+
33
+ h3 {
34
+ font-size: 1.8em;
35
+ display: block;
36
+ }
37
+
38
+ ul {
39
+ list-style: none;
40
+ padding: 0;
41
+ }
42
+
43
+ .reports li {
44
+ color: #aaa;
45
+ background-color: #333;
46
+ border-radius: 10px;
47
+ -moz-box-shadow: 0 5px 5px #999;
48
+ -webkit-box-shadow: 0 5px 5px #999;
49
+ box-shadow: 0 5px 5px #999;
50
+ display: inline-block;
51
+ font-weight: bold;
52
+ padding: 10px 10px;
53
+ margin: 10px 10px 10px 0;
54
+ position: relative;
55
+ height: 80px;
56
+ width: 170px;
57
+ }
58
+
59
+ .reports li span {
60
+ font-weight: normal;
61
+ position: absolute;
62
+ bottom: 10px;
63
+ left: 10px;
64
+ width: 160px;
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ }
68
+
69
+ .reports li a, .reports li a:visited {
70
+ color: #f8f8f8;
71
+ display: inline-block;
72
+ text-decoration: none;
73
+ position: absolute;
74
+ top: 10px;
75
+ height: 80px;
76
+ width: 170px;
77
+ }
78
+
79
+ li.variant {
80
+ border-left: 3px solid #eee;
81
+ color: #333;
82
+ display: inline-block;
83
+ margin-top: 12px;
84
+ padding-left: 12px;
85
+ width: 25%;
86
+ }
87
+
88
+ li.variant:first-child {
89
+ border-left: none;
90
+ padding: 0 12px 0 0;
91
+ }
92
+
93
+ .metric {
94
+ font-weight: bold;
95
+ }
96
+
@@ -0,0 +1,96 @@
1
+ require 'test_helper'
2
+
3
+ class AbsurdityTest < MiniTest::Unit::TestCase
4
+
5
+ def test_redis_setter_and_getter
6
+ a_redis = MockRedis.new
7
+ Absurdity.redis = a_redis
8
+ assert_equal a_redis, Absurdity.redis
9
+ end
10
+
11
+ def test_track_missing_experiment
12
+ Absurdity.redis = MockRedis.new
13
+
14
+ assert_raises Absurdity::Experiment::NotFoundError do
15
+ Absurdity.track! :clicked, :shared_contacts_link
16
+ end
17
+ end
18
+
19
+ def test_track_missing_metric
20
+ Absurdity.redis = MockRedis.new
21
+ Absurdity::Experiment.create(:shared_contacts_link,
22
+ [:clicked])
23
+
24
+ assert_raises Absurdity::Metric::NotFoundError do
25
+ Absurdity.track! :seen, :shared_contacts_link
26
+ end
27
+ end
28
+
29
+ def test_track_missing_identity_id
30
+ Absurdity.redis = MockRedis.new
31
+ Absurdity::Experiment.create(:shared_contacts_link,
32
+ [:clicked],
33
+ [:with_photos, :without_photos])
34
+
35
+ assert_raises Absurdity::MissingIdentityIDError do
36
+ Absurdity.track! :seen, :shared_contacts_link
37
+ end
38
+ end
39
+
40
+ def test_track_experiment_metric_without_variants
41
+ Absurdity.redis = MockRedis.new
42
+ Absurdity::Experiment.create(:shared_contacts_link,
43
+ [:clicked])
44
+
45
+ Absurdity.track! :clicked, :shared_contacts_link
46
+ assert_equal 1, Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
47
+ end
48
+
49
+ def test_track_experiment_metric_with_variants
50
+ Absurdity.redis = MockRedis.new
51
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
52
+
53
+ Absurdity::Experiment.create(:shared_contacts_link,
54
+ [:clicked],
55
+ [:with_photos, :without_photos])
56
+
57
+ Absurdity.track! :clicked, :shared_contacts_link, 1
58
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
59
+
60
+ assert_equal 1, count[:with_photos]
61
+ assert_equal 0, count[:without_photos]
62
+ end
63
+
64
+ def test_track_experiment_with_multiple_metrics_with_variants
65
+ Absurdity.redis = MockRedis.new
66
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
67
+
68
+ Absurdity::Experiment.create(:shared_contacts_link,
69
+ [:clicked, :seen],
70
+ [:with_photos, :without_photos])
71
+
72
+ Absurdity.track! :clicked, :shared_contacts_link, 1
73
+ Absurdity.track! :seen, :shared_contacts_link, 1
74
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
75
+
76
+ assert_equal 1, count[:with_photos]
77
+ assert_equal 0, count[:without_photos]
78
+
79
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:seen)
80
+ assert_equal 1, count[:with_photos]
81
+ assert_equal 0, count[:without_photos]
82
+ end
83
+
84
+ def test_variant
85
+ Absurdity.redis = MockRedis.new
86
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
87
+
88
+ Absurdity::Experiment.create(:shared_contacts_link,
89
+ [:clicked, :seen],
90
+ [:with_photos, :without_photos])
91
+
92
+ assert_equal :with_photos, Absurdity.variant(:shared_contacts_link, 1)
93
+ end
94
+
95
+ end
96
+
@@ -0,0 +1,13 @@
1
+ require 'test_helper'
2
+
3
+ class AbsurdityTest < MiniTest::Unit::TestCase
4
+
5
+ def test_redis_setter_and_getter
6
+ a_redis = MockRedis.new
7
+ Absurdity::Config.instance.redis = a_redis
8
+
9
+ assert_equal a_redis, Absurdity::Config.instance.redis
10
+ end
11
+
12
+ end
13
+
@@ -0,0 +1,36 @@
1
+ require 'test_helper'
2
+
3
+ class ExperimentTest < MiniTest::Unit::TestCase
4
+
5
+ def setup
6
+ @experiment_slug = :shared_contacts_link
7
+ @metrics_list = [:clicked, :seen]
8
+ @variants_list = [:with_photos, :without_photos]
9
+ Absurdity.redis = MockRedis.new
10
+ end
11
+
12
+ def test_find_and_test_create
13
+ experiment = Absurdity::Experiment.create(@experiment_slug,
14
+ @metrics_list,
15
+ @variants_list)
16
+
17
+ assert_equal experiment, Absurdity::Experiment.find(@experiment_slug)
18
+ end
19
+
20
+ def test_initialize
21
+ # crappy test
22
+ experiment = Absurdity::Experiment.new(@experiment_slug,
23
+ metrics_list: @metrics_list,
24
+ variants_list: @variants_list)
25
+ end
26
+
27
+ def test_save
28
+
29
+ end
30
+
31
+ def test_metric
32
+
33
+ end
34
+
35
+ end
36
+
@@ -0,0 +1,83 @@
1
+ require 'test_helper'
2
+
3
+ class MetricTest < MiniTest::Unit::TestCase
4
+
5
+ def setup
6
+ Absurdity.redis = MockRedis.new
7
+ end
8
+
9
+ def test_find_and_create
10
+ experiment_slug = :shared_contacts_link
11
+ metric_slug = :clicked
12
+ experiment = stub(slug: experiment_slug, metrics_list: [metric_slug])
13
+ Absurdity::Datastore.stubs(:find_experiment).returns(experiment)
14
+
15
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
16
+
17
+ assert_equal metric, Absurdity::Metric.find(metric_slug, experiment_slug)
18
+ end
19
+
20
+ def test_find_not_found
21
+ assert_raises Absurdity::Metric::NotFoundError do
22
+ Absurdity::Metric.find(:blah, :boo)
23
+ end
24
+ end
25
+
26
+ def test_save_first_time
27
+ experiment_slug = :shared_contacts_link
28
+ metric_slug = :clicked
29
+ experiment = stub(slug: experiment_slug, metrics_list: [metric_slug])
30
+ Absurdity::Datastore.stubs(:find_experiment).returns(experiment)
31
+
32
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug)
33
+
34
+ metric.save
35
+ assert_equal metric, Absurdity::Metric.find(metric_slug, experiment_slug)
36
+ end
37
+
38
+ def test_save_after_first_time
39
+ experiment_slug = :shared_contacts_link
40
+ metric_slug = :clicked
41
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
42
+
43
+ assert_equal 0, metric.count
44
+ metric.track!
45
+ assert_equal 1, metric.count
46
+ metric.save
47
+ assert_equal 1, metric.count
48
+ end
49
+
50
+ def test_count
51
+ experiment_slug = :shared_contacts_link
52
+ metric_slug = :clicked
53
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
54
+
55
+ assert_equal 0, metric.count
56
+ metric.track!
57
+ assert_equal 1, metric.count
58
+ end
59
+
60
+ def test_track_metric
61
+ experiment_slug = :shared_contacts_link
62
+ metric_slug = :clicked
63
+
64
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug)
65
+
66
+ assert_equal 0, metric.count
67
+ metric.track!
68
+ assert_equal 1, metric.count
69
+ end
70
+
71
+ def test_track_variant_metric
72
+ experiment_slug = :shared_contacts_link
73
+ metric_slug = :clicked
74
+ variant_slug = :with_photos
75
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug, variant_slug)
76
+
77
+ assert_equal 0, metric.count
78
+ metric.track!
79
+ assert_equal 1, metric.count
80
+ end
81
+
82
+ end
83
+
@@ -0,0 +1,15 @@
1
+ require 'minitest/autorun'
2
+ require 'mock_redis'
3
+ require 'absurdity'
4
+ require 'redis'
5
+ require 'mocha'
6
+
7
+ class MiniTest::Unit::TestCase
8
+
9
+ def teardown
10
+ config = Absurdity::Config.instance
11
+ config.redis && config.redis.flushdb
12
+ config.instance_variable_set(:@redis, nil)
13
+ end
14
+
15
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: absurdity
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.2.5
6
+ platform: ruby
7
+ authors:
8
+ - Tim Payton
9
+ - "\xC3\x96m\xC3\xBCr \xC3\x96zkir"
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+
14
+ date: 2011-10-21 00:00:00 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: redis
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.8.7
36
+ type: :runtime
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: mock_redis
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ type: :development
48
+ version_requirements: *id003
49
+ - !ruby/object:Gem::Dependency
50
+ name: mocha
51
+ prerelease: false
52
+ requirement: &id004 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ type: :development
59
+ version_requirements: *id004
60
+ - !ruby/object:Gem::Dependency
61
+ name: minitest
62
+ prerelease: false
63
+ requirement: &id005 !ruby/object:Gem::Requirement
64
+ none: false
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: "0"
69
+ type: :development
70
+ version_requirements: *id005
71
+ description: See summary
72
+ email: opensource@xing.com
73
+ executables: []
74
+
75
+ extensions: []
76
+
77
+ extra_rdoc_files: []
78
+
79
+ files:
80
+ - .gitignore
81
+ - .rvmrc
82
+ - Gemfile
83
+ - README.rdoc
84
+ - Rakefile
85
+ - absurdity.gemspec
86
+ - app/controllers/absurdities_controller.rb
87
+ - app/helpers/absurdity_helper.rb
88
+ - app/views/absurdities/index.html.erb
89
+ - app/views/absurdities/show.html.erb
90
+ - app/views/layouts/absurdities.html.erb
91
+ - config/routes.rb
92
+ - lib/absurdity.rb
93
+ - lib/absurdity/config.rb
94
+ - lib/absurdity/datastore.rb
95
+ - lib/absurdity/datastore/redis.rb
96
+ - lib/absurdity/engine.rb
97
+ - lib/absurdity/experiment.rb
98
+ - lib/absurdity/metric.rb
99
+ - lib/absurdity/railtie.rb
100
+ - lib/absurdity/variant.rb
101
+ - lib/absurdity/version.rb
102
+ - public/stylesheets/absurdity.css
103
+ - test/absurdity_test.rb
104
+ - test/config_test.rb
105
+ - test/experiment_test.rb
106
+ - test/metric_test.rb
107
+ - test/test_helper.rb
108
+ homepage: http://xing.github.com/absurdity/
109
+ licenses: []
110
+
111
+ post_install_message:
112
+ rdoc_options: []
113
+
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: "0"
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ none: false
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: "0"
128
+ requirements: []
129
+
130
+ rubyforge_project: absurdity
131
+ rubygems_version: 1.8.10
132
+ signing_key:
133
+ specification_version: 3
134
+ summary: Absurdly simple a/b testing
135
+ test_files:
136
+ - test/absurdity_test.rb
137
+ - test/config_test.rb
138
+ - test/experiment_test.rb
139
+ - test/metric_test.rb
140
+ - test/test_helper.rb