From: Sam Smith Date: Wed, 12 Aug 2015 19:53:19 +0000 (+0100) Subject: Add the mediawiki.experiments module X-Git-Tag: 1.31.0-rc.0~10266 X-Git-Url: https://git.cyclocoop.org/%7B%24admin_url%7Dmembres/cotisations/voir.php?a=commitdiff_plain;h=8da91885e02a365d5ad7905606ed2c5bb50334f5;p=lhc%2Fweb%2Fwiklou.git Add the mediawiki.experiments module The module provides a generic bucketing function - it accepts an experiment specification and a token that identifies a unique user - and doesn't have any side effects, i.e. the bucket isn't persisted to storage. It is therefore assumed that clients are responsible for either storing the token or storing the bucket for the duration of an experiment. The module was extracted from the - admittedly, unused - module of the same name in the MobileFrontend extension as it's intended to be used by the Gather and QuickSurveys extensions. Bug: T109010 Change-Id: Icf7f6fedf0c2deb5d5548c9e24456cc7a7c6a743 --- diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index d547b7bde6..ec2e51d6d2 100644 --- a/maintenance/jsduck/categories.json +++ b/maintenance/jsduck/categories.json @@ -31,7 +31,8 @@ "mw.user", "mw.util", "mw.plugin.*", - "mw.cookie" + "mw.cookie", + "mw.experiments" ] }, { diff --git a/resources/Resources.php b/resources/Resources.php index 8e7e368a9b..28a27d7bf4 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1198,6 +1198,10 @@ return array( 'styles' => 'resources/src/mediawiki.toolbar/toolbar.less', 'position' => 'top', ), + 'mediawiki.experiments' => array( + 'scripts' => 'resources/src/mediawiki/mediawiki.experiments.js', + 'targets' => array( 'desktop', 'mobile' ), + ), /* MediaWiki Action */ diff --git a/resources/src/mediawiki/mediawiki.experiments.js b/resources/src/mediawiki/mediawiki.experiments.js new file mode 100644 index 0000000000..930bfec5ee --- /dev/null +++ b/resources/src/mediawiki/mediawiki.experiments.js @@ -0,0 +1,110 @@ +/* jshint bitwise:false */ +( function ( mw, $ ) { + + var CONTROL_BUCKET = 'control', + MAX_INT32_UNSIGNED = 4294967295; + + /** + * An implementation of Jenkins' one-at-a-time hash. + * + * @see http://en.wikipedia.org/wiki/Jenkins_hash_function + * + * @param {String} string String to hash + * @return {Number} The hash as a 32-bit unsigned integer + * @ignore + * + * @author Ori Livneh + * @see http://jsbin.com/kejewi/4/watch?js,console + */ + function hashString( string ) { + var hash = 0, + i = string.length; + + while ( i-- ) { + hash += string.charCodeAt( i ); + hash += ( hash << 10 ); + hash ^= ( hash >> 6 ); + } + hash += ( hash << 3 ); + hash ^= ( hash >> 11 ); + hash += ( hash << 15 ); + + return hash >>> 0; + } + + /** + * Provides an API for bucketing users in experiments. + * + * @class mw.experiments + * @singleton + */ + mw.experiments = { + + /** + * Gets the bucket for the experiment given the token. + * + * The name of the experiment and the token are hashed. The hash is converted + * to a number which is then used to get a bucket. + * + * Consider the following experiment specification: + * + * ``` + * { + * name: 'My first experiment', + * enabled: true, + * buckets: { + * control: 0.5 + * A: 0.25, + * B: 0.25 + * } + * } + * ``` + * + * The experiment has three buckets: control, A, and B. The user has a 50% + * chance of being assigned to the control bucket, and a 25% chance of being + * assigned to either the A or B buckets. If the experiment were disabled, + * then the user would always be assigned to the control bucket. + * + * This function is based on the deprecated `mw.user.bucket` function. + * + * @param {Object} experiment + * @param {String} experiment.name The name of the experiment + * @param {Boolean} experiment.enabled Whether or not the experiment is + * enabled. If the experiment is disabled, then the user is always assigned + * to the control bucket + * @param {Object} experiment.buckets A map of bucket name to probability + * that the user will be assigned to that bucket + * @param {String} token A token that uniquely identifies the user for the + * duration of the experiment + * @returns {String} The bucket + */ + getBucket: function ( experiment, token ) { + var buckets = experiment.buckets, + key, + range = 0, + hash, + max, + acc = 0; + + if ( !experiment.enabled || $.isEmptyObject( experiment.buckets ) ) { + return CONTROL_BUCKET; + } + + for ( key in buckets ) { + range += buckets[key]; + } + + hash = hashString( experiment.name + ':' + token ); + max = ( hash / MAX_INT32_UNSIGNED ) * range; + + for ( key in buckets ) { + acc += buckets[key]; + + if ( max <= acc ) { + return key; + } + } + } + }; + +}( mediaWiki, jQuery ) ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 60b2802cd8..f9ddcf2746 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -88,6 +88,7 @@ return array( 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js', ), 'dependencies' => array( 'jquery.accessKeyLabel', @@ -127,6 +128,7 @@ return array( 'mediawiki.language', 'mediawiki.cldr', 'mediawiki.cookie', + 'mediawiki.experiments', 'test.mediawiki.qunit.testrunner', ), ) diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js new file mode 100644 index 0000000000..774b205371 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js @@ -0,0 +1,63 @@ +( function ( mw ) { + + var getBucket = mw.experiments.getBucket; + + function createExperiment() { + return { + name: 'experiment', + enabled: true, + buckets: { + control: 0.25, + A: 0.25, + B: 0.25, + C: 0.25 + } + }; + } + + QUnit.module( 'mediawiki.experiments' ); + + QUnit.test( 'getBucket( experiment, token )', 4, function ( assert ) { + var experiment = createExperiment(), + token = '123457890'; + + assert.equal( + getBucket( experiment, token ), + getBucket( experiment, token ), + 'It returns the same bucket for the same experiment-token pair.' + ); + + // -------- + experiment = createExperiment(); + experiment.buckets = { + A: 0.314159265359 + }; + + assert.equal( + 'A', + getBucket( experiment, token ), + 'It returns the bucket if only one is defined.' + ); + + // -------- + experiment = createExperiment(); + experiment.enabled = false; + + assert.equal( + 'control', + getBucket( experiment, token ), + 'It returns "control" if the experiment is disabled.' + ); + + // -------- + experiment = createExperiment(); + experiment.buckets = {}; + + assert.equal( + 'control', + getBucket( experiment, token ), + 'It returns "control" if the experiment doesn\'t have any buckets.' + ); + } ); + +}( mediaWiki ) );