Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement function that allocate variant based on an experiment confi…
…g. (#3762)
- Loading branch information
Showing
2 changed files
with
231 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
/** | ||
* Copyright 2016 The AMP HTML Authors. All Rights Reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS-IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import {allocateVariant} from '../variant'; | ||
|
||
describe('allocateVariant', () => { | ||
|
||
let fakeWin; | ||
|
||
beforeEach(() => { | ||
fakeWin = { | ||
Math: { | ||
random: () => { | ||
return 0.567; | ||
}, | ||
}, | ||
}; | ||
}); | ||
|
||
it('should throw for invalid config', () => { | ||
expect(() => { | ||
allocateVariant(fakeWin, null); | ||
}).to.throw(); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, undefined); | ||
}).to.throw(); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, {}); | ||
}).to.throw(/Missing experiment variants config/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, {variants: {}}); | ||
}).to.throw(/Missing experiment variants config/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'invalid_char_%_in_name': 1, | ||
}, | ||
}); | ||
}).to.throw(/Invalid variant name/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'variant_1': 50, | ||
'variant_2': 51, | ||
}, | ||
}); | ||
}).to.throw(/Total percentage is bigger than 100/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'negative_percentage': -1, | ||
}, | ||
}); | ||
}).to.throw(/Invalid percentage/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'too_big_percentage': 101, | ||
}, | ||
}); | ||
}).to.throw(/Invalid percentage/); | ||
|
||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'non_number_percentage': '50', | ||
}, | ||
}); | ||
}).to.throw(/Invalid percentage/); | ||
}); | ||
|
||
it('should work around float rounding error', () => { | ||
expect(() => { | ||
allocateVariant(fakeWin, { | ||
variants: { | ||
'a': 50.1, | ||
'b': 40.3, | ||
'c': 9.2, | ||
'd': 0.4, | ||
// They add up to 100.00000000000001 in JS | ||
}, | ||
}); | ||
}).to.not.throw(); | ||
}); | ||
|
||
it('without CID scope, succeed with a variant allocated', () => { | ||
return expect(allocateVariant(fakeWin, { | ||
cidScope: null, | ||
variants: { | ||
'-Variant_1': 56.1, | ||
'-Variant_2': 23.3, | ||
}, | ||
})).to.eventually.equal('-Variant_2'); | ||
}); | ||
|
||
it('should allocate variant in name order', () => { | ||
return expect(allocateVariant(fakeWin, { | ||
cidScope: null, | ||
variants: { | ||
'-Variant_2': 50, | ||
'-Variant_1': 50, | ||
}, | ||
})).to.eventually.equal('-Variant_2'); | ||
}); | ||
|
||
it('can have no variant allocated if variants don\'t add up to 100', () => { | ||
return expect(allocateVariant(fakeWin, { | ||
cidScope: null, | ||
variants: { | ||
'-Variant_1': 2.1, | ||
'-Variant_2': 23.3, | ||
'-Variant_3': 20.123, | ||
}, | ||
})).to.eventually.equal(null); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
/** | ||
* Copyright 2016 The AMP HTML Authors. All Rights Reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS-IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import {isObject} from '../../../src/types'; | ||
import {user} from '../../../src/log'; | ||
|
||
const nameValidator = /^[\w-]+$/; | ||
|
||
/** | ||
* Allocates the current page view to an experiment variant based on the given | ||
* experiment config. | ||
* @param {!Window} win | ||
* @param {!Object} config | ||
* @return {!Promise<?string>} | ||
*/ | ||
export function allocateVariant(win, config) { | ||
validateConfig(config); | ||
|
||
const cidScope = | ||
config.cidScope === undefined ? 'amp-experiment' : config.cidScope; | ||
|
||
return getBucketTicket(win, cidScope).then(bucketTicket => { | ||
let upperBound = 0; | ||
|
||
// Loop through keys in a specific order since the default object key | ||
// enumeration is implementation (browser) dependent. | ||
const variantNames = Object.keys(config.variants).sort(); | ||
for (let i = 0; i < variantNames.length; i++) { | ||
upperBound += config.variants[variantNames[i]]; | ||
if (bucketTicket < upperBound) { | ||
return variantNames[i]; | ||
} | ||
} | ||
return null; | ||
}); | ||
} | ||
|
||
/** | ||
* Validates an experiment config. | ||
* @param {!Object} config | ||
* @throws {!Error} | ||
*/ | ||
function validateConfig(config) { | ||
const variants = config.variants; | ||
user.assert(isObject(variants) && Object.keys(variants).length > 0, | ||
'Missing experiment variants config.'); | ||
|
||
let totalPercentage = 0; | ||
for (const variantName in variants) { | ||
if (variants.hasOwnProperty(variantName)) { | ||
user.assert(nameValidator.test(variantName), | ||
'Invalid variant name: %s. Allowed chars are [a-zA-Z0-9-_].', | ||
variantName); | ||
|
||
const percentage = variants[variantName]; | ||
user.assert( | ||
typeof percentage === 'number' && percentage > 0 && percentage < 100, | ||
'Invalid percentage %s:%s. Has to be in range of (0,100)', | ||
variantName, percentage); | ||
totalPercentage += percentage; | ||
} | ||
} | ||
user.assert(totalPercentage./*avoid float precision error*/toFixed(6) <= 100, | ||
'Total percentage is bigger than 100: ' + totalPercentage); | ||
} | ||
|
||
/** | ||
* Returns a float number (bucket ticket) in the range of [0, 100). The number | ||
* is hashed from the CID of the given scope (opt_cidScope). If the | ||
* scope is not provided, a random number is used. | ||
* @param {!Window} win | ||
* @param {string=} opt_cidScope | ||
* @return {!Promise<!number>} a float number in the range of [0, 100) | ||
*/ | ||
function getBucketTicket(win, opt_cidScope) { | ||
if (opt_cidScope) { | ||
// TODO(@lannka, #1411): implement hashing with CID | ||
return Promise.resolve(1); | ||
} else { | ||
return Promise.resolve(win.Math.random() * 100); | ||
} | ||
} |