Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial git commit of A/Bingo

  • Loading branch information...
commit fb0bc1dd9caa5ae57b5f1b22933c9a3559b1508c 0 parents
@patio11 patio11 authored
Showing with 1,739 additions and 0 deletions.
  1. +20 −0 MIT-LICENSE
  2. +107 −0 README
  3. +23 −0 Rakefile
  4. +38 −0 generators/.svn/entries
  5. +1 −0  generators/.svn/format
  6. +72 −0 generators/abingo_migration/.svn/entries
  7. +1 −0  generators/abingo_migration/.svn/format
  8. +11 −0 generators/abingo_migration/.svn/text-base/abingo_migration_generator.rb.svn-base
  9. +11 −0 generators/abingo_migration/abingo_migration_generator.rb
  10. +69 −0 generators/abingo_migration/templates/.svn/entries
  11. +1 −0  generators/abingo_migration/templates/.svn/format
  12. +30 −0 generators/abingo_migration/templates/.svn/text-base/abingo_migration.rb.svn-base
  13. +30 −0 generators/abingo_migration/templates/abingo_migration.rb
  14. +1 −0  init.rb
  15. +1 −0  install.rb
  16. +164 −0 lib/.svn/entries
  17. +1 −0  lib/.svn/format
  18. +5 −0 lib/.svn/prop-base/abingo_sugar.rb.svn-base
  19. +26 −0 lib/.svn/text-base/a_bingo.rb.netbeans-base
  20. +159 −0 lib/.svn/text-base/abingo.rb.netbeans-base
  21. +159 −0 lib/.svn/text-base/abingo.rb.svn-base
  22. +26 −0 lib/.svn/text-base/abingo_sugar.rb.netbeans-base
  23. +26 −0 lib/.svn/text-base/abingo_sugar.rb.svn-base
  24. +33 −0 lib/.svn/text-base/abingo_view_helper.rb.netbeans-base
  25. +33 −0 lib/.svn/text-base/abingo_view_helper.rb.svn-base
  26. +159 −0 lib/abingo.rb
  27. +103 −0 lib/abingo/.svn/entries
  28. +1 −0  lib/abingo/.svn/format
  29. +25 −0 lib/abingo/.svn/text-base/alternative.rb.svn-base
  30. +57 −0 lib/abingo/.svn/text-base/experiment.rb.svn-base
  31. +25 −0 lib/abingo/alternative.rb
  32. +57 −0 lib/abingo/experiment.rb
  33. +26 −0 lib/abingo_sugar.rb
  34. +33 −0 lib/abingo_view_helper.rb
  35. +69 −0 tasks/.svn/entries
  36. +1 −0  tasks/.svn/format
  37. +4 −0 tasks/.svn/text-base/abingo_tasks.rake.svn-base
  38. +4 −0 tasks/abingo_tasks.rake
  39. +103 −0 test/.svn/entries
  40. +1 −0  test/.svn/format
  41. +8 −0 test/.svn/text-base/abingo_test.rb.svn-base
  42. +3 −0  test/.svn/text-base/test_helper.rb.svn-base
  43. +8 −0 test/abingo_test.rb
  44. +3 −0  test/test_helper.rb
  45. +1 −0  uninstall.rb
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2009 Patrick McKenzie
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
107 README
@@ -0,0 +1,107 @@
+A/Bingo
+======
+
+Rails A/B testing. One minute to install. One line to set up a new A/B test.
+One line to track conversion.
+
+For usage notes, see: http://www.bingocardcreator.com/abingo
+
+Installation instructions are below usage examples.
+
+Key default features:
+ -- Conversions only tracked once per individual.
+ -- Conversions only tracked if individual saw test.
+ -- Same individual ALWAYS sees same alternative for same test.
+ -- Syntax sugar. Specify alternatives as a range, array,
+ hash of alternative to weighting, or just let it default to true or false.
+
+Example: View
+
+<% ab_test("login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file|
+ <%= img_tag(button_file, :alt => "Login!") %>
+<% end %>
+
+Example: Controller
+
+def register_new_user
+ #See what level of free points maximizes users' decision to buy replacement points.
+ @starter_points = ab_test("new_user_free_points", [100, 200, 300])
+end
+
+Example: Controller
+
+def registration
+ if (ab_test("send_welcome_email"))
+ #send the email, track to see if it later increases conversion to full version
+ end
+end
+
+Example: Conversion tracking (in a controller!)
+
+def buy_new_points
+ #some business logic
+ bingo!("new_user_free_points") # could have been just "bingo!" if that is your only test -- I like syntax sugar
+end
+
+Example: Conversion tracking (in a view)
+
+Thanks for signing up, dude! <% bingo!("signup_page_redesign") >
+
+
+Installation
+=======
+
+1) REQUIRED: You'll need to generate a DB migration to prepare two tables,
+then migrate your database. (Note: slight edits required if you use the table names
+"experiments" or "alternatives" at present.)
+
+ruby script/generate abingo_migration
+rake db:migrate
+
+2) REQUIRED: You need to tell A/Bingo a user's identity so that it knows who is
+who if they come back to a test. (The same identity will ALWAYS see the same
+alternative for the same test.) How you do this is up to you -- I suggest integrating
+with your login/account infrastructure. The simplest thing that can possibly work
+
+#Somewhere in application.rb
+before_filter :set_abingo_identity
+
+def set_abingo_identity
+ if (session[:abingo_identity])
+ Abingo.identity = session[:abingo_identity]
+ else
+ session[:abingo_identity] = Abingo.identity = rand(10 ** 10).to_i
+ end
+end
+
+3) HIGHLY RECOMMENDED: All the A/Bingo methods are accessible from the class Abingo (note capitalization!).
+However, you can get some nice syntactical sugar by doing the following
+
+#Within ApplicationController
+ include ABingo #Note capitalization! ABingo is a module, Abingo is a class.
+
+#Within ApplicationHelper
+ include AbingoViewHelper
+
+3) RECOMMENDED: A/Bingo makes HEAVY use of the cache to reduce load on the
+database and share potentially long-lived "temporary" data, such as what alternative
+a given visitor should be shown for a particular test. You SHOULD use a cache
+which is shared across all Rails processes -- that probably means MemcachedStore,
+although you can get away with MemStore if you only have one Rails process.
+
+You PROBABLY SHOULD use a persistent cache in case you need to restart your
+machine. This is an amazingly good use case for MemcacheDB, so if you want to
+try playing with that, Google it. (Sets up VERY easily on the newer Ubuntu distros.)
+
+If you can't use a persistent cache, you're probably still OK if Memcached very
+rarely needs to be restarted. If the cache gets flushed, you will double-count
+entrants to a particular experiment and possibly double-count conversions, but
+that may not be the worse thing in the world.
+
+A/Bingo defaults to using the same cache store as Rails. If you want to change it
+
+#production.rb
+Abingo.cache = ActiveSupport::Cache::MemCacheStore.new("cache.example.com:12345") #best if really memcacheDB
+
+
+Copyright (c) 2009 Patrick McKenzie, released under the MIT license
23 Rakefile
@@ -0,0 +1,23 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+desc 'Test the abingo plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.libs << 'test'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the abingo plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'Abingo'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
38 generators/.svn/entries
@@ -0,0 +1,38 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/generators
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+abingo_migration
+dir
+
1  generators/.svn/format
@@ -0,0 +1 @@
+9
72 generators/abingo_migration/.svn/entries
@@ -0,0 +1,72 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/generators/abingo_migration
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+abingo_migration_generator.rb
+file
+
+
+
+
+2009-08-11T13:09:02.000000Z
+fd12006cdd9ea41ce44e915f8687e755
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+216
+
+templates
+dir
+
1  generators/abingo_migration/.svn/format
@@ -0,0 +1 @@
+9
11 generators/abingo_migration/.svn/text-base/abingo_migration_generator.rb.svn-base
@@ -0,0 +1,11 @@
+class AbingoMigrationGenerator < Rails::Generator::Base
+ def manifest
+ record do |m|
+ m.migration_template 'abingo_migration.rb', 'db/migrate'
+ end
+ end
+
+ def file_name
+ "abingo_migration"
+ end
+end
11 generators/abingo_migration/abingo_migration_generator.rb
@@ -0,0 +1,11 @@
+class AbingoMigrationGenerator < Rails::Generator::Base
+ def manifest
+ record do |m|
+ m.migration_template 'abingo_migration.rb', 'db/migrate'
+ end
+ end
+
+ def file_name
+ "abingo_migration"
+ end
+end
69 generators/abingo_migration/templates/.svn/entries
@@ -0,0 +1,69 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/generators/abingo_migration/templates
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+abingo_migration.rb
+file
+
+
+
+
+2009-08-11T13:52:02.000000Z
+cd6b61ec8c9def7250a0b3b8d1e5dcd5
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+856
+
1  generators/abingo_migration/templates/.svn/format
@@ -0,0 +1 @@
+9
30 generators/abingo_migration/templates/.svn/text-base/abingo_migration.rb.svn-base
@@ -0,0 +1,30 @@
+#Creates the two database tables, plus indexes, you'll need to use A/Bingo.
+
+class AbingoMigration < ActiveRecord::Migration
+ def self.up
+ create_table "experiments", :force => true do |t|
+ t.string "test_name"
+ t.timestamps
+ end
+
+ add_index "experiments", "test_name"
+ add_index "experiments", "created_on"
+
+ create_table "alternatives", :force => true do |t|
+ t.string :experiment_id
+ t.string :content
+ t.string :lookup, :limit => 32
+ t.integer :weight, :default => 1
+ t.integer :participants, :default => 0
+ t.integer :conversions, :default => 0
+ end
+
+ add_index "alternatives", "experiment_id"
+ add_index "alternatives", "lookup" #Critical for speed, since we'll primarily be updating by that.
+ end
+
+ def self.down
+ drop_table :experiments
+ drop_table :alternatives
+ end
+end
30 generators/abingo_migration/templates/abingo_migration.rb
@@ -0,0 +1,30 @@
+#Creates the two database tables, plus indexes, you'll need to use A/Bingo.
+
+class AbingoMigration < ActiveRecord::Migration
+ def self.up
+ create_table "experiments", :force => true do |t|
+ t.string "test_name"
+ t.timestamps
+ end
+
+ add_index "experiments", "test_name"
+ add_index "experiments", "created_on"
+
+ create_table "alternatives", :force => true do |t|
+ t.string :experiment_id
+ t.string :content
+ t.string :lookup, :limit => 32
+ t.integer :weight, :default => 1
+ t.integer :participants, :default => 0
+ t.integer :conversions, :default => 0
+ end
+
+ add_index "alternatives", "experiment_id"
+ add_index "alternatives", "lookup" #Critical for speed, since we'll primarily be updating by that.
+ end
+
+ def self.down
+ drop_table :experiments
+ drop_table :alternatives
+ end
+end
1  init.rb
@@ -0,0 +1 @@
+require File.dirname(__FILE__) + '/lib/abingo'
1  install.rb
@@ -0,0 +1 @@
+# Install hook code here
164 lib/.svn/entries
@@ -0,0 +1,164 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/lib
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+abingo
+dir
+
+a_bingo.rb
+file
+1039
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+deleted
+
+abingo.rb
+file
+1042
+
+
+
+2009-08-12T13:30:24.000000Z
+63394f5d7fffb0ea8ac0d8f87f9a830e
+2009-08-12T13:33:51.703250Z
+1042
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+6193
+
+abingo_sugar.rb
+file
+1039
+
+
+
+2009-08-12T13:13:16.000000Z
+2d83e2e461e531cb91b0dc8e65c9cecd
+2009-08-12T13:14:00.323872Z
+1039
+deploy
+has-props
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+569
+
+abingo_view_helper.rb
+file
+
+
+
+
+2009-08-12T12:36:09.000000Z
+abdd68897675f6f42796de25ae399072
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+775
+
1  lib/.svn/format
@@ -0,0 +1 @@
+9
5 lib/.svn/prop-base/abingo_sugar.rb.svn-base
@@ -0,0 +1,5 @@
+K 13
+svn:mergeinfo
+V 0
+
+END
26 lib/.svn/text-base/a_bingo.rb.netbeans-base
@@ -0,0 +1,26 @@
+#This module exists entirely to save finger strain for programmers.
+#It is designed to be included in your ApplicationController.
+#
+#See abingo.rb for descriptions of what these do.
+
+module ABingo
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
159 lib/.svn/text-base/abingo.rb.netbeans-base
@@ -0,0 +1,159 @@
+#This class is outside code's main interface into the ABingo A/B testing framework.
+#Unless you're fiddling with implementation details, it is the only one you need worry about.
+
+#Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
+
+class Abingo
+
+ #Not strictly necessary, but eh, as long as I'm here.
+ cattr_accessor :salt
+ @@salt = "Not really necessary."
+
+ #ABingo stores whether a particular user has participated in a particular
+ #experiment yet, and if so whether they converted, in the cache.
+ #
+ #It is STRONGLY recommended that you use a MemcacheStore for this.
+ #If you'd like to persist this through a system restart or the like, you can
+ #look into memcachedb, which speaks the memcached protocol. From the perspective
+ #of Rails it is just another MemcachedStore.
+ #
+ #You can overwrite Abingo's cache instance, if you would like it to not share
+ #your generic Rails cache.
+ cattr_writer :cache
+
+ def self.cache
+ @@cache || Rails.cache
+ end
+
+ #This method gives a unique identity to a user. It can be absolutely anything
+ #you want, as long as it is consistent.
+ #
+ #We use the identity to determine, deterministically, which alternative a user sees.
+ #This means that if you use Abingo.identify_user on someone at login, they will
+ #always see the same alternative for a particular test which is past the login
+ #screen. For details and usage notes, see the docs.
+ def self.identity=(new_identity)
+ @@identity = new_identity.to_s
+ end
+
+ def self.identity
+ @@identity ||= rand(10 ** 10).to_i.to_s
+ end
+
+ #A simple convenience method for doing an A/B test. Returns true or false.
+ #If you pass it a block, it will bind the choice to the variable given to the block.
+ def self.flip(test_name)
+ if block_given?
+ yield(self.test(test_name, [true, false]))
+ else
+ self.test(test_name, [true, false])
+ end
+ end
+
+ #This is the meat of A/Bingo.
+ def self.test(test_name, alternatives, options = {})
+ unless Abingo::Experiment.exists?(test_name)
+ Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives))
+ end
+
+ choice = self.find_alternative_for_user(test_name, alternatives)
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+
+ #Set this user to participate in this experiment, and increment participants count.
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
+ participating_tests << test_name unless participating_tests.include?(test_name)
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
+ Abingo::Alternative.score_participation(test_name)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+
+ def Abingo.bingo!(test_name_or_array = nil, options = {})
+ if test_name_or_array.kind_of? Array
+ test_name_or_array.map do |single_test|
+ self.bingo!(single_test, options)
+ end
+ else
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+ if test_name_or_array.nil?
+ participating_tests.each do |participating_test|
+ self.bingo!(participating_test, options)
+ end
+ else #Individual, non-nil test is named
+ test_name_str = test_name_or_array.to_s
+ if options[:assume_participation] || participating_tests.include?(test_name_str)
+ cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name_str}"
+ if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
+ Abingo::Alternative.score_conversion(test_name_str)
+ if Abingo.cache.exist?(cache_key)
+ Abingo.cache.increment(cache_key)
+ else
+ Abingo.cache.write(cache_key, 1)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ #For programmer convenience, we allow you to specify what the alternatives for
+ #an experiment are in a few ways. Thus, we need to actually be able to handle
+ #all of them. We fire this parser very infrequently (once per test, typically)
+ #so it can be as complicated as we want.
+ # Integer => a number 1 through N
+ # Range => a number within the range
+ # Array => an element of the array.
+ # Hash => assumes a hash of something to int. We pick one of the
+ # somethings, weighted accorded to the ints provided. e.g.
+ # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
+ #
+ #Alternatives are always represented internally as an array.
+ def self.parse_alternatives(alternatives)
+ if alternatives.kind_of? Array
+ return alternatives
+ elsif alternatives.kind_of? Integer
+ return (1..alternatives).to_a
+ elsif alternatives.kind_of? Range
+ return alternatives.to_a
+ elsif alternatives.kind_of? Hash
+ alternatives_array = []
+ alternatives.each do |key, value|
+ if value.kind_of? Integer
+ alternatives_array += [key] * value
+ else
+ raise "You gave an array with #{value} as a value. It needed to be an integer."
+ end
+ end
+ return alternatives_array
+ else
+ raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
+ end
+ end
+
+ def self.retrieve_alternatives(test_name, alternatives)
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","")
+ alternative_array = self.cache.fetch(cache_key) do
+ self.parse_alternatives(alternatives)
+ end
+ alternative_array
+ end
+
+ def self.find_alternative_for_user(test_name, alternatives)
+ alternatives_array = retrieve_alternatives(test_name, alternatives)
+ alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
+ end
+
+ #Quickly determines what alternative to show a given user. Given a test name
+ #and their identity, we hash them together (which, for MD5, provably introduces
+ #enough entropy that we don't care) otherwise
+ def self.modulo_choice(test_name, choices_count)
+ Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
+ end
+
+end
159 lib/.svn/text-base/abingo.rb.svn-base
@@ -0,0 +1,159 @@
+#This class is outside code's main interface into the ABingo A/B testing framework.
+#Unless you're fiddling with implementation details, it is the only one you need worry about.
+
+#Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
+
+class Abingo
+
+ #Not strictly necessary, but eh, as long as I'm here.
+ cattr_accessor :salt
+ @@salt = "Not really necessary."
+
+ #ABingo stores whether a particular user has participated in a particular
+ #experiment yet, and if so whether they converted, in the cache.
+ #
+ #It is STRONGLY recommended that you use a MemcacheStore for this.
+ #If you'd like to persist this through a system restart or the like, you can
+ #look into memcachedb, which speaks the memcached protocol. From the perspective
+ #of Rails it is just another MemcachedStore.
+ #
+ #You can overwrite Abingo's cache instance, if you would like it to not share
+ #your generic Rails cache.
+ cattr_writer :cache
+
+ def self.cache
+ @@cache || Rails.cache
+ end
+
+ #This method gives a unique identity to a user. It can be absolutely anything
+ #you want, as long as it is consistent.
+ #
+ #We use the identity to determine, deterministically, which alternative a user sees.
+ #This means that if you use Abingo.identify_user on someone at login, they will
+ #always see the same alternative for a particular test which is past the login
+ #screen. For details and usage notes, see the docs.
+ def self.identity=(new_identity)
+ @@identity = new_identity.to_s
+ end
+
+ def self.identity
+ @@identity ||= rand(10 ** 10).to_i.to_s
+ end
+
+ #A simple convenience method for doing an A/B test. Returns true or false.
+ #If you pass it a block, it will bind the choice to the variable given to the block.
+ def self.flip(test_name)
+ if block_given?
+ yield(self.test(test_name, [true, false]))
+ else
+ self.test(test_name, [true, false])
+ end
+ end
+
+ #This is the meat of A/Bingo.
+ def self.test(test_name, alternatives, options = {})
+ unless Abingo::Experiment.exists?(test_name)
+ Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives))
+ end
+
+ choice = self.find_alternative_for_user(test_name, alternatives)
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+
+ #Set this user to participate in this experiment, and increment participants count.
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
+ participating_tests << test_name unless participating_tests.include?(test_name)
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
+ Abingo::Alternative.score_participation(test_name)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+
+ def Abingo.bingo!(test_name_or_array = nil, options = {})
+ if test_name_or_array.kind_of? Array
+ test_name_or_array.map do |single_test|
+ self.bingo!(single_test, options)
+ end
+ else
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+ if test_name_or_array.nil?
+ participating_tests.each do |participating_test|
+ self.bingo!(participating_test, options)
+ end
+ else #Individual, non-nil test is named
+ test_name_str = test_name_or_array.to_s
+ if options[:assume_participation] || participating_tests.include?(test_name_str)
+ cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name_str}"
+ if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
+ Abingo::Alternative.score_conversion(test_name_str)
+ if Abingo.cache.exist?(cache_key)
+ Abingo.cache.increment(cache_key)
+ else
+ Abingo.cache.write(cache_key, 1)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ #For programmer convenience, we allow you to specify what the alternatives for
+ #an experiment are in a few ways. Thus, we need to actually be able to handle
+ #all of them. We fire this parser very infrequently (once per test, typically)
+ #so it can be as complicated as we want.
+ # Integer => a number 1 through N
+ # Range => a number within the range
+ # Array => an element of the array.
+ # Hash => assumes a hash of something to int. We pick one of the
+ # somethings, weighted accorded to the ints provided. e.g.
+ # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
+ #
+ #Alternatives are always represented internally as an array.
+ def self.parse_alternatives(alternatives)
+ if alternatives.kind_of? Array
+ return alternatives
+ elsif alternatives.kind_of? Integer
+ return (1..alternatives).to_a
+ elsif alternatives.kind_of? Range
+ return alternatives.to_a
+ elsif alternatives.kind_of? Hash
+ alternatives_array = []
+ alternatives.each do |key, value|
+ if value.kind_of? Integer
+ alternatives_array += [key] * value
+ else
+ raise "You gave an array with #{value} as a value. It needed to be an integer."
+ end
+ end
+ return alternatives_array
+ else
+ raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
+ end
+ end
+
+ def self.retrieve_alternatives(test_name, alternatives)
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","")
+ alternative_array = self.cache.fetch(cache_key) do
+ self.parse_alternatives(alternatives)
+ end
+ alternative_array
+ end
+
+ def self.find_alternative_for_user(test_name, alternatives)
+ alternatives_array = retrieve_alternatives(test_name, alternatives)
+ alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
+ end
+
+ #Quickly determines what alternative to show a given user. Given a test name
+ #and their identity, we hash them together (which, for MD5, provably introduces
+ #enough entropy that we don't care) otherwise
+ def self.modulo_choice(test_name, choices_count)
+ Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
+ end
+
+end
26 lib/.svn/text-base/abingo_sugar.rb.netbeans-base
@@ -0,0 +1,26 @@
+#This module exists entirely to save finger strain for programmers.
+#It is designed to be included in your ApplicationController.
+#
+#See abingo.rb for descriptions of what these do.
+
+module AbingoSugar
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
26 lib/.svn/text-base/abingo_sugar.rb.svn-base
@@ -0,0 +1,26 @@
+#This module exists entirely to save finger strain for programmers.
+#It is designed to be included in your ApplicationController.
+#
+#See abingo.rb for descriptions of what these do.
+
+module AbingoSugar
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
33 lib/.svn/text-base/abingo_view_helper.rb.netbeans-base
@@ -0,0 +1,33 @@
+#Gives you easy syntax to use ABingo in your views.
+
+module AbingoViewHelper
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def ab_test(test_name, alternatives = nil, options = {}, &block)
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+ content_tag = capture(choice, &block)
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
33 lib/.svn/text-base/abingo_view_helper.rb.svn-base
@@ -0,0 +1,33 @@
+#Gives you easy syntax to use ABingo in your views.
+
+module AbingoViewHelper
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def ab_test(test_name, alternatives = nil, options = {}, &block)
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+ content_tag = capture(choice, &block)
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
159 lib/abingo.rb
@@ -0,0 +1,159 @@
+#This class is outside code's main interface into the ABingo A/B testing framework.
+#Unless you're fiddling with implementation details, it is the only one you need worry about.
+
+#Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
+
+class Abingo
+
+ #Not strictly necessary, but eh, as long as I'm here.
+ cattr_accessor :salt
+ @@salt = "Not really necessary."
+
+ #ABingo stores whether a particular user has participated in a particular
+ #experiment yet, and if so whether they converted, in the cache.
+ #
+ #It is STRONGLY recommended that you use a MemcacheStore for this.
+ #If you'd like to persist this through a system restart or the like, you can
+ #look into memcachedb, which speaks the memcached protocol. From the perspective
+ #of Rails it is just another MemcachedStore.
+ #
+ #You can overwrite Abingo's cache instance, if you would like it to not share
+ #your generic Rails cache.
+ cattr_writer :cache
+
+ def self.cache
+ @@cache || Rails.cache
+ end
+
+ #This method gives a unique identity to a user. It can be absolutely anything
+ #you want, as long as it is consistent.
+ #
+ #We use the identity to determine, deterministically, which alternative a user sees.
+ #This means that if you use Abingo.identify_user on someone at login, they will
+ #always see the same alternative for a particular test which is past the login
+ #screen. For details and usage notes, see the docs.
+ def self.identity=(new_identity)
+ @@identity = new_identity.to_s
+ end
+
+ def self.identity
+ @@identity ||= rand(10 ** 10).to_i.to_s
+ end
+
+ #A simple convenience method for doing an A/B test. Returns true or false.
+ #If you pass it a block, it will bind the choice to the variable given to the block.
+ def self.flip(test_name)
+ if block_given?
+ yield(self.test(test_name, [true, false]))
+ else
+ self.test(test_name, [true, false])
+ end
+ end
+
+ #This is the meat of A/Bingo.
+ def self.test(test_name, alternatives, options = {})
+ unless Abingo::Experiment.exists?(test_name)
+ Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives))
+ end
+
+ choice = self.find_alternative_for_user(test_name, alternatives)
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+
+ #Set this user to participate in this experiment, and increment participants count.
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
+ participating_tests << test_name unless participating_tests.include?(test_name)
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
+ Abingo::Alternative.score_participation(test_name)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+
+ def Abingo.bingo!(test_name_or_array = nil, options = {})
+ if test_name_or_array.kind_of? Array
+ test_name_or_array.map do |single_test|
+ self.bingo!(single_test, options)
+ end
+ else
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
+ if test_name_or_array.nil?
+ participating_tests.each do |participating_test|
+ self.bingo!(participating_test, options)
+ end
+ else #Individual, non-nil test is named
+ test_name_str = test_name_or_array.to_s
+ if options[:assume_participation] || participating_tests.include?(test_name_str)
+ cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name_str}"
+ if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
+ Abingo::Alternative.score_conversion(test_name_str)
+ if Abingo.cache.exist?(cache_key)
+ Abingo.cache.increment(cache_key)
+ else
+ Abingo.cache.write(cache_key, 1)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ #For programmer convenience, we allow you to specify what the alternatives for
+ #an experiment are in a few ways. Thus, we need to actually be able to handle
+ #all of them. We fire this parser very infrequently (once per test, typically)
+ #so it can be as complicated as we want.
+ # Integer => a number 1 through N
+ # Range => a number within the range
+ # Array => an element of the array.
+ # Hash => assumes a hash of something to int. We pick one of the
+ # somethings, weighted accorded to the ints provided. e.g.
+ # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
+ #
+ #Alternatives are always represented internally as an array.
+ def self.parse_alternatives(alternatives)
+ if alternatives.kind_of? Array
+ return alternatives
+ elsif alternatives.kind_of? Integer
+ return (1..alternatives).to_a
+ elsif alternatives.kind_of? Range
+ return alternatives.to_a
+ elsif alternatives.kind_of? Hash
+ alternatives_array = []
+ alternatives.each do |key, value|
+ if value.kind_of? Integer
+ alternatives_array += [key] * value
+ else
+ raise "You gave an array with #{value} as a value. It needed to be an integer."
+ end
+ end
+ return alternatives_array
+ else
+ raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
+ end
+ end
+
+ def self.retrieve_alternatives(test_name, alternatives)
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","")
+ alternative_array = self.cache.fetch(cache_key) do
+ self.parse_alternatives(alternatives)
+ end
+ alternative_array
+ end
+
+ def self.find_alternative_for_user(test_name, alternatives)
+ alternatives_array = retrieve_alternatives(test_name, alternatives)
+ alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
+ end
+
+ #Quickly determines what alternative to show a given user. Given a test name
+ #and their identity, we hash them together (which, for MD5, provably introduces
+ #enough entropy that we don't care) otherwise
+ def self.modulo_choice(test_name, choices_count)
+ Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
+ end
+
+end
103 lib/abingo/.svn/entries
@@ -0,0 +1,103 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/lib/abingo
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+alternative.rb
+file
+
+
+
+
+2009-08-11T15:29:28.000000Z
+e70e3cea1e919b312cbb937385d507bb
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+871
+
+experiment.rb
+file
+
+
+
+
+2009-08-11T15:48:25.000000Z
+bf84ce4cca568145662e836645d9a1ad
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+1911
+
1  lib/abingo/.svn/format
@@ -0,0 +1 @@
+9
25 lib/abingo/.svn/text-base/alternative.rb.svn-base
@@ -0,0 +1,25 @@
+class Abingo::Alternative < ActiveRecord::Base
+
+ belongs_to :experiment
+
+ def conversion_rate
+ 1.0 * conversions / participants
+ end
+
+ def self.calculate_lookup(test_name, alternative_name)
+ Digest::MD5.hexdigest(Abingo.salt + test_name + alternative_name.to_s)
+ end
+
+ def self.score_conversion(test_name)
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
+ Abingo::Experiment.alternatives_for_test(test_name))
+ self.update_all("conversions = conversions + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
+ end
+
+ def self.score_participation(test_name)
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
+ Abingo::Experiment.alternatives_for_test(test_name))
+ self.update_all("participants = participants + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
+ end
+
+end
57 lib/abingo/.svn/text-base/experiment.rb.svn-base
@@ -0,0 +1,57 @@
+class Abingo::Experiment < ActiveRecord::Base
+ has_many :alternatives, :dependent => :destroy
+ validates_uniqueness_of :test_name
+
+ def before_destroy
+ Abingo.cache.delete("Abingo::Experiment::exists(#{test_name})".gsub(" ", ""))
+ end
+
+ def participants
+ alternatives.sum("participants")
+ end
+
+ def conversions
+ alternatives.sum("conversions")
+ end
+
+ def conversion_rate
+ 1.0 * conversions / participants
+ end
+
+ def self.exists?(test_name)
+ ret = Abingo.cache.fetch("Abingo::Experiment::exists(#{test_name})".gsub(" ", "")) do
+ Abingo::Experiment.count(:conditions => {:test_name => test_name})
+ end
+ (!ret.nil?) && (ret > 0)
+ end
+
+ def self.alternatives_for_test(test_name)
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","")
+ Abingo.cache.fetch(cache_key) do
+ experiment = Abingo::Experiment.find_by_test_name(test_name)
+ alternatives_array = Abingo.cache.fetch(cache_key) do
+ tmp_array = experiment.alternatives.map do |alt|
+ [alt.content, alt.weight]
+ end
+ tmp_hash = tmp_array.inject({}) {|hash, couplet| hash[couplet[0]] = couplet[1]; hash}
+ Abingo.parse_alternatives(tmp_hash)
+ end
+ alternatives_array
+ end
+ end
+
+ def self.start_experiment!(test_name, alternatives_array)
+ cloned_alternatives_array = alternatives_array.clone
+ experiment = Abingo::Experiment.find_or_create_by_test_name(test_name)
+ while (cloned_alternatives_array.size > 0)
+ alt = cloned_alternatives_array[0]
+ weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
+ experiment.alternatives.create(:content => alt, :weight => weight,
+ :lookup => Abingo::Alternative.calculate_lookup(test_name, alt))
+ cloned_alternatives_array -= [alt]
+ end
+ Abingo.cache.delete("Abingo::Experiment::exists(#{test_name})".gsub(" ", ""))
+ experiment
+ end
+
+ end
25 lib/abingo/alternative.rb
@@ -0,0 +1,25 @@
+class Abingo::Alternative < ActiveRecord::Base
+
+ belongs_to :experiment
+
+ def conversion_rate
+ 1.0 * conversions / participants
+ end
+
+ def self.calculate_lookup(test_name, alternative_name)
+ Digest::MD5.hexdigest(Abingo.salt + test_name + alternative_name.to_s)
+ end
+
+ def self.score_conversion(test_name)
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
+ Abingo::Experiment.alternatives_for_test(test_name))
+ self.update_all("conversions = conversions + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
+ end
+
+ def self.score_participation(test_name)
+ viewed_alternative = Abingo.find_alternative_for_user(test_name,
+ Abingo::Experiment.alternatives_for_test(test_name))
+ self.update_all("participants = participants + 1", :lookup => self.calculate_lookup(test_name, viewed_alternative))
+ end
+
+end
57 lib/abingo/experiment.rb
@@ -0,0 +1,57 @@
+class Abingo::Experiment < ActiveRecord::Base
+ has_many :alternatives, :dependent => :destroy
+ validates_uniqueness_of :test_name
+
+ def before_destroy
+ Abingo.cache.delete("Abingo::Experiment::exists(#{test_name})".gsub(" ", ""))
+ end
+
+ def participants
+ alternatives.sum("participants")
+ end
+
+ def conversions
+ alternatives.sum("conversions")
+ end
+
+ def conversion_rate
+ 1.0 * conversions / participants
+ end
+
+ def self.exists?(test_name)
+ ret = Abingo.cache.fetch("Abingo::Experiment::exists(#{test_name})".gsub(" ", "")) do
+ Abingo::Experiment.count(:conditions => {:test_name => test_name})
+ end
+ (!ret.nil?) && (ret > 0)
+ end
+
+ def self.alternatives_for_test(test_name)
+ cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","")
+ Abingo.cache.fetch(cache_key) do
+ experiment = Abingo::Experiment.find_by_test_name(test_name)
+ alternatives_array = Abingo.cache.fetch(cache_key) do
+ tmp_array = experiment.alternatives.map do |alt|
+ [alt.content, alt.weight]
+ end
+ tmp_hash = tmp_array.inject({}) {|hash, couplet| hash[couplet[0]] = couplet[1]; hash}
+ Abingo.parse_alternatives(tmp_hash)
+ end
+ alternatives_array
+ end
+ end
+
+ def self.start_experiment!(test_name, alternatives_array)
+ cloned_alternatives_array = alternatives_array.clone
+ experiment = Abingo::Experiment.find_or_create_by_test_name(test_name)
+ while (cloned_alternatives_array.size > 0)
+ alt = cloned_alternatives_array[0]
+ weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
+ experiment.alternatives.create(:content => alt, :weight => weight,
+ :lookup => Abingo::Alternative.calculate_lookup(test_name, alt))
+ cloned_alternatives_array -= [alt]
+ end
+ Abingo.cache.delete("Abingo::Experiment::exists(#{test_name})".gsub(" ", ""))
+ experiment
+ end
+
+ end
26 lib/abingo_sugar.rb
@@ -0,0 +1,26 @@
+#This module exists entirely to save finger strain for programmers.
+#It is designed to be included in your ApplicationController.
+#
+#See abingo.rb for descriptions of what these do.
+
+module AbingoSugar
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
33 lib/abingo_view_helper.rb
@@ -0,0 +1,33 @@
+#Gives you easy syntax to use ABingo in your views.
+
+module AbingoViewHelper
+
+ def ab_test(test_name, alternatives = nil, options = {})
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+
+ if block_given?
+ yield(choice)
+ else
+ choice
+ end
+ end
+
+ def ab_test(test_name, alternatives = nil, options = {}, &block)
+ if (alternatives.nil?)
+ choice = Abingo.flip(test_name)
+ else
+ choice = Abingo.test(test_name, alternatives, options)
+ end
+ content_tag = capture(choice, &block)
+ block_called_from_erb?(block) ? concat(content_tag) : content_tag
+ end
+
+ def bingo!(test_name, options = {})
+ Abingo.bingo!(test_name, options)
+ end
+
+end
69 tasks/.svn/entries
@@ -0,0 +1,69 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/tasks
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+abingo_tasks.rake
+file
+
+
+
+
+2009-08-09T13:19:35.000000Z
+ab00fbfef01bccfe7ede4d03ad2c90d2
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+84
+
1  tasks/.svn/format
@@ -0,0 +1 @@
+9
4 tasks/.svn/text-base/abingo_tasks.rake.svn-base
@@ -0,0 +1,4 @@
+# desc "Explaining what the task does"
+# task :abingo do
+# # Task goes here
+# end
4 tasks/abingo_tasks.rake
@@ -0,0 +1,4 @@
+# desc "Explaining what the task does"
+# task :abingo do
+# # Task goes here
+# end
103 test/.svn/entries
@@ -0,0 +1,103 @@
+9
+
+dir
+1038
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards/vendor/plugins/abingo/test
+svn+ssh://www.dailybingocards.com/usr/local/svn/DailyBingoCards
+
+
+
+2009-08-12T13:05:33.319511Z
+1038
+deploy
+
+
+svn:special svn:externals svn:needs-lock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+0
+
+test_helper.rb
+file
+1042
+
+
+
+2009-08-09T13:19:35.000000Z
+0f57f5fd3650bdad64fa70be9fd4f761
+2009-08-12T13:33:51.703250Z
+1042
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+78
+
+abingo_test.rb
+file
+1042
+
+
+
+2009-08-09T13:19:35.000000Z
+5369b5d3762bd08ff1e2085b988de38f
+2009-08-12T13:33:51.703250Z
+1042
+deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+153
+
1  test/.svn/format
@@ -0,0 +1 @@
+9
8 test/.svn/text-base/abingo_test.rb.svn-base
@@ -0,0 +1,8 @@
+require 'test_helper'
+
+class AbingoTest < ActiveSupport::TestCase
+ # Replace this with your real tests.
+ test "the truth" do
+ assert true
+ end
+end
3  test/.svn/text-base/test_helper.rb.svn-base
@@ -0,0 +1,3 @@
+require 'rubygems'
+require 'active_support'
+require 'active_support/test_case'
8 test/abingo_test.rb
@@ -0,0 +1,8 @@
+require 'test_helper'
+
+class AbingoTest < ActiveSupport::TestCase
+ # Replace this with your real tests.
+ test "the truth" do
+ assert true
+ end
+end
3  test/test_helper.rb
@@ -0,0 +1,3 @@
+require 'rubygems'
+require 'active_support'
+require 'active_support/test_case'
1  uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here
Please sign in to comment.
Something went wrong with that request. Please try again.