diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.js b/packages/optimizely-sdk/lib/core/audience_evaluator/index.js index 14f7cc594..2cd416151 100644 --- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.js +++ b/packages/optimizely-sdk/lib/core/audience_evaluator/index.js @@ -13,21 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -var conditionEvaluator = require('../condition_evaluator'); +var conditionTreeEvaluator = require('../condition_tree_evaluator'); +var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator'); module.exports = { /** * Determine if the given user attributes satisfy the given audience conditions - * @param {Object[]} audiences Audiences to match the user attributes against - * @param {Object[]} audiences.conditions Audience conditions to match the user attributes against - * @param {Object} [userAttributes] Hash representing user attributes which will be used in - * determining if the audience conditions are met. If not - * provided, defaults to an empty object. - * @return {Boolean} True if the user attributes match the given audience conditions + * @param {Array|String|null|undefined} audienceConditions Audience conditions to match the user attributes against - can be an array + * of audience IDs, a nested array of conditions, or a single leaf condition. + * Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"], "1" + * @param {Object} audiencesById Object providing access to full audience objects for audience IDs + * contained in audienceConditions. Keys should be audience IDs, values + * should be full audience objects with conditions properties + * @param {Object} [userAttributes] User attributes which will be used in determining if audience conditions + * are met. If not provided, defaults to an empty object + * @return {Boolean} true if the user attributes match the given audience conditions, false + * otherwise */ - evaluate: function(audiences, userAttributes) { + evaluate: function(audienceConditions, audiencesById, userAttributes) { // if there are no audiences, return true because that means ALL users are included in the experiment - if (!audiences || audiences.length === 0) { + if (!audienceConditions || audienceConditions.length === 0) { return true; } @@ -35,14 +40,18 @@ module.exports = { userAttributes = {}; } - for (var i = 0; i < audiences.length; i++) { - var audience = audiences[i]; - var conditions = audience.conditions; - if (conditionEvaluator.evaluate(conditions, userAttributes)) { - return true; + var evaluateConditionWithUserAttributes = function(condition) { + return customAttributeConditionEvaluator.evaluate(condition, userAttributes); + }; + + var evaluateAudience = function(audienceId) { + var audience = audiencesById[audienceId]; + if (audience) { + return conditionTreeEvaluator.evaluate(audience.conditions, evaluateConditionWithUserAttributes); } - } + return null; + }; - return false; + return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false; }, }; diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js index ca7d89094..7fdfd9bce 100644 --- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js +++ b/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js @@ -15,6 +15,10 @@ */ var audienceEvaluator = require('./'); var chai = require('chai'); +var conditionTreeEvaluator = require('../condition_tree_evaluator'); +var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator'); +var sinon = require('sinon'); + var assert = chai.assert; var chromeUserAudience = { @@ -31,16 +35,29 @@ var iphoneUserAudience = { type: 'custom_attribute', }], }; +var conditionsPassingWithNoAttrs = ['not', { + match: 'exists', + name: 'input_value', + type: 'custom_attribute', +}]; +var conditionsPassingWithNoAttrsAudience = { + conditions: conditionsPassingWithNoAttrs, +}; +var audiencesById = { + 0: chromeUserAudience, + 1: iphoneUserAudience, + 2: conditionsPassingWithNoAttrsAudience, +}; describe('lib/core/audience_evaluator', function() { describe('APIs', function() { describe('evaluate', function() { it('should return true if there are no audiences', function() { - assert.isTrue(audienceEvaluator.evaluate([], {})); + assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {})); }); it('should return false if there are audiences but no attributes', function() { - assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience], {})); + assert.isFalse(audienceEvaluator.evaluate(['0'], audiencesById, {})); }); it('should return true if any of the audience conditions are met', function() { @@ -57,9 +74,9 @@ describe('lib/core/audience_evaluator', function() { 'device_model': 'iphone', }; - assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], iphoneUsers)); - assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], chromeUsers)); - assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], iphoneChromeUsers)); + assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers)); + assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers)); + assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers)); }); it('should return false if none of the audience conditions are met', function() { @@ -76,21 +93,98 @@ describe('lib/core/audience_evaluator', function() { 'device_model': 'nexus5', }; - assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusUsers)); - assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], safariUsers)); - assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusSafariUsers)); + assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers)); + assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers)); + assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers)); }); it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() { - var conditionsPassingWithNoAttrs = ['not', { - match: 'exists', - name: 'input_value', - type: 'custom_attribute', - }]; - var audience = { - conditions: conditionsPassingWithNoAttrs, - }; - assert.isTrue(audienceEvaluator.evaluate([audience])); + assert.isTrue(audienceEvaluator.evaluate(['2'], audiencesById)); + }); + + describe('complex audience conditions', function() { + it('should return true if any of the audiences in an "OR" condition pass', function() { + var result = audienceEvaluator.evaluate( + ['or', '0', '1'], + audiencesById, + { browser_type: 'chrome' } + ); + assert.isTrue(result); + }); + + it('should return true if all of the audiences in an "AND" condition pass', function() { + var result = audienceEvaluator.evaluate( + ['and', '0', '1'], + audiencesById, + { browser_type: 'chrome', device_model: 'iphone' } + ); + assert.isTrue(result); + }); + + it('should return true if the audience in a "NOT" condition does not pass', function() { + var result = audienceEvaluator.evaluate( + ['not', '1'], + audiencesById, + { device_model: 'android' } + ); + assert.isTrue(result); + }); + + }); + + describe('integration with dependencies', function() { + var sandbox = sinon.sandbox.create(); + + beforeEach(function() { + sandbox.stub(conditionTreeEvaluator, 'evaluate'); + sandbox.stub(customAttributeConditionEvaluator, 'evaluate'); + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('returns true if conditionTreeEvaluator.evaluate returns true', function() { + conditionTreeEvaluator.evaluate.returns(true); + var result = audienceEvaluator.evaluate( + ['or', '0', '1'], + audiencesById, + { browser_type: 'chrome' } + ); + assert.isTrue(result); + }); + + it('returns false if conditionTreeEvaluator.evaluate returns false', function() { + conditionTreeEvaluator.evaluate.returns(false); + var result = audienceEvaluator.evaluate( + ['or', '0', '1'], + audiencesById, + { browser_type: 'safari' } + ); + assert.isFalse(result); + }); + + it('returns false if conditionTreeEvaluator.evaluate returns null', function() { + conditionTreeEvaluator.evaluate.returns(null); + var result = audienceEvaluator.evaluate( + ['or', '0', '1'], + audiencesById, + { state: 'California' } + ); + assert.isFalse(result); + }); + + it('calls customAttributeConditionEvaluator.evaluate in the leaf evaluator for audience conditions', function() { + conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) { + return leafEvaluator(conditions[1]); + }); + customAttributeConditionEvaluator.evaluate.returns(false); + var userAttributes = { device_model: 'android' }; + var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes); + sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate); + sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes); + assert.isFalse(result); + }); }); }); }); diff --git a/packages/optimizely-sdk/lib/core/condition_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/condition_evaluator/index.tests.js deleted file mode 100644 index 2d31e00bc..000000000 --- a/packages/optimizely-sdk/lib/core/condition_evaluator/index.tests.js +++ /dev/null @@ -1,497 +0,0 @@ -/**************************************************************************** - * Copyright 2016, 2018, Optimizely, Inc. and contributors * - * * - * 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. * - ***************************************************************************/ - -var chai = require('chai'); -var assert = chai.assert; -var conditionEvaluator = require('./'); - -var browserConditionSafari = { - name: 'browser_type', - value: 'safari', - type: 'custom_attribute', -}; -var deviceConditionIphone = { - name: 'device_model', - value: 'iphone6', - type: 'custom_attribute', -}; -var booleanCondition = { - name: 'is_firefox', - value: true, - type: 'custom_attribute', -}; -var integerCondition = { - name: 'num_users', - value: 10, - type: 'custom_attribute', -}; -var doubleCondition = { - name: 'pi_value', - value: 3.14, - type: 'custom_attribute', -}; -var exactSafariCondition = { - name: 'browser_type', - match: 'exact', - type: 'custom_attribute', - value: 'safari', -}; -var exactIphone6Condition = { - name: 'device_model', - match: 'exact', - type: 'custom_attribute', - value: 'iphone6', -}; -var exactLocationCondition = { - name: 'location', - match: 'exact', - type: 'custom_attribute', - value: 'CA', -}; - - -describe('lib/core/condition_evaluator', function() { - describe('APIs', function() { - describe('evaluate', function() { - it('should return true there is a match', function() { - var userAttributes = { - browser_type: 'safari', - }; - - assert.isTrue(conditionEvaluator.evaluate(['and', browserConditionSafari], userAttributes)); - }); - - it('should return false when there is no match', function() { - var userAttributes = { - browser_type: 'firefox', - }; - - assert.isFalse(conditionEvaluator.evaluate(['and', browserConditionSafari], userAttributes)); - }); - - it('should evaluate different typed attributes', function() { - var userAttributes = { - browser_type: 'safari', - is_firefox: true, - num_users: 10, - pi_value: 3.14, - }; - - assert.isTrue(conditionEvaluator.evaluate(['and', browserConditionSafari, booleanCondition, integerCondition, doubleCondition], userAttributes)); - }); - - it('should return null when condition has an invalid type property', function() { - var result = conditionEvaluator.evaluate( - ['and', { match: 'exact', name: 'weird_condition', type: 'weird', value: 'hi' }], - { weird_condition: 'bye' } - ); - assert.isNull(result); - }); - - it('should return null when condition has an invalid match property', function() { - var result = conditionEvaluator.evaluate( - ['and', { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' }], - { weird_condition: 'bye' } - ); - assert.isNull(result); - }); - - describe('and evaluation', function() { - it('should return true when ALL conditions evaluate to true', function() { - var userAttributes = { - 'browser_type': 'safari', - 'device_model': 'iphone6', - }; - - assert.isTrue(conditionEvaluator.evaluate(['and', browserConditionSafari, deviceConditionIphone], userAttributes)); - }); - - it('should return false if one condition evaluates to false', function() { - var userAttributes = { - 'browser_type': 'safari', - 'device_model': 'nexus7', - }; - - assert.isFalse(conditionEvaluator.evaluate(['and', browserConditionSafari, deviceConditionIphone], userAttributes)); - }); - - describe('null handling', function() { - it('should return null when all operands evaluate to null', function() { - var userAttributes = { - browser_type: 4.5, - device_model: false, - }; - assert.isNull(conditionEvaluator.evaluate(['and', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return null when operands evaluate to trues and nulls', function() { - var userAttributes = { - browser_type: 'safari', - device_model: false, - }; - assert.isNull(conditionEvaluator.evaluate(['and', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return false when operands evaluate to falses and nulls', function() { - var userAttributes = { - browser_type: 'firefox', - device_model: false, - }; - assert.isFalse(conditionEvaluator.evaluate(['and', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return false when operands evaluate to trues, falses, and nulls', function() { - var userAttributes = { - browser_type: 'safari', - device_model: false, - location: 'NY', - }; - assert.isFalse(conditionEvaluator.evaluate(['and', exactSafariCondition, exactIphone6Condition, exactLocationCondition], userAttributes)); - }); - }); - }); - - describe('or evaluation', function() { - it('should return true if any condition evaluates to true', function() { - var userAttributes = { - 'browser_type': 'safari', - 'device_model': 'nexus5', - }; - - assert.isTrue(conditionEvaluator.evaluate(['or', browserConditionSafari, deviceConditionIphone], userAttributes)); - }); - - it('should return false if all conditions evaluate to false', function() { - var userAttributes = { - 'browser_type': 'chrome', - 'device_model': 'nexus6', - }; - - assert.isFalse(conditionEvaluator.evaluate(['or', browserConditionSafari, deviceConditionIphone], userAttributes)); - }); - - describe('null handling', function() { - it('should return null when all operands evaluate to null', function() { - var userAttributes = { - browser_type: 4.5, - device_model: false, - }; - assert.isNull(conditionEvaluator.evaluate(['or', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return true when operands evaluate to trues and nulls', function() { - var userAttributes = { - browser_type: 'safari', - device_model: false, - }; - assert.isTrue(conditionEvaluator.evaluate(['or', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return null when operands evaluate to falses and nulls', function() { - var userAttributes = { - browser_type: 'firefox', - device_model: false, - }; - assert.isNull(conditionEvaluator.evaluate(['or', exactSafariCondition, exactIphone6Condition], userAttributes)); - }); - - it('should return true when operands evaluate to trues, falses, and nulls', function() { - var userAttributes = { - browser_type: 'safari', - device_model: false, - location: 'NY', - }; - assert.isTrue(conditionEvaluator.evaluate(['or', exactSafariCondition, exactIphone6Condition, exactLocationCondition], userAttributes)); - }); - }); - }); - - describe('not evaluation', function() { - it('should return true if the condition evaluates to false', function() { - var userAttributes = { - 'browser_type': 'chrome', - }; - - assert.isTrue(conditionEvaluator.evaluate(['not', browserConditionSafari], userAttributes)); - }); - - it('should return false if the condition evaluates to true', function() { - var userAttributes = { - 'device_model': 'iphone6', - }; - - assert.isFalse(conditionEvaluator.evaluate(['not', deviceConditionIphone], userAttributes)); - }); - - describe('null handling', function() { - it('should return null when operand evaluates to null', function() { - var userAttributes = { - browser_type: 4.5, - }; - assert.isNull(conditionEvaluator.evaluate(['not', exactSafariCondition], userAttributes)); - }); - }); - }); - - describe('implicit operator', function() { - it('should behave like an "or" operator when the first item in the array is not a recognized operator', function() { - var userAttributes = { - browser_type: 'safari', - device_model: 'nexus5', - }; - assert.isTrue(conditionEvaluator.evaluate([browserConditionSafari, deviceConditionIphone], userAttributes)); - userAttributes = { - browser_type: 'chrome', - device_model: 'nexus6', - }; - assert.isFalse(conditionEvaluator.evaluate([browserConditionSafari, deviceConditionIphone], userAttributes)); - }); - }); - - describe('exists match type', function() { - var existsConditions = ['and', { - match: 'exists', - name: 'input_value', - type: 'custom_attribute', - }]; - - it('should return false if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(existsConditions, {}); - assert.isFalse(result); - }); - - it('should return false if the user-provided value is undefined', function() { - var result = conditionEvaluator.evaluate(existsConditions, { input_value: undefined }); - assert.isFalse(result); - }); - - it('should return false if the user-provided value is null', function() { - var result = conditionEvaluator.evaluate(existsConditions, { input_value: null }); - assert.isFalse(result); - }); - - it('should return true if the user-provided value is a string', function() { - var result = conditionEvaluator.evaluate(existsConditions, { input_value: 'hi' }); - assert.isTrue(result); - }); - - it('should return true if the user-provided value is a number', function() { - var result = conditionEvaluator.evaluate(existsConditions, { input_value: 10 }); - assert.isTrue(result); - }); - - it('should return true if the user-provided value is a boolean', function() { - var result = conditionEvaluator.evaluate(existsConditions, { input_value: true }); - assert.isTrue(result); - }); - }); - - describe('exact match type', function() { - describe('with a string condition value', function() { - var exactStringConditions = ['and', { - match: 'exact', - name: 'favorite_constellation', - type: 'custom_attribute', - value: 'Lacerta', - }]; - - it('should return true if the user-provided value is equal to the condition value', function() { - var result = conditionEvaluator.evaluate(exactStringConditions, { favorite_constellation: 'Lacerta' }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not equal to the condition value', function() { - var result = conditionEvaluator.evaluate(exactStringConditions, { favorite_constellation: 'The Big Dipper' }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is of a different type than the condition value', function() { - var result = conditionEvaluator.evaluate(exactStringConditions, { favorite_constellation: false }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(exactStringConditions, {}); - assert.isNull(result); - }); - }); - - describe('with a number condition value', function() { - var exactNumberConditions = ['and', { - match: 'exact', - name: 'lasers_count', - type: 'custom_attribute', - value: 9000, - }]; - - it('should return true if the user-provided value is equal to the condition value', function() { - var result = conditionEvaluator.evaluate(exactNumberConditions, { lasers_count: 9000 }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not equal to the condition value', function() { - var result = conditionEvaluator.evaluate(exactNumberConditions, { lasers_count: 8000 }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is of a different type than the condition value', function() { - var result = conditionEvaluator.evaluate(exactNumberConditions, { lasers_count: 'yes' }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(exactNumberConditions, {}); - assert.isNull(result); - }); - }); - - describe('with a boolean condition value', function() { - var boolExactConditions = ['and', { - match: 'exact', - name: 'did_register_user', - type: 'custom_attribute', - value: false, - }]; - - it('should return true if the user-provided value is equal to the condition value', function() { - var result = conditionEvaluator.evaluate(boolExactConditions, { did_register_user: false }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not equal to the condition value', function() { - var result = conditionEvaluator.evaluate(boolExactConditions, { did_register_user: true }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is of a different type than the condition value', function() { - var result = conditionEvaluator.evaluate(boolExactConditions, { did_register_user: 10 }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(boolExactConditions, {}); - assert.isNull(result); - }); - }); - }); - - describe('substring match type', function() { - var substringConditions = ['and', { - match: 'substring', - name: 'headline_text', - type: 'custom_attribute', - value: 'buy now', - }]; - - it('should return true if the condition value is a substring of the user-provided value', function() { - var result = conditionEvaluator.evaluate(substringConditions, { - headline_text: 'Limited time, buy now!', - }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not a substring of the condition value', function() { - var result = conditionEvaluator.evaluate(substringConditions, { - headline_text: 'Breaking news!', - }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is not a string', function() { - var result = conditionEvaluator.evaluate(substringConditions, { - headline_text: 10, - }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(substringConditions, {}); - assert.isNull(result); - }); - }); - - describe('greater than match type', function() { - var gtConditions = ['and', { - match: 'gt', - name: 'meters_travelled', - type: 'custom_attribute', - value: 48.2, - }]; - - it('should return true if the user-provided value is greater than the condition value', function() { - var result = conditionEvaluator.evaluate(gtConditions, { - meters_travelled: 58.4, - }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not greater than the condition value', function() { - var result = conditionEvaluator.evaluate(gtConditions, { - meters_travelled: 20, - }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is not a number', function() { - var result = conditionEvaluator.evaluate(gtConditions, { - meters_travelled: 'a long way', - }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(gtConditions, {}); - assert.isNull(result); - }); - }); - - describe('less than match type', function() { - var ltConditions = ['and', { - match: 'lt', - name: 'meters_travelled', - type: 'custom_attribute', - value: 48.2, - }]; - - it('should return true if the user-provided value is less than the condition value', function() { - var result = conditionEvaluator.evaluate(ltConditions, { - meters_travelled: 10, - }); - assert.isTrue(result); - }); - - it('should return false if the user-provided value is not less than the condition value', function() { - var result = conditionEvaluator.evaluate(ltConditions, { - meters_travelled: 64.64, - }); - assert.isFalse(result); - }); - - it('should return null if the user-provided value is not a number', function() { - var result = conditionEvaluator.evaluate(ltConditions, { - meters_travelled: true, - }); - assert.isNull(result); - }); - - it('should return null if there is no user-provided value', function() { - var result = conditionEvaluator.evaluate(ltConditions, {}); - assert.isNull(result); - }); - }); - }); - }); -}); diff --git a/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.js b/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.js new file mode 100644 index 000000000..2f5a11c2b --- /dev/null +++ b/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.js @@ -0,0 +1,125 @@ +/**************************************************************************** + * Copyright 2018, Optimizely, Inc. and contributors * + * * + * 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. * + ***************************************************************************/ + +var AND_CONDITION = 'and'; +var OR_CONDITION = 'or'; +var NOT_CONDITION = 'not'; + +var DEFAULT_OPERATOR_TYPES = [AND_CONDITION, OR_CONDITION, NOT_CONDITION]; + +/** + * Top level method to evaluate conditions + * @param {Array|*} conditions Nested array of and/or conditions, or a single leaf + * condition value of any type + * Example: ['and', '0', ['or', '1', '2']] + * @param {Function} leafEvaluator Function which will be called to evaluate leaf condition + * values + * @return {?Boolean} Result of evaluating the conditions using the operator + * rules and the leaf evaluator. A return value of null + * indicates that the conditions are invalid or unable to be + * evaluated + */ +function evaluate(conditions, leafEvaluator) { + if (Array.isArray(conditions)) { + var firstOperator = conditions[0]; + var restOfConditions = conditions.slice(1); + + if (DEFAULT_OPERATOR_TYPES.indexOf(firstOperator) === -1) { + // Operator to apply is not explicit - assume 'or' + firstOperator = OR_CONDITION; + restOfConditions = conditions; + } + + switch (firstOperator) { + case AND_CONDITION: + return andEvaluator(restOfConditions, leafEvaluator); + case NOT_CONDITION: + return notEvaluator(restOfConditions, leafEvaluator); + default: // firstOperator is OR_CONDITION + return orEvaluator(restOfConditions, leafEvaluator); + } + } + + var leafCondition = conditions; + return leafEvaluator(leafCondition); +} + +/** + * Evaluates an array of conditions as if the evaluator had been applied + * to each entry and the results AND-ed together. + * @param {Array} conditions Array of conditions ex: [operand_1, operand_2] + * @param {Function} leafEvaluator Function which will be called to evaluate leaf condition values + * @return {?Boolean} Result of evaluating the conditions. A return value of null + * indicates that the conditions are invalid or unable to be + * evaluated. + */ +function andEvaluator(conditions, leafEvaluator) { + var sawNullResult = false; + for (var i = 0; i < conditions.length; i++) { + var conditionResult = evaluate(conditions[i], leafEvaluator); + if (conditionResult === false) { + return false; + } + if (conditionResult === null) { + sawNullResult = true; + } + } + return sawNullResult ? null : true; +} + +/** + * Evaluates an array of conditions as if the evaluator had been applied + * to a single entry and NOT was applied to the result. + * @param {Array} conditions Array of conditions ex: [operand_1] + * @param {Function} leafEvaluator Function which will be called to evaluate leaf condition values + * @return {?Boolean} Result of evaluating the conditions. A return value of null + * indicates that the conditions are invalid or unable to be + * evaluated. + */ +function notEvaluator(conditions, leafEvaluator) { + if (conditions.length > 0) { + var result = evaluate(conditions[0], leafEvaluator); + return result === null ? null : !result; + } + return null; +} + +/** + * Evaluates an array of conditions as if the evaluator had been applied + * to each entry and the results OR-ed together. + * @param {Array} conditions Array of conditions ex: [operand_1, operand_2] + * @param {Function} leafEvaluator Function which will be called to evaluate leaf condition values + * @return {?Boolean} Result of evaluating the conditions. A return value of null + * indicates that the conditions are invalid or unable to be + * evaluated. + */ +function orEvaluator(conditions, leafEvaluator) { + var sawNullResult = false; + for (var i = 0; i < conditions.length; i++) { + var conditionResult = evaluate(conditions[i], leafEvaluator); + if (conditionResult === true) { + return true; + } + if (conditionResult === null) { + sawNullResult = true; + } + } + return sawNullResult ? null : false; +} + +module.exports = { + evaluate: evaluate, +}; diff --git a/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js new file mode 100644 index 000000000..22ca3c6e0 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/condition_tree_evaluator/index.tests.js @@ -0,0 +1,240 @@ +/**************************************************************************** + * Copyright 2018, Optimizely, Inc. and contributors * + * * + * 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. * + ***************************************************************************/ + +var chai = require('chai'); +var sinon = require('sinon'); +var assert = chai.assert; +var conditionTreeEvaluator = require('./'); + +var conditionA = { + name: 'browser_type', + value: 'safari', + type: 'custom_attribute', +}; +var conditionB = { + name: 'device_model', + value: 'iphone6', + type: 'custom_attribute', +}; +var conditionC = { + name: 'location', + match: 'exact', + type: 'custom_attribute', + value: 'CA', +}; + +describe('lib/core/condition_tree_evaluator', function() { + describe('APIs', function() { + describe('evaluate', function() { + it('should return true for a leaf condition when the leaf condition evaluator returns true', function() { + assert.isTrue(conditionTreeEvaluator.evaluate(conditionA, function() { return true; })); + }); + + it('should return false for a leaf condition when the leaf condition evaluator returns false', function() { + assert.isFalse(conditionTreeEvaluator.evaluate(conditionA, function() { return false; })); + }); + + describe('and evaluation', function() { + it('should return true when ALL conditions evaluate to true', function() { + assert.isTrue(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + function() { return true; } + )); + }); + + it('should return false if one condition evaluates to false', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(false); + assert.isFalse(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + leafEvaluator + )); + }); + + describe('null handling', function() { + it('should return null when all operands evaluate to null', function() { + assert.isNull(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + function() { return null; } + )); + }); + + it('should return null when operands evaluate to trues and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(null); + assert.isNull(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + leafEvaluator + )); + }); + + it('should return false when operands evaluate to falses and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(false); + leafEvaluator.onCall(1).returns(null); + assert.isFalse(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + leafEvaluator + )); + + leafEvaluator.reset(); + leafEvaluator.onCall(0).returns(null); + leafEvaluator.onCall(1).returns(false); + assert.isFalse(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB], + leafEvaluator + )); + + }); + + it('should return false when operands evaluate to trues, falses, and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(false); + leafEvaluator.onCall(2).returns(null); + assert.isFalse(conditionTreeEvaluator.evaluate( + ['and', conditionA, conditionB, conditionC], + leafEvaluator + )); + }); + }); + }); + + describe('or evaluation', function() { + it('should return true if any condition evaluates to true', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(false); + leafEvaluator.onCall(1).returns(true); + assert.isTrue(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + leafEvaluator + )); + }); + + it('should return false if all conditions evaluate to false', function() { + assert.isFalse(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + function() { return false; } + )); + }); + + describe('null handling', function() { + it('should return null when all operands evaluate to null', function() { + assert.isNull(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + function() { return null; } + )); + }); + + it('should return true when operands evaluate to trues and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(null); + assert.isTrue(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + leafEvaluator + )); + }); + + it('should return null when operands evaluate to falses and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(null); + leafEvaluator.onCall(1).returns(false); + assert.isNull(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + leafEvaluator + )); + + leafEvaluator.reset(); + leafEvaluator.onCall(0).returns(false); + leafEvaluator.onCall(1).returns(null); + assert.isNull(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB], + leafEvaluator + )); + }); + + it('should return true when operands evaluate to trues, falses, and nulls', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(null); + leafEvaluator.onCall(2).returns(false); + assert.isTrue(conditionTreeEvaluator.evaluate( + ['or', conditionA, conditionB, conditionC], + leafEvaluator + )); + }); + }); + }); + + describe('not evaluation', function() { + it('should return true if the condition evaluates to false', function() { + assert.isTrue(conditionTreeEvaluator.evaluate(['not', conditionA], function() { return false; })); + }); + + it('should return false if the condition evaluates to true', function() { + assert.isFalse(conditionTreeEvaluator.evaluate(['not', conditionB], function() { return true; })); + }); + + it('should return the result of negating the first condition, and ignore any additional conditions', function() { + var result = conditionTreeEvaluator.evaluate( + ['not', '1', '2', '1'], + function(id) { return id === '1'; } + ); + assert.isFalse(result); + result = conditionTreeEvaluator.evaluate( + ['not', '1', '2', '1'], + function(id) { return id === '2'; } + ); + assert.isTrue(result); + result = conditionTreeEvaluator.evaluate( + ['not', '1', '2', '3'], + function(id) { return id === '1' ? null : id === '3'; } + ); + assert.isNull(result); + }); + + describe('null handling', function() { + it('should return null when operand evaluates to null', function() { + assert.isNull(conditionTreeEvaluator.evaluate(['not', conditionA], function() { return null; })); + }); + + it('should return null when there are no operands', function() { + assert.isNull(conditionTreeEvaluator.evaluate(['not'], function() { return null; })); + }); + }); + }); + + describe('implicit operator', function() { + it('should behave like an "or" operator when the first item in the array is not a recognized operator', function() { + var leafEvaluator = sinon.stub(); + leafEvaluator.onCall(0).returns(true); + leafEvaluator.onCall(1).returns(false); + assert.isTrue(conditionTreeEvaluator.evaluate( + [conditionA, conditionB], + leafEvaluator + )); + assert.isFalse(conditionTreeEvaluator.evaluate( + [conditionA, conditionB], + function() { return false; } + )); + }); + }); + }); + }); +}); diff --git a/packages/optimizely-sdk/lib/core/condition_evaluator/index.js b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js similarity index 59% rename from packages/optimizely-sdk/lib/core/condition_evaluator/index.js rename to packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js index 9f566c768..4081bae8c 100644 --- a/packages/optimizely-sdk/lib/core/condition_evaluator/index.js +++ b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016, 2018, Optimizely, Inc. and contributors * + * Copyright 2018, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -16,12 +16,6 @@ var fns = require('../../utils/fns'); -var AND_CONDITION = 'and'; -var OR_CONDITION = 'or'; -var NOT_CONDITION = 'not'; - -var DEFAULT_OPERATOR_TYPES = [AND_CONDITION, OR_CONDITION, NOT_CONDITION]; - var CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute'; var EXACT_MATCH_TYPE = 'exact'; @@ -46,108 +40,25 @@ EVALUATORS_BY_MATCH_TYPE[LESS_THAN_MATCH_TYPE] = lessThanEvaluator; EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator; /** - * Top level method to evaluate audience conditions - * @param {Object[]|Object} conditions Nested array of and/or conditions, or a single condition object - * Example: ['and', { type: 'custom_attribute', ... }, ['or', { type: 'custom_attribute', ... }, { type: 'custom_attribute', ... }]] - * @param {Object} userAttributes Hash representing user attributes which will be used in determining if - * the audience conditions are met. - * @return {?Boolean} true/false if the given user attributes match/don't match the given conditions, null if - * the given user attributes and conditions can't be evaluated + * Given a custom attribute audience condition and user attributes, evaluate the + * condition against the attributes. + * @param {Object} condition + * @param {Object} userAttributes + * @return {?Boolean} true/false if the given user attributes match/don't match the given condition, null if + * the given user attributes and condition can't be evaluated */ -function evaluate(conditions, userAttributes) { - if (Array.isArray(conditions)) { - var firstOperator = conditions[0]; - var restOfConditions = conditions.slice(1); - - if (DEFAULT_OPERATOR_TYPES.indexOf(firstOperator) === -1) { - // Operator to apply is not explicit - assume 'or' - firstOperator = OR_CONDITION; - restOfConditions = conditions; - } - - switch (firstOperator) { - case AND_CONDITION: - return andEvaluator(restOfConditions, userAttributes); - case NOT_CONDITION: - return notEvaluator(restOfConditions, userAttributes); - default: // firstOperator is OR_CONDITION - return orEvaluator(restOfConditions, userAttributes); - } - } - - var leafCondition = conditions; - - if (leafCondition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) { +function evaluate(condition, userAttributes) { + if (condition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) { return null; } - var conditionMatch = leafCondition.match; + var conditionMatch = condition.match; if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) { return null; } var evaluatorForMatch = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || exactEvaluator; - return evaluatorForMatch(leafCondition, userAttributes); -} - -/** - * Evaluates an array of conditions as if the evaluator had been applied - * to each entry and the results AND-ed together. - * @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2] - * @param {Object} userAttributes Hash representing user attributes - * @return {?Boolean} true/false if the user attributes match/don't match the given conditions, - * null if the user attributes and conditions can't be evaluated - */ -function andEvaluator(conditions, userAttributes) { - var sawNullResult = false; - for (var i = 0; i < conditions.length; i++) { - var conditionResult = evaluate(conditions[i], userAttributes); - if (conditionResult === false) { - return false; - } - if (conditionResult === null) { - sawNullResult = true; - } - } - return sawNullResult ? null : true; -} - -/** - * Evaluates an array of conditions as if the evaluator had been applied - * to a single entry and NOT was applied to the result. - * @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2] - * @param {Object} userAttributes Hash representing user attributes - * @return {?Boolean} true/false if the user attributes match/don't match the given conditions, - * null if the user attributes and conditions can't be evaluated - */ -function notEvaluator(conditions, userAttributes) { - if (conditions.length > 0) { - var result = evaluate(conditions[0], userAttributes); - return result === null ? null : !result; - } - return null; -} - -/** - * Evaluates an array of conditions as if the evaluator had been applied - * to each entry and the results OR-ed together. - * @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2] - * @param {Object} userAttributes Hash representing user attributes - * @return {?Boolean} true/false if the user attributes match/don't match the given conditions, - * null if the user attributes and conditions can't be evaluated - */ -function orEvaluator(conditions, userAttributes) { - var sawNullResult = false; - for (var i = 0; i < conditions.length; i++) { - var conditionResult = evaluate(conditions[i], userAttributes); - if (conditionResult === true) { - return true; - } - if (conditionResult === null) { - sawNullResult = true; - } - } - return sawNullResult ? null : false; + return evaluatorForMatch(condition, userAttributes); } /** @@ -261,5 +172,5 @@ function substringEvaluator(condition, userAttributes) { } module.exports = { - evaluate: evaluate, + evaluate: evaluate }; diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js new file mode 100644 index 000000000..47e93da1c --- /dev/null +++ b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js @@ -0,0 +1,339 @@ +/**************************************************************************** + * Copyright 2018, Optimizely, Inc. and contributors * + * * + * 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. * + ***************************************************************************/ + +var chai = require('chai'); +var customAttributeEvaluator = require('./'); + +var assert = chai.assert; + +var browserConditionSafari = { + name: 'browser_type', + value: 'safari', + type: 'custom_attribute', +}; +var booleanCondition = { + name: 'is_firefox', + value: true, + type: 'custom_attribute', +}; +var integerCondition = { + name: 'num_users', + value: 10, + type: 'custom_attribute', +}; +var doubleCondition = { + name: 'pi_value', + value: 3.14, + type: 'custom_attribute', +}; + +describe('lib/core/custom_attribute_condition_evaluator', function() { + it('should return true when the attributes pass the audience conditions and no match type is provided', function() { + var userAttributes = { + browser_type: 'safari', + }; + + assert.isTrue(customAttributeEvaluator.evaluate(browserConditionSafari, userAttributes)); + }); + + it('should return false when the attributes do not pass the audience conditions and no match type is provided', function() { + var userAttributes = { + browser_type: 'firefox', + }; + + assert.isFalse(customAttributeEvaluator.evaluate(browserConditionSafari, userAttributes)); + }); + + it('should evaluate different typed attributes', function() { + var userAttributes = { + browser_type: 'safari', + is_firefox: true, + num_users: 10, + pi_value: 3.14, + }; + + assert.isTrue(customAttributeEvaluator.evaluate(browserConditionSafari, userAttributes)); + assert.isTrue(customAttributeEvaluator.evaluate(booleanCondition, userAttributes)); + assert.isTrue(customAttributeEvaluator.evaluate(integerCondition, userAttributes)); + assert.isTrue(customAttributeEvaluator.evaluate(doubleCondition, userAttributes)); + }); + + it('should return null when condition has an invalid type property', function() { + var result = customAttributeEvaluator.evaluate( + { match: 'exact', name: 'weird_condition', type: 'weird', value: 'hi' }, + { weird_condition: 'bye' } + ); + assert.isNull(result); + }); + + it('should return null when condition has no type property', function() { + var result = customAttributeEvaluator.evaluate( + { match: 'exact', name: 'weird_condition', value: 'hi' }, + { weird_condition: 'bye' } + ); + assert.isNull(result); + }); + + it('should return null when condition has an invalid match property', function() { + var result = customAttributeEvaluator.evaluate( + { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' }, + { weird_condition: 'bye' } + ); + assert.isNull(result); + }); + + describe('exists match type', function() { + var existsCondition = { + match: 'exists', + name: 'input_value', + type: 'custom_attribute', + }; + + it('should return false if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, {}); + assert.isFalse(result); + }); + + it('should return false if the user-provided value is undefined', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, { input_value: undefined }); + assert.isFalse(result); + }); + + it('should return false if the user-provided value is null', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, { input_value: null }); + assert.isFalse(result); + }); + + it('should return true if the user-provided value is a string', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, { input_value: 'hi' }); + assert.isTrue(result); + }); + + it('should return true if the user-provided value is a number', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, { input_value: 10 }); + assert.isTrue(result); + }); + + it('should return true if the user-provided value is a boolean', function() { + var result = customAttributeEvaluator.evaluate(existsCondition, { input_value: true }); + assert.isTrue(result); + }); + }); + + describe('exact match type', function() { + describe('with a string condition value', function() { + var exactStringCondition = { + match: 'exact', + name: 'favorite_constellation', + type: 'custom_attribute', + value: 'Lacerta', + }; + + it('should return true if the user-provided value is equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactStringCondition, { favorite_constellation: 'Lacerta' }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactStringCondition, { favorite_constellation: 'The Big Dipper' }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is of a different type than the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactStringCondition, { favorite_constellation: false }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(exactStringCondition, {}); + assert.isNull(result); + }); + }); + + describe('with a number condition value', function() { + var exactNumberCondition = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: 9000, + }; + + it('should return true if the user-provided value is equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactNumberCondition, { lasers_count: 9000 }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactNumberCondition, { lasers_count: 8000 }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is of a different type than the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactNumberCondition, { lasers_count: 'yes' }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(exactNumberCondition, {}); + assert.isNull(result); + }); + }); + + describe('with a boolean condition value', function() { + var exactBoolCondition = { + match: 'exact', + name: 'did_register_user', + type: 'custom_attribute', + value: false, + }; + + it('should return true if the user-provided value is equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactBoolCondition, { did_register_user: false }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not equal to the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactBoolCondition, { did_register_user: true }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is of a different type than the condition value', function() { + var result = customAttributeEvaluator.evaluate(exactBoolCondition, { did_register_user: 10 }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(exactBoolCondition, {}); + assert.isNull(result); + }); + }); + }); + + describe('substring match type', function() { + var substringCondition = { + match: 'substring', + name: 'headline_text', + type: 'custom_attribute', + value: 'buy now', + }; + + it('should return true if the condition value is a substring of the user-provided value', function() { + var result = customAttributeEvaluator.evaluate(substringCondition, { + headline_text: 'Limited time, buy now!', + }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not a substring of the condition value', function() { + var result = customAttributeEvaluator.evaluate(substringCondition, { + headline_text: 'Breaking news!', + }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is not a string', function() { + var result = customAttributeEvaluator.evaluate(substringCondition, { + headline_text: 10, + }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(substringCondition, {}); + assert.isNull(result); + }); + }); + + describe('greater than match type', function() { + var gtCondition = { + match: 'gt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + + it('should return true if the user-provided value is greater than the condition value', function() { + var result = customAttributeEvaluator.evaluate(gtCondition, { + meters_travelled: 58.4, + }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not greater than the condition value', function() { + var result = customAttributeEvaluator.evaluate(gtCondition, { + meters_travelled: 20, + }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is not a number', function() { + var result = customAttributeEvaluator.evaluate(gtCondition, { + meters_travelled: 'a long way', + }); + assert.isNull(result); + + var result = customAttributeEvaluator.evaluate(gtCondition, { + meters_travelled: '1000', + }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(gtCondition, {}); + assert.isNull(result); + }); + }); + + describe('less than match type', function() { + var ltCondition = { + match: 'lt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + + it('should return true if the user-provided value is less than the condition value', function() { + var result = customAttributeEvaluator.evaluate(ltCondition, { + meters_travelled: 10, + }); + assert.isTrue(result); + }); + + it('should return false if the user-provided value is not less than the condition value', function() { + var result = customAttributeEvaluator.evaluate(ltCondition, { + meters_travelled: 64.64, + }); + assert.isFalse(result); + }); + + it('should return null if the user-provided value is not a number', function() { + var result = customAttributeEvaluator.evaluate(ltCondition, { + meters_travelled: true, + }); + assert.isNull(result); + + var result = customAttributeEvaluator.evaluate(ltCondition, { + meters_travelled: '48.2', + }); + assert.isNull(result); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(ltCondition, {}); + assert.isNull(result); + }); + }); +}); diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.js b/packages/optimizely-sdk/lib/core/decision_service/index.js index 13a8d4cf6..3aca9b89c 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.js @@ -151,8 +151,9 @@ DecisionService.prototype.__getWhitelistedVariation = function(experiment, userI * @return {boolean} True if user meets audience conditions */ DecisionService.prototype.__checkIfUserIsInAudience = function(experimentKey, userId, attributes) { - var audiences = projectConfig.getAudiencesForExperiment(this.configObj, experimentKey); - if (!audienceEvaluator.evaluate(audiences, attributes)) { + var experimentAudienceConditions = projectConfig.getExperimentAudienceConditions(this.configObj, experimentKey); + var audiencesById = projectConfig.getAudiencesById(this.configObj); + if (!audienceEvaluator.evaluate(experimentAudienceConditions, audiencesById, attributes)) { var userDoesNotMeetConditionsLogMessage = sprintf(LOG_MESSAGES.USER_NOT_IN_EXPERIMENT, MODULE_NAME, userId, experimentKey); this.logger.log(LOG_LEVEL.INFO, userDoesNotMeetConditionsLogMessage); return false; diff --git a/packages/optimizely-sdk/lib/core/project_config/index.js b/packages/optimizely-sdk/lib/core/project_config/index.js index 3b703f98d..e8415d9bd 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.js @@ -206,27 +206,21 @@ module.exports = { }, /** - * Get audiences for the experiment + * Get audience conditions for the experiment * @param {Object} projectConfig Object representing project configuration - * @param {string} experimentKey Experiment key for which audience IDs are to be determined - * @return {Array} Audiences corresponding to the experiment + * @param {string} experimentKey Experiment key for which audience conditions are to be determined + * @return {Array} Audience conditions for the experiment - can be an array of audience IDs, or a + * nested array of conditions + * Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"] * @throws If experiment key is not in datafile */ - getAudiencesForExperiment: function(projectConfig, experimentKey) { + getExperimentAudienceConditions: function(projectConfig, experimentKey) { var experiment = projectConfig.experimentKeyMap[experimentKey]; if (fns.isEmpty(experiment)) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, MODULE_NAME, experimentKey)); } - var audienceIds = experiment.audienceIds; - var audiencesInExperiment = []; - fns.forEach(audienceIds, function(audienceId) { - var audience = projectConfig.audiencesById[audienceId]; - if (audience) { - audiencesInExperiment.push(audience); - } - }); - return audiencesInExperiment; + return experiment.audienceConditions || experiment.audienceIds; }, /** @@ -599,4 +593,14 @@ module.exports = { return castValue; }, + + /** + * Returns an object containing all audiences in the project config. Keys are audience IDs + * and values are audience objects. + * @param projectConfig + * @returns {Object} + */ + getAudiencesById: function(projectConfig) { + return projectConfig.audiencesById; + }, }; diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index 57b3cc151..8d56c1599 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -297,17 +297,6 @@ describe('lib/core/project_config', function() { }, sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, 'PROJECT_CONFIG', 'invalidExperimentKey')); }); - it('should retrieve audiences for valid experiment key in getAudiencesForExperiment', function() { - assert.deepEqual(projectConfig.getAudiencesForExperiment(configObj, testData.experiments[1].key), - parsedAudiences); - }); - - it('should throw error for invalid experiment key in getAudiencesForExperiment', function() { - assert.throws(function() { - projectConfig.getAudiencesForExperiment(configObj, 'invalidExperimentKey'); - }, sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, 'PROJECT_CONFIG', 'invalidExperimentKey')); - }); - it('should return true if experiment status is set to Running or Launch in isActive', function() { assert.isTrue(projectConfig.isActive(configObj, 'testExperiment')); @@ -532,18 +521,47 @@ describe('lib/core/project_config', function() { }); }); - describe('audience match types', function() { + describe('#getAudiencesById', function() { beforeEach(function() { configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); }); - it('should retrieve audiences in getAudiencesForExperiment by checking first in typedAudiences, and then second in audiences', function() { + it('should retrieve audiences by checking first in typedAudiences, and then second in audiences', function() { assert.deepEqual( - projectConfig.getAudiencesForExperiment(configObj, 'feat_with_var_test'), + projectConfig.getAudiencesById(configObj), testDatafile.parsedTypedAudiences ); }); }); + + describe('#getExperimentAudienceConditions', function() { + it('should retrieve audiences for valid experiment key', function() { + configObj = projectConfig.createProjectConfig(testData); + assert.deepEqual(projectConfig.getExperimentAudienceConditions(configObj, testData.experiments[1].key), + ['11154']); + }); + + it('should throw error for invalid experiment key', function() { + configObj = projectConfig.createProjectConfig(testData); + assert.throws(function() { + projectConfig.getExperimentAudienceConditions(configObj, 'invalidExperimentKey'); + }, sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, 'PROJECT_CONFIG', 'invalidExperimentKey')); + }); + + it('should return experiment audienceIds if experiment has no audienceConditions', function() { + configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + var result = projectConfig.getExperimentAudienceConditions(configObj, 'feat_with_var_test'); + assert.deepEqual(result, ['3468206642', '3988293898', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643']); + }); + + it('should return experiment audienceConditions if experiment has audienceConditions', function() { + configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + // audience_combinations_experiment has both audienceConditions and audienceIds + // audienceConditions should be preferred over audienceIds + var result = projectConfig.getExperimentAudienceConditions(configObj, 'audience_combinations_experiment'); + assert.deepEqual(result, ['and', ['or', '3468206642', '3988293898'], ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643']]); + }); + }); }); describe('#getForcedVariation', function() { diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 9a4ed4812..79ae49316 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -15,6 +15,7 @@ ***************************************************************************/ var Optimizely = require('./'); +var audienceEvaluator = require('../core/audience_evaluator'); var bluebird = require('bluebird'); var bucketer = require('../core/bucketer'); var enums = require('../utils/enums'); @@ -3418,4 +3419,171 @@ describe('lib/optimizely', function() { assert.strictEqual(variableValue, 'x'); }); }); + + describe('audience combinations', function() { + var sandbox = sinon.sandbox.create(); + var createdLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO}); + var optlyInstance; + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTypedAudiencesConfig(), + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + }); + + sandbox.stub(eventDispatcher, 'dispatchEvent'); + sandbox.stub(errorHandler, 'handleError'); + sandbox.stub(createdLogger, 'log'); + sandbox.spy(audienceEvaluator, 'evaluate'); + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('can activate an experiment with complex audience conditions', function() { + var variationKey = optlyInstance.activate('audience_combinations_experiment', 'user1', { + // Should be included via substring match string audience with id '3988293898', and + // exact match number audience with id '3468206646' + house: 'Welcome to Slytherin!', + lasers: 45.5, + }); + assert.strictEqual(variationKey, 'A'); + sinon.assert.calledOnce(eventDispatcher.dispatchEvent); + assert.includeDeepMembers( + eventDispatcher.dispatchEvent.getCall(0).args[0].params.visitors[0].attributes, + [ + { entity_id: '594015', key: 'house', type: 'custom', value: 'Welcome to Slytherin!' }, + { entity_id: '594016', key: 'lasers', type: 'custom', value: 45.5 }, + ] + ); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[2].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Welcome to Slytherin!', lasers: 45.5 } + ); + }); + + it('can exclude a user from an experiment with complex audience conditions', function() { + var variationKey = optlyInstance.activate('audience_combinations_experiment', 'user1', { + // Should be excluded - substring string audience with id '3988293898' does not match, + // so the overall conditions fail + house: 'Hufflepuff', + lasers: 45.5, + }); + assert.isNull(variationKey); + sinon.assert.notCalled(eventDispatcher.dispatchEvent); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[2].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Hufflepuff', lasers: 45.5 } + ); + }); + + it('can track an experiment with complex audience conditions', function() { + optlyInstance.track('user_signed_up', 'user1', { + // Should be included via exact match string audience with id '3468206642', and + // exact match boolean audience with id '3468206643' + house: 'Gryffindor', + should_do_it: true, + }); + sinon.assert.calledOnce(eventDispatcher.dispatchEvent); + assert.includeDeepMembers( + eventDispatcher.dispatchEvent.getCall(0).args[0].params.visitors[0].attributes, + [ + { entity_id: '594015', key: 'house', type: 'custom', value: 'Gryffindor' }, + { entity_id: '594017', key: 'should_do_it', type: 'custom', value: true } + ] + ); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[2].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Gryffindor', should_do_it: true } + ); + }); + + it('can exclude a user from an experiment with complex audience conditions via track', function() { + optlyInstance.track('user_signed_up', 'user1', { + // Should be excluded - exact match boolean audience with id '3468206643' does not match, + // so the overall conditions fail + house: 'Gryffindor', + should_do_it: false, + }); + sinon.assert.notCalled(eventDispatcher.dispatchEvent); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[2].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Gryffindor', should_do_it: false } + ); + }); + + it('can include a user in a rollout with complex audience conditions via isFeatureEnabled', function() { + var featureEnabled = optlyInstance.isFeatureEnabled('feat2', 'user1', { + // Should be included via substring match string audience with id '3988293898', and + // exists audience with id '3988293899' + house: '...Slytherinnn...sss.', + favorite_ice_cream: 'matcha', + }); + assert.isTrue(featureEnabled); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.rollouts[2].experiments[0].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: '...Slytherinnn...sss.', favorite_ice_cream: 'matcha' } + ); + }); + + it('can exclude a user from a rollout with complex audience conditions via isFeatureEnabled', function() { + var featureEnabled = optlyInstance.isFeatureEnabled('feat2', 'user1', { + // Should be excluded - substring match string audience with id '3988293898' does not match, + // and no audience in the other branch of the 'and' matches either + house: 'Lannister', + }); + assert.isFalse(featureEnabled); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.rollouts[2].experiments[0].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Lannister' } + ); + }); + + it('can return a variable value from a feature test with complex audience conditions via getFeatureVariableString', function() { + var variableValue = optlyInstance.getFeatureVariableInteger('feat2_with_var', 'z', 'user1', { + // Should be included via exact match string audience with id '3468206642', and + // greater than audience with id '3468206647' + house: 'Gryffindor', + lasers: 700, + }); + assert.strictEqual(variableValue, 150); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[3].audienceConditions, + optlyInstance.configObj.audiencesById, + { house: 'Gryffindor', lasers: 700 } + ); + }); + + it('can return the default value for a feature variable from getFeatureVariableString, via excluding a user from a feature test with complex audience conditions', function() { + var variableValue = optlyInstance.getFeatureVariableInteger('feat2_with_var', 'z', 'user1', { + // Should be excluded - no audiences match with no attributes + }); + assert.strictEqual(variableValue, 10); + sinon.assert.calledWithExactly( + audienceEvaluator.evaluate, + optlyInstance.configObj.experiments[3].audienceConditions, + optlyInstance.configObj.audiencesById, + {} + ); + }); + }); }); diff --git a/packages/optimizely-sdk/lib/tests/test_data.js b/packages/optimizely-sdk/lib/tests/test_data.js index 02e05b0e4..51e721155 100644 --- a/packages/optimizely-sdk/lib/tests/test_data.js +++ b/packages/optimizely-sdk/lib/tests/test_data.js @@ -1832,6 +1832,61 @@ var typedAudiencesConfig = { } ], 'id': '11638870867' + }, + { + 'experiments': [ + { + 'status': 'Running', + 'key': '11488548028', + 'layerId': '11551226732', + 'trafficAllocation': [ + { + 'entityId': '11557362670', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['0'], + 'audienceConditions': ['and', ['or', '3468206642', '3988293898'], ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643']], + 'variations': [ + { + 'variables': [], + 'id': '11557362670', + 'key': '11557362670', + 'featureEnabled': true + } + ], + 'forcedVariations': {}, + 'id': '11488548028' + } + ], + 'id': '11551226732' + }, + { + 'experiments': [ + { + 'status': 'Paused', + 'key': '11630490912', + 'layerId': '11638870868', + 'trafficAllocation': [ + { + 'entityId': '11475708559', + 'endOfRange': 0 + } + ], + 'audienceIds': [], + 'variations': [ + { + 'variables': [], + 'id': '11475708559', + 'key': '11475708559', + 'featureEnabled': false + } + ], + 'forcedVariations': {}, + 'id': '11630490912' + } + ], + 'id': '11638870868' } ], 'anonymizeIP': false, @@ -1860,7 +1915,28 @@ var typedAudiencesConfig = { ], 'id': '11567102051', 'key': 'feat_with_var' - } + }, + { + 'experimentIds': [], + 'rolloutId': '11551226732', + 'variables': [], + 'id': '11567102052', + 'key': 'feat2' + }, + { + 'experimentIds': ['1323241599'], + 'rolloutId': '11638870868', + 'variables': [ + { + 'defaultValue': '10', + 'type': 'integer', + 'id': '11535264367', + 'key': 'z' + } + ], + 'id': '11567102053', + 'key': 'feat2_with_var' + }, ], 'experiments': [ { @@ -1910,7 +1986,57 @@ var typedAudiencesConfig = { ], 'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], 'forcedVariations': {} - } + }, + { + 'id': '1323241598', + 'key': 'audience_combinations_experiment', + 'layerId': '1323241598', + 'status': 'Running', + 'variations': [ + { + 'id': '1423767504', + 'key': 'A', + 'variables': [] + } + ], + 'trafficAllocation': [ + { + 'entityId': '1423767504', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['0'], + 'audienceConditions': ['and', ['or', '3468206642', '3988293898'], ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643']], + 'forcedVariations': {} + }, + { + 'id': '1323241599', + 'key': 'feat2_with_var_test', + 'layerId': '1323241600', + 'status': 'Running', + 'variations': [ + { + 'variables': [ + { + 'id': '11535264367', + 'value': '150' + } + ], + 'id': '1423767505', + 'key': 'variation_2', + 'featureEnabled': true + } + ], + 'trafficAllocation': [ + { + 'entityId': '1423767505', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['0'], + 'audienceConditions': ['and', ['or', '3468206642', '3988293898'], ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643']], + 'forcedVariations': {} + }, ], 'audiences': [ { @@ -1948,6 +2074,11 @@ var typedAudiencesConfig = { 'id': '3468206643', 'name': '$$dummyExactBoolean', 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '0', + 'name': '$$dummy', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }', } ], 'typedAudiences': [ @@ -2011,6 +2142,11 @@ var typedAudiencesConfig = { '11564051718', '1323241597' ] + }, + { + 'key': 'user_signed_up', + 'id': '594090', + 'experimentIds': ['1323241598', '1323241599'], } ], 'revision': '3' @@ -2020,43 +2156,48 @@ var getTypedAudiencesConfig = function() { return cloneDeep(typedAudiencesConfig); }; -var parsedTypedAudiences = [ - { +var parsedTypedAudiences = { + 3468206642: { 'id': '3468206642', 'name': 'exactString', 'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute', 'value': 'Gryffindor'}]]] }, - { + 3988293898: { 'id': '3988293898', 'name': 'substringString', 'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute', 'match': 'substring', 'value': 'Slytherin'}]]], }, - { + 3988293899: { 'id': '3988293899', 'name': 'exists', 'conditions': ['and', ['or', ['or', {'name': 'favorite_ice_cream', 'type': 'custom_attribute', 'match': 'exists'}]]], }, - { + 3468206646: { 'id': '3468206646', 'name': 'exactNumber', 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', 'match': 'exact', 'value': 45.5}]]] }, - { + 3468206647: { 'id': '3468206647', 'name': 'gtNumber', 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', 'match': 'gt', 'value': 70}]]] }, - { + 3468206644: { 'id': '3468206644', 'name': 'ltNumber', 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', 'match': 'lt', 'value': 1.0}]]] }, - { + 3468206643: { 'id': '3468206643', 'name': 'exactBoolean', 'conditions': ['and', ['or', ['or', {'name': 'should_do_it', 'type': 'custom_attribute', 'match': 'exact', 'value': true}]]] }, -]; + 0: { + 'id': '0', + 'name': '$$dummy', + 'conditions': { 'type': 'custom_attribute', 'name': '$opt_dummy_attribute', 'value': 'impossible_value' }, + } +}; module.exports = { getTestProjectConfig: getTestProjectConfig,