Skip to content

Commit 79e3baa

Browse files
oakbanialiabbasrizvi
authored andcommitted
feat (audiences): Audience combinations (#146)
1 parent 907558b commit 79e3baa

File tree

6 files changed

+535
-45
lines changed

6 files changed

+535
-45
lines changed

src/Optimizely/Entity/Experiment.php

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22
/**
3-
* Copyright 2016, Optimizely
3+
* Copyright 2016, 2018, Optimizely
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -76,6 +76,12 @@ class Experiment
7676
*/
7777
private $_audienceIds;
7878

79+
/**
80+
* @var array/string Single audience ID the experiment is attached to or
81+
* hierarchical conditions array of audience IDs related by AND/OR/NOT operators.
82+
*/
83+
private $_audienceConditions;
84+
7985
/**
8086
* @var array Traffic allocation of variations in the experiment.
8187
*/
@@ -92,6 +98,7 @@ public function __construct(
9298
$forcedVariations = null,
9399
$groupPolicy = null,
94100
$audienceIds = null,
101+
$audienceConditions = null,
95102
$trafficAllocation = null
96103
) {
97104
$this->_id = $id;
@@ -103,6 +110,7 @@ public function __construct(
103110
$this->_forcedVariations = $forcedVariations;
104111
$this->_groupPolicy = $groupPolicy;
105112
$this->_audienceIds = $audienceIds;
113+
$this->_audienceConditions = $audienceConditions;
106114
$this->_trafficAllocation = $trafficAllocation;
107115
}
108116

@@ -250,6 +258,22 @@ public function setAudienceIds($audienceIds)
250258
$this->_audienceIds = $audienceIds;
251259
}
252260

261+
/**
262+
* @return array/string Audience conditions attached to experiment.
263+
*/
264+
public function getAudienceConditions()
265+
{
266+
return $this->_audienceConditions;
267+
}
268+
269+
/**
270+
* @param array/string $audienceConditions Audience conditions attached to experiment.
271+
*/
272+
public function setAudienceConditions($audienceConditions)
273+
{
274+
$this->_audienceConditions = $audienceConditions;
275+
}
276+
253277
/**
254278
* @return array Traffic allocation of variations in experiment.
255279
*/

src/Optimizely/Utils/ConditionTreeEvaluator.php

+14-12
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,21 @@ protected function notEvaluator(array $condition, callable $leafEvaluator)
140140
*/
141141
public function evaluate($conditions, callable $leafEvaluator)
142142
{
143-
if (!Validator::doesArrayContainOnlyStringKeys($conditions)) {
144-
145-
if(in_array($conditions[0], $this->getOperators())) {
146-
$operator = array_shift($conditions);
147-
} else {
148-
$operator = self::OR_OPERATOR;
149-
}
150-
151-
$evaluatorFunc = $this->getEvaluatorByOperatorType($operator);
152-
return $this->{$evaluatorFunc}($conditions, $leafEvaluator);
143+
// When parsing audiences tree the leaf node is a string representing an audience ID.
144+
// When parsing conditions of a single audience the leaf node is an associative array with all keys of type string.
145+
if (is_string($conditions) || Validator::doesArrayContainOnlyStringKeys($conditions)) {
146+
147+
$leafCondition = $conditions;
148+
return $leafEvaluator($leafCondition);
149+
}
150+
151+
if(in_array($conditions[0], $this->getOperators())) {
152+
$operator = array_shift($conditions);
153+
} else {
154+
$operator = self::OR_OPERATOR;
153155
}
154156

155-
$leafCondition = $conditions;
156-
return $leafEvaluator($leafCondition);
157+
$evaluatorFunc = $this->getEvaluatorByOperatorType($operator);
158+
return $this->{$evaluatorFunc}($conditions, $leafEvaluator);
157159
}
158160
}

src/Optimizely/Utils/Validator.php

+14-12
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,17 @@ public static function areEventTagsValid($eventTags)
139139
*/
140140
public static function isUserInExperiment($config, $experiment, $userAttributes)
141141
{
142-
$audienceIds = $experiment->getAudienceIds();
142+
$audienceConditions = $experiment->getAudienceConditions();
143+
if ($audienceConditions === null) {
144+
$audienceConditions = $experiment->getAudienceIds();
145+
}
143146

144147
// Return true if experiment is not targeted to any audience.
145-
if (empty($audienceIds)) {
148+
if (empty($audienceConditions)) {
146149
return true;
147150
}
148151

149-
if ($userAttributes == null) {
152+
if ($userAttributes === null) {
150153
$userAttributes = [];
151154
}
152155

@@ -155,17 +158,16 @@ public static function isUserInExperiment($config, $experiment, $userAttributes)
155158
return $customAttrCondEval->evaluate($leafCondition);
156159
};
157160

158-
// Return true if conditions for any audience are met.
159-
$conditionTreeEvaluator = new ConditionTreeEvaluator();
160-
foreach ($audienceIds as $audienceId) {
161+
$evaluateAudience = function($audienceId) use ($config, $evaluateCustomAttr) {
162+
$conditionTreeEvaluator = new ConditionTreeEvaluator();
161163
$audience = $config->getAudience($audienceId);
162-
$result = $conditionTreeEvaluator->evaluate($audience->getConditionsList(), $evaluateCustomAttr);
163-
if ($result) {
164-
return true;
165-
}
166-
}
164+
return $conditionTreeEvaluator->evaluate($audience->getConditionsList(), $evaluateCustomAttr);
165+
};
167166

168-
return false;
167+
$conditionTreeEvaluator = new ConditionTreeEvaluator();
168+
$evalResult = $conditionTreeEvaluator->evaluate($audienceConditions, $evaluateAudience);
169+
170+
return $evalResult || false;
169171
}
170172

171173
/**

tests/OptimizelyTest.php

+143
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,48 @@ public function testActivateWithAttributesTypedAudienceMismatch()
659659
$this->assertNull($optimizelyMock->activate('typed_audience_experiment', 'test_user', $userAttributes));
660660
}
661661

662+
public function testActivateWithAttributesComplexAudienceMatch()
663+
{
664+
$optimizelyMock = $this->getMockBuilder(Optimizely::class)
665+
->setConstructorArgs(array($this->typedAudiencesDataFile , null, null))
666+
->setMethods(array('sendImpressionEvent'))
667+
->getMock();
668+
669+
$userAttributes = [
670+
'house' => 'Welcome to Slytherin!',
671+
'lasers' => 45.5
672+
];
673+
674+
// Verify that sendImpressionEvent is called once with expected attributes
675+
$optimizelyMock->expects($this->exactly(1))
676+
->method('sendImpressionEvent')
677+
->with('audience_combinations_experiment', 'A', 'test_user', $userAttributes);
678+
679+
// Should be included via substring match string audience with id '3988293898', and
680+
// exact match number audience with id '3468206646'
681+
$this->assertEquals('A', $optimizelyMock->activate('audience_combinations_experiment', 'test_user', $userAttributes));
682+
}
683+
684+
public function testActivateWithAttributesComplexAudienceMismatch()
685+
{
686+
$userAttributes = [
687+
'house' => 'Hufflepuff',
688+
'lasers' => 45.5
689+
];
690+
691+
$optimizelyMock = $this->getMockBuilder(Optimizely::class)
692+
->setConstructorArgs(array($this->typedAudiencesDataFile , null, $this->loggerMock))
693+
->setMethods(array('sendImpressionEvent'))
694+
->getMock();
695+
696+
// Verify that sendImpressionEvent is not called
697+
$optimizelyMock->expects($this->never())
698+
->method('sendImpressionEvent');
699+
700+
// Call activate
701+
$this->assertNull($optimizelyMock->activate('audience_combinations_experiment', 'test_user', $userAttributes));
702+
}
703+
662704
public function testActivateExperimentNotRunning()
663705
{
664706
$optimizelyMock = $this->getMockBuilder(Optimizely::class)
@@ -1970,6 +2012,60 @@ public function testTrackWithAttributesTypedAudienceMismatch()
19702012
$optlyObject->track('item_bought', 'test_user', $userAttributes, array('revenue' => 42));
19712013
}
19722014

2015+
public function testTrackWithAttributesComplexAudienceMatch()
2016+
{
2017+
$userAttributes = [
2018+
'house' => 'Gryffindor',
2019+
'should_do_it' => true
2020+
];
2021+
2022+
$this->eventBuilderMock->expects($this->once())
2023+
->method('createConversionEvent')
2024+
->with(
2025+
$this->projectConfigForTypedAudience,
2026+
'user_signed_up',
2027+
[
2028+
'1323241598' => '1423767504',
2029+
'1323241599' => '1423767505'
2030+
],
2031+
'test_user',
2032+
$userAttributes,
2033+
array('revenue' => 42)
2034+
)
2035+
->willReturn(new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []));
2036+
2037+
$optlyObject = new Optimizely($this->typedAudiencesDataFile, new ValidEventDispatcher(), $this->loggerMock);
2038+
2039+
$eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder');
2040+
$eventBuilder->setAccessible(true);
2041+
$eventBuilder->setValue($optlyObject, $this->eventBuilderMock);
2042+
2043+
// Should be included via exact match string audience with id '3468206642', and
2044+
// exact match boolean audience with id '3468206643'
2045+
$optlyObject->track('user_signed_up', 'test_user', $userAttributes, array('revenue' => 42));
2046+
}
2047+
2048+
public function testTrackWithAttributesComplexAudienceMismatch()
2049+
{
2050+
$userAttributes = [
2051+
'house' => 'Gryffindor',
2052+
'should_do_it' => false
2053+
];
2054+
2055+
$this->eventBuilderMock->expects($this->never())
2056+
->method('createConversionEvent');
2057+
2058+
$optlyObject = new Optimizely($this->typedAudiencesDataFile, new ValidEventDispatcher(), $this->loggerMock);
2059+
2060+
$eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder');
2061+
$eventBuilder->setAccessible(true);
2062+
$eventBuilder->setValue($optlyObject, $this->eventBuilderMock);
2063+
2064+
// Should be excluded - exact match boolean audience with id '3468206643' does not match,
2065+
// so the overall conditions fail
2066+
$optlyObject->track('user_signed_up', 'test_user', $userAttributes, array('revenue' => 42));
2067+
}
2068+
19732069
public function testTrackWithEmptyUserID()
19742070
{
19752071
$userAttributes = [
@@ -2791,6 +2887,33 @@ public function testIsFeatureEnabledGivenFeatureRolloutTypedAudienceMismatch()
27912887
);
27922888
}
27932889

2890+
public function testIsFeatureEnabledGivenFeatureRolloutComplexAudienceMatch()
2891+
{
2892+
$userAttributes = [
2893+
'house' => '...Slytherinnn...sss.',
2894+
'favorite_ice_cream' => 'matcha'
2895+
];
2896+
2897+
// Should be included via substring match string audience with id '3988293898', and
2898+
// exists audience with id '3988293899'
2899+
$this->assertTrue(
2900+
$this->optimizelyTypedAudienceObject->isFeatureEnabled('feat2', 'test_user', $userAttributes)
2901+
);
2902+
}
2903+
2904+
public function testIsFeatureEnabledGivenFeatureRolloutComplexAudienceMismatch()
2905+
{
2906+
$userAttributes = [
2907+
'house' => 'Lannister'
2908+
];
2909+
2910+
// Should be excluded - substring match string audience with id '3988293898' does not match,
2911+
// and no other audience matches either
2912+
$this->assertFalse(
2913+
$this->optimizelyTypedAudienceObject->isFeatureEnabled('feat2', 'test_user', $userAttributes)
2914+
);
2915+
}
2916+
27942917
public function testIsFeatureEnabledWithEmptyUserID()
27952918
{
27962919
$optimizelyMock = $this->getMockBuilder(Optimizely::class)
@@ -3423,6 +3546,26 @@ public function testGetFeatureVariableReturnsDefaultValueForTypedAudienceMismatc
34233546
$this->assertEquals('x', $this->optimizelyTypedAudienceObject->getFeatureVariableString('feat_with_var', 'x', 'user1', $userAttributes));
34243547
}
34253548

3549+
public function testGetFeatureVariableReturnsVariableValueForComplexAudienceMatch()
3550+
{
3551+
$userAttributes = [
3552+
'house' => 'Gryffindor',
3553+
'lasers' => 700
3554+
];
3555+
3556+
// Should be included via exact match string audience with id '3468206642', and
3557+
// greater than audience with id '3468206647'
3558+
$this->assertSame(150, $this->optimizelyTypedAudienceObject->getFeatureVariableInteger('feat2_with_var', 'z', 'user1', $userAttributes));
3559+
}
3560+
3561+
public function testGetFeatureVariableReturnsDefaultValueForComplexAudienceMismatch()
3562+
{
3563+
$userAttributes = [];
3564+
3565+
// Should be excluded - no audiences match with no attributes
3566+
$this->assertSame(10, $this->optimizelyTypedAudienceObject->getFeatureVariableInteger('feat2_with_var', 'z', 'user1', $userAttributes));
3567+
}
3568+
34263569
public function testSendImpressionEventWithNoAttributes()
34273570
{
34283571
$optlyObject = new OptimizelyTester($this->datafile, new ValidEventDispatcher(), $this->loggerMock);

0 commit comments

Comments
 (0)