Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit cb9d500

Browse files
authored
feat (audiences): Audience combinations (optimizely#175)
This adds support for audience combinations on experiments. If `experiment.audienceConditions` is present, it will be used as a condition tree where the leaf conditions are audience Ids. - Condition tree evaluation and custom attribute condition evaluation are split into separate modules. Condition tree evaluation accepts a leaf evaluator function argument. - The audience evaluator uses the condition tree evaluator on both the experiment audience conditions and the individual audience conditions. - Project config methods are updated to support audience combinations. `getExperimentAudienceConditions` returns `audienceConditions` if it exists, otherwise it falls back to `audienceIds`. - The audience evaluator treats conditions trees and flat lists of audience ids (the old format) the same way, because a flat list of audience ids is a valid condition tree with an implicit OR condition.
1 parent 5c18367 commit cb9d500

File tree

12 files changed

+1224
-671
lines changed

12 files changed

+1224
-671
lines changed

packages/optimizely-sdk/lib/core/audience_evaluator/index.js

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,45 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
var conditionEvaluator = require('../condition_evaluator');
16+
var conditionTreeEvaluator = require('../condition_tree_evaluator');
17+
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
1718

1819
module.exports = {
1920
/**
2021
* Determine if the given user attributes satisfy the given audience conditions
21-
* @param {Object[]} audiences Audiences to match the user attributes against
22-
* @param {Object[]} audiences.conditions Audience conditions to match the user attributes against
23-
* @param {Object} [userAttributes] Hash representing user attributes which will be used in
24-
* determining if the audience conditions are met. If not
25-
* provided, defaults to an empty object.
26-
* @return {Boolean} True if the user attributes match the given audience conditions
22+
* @param {Array|String|null|undefined} audienceConditions Audience conditions to match the user attributes against - can be an array
23+
* of audience IDs, a nested array of conditions, or a single leaf condition.
24+
* Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"], "1"
25+
* @param {Object} audiencesById Object providing access to full audience objects for audience IDs
26+
* contained in audienceConditions. Keys should be audience IDs, values
27+
* should be full audience objects with conditions properties
28+
* @param {Object} [userAttributes] User attributes which will be used in determining if audience conditions
29+
* are met. If not provided, defaults to an empty object
30+
* @return {Boolean} true if the user attributes match the given audience conditions, false
31+
* otherwise
2732
*/
28-
evaluate: function(audiences, userAttributes) {
33+
evaluate: function(audienceConditions, audiencesById, userAttributes) {
2934
// if there are no audiences, return true because that means ALL users are included in the experiment
30-
if (!audiences || audiences.length === 0) {
35+
if (!audienceConditions || audienceConditions.length === 0) {
3136
return true;
3237
}
3338

3439
if (!userAttributes) {
3540
userAttributes = {};
3641
}
3742

38-
for (var i = 0; i < audiences.length; i++) {
39-
var audience = audiences[i];
40-
var conditions = audience.conditions;
41-
if (conditionEvaluator.evaluate(conditions, userAttributes)) {
42-
return true;
43+
var evaluateConditionWithUserAttributes = function(condition) {
44+
return customAttributeConditionEvaluator.evaluate(condition, userAttributes);
45+
};
46+
47+
var evaluateAudience = function(audienceId) {
48+
var audience = audiencesById[audienceId];
49+
if (audience) {
50+
return conditionTreeEvaluator.evaluate(audience.conditions, evaluateConditionWithUserAttributes);
4351
}
44-
}
52+
return null;
53+
};
4554

46-
return false;
55+
return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false;
4756
},
4857
};

packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
*/
1616
var audienceEvaluator = require('./');
1717
var chai = require('chai');
18+
var conditionTreeEvaluator = require('../condition_tree_evaluator');
19+
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
20+
var sinon = require('sinon');
21+
1822
var assert = chai.assert;
1923

2024
var chromeUserAudience = {
@@ -31,16 +35,29 @@ var iphoneUserAudience = {
3135
type: 'custom_attribute',
3236
}],
3337
};
38+
var conditionsPassingWithNoAttrs = ['not', {
39+
match: 'exists',
40+
name: 'input_value',
41+
type: 'custom_attribute',
42+
}];
43+
var conditionsPassingWithNoAttrsAudience = {
44+
conditions: conditionsPassingWithNoAttrs,
45+
};
46+
var audiencesById = {
47+
0: chromeUserAudience,
48+
1: iphoneUserAudience,
49+
2: conditionsPassingWithNoAttrsAudience,
50+
};
3451

3552
describe('lib/core/audience_evaluator', function() {
3653
describe('APIs', function() {
3754
describe('evaluate', function() {
3855
it('should return true if there are no audiences', function() {
39-
assert.isTrue(audienceEvaluator.evaluate([], {}));
56+
assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {}));
4057
});
4158

4259
it('should return false if there are audiences but no attributes', function() {
43-
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience], {}));
60+
assert.isFalse(audienceEvaluator.evaluate(['0'], audiencesById, {}));
4461
});
4562

4663
it('should return true if any of the audience conditions are met', function() {
@@ -57,9 +74,9 @@ describe('lib/core/audience_evaluator', function() {
5774
'device_model': 'iphone',
5875
};
5976

60-
assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], iphoneUsers));
61-
assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], chromeUsers));
62-
assert.isTrue(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], iphoneChromeUsers));
77+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers));
78+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers));
79+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers));
6380
});
6481

6582
it('should return false if none of the audience conditions are met', function() {
@@ -76,21 +93,98 @@ describe('lib/core/audience_evaluator', function() {
7693
'device_model': 'nexus5',
7794
};
7895

79-
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusUsers));
80-
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], safariUsers));
81-
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusSafariUsers));
96+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers));
97+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers));
98+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers));
8299
});
83100

84101
it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() {
85-
var conditionsPassingWithNoAttrs = ['not', {
86-
match: 'exists',
87-
name: 'input_value',
88-
type: 'custom_attribute',
89-
}];
90-
var audience = {
91-
conditions: conditionsPassingWithNoAttrs,
92-
};
93-
assert.isTrue(audienceEvaluator.evaluate([audience]));
102+
assert.isTrue(audienceEvaluator.evaluate(['2'], audiencesById));
103+
});
104+
105+
describe('complex audience conditions', function() {
106+
it('should return true if any of the audiences in an "OR" condition pass', function() {
107+
var result = audienceEvaluator.evaluate(
108+
['or', '0', '1'],
109+
audiencesById,
110+
{ browser_type: 'chrome' }
111+
);
112+
assert.isTrue(result);
113+
});
114+
115+
it('should return true if all of the audiences in an "AND" condition pass', function() {
116+
var result = audienceEvaluator.evaluate(
117+
['and', '0', '1'],
118+
audiencesById,
119+
{ browser_type: 'chrome', device_model: 'iphone' }
120+
);
121+
assert.isTrue(result);
122+
});
123+
124+
it('should return true if the audience in a "NOT" condition does not pass', function() {
125+
var result = audienceEvaluator.evaluate(
126+
['not', '1'],
127+
audiencesById,
128+
{ device_model: 'android' }
129+
);
130+
assert.isTrue(result);
131+
});
132+
133+
});
134+
135+
describe('integration with dependencies', function() {
136+
var sandbox = sinon.sandbox.create();
137+
138+
beforeEach(function() {
139+
sandbox.stub(conditionTreeEvaluator, 'evaluate');
140+
sandbox.stub(customAttributeConditionEvaluator, 'evaluate');
141+
});
142+
143+
afterEach(function() {
144+
sandbox.restore();
145+
});
146+
147+
it('returns true if conditionTreeEvaluator.evaluate returns true', function() {
148+
conditionTreeEvaluator.evaluate.returns(true);
149+
var result = audienceEvaluator.evaluate(
150+
['or', '0', '1'],
151+
audiencesById,
152+
{ browser_type: 'chrome' }
153+
);
154+
assert.isTrue(result);
155+
});
156+
157+
it('returns false if conditionTreeEvaluator.evaluate returns false', function() {
158+
conditionTreeEvaluator.evaluate.returns(false);
159+
var result = audienceEvaluator.evaluate(
160+
['or', '0', '1'],
161+
audiencesById,
162+
{ browser_type: 'safari' }
163+
);
164+
assert.isFalse(result);
165+
});
166+
167+
it('returns false if conditionTreeEvaluator.evaluate returns null', function() {
168+
conditionTreeEvaluator.evaluate.returns(null);
169+
var result = audienceEvaluator.evaluate(
170+
['or', '0', '1'],
171+
audiencesById,
172+
{ state: 'California' }
173+
);
174+
assert.isFalse(result);
175+
});
176+
177+
it('calls customAttributeConditionEvaluator.evaluate in the leaf evaluator for audience conditions', function() {
178+
conditionTreeEvaluator.evaluate.callsFake(function(conditions, leafEvaluator) {
179+
return leafEvaluator(conditions[1]);
180+
});
181+
customAttributeConditionEvaluator.evaluate.returns(false);
182+
var userAttributes = { device_model: 'android' };
183+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
184+
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
185+
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes);
186+
assert.isFalse(result);
187+
});
94188
});
95189
});
96190
});

0 commit comments

Comments
 (0)