From 8e596c5f27507ad5e43c839e06e71498527c641b Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Wed, 19 Dec 2018 00:00:25 +0500 Subject: [PATCH] feat (audience match types): Update condition evaluator for new audience match types (#146) --- optimizely/helpers/audience.py | 22 +- optimizely/helpers/condition.py | 190 ++++-- .../helpers/condition_tree_evaluator.py | 118 ++++ optimizely/helpers/validator.py | 56 ++ optimizely/project_config.py | 10 + tests/base.py | 254 +++++++ tests/helpers_tests/test_audience.py | 23 +- tests/helpers_tests/test_condition.py | 642 +++++++++++++++--- .../test_condition_tree_evaluator.py | 260 +++++++ tests/test_config.py | 43 +- tests/test_optimizely.py | 128 ++++ 11 files changed, 1588 insertions(+), 158 deletions(-) create mode 100644 optimizely/helpers/condition_tree_evaluator.py create mode 100644 tests/helpers_tests/test_condition_tree_evaluator.py diff --git a/optimizely/helpers/audience.py b/optimizely/helpers/audience.py index b1c7a6b1..85cad74a 100644 --- a/optimizely/helpers/audience.py +++ b/optimizely/helpers/audience.py @@ -1,4 +1,4 @@ -# Copyright 2016, Optimizely +# Copyright 2016, 2018, Optimizely # 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 @@ -12,6 +12,7 @@ # limitations under the License. from . import condition as condition_helper +from . import condition_tree_evaluator def is_match(audience, attributes): @@ -24,8 +25,15 @@ def is_match(audience, attributes): Return: Boolean representing if user satisfies audience conditions or not. """ - condition_evaluator = condition_helper.ConditionEvaluator(audience.conditionList, attributes) - return condition_evaluator.evaluate(audience.conditionStructure) + custom_attr_condition_evaluator = condition_helper.CustomAttributeConditionEvaluator( + audience.conditionList, attributes) + + is_match = condition_tree_evaluator.evaluate( + audience.conditionStructure, + lambda index: custom_attr_condition_evaluator.evaluate(index) + ) + + return is_match or False def is_user_in_experiment(config, experiment, attributes): @@ -34,7 +42,8 @@ def is_user_in_experiment(config, experiment, attributes): Args: config: project_config.ProjectConfig object representing the project. experiment: Object representing the experiment. - attributes: Dict representing user attributes which will be used in determining if the audience conditions are met. + attributes: Dict representing user attributes which will be used in determining + if the audience conditions are met. If not provided, default to an empty dict. Returns: Boolean representing if user satisfies audience conditions for any of the audiences or not. @@ -44,9 +53,8 @@ def is_user_in_experiment(config, experiment, attributes): if not experiment.audienceIds: return True - # Return False if there are audiences, but no attributes - if not attributes: - return False + if attributes is None: + attributes = {} # Return True if conditions for any one audience are met for audience_id in experiment.audienceIds: diff --git a/optimizely/helpers/condition.py b/optimizely/helpers/condition.py index 37b669ec..f274f96b 100644 --- a/optimizely/helpers/condition.py +++ b/optimizely/helpers/condition.py @@ -1,4 +1,4 @@ -# Copyright 2016, Optimizely +# Copyright 2016, 2018, Optimizely # 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 @@ -12,115 +12,180 @@ # limitations under the License. import json +import numbers +from six import string_types -class ConditionalOperatorTypes(object): +from . import validator + + +class ConditionOperatorTypes(object): AND = 'and' OR = 'or' NOT = 'not' -DEFAULT_OPERATOR_TYPES = [ - ConditionalOperatorTypes.AND, - ConditionalOperatorTypes.OR, - ConditionalOperatorTypes.NOT -] +class ConditionMatchTypes(object): + EXACT = 'exact' + EXISTS = 'exists' + GREATER_THAN = 'gt' + LESS_THAN = 'lt' + SUBSTRING = 'substring' + +class CustomAttributeConditionEvaluator(object): + """ Class encapsulating methods to be used in audience leaf condition evaluation. """ -class ConditionEvaluator(object): - """ Class encapsulating methods to be used in audience condition evaluation. """ + CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute' def __init__(self, condition_data, attributes): self.condition_data = condition_data - self.attributes = attributes + self.attributes = attributes or {} - def evaluator(self, condition): - """ Method to compare single audience condition against provided user data i.e. attributes. + def is_value_valid_for_exact_conditions(self, value): + """ Method to validate if the value is valid for exact match type evaluation. Args: - condition: Integer representing the index of condition_data that needs to be used for comparison. + value: Value to validate. Returns: - Boolean indicating the result of comparing the condition value against the user attributes. + Boolean: True if value is a string type, or a boolean, or is finite. Otherwise False. """ + if isinstance(value, string_types) or isinstance(value, bool) or validator.is_finite_number(value): + return True - return self.attributes.get(self.condition_data[condition][0]) == self.condition_data[condition][1] + return False - def and_evaluator(self, conditions): - """ Evaluates a list of conditions as if the evaluator had been applied - to each entry and the results AND-ed together + def exact_evaluator(self, index): + """ Evaluate the given exact match condition for the user attributes. Args: - conditions: List of conditions ex: [operand_1, operand_2] + index: Index of the condition to be evaluated. Returns: - Boolean: True if all operands evaluate to True + Boolean: + - True if the user attribute value is equal (===) to the condition value. + - False if the user attribute value is not equal (!==) to the condition value. + None: + - if the condition value or user attribute value has an invalid type. + - if there is a mismatch between the user attribute type and the condition value type. + """ + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(self.condition_data[index][0]) + + if not self.is_value_valid_for_exact_conditions(condition_value) or \ + not self.is_value_valid_for_exact_conditions(user_value) or \ + not validator.are_values_same_type(condition_value, user_value): + return None + + return condition_value == user_value + + def exists_evaluator(self, index): + """ Evaluate the given exists match condition for the user attributes. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: True if the user attributes have a non-null value for the given condition, + otherwise False. + """ + attr_name = self.condition_data[index][0] + return self.attributes.get(attr_name) is not None + + def greater_than_evaluator(self, index): + """ Evaluate the given greater than match condition for the user attributes. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user attribute value is greater than the condition value. + - False if the user attribute value is less than or equal to the condition value. + None: if the condition value isn't finite or the user attribute value isn't finite. """ + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(self.condition_data[index][0]) - for condition in conditions: - result = self.evaluate(condition) - if result is False: - return False + if not validator.is_finite_number(condition_value) or not validator.is_finite_number(user_value): + return None - return True + return user_value > condition_value - def or_evaluator(self, conditions): - """ Evaluates a list of conditions as if the evaluator had been applied - to each entry and the results OR-ed together + def less_than_evaluator(self, index): + """ Evaluate the given less than match condition for the user attributes. Args: - conditions: List of conditions ex: [operand_1, operand_2] + index: Index of the condition to be evaluated. Returns: - Boolean: True if any operand evaluates to True + Boolean: + - True if the user attribute value is less than the condition value. + - False if the user attribute value is greater than or equal to the condition value. + None: if the condition value isn't finite or the user attribute value isn't finite. """ + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(self.condition_data[index][0]) - for condition in conditions: - result = self.evaluate(condition) - if result is True: - return True + if not validator.is_finite_number(condition_value) or not validator.is_finite_number(user_value): + return None - return False + return user_value < condition_value - def not_evaluator(self, single_condition): - """ Evaluates a list of conditions as if the evaluator had been applied - to a single entry and NOT was applied to the result. + def substring_evaluator(self, index): + """ Evaluate the given substring match condition for the given user attributes. Args: - single_condition: List of of a single condition ex: [operand_1] + index: Index of the condition to be evaluated. Returns: - Boolean: True if the operand evaluates to False + Boolean: + - True if the condition value is a substring of the user attribute value. + - False if the condition value is not a substring of the user attribute value. + None: if the condition value isn't a string or the user attribute value isn't a string. """ - if len(single_condition) != 1: - return False + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(self.condition_data[index][0]) + + if not isinstance(condition_value, string_types) or not isinstance(user_value, string_types): + return None - return not self.evaluate(single_condition[0]) + return condition_value in user_value - OPERATORS = { - ConditionalOperatorTypes.AND: and_evaluator, - ConditionalOperatorTypes.OR: or_evaluator, - ConditionalOperatorTypes.NOT: not_evaluator + EVALUATORS_BY_MATCH_TYPE = { + ConditionMatchTypes.EXACT: exact_evaluator, + ConditionMatchTypes.EXISTS: exists_evaluator, + ConditionMatchTypes.GREATER_THAN: greater_than_evaluator, + ConditionMatchTypes.LESS_THAN: less_than_evaluator, + ConditionMatchTypes.SUBSTRING: substring_evaluator } - def evaluate(self, conditions): - """ Top level method to evaluate audience conditions. + def evaluate(self, index): + """ Given a custom attribute audience condition and user attributes, evaluate the + condition against the attributes. Args: - conditions: Nested list of and/or conditions. - Ex: ['and', operand_1, ['or', operand_2, operand_3]] + index: Index of the condition to be evaluated. Returns: - Boolean result of evaluating the conditions evaluate + Boolean: + - True if the user attributes match the given condition. + - False if the user attributes don't match the given condition. + None: if the user attributes and condition can't be evaluated. """ - if isinstance(conditions, list): - if conditions[0] in DEFAULT_OPERATOR_TYPES: - return self.OPERATORS[conditions[0]](self, conditions[1:]) - else: - return False + if self.condition_data[index][2] != self.CUSTOM_ATTRIBUTE_CONDITION_TYPE: + return None + + condition_match = self.condition_data[index][3] + if condition_match is None: + condition_match = ConditionMatchTypes.EXACT + + if condition_match not in self.EVALUATORS_BY_MATCH_TYPE: + return None - return self.evaluator(conditions) + return self.EVALUATORS_BY_MATCH_TYPE[condition_match](self, index) class ConditionDecoder(object): @@ -157,9 +222,14 @@ def _audience_condition_deserializer(obj_dict): obj_dict: Dict representing one audience condition. Returns: - List consisting of condition key and corresponding value. + List consisting of condition key with corresponding value, type and match. """ - return [obj_dict.get('name'), obj_dict.get('value')] + return [ + obj_dict.get('name'), + obj_dict.get('value'), + obj_dict.get('type'), + obj_dict.get('match') + ] def loads(conditions_string): diff --git a/optimizely/helpers/condition_tree_evaluator.py b/optimizely/helpers/condition_tree_evaluator.py new file mode 100644 index 00000000..aec01e13 --- /dev/null +++ b/optimizely/helpers/condition_tree_evaluator.py @@ -0,0 +1,118 @@ +# Copyright 2018, Optimizely +# 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. + +from .condition import ConditionOperatorTypes + + +def and_evaluator(conditions, leaf_evaluator): + """ Evaluates a list of conditions as if the evaluator had been applied + to each entry and the results AND-ed together. + + Args: + conditions: List of conditions ex: [operand_1, operand_2]. + leaf_evaluator: Function which will be called to evaluate leaf condition values. + + Returns: + Boolean: + - True if all operands evaluate to True. + - False if a single operand evaluates to False. + None: if conditions couldn't be evaluated. + """ + saw_null_result = False + + for condition in conditions: + result = evaluate(condition, leaf_evaluator) + if result is False: + return False + if result is None: + saw_null_result = True + + return None if saw_null_result else True + + +def or_evaluator(conditions, leaf_evaluator): + """ Evaluates a list of conditions as if the evaluator had been applied + to each entry and the results OR-ed together. + + Args: + conditions: List of conditions ex: [operand_1, operand_2]. + leaf_evaluator: Function which will be called to evaluate leaf condition values. + + Returns: + Boolean: + - True if any operand evaluates to True. + - False if all operands evaluate to False. + None: if conditions couldn't be evaluated. + """ + saw_null_result = False + + for condition in conditions: + result = evaluate(condition, leaf_evaluator) + if result is True: + return True + if result is None: + saw_null_result = True + + return None if saw_null_result else False + + +def not_evaluator(conditions, leaf_evaluator): + """ Evaluates a list of conditions as if the evaluator had been applied + to a single entry and NOT was applied to the result. + + Args: + conditions: List of conditions ex: [operand_1, operand_2]. + leaf_evaluator: Function which will be called to evaluate leaf condition values. + + Returns: + Boolean: + - True if the operand evaluates to False. + - False if the operand evaluates to True. + None: if conditions is empty or condition couldn't be evaluated. + """ + if not len(conditions) > 0: + return None + + result = evaluate(conditions[0], leaf_evaluator) + return None if result is None else not result + +EVALUATORS_BY_OPERATOR_TYPE = { + ConditionOperatorTypes.AND: and_evaluator, + ConditionOperatorTypes.OR: or_evaluator, + ConditionOperatorTypes.NOT: not_evaluator +} + + +def evaluate(conditions, leaf_evaluator): + """ Top level method to evaluate conditions. + + Args: + conditions: Nested array of and/or conditions, or a single leaf condition value of any type. + Example: ['and', '0', ['or', '1', '2']] + leaf_evaluator: Function which will be called to evaluate leaf condition values. + + Returns: + Boolean: Result of evaluating the conditions using the operator rules and the leaf evaluator. + None: if conditions couldn't be evaluated. + + """ + + if isinstance(conditions, list): + if conditions[0] in list(EVALUATORS_BY_OPERATOR_TYPE.keys()): + return EVALUATORS_BY_OPERATOR_TYPE[conditions[0]](conditions[1:], leaf_evaluator) + else: + # assume OR when operator is not explicit. + return EVALUATORS_BY_OPERATOR_TYPE[ConditionOperatorTypes.OR](conditions, leaf_evaluator) + + leaf_condition = conditions + return leaf_evaluator(leaf_condition) diff --git a/optimizely/helpers/validator.py b/optimizely/helpers/validator.py index 3e819f42..b8cd3f42 100644 --- a/optimizely/helpers/validator.py +++ b/optimizely/helpers/validator.py @@ -13,6 +13,8 @@ import json import jsonschema +import math +import numbers from six import string_types from optimizely.user_profile import UserProfile @@ -189,3 +191,57 @@ def is_attribute_valid(attribute_key, attribute_value): return True return False + + +def is_finite_number(value): + """ Method to validate if the given value is a number and not one of NAN, INF, -INF. + + Args: + value: Value to be validated. + + Returns: + Boolean: True if value is a number and not NAN, INF or -INF else False. + """ + if not isinstance(value, (numbers.Integral, float)): + # numbers.Integral instead of int to accomodate long integer in python 2 + return False + + if isinstance(value, bool): + # bool is a subclass of int + return False + + if isinstance(value, float): + if math.isnan(value) or math.isinf(value): + return False + + return True + + +def are_values_same_type(first_val, second_val): + """ Method to verify that both values belong to same type. Float and integer are + considered as same type. + + Args: + first_val: Value to validate. + second_Val: Value to validate. + + Returns: + Boolean: True if both values belong to same type. Otherwise False. + """ + + first_val_type = type(first_val) + second_val_type = type(second_val) + + # use isinstance to accomodate Python 2 unicode and str types. + if isinstance(first_val, string_types) and isinstance(second_val, string_types): + return True + + # Compare types if one of the values is bool because bool is a subclass on Integer. + if isinstance(first_val, bool) or isinstance(second_val, bool): + return first_val_type == second_val_type + + # Treat ints and floats as same type. + if isinstance(first_val, (numbers.Integral, float)) and isinstance(second_val, (numbers.Integral, float)): + return True + + return False diff --git a/optimizely/project_config.py b/optimizely/project_config.py index e5d9dc1d..752dc6c6 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -53,6 +53,7 @@ def __init__(self, datafile, logger, error_handler): self.events = config.get('events', []) self.attributes = config.get('attributes', []) self.audiences = config.get('audiences', []) + self.typed_audiences = config.get('typedAudiences', []) self.feature_flags = config.get('featureFlags', []) self.rollouts = config.get('rollouts', []) self.anonymize_ip = config.get('anonymizeIP', False) @@ -63,7 +64,16 @@ def __init__(self, datafile, logger, error_handler): self.experiment_key_map = self._generate_key_map(self.experiments, 'key', entities.Experiment) self.event_key_map = self._generate_key_map(self.events, 'key', entities.Event) self.attribute_key_map = self._generate_key_map(self.attributes, 'key', entities.Attribute) + self.audience_id_map = self._generate_key_map(self.audiences, 'id', entities.Audience) + + # Conditions of audiences in typedAudiences are not expected + # to be string-encoded as they are in audiences. + for typed_audience in self.typed_audiences: + typed_audience['conditions'] = json.dumps(typed_audience['conditions']) + typed_audience_id_map = self._generate_key_map(self.typed_audiences, 'id', entities.Audience) + self.audience_id_map.update(typed_audience_id_map) + self.rollout_id_map = self._generate_key_map(self.rollouts, 'id', entities.Layer) for layer in self.rollout_id_map.values(): for experiment in layer.experiments: diff --git a/tests/base.py b/tests/base.py index 6e3c2108..913efe92 100644 --- a/tests/base.py +++ b/tests/base.py @@ -19,6 +19,12 @@ class BaseTest(unittest.TestCase): + def assertStrictTrue(self, to_assert): + self.assertIs(to_assert, True) + + def assertStrictFalse(self, to_assert): + self.assertIs(to_assert, False) + def setUp(self, config_dict='config_dict'): self.config_dict = { 'revision': '42', @@ -589,6 +595,254 @@ def setUp(self, config_dict='config_dict'): 'revision': '1337' } + self.config_dict_with_typed_audiences = { + 'version': '4', + 'rollouts': [ + { + 'experiments': [ + { + 'status': 'Running', + 'key': '11488548027', + 'layerId': '11551226731', + 'trafficAllocation': [ + { + 'entityId': '11557362669', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646', + '3468206647', '3468206644', '3468206643'], + 'variations': [ + { + 'variables': [], + 'id': '11557362669', + 'key': '11557362669', + 'featureEnabled':True + } + ], + 'forcedVariations': {}, + 'id': '11488548027' + } + ], + 'id': '11551226731' + }, + { + 'experiments': [ + { + 'status': 'Paused', + 'key': '11630490911', + 'layerId': '11638870867', + 'trafficAllocation': [ + { + 'entityId': '11475708558', + 'endOfRange': 0 + } + ], + 'audienceIds': [], + 'variations': [ + { + 'variables': [], + 'id': '11475708558', + 'key': '11475708558', + 'featureEnabled':False + } + ], + 'forcedVariations': {}, + 'id': '11630490911' + } + ], + 'id': '11638870867' + } + ], + 'anonymizeIP': False, + 'projectId': '11624721371', + 'variables': [], + 'featureFlags': [ + { + 'experimentIds': [], + 'rolloutId': '11551226731', + 'variables': [], + 'id': '11477755619', + 'key': 'feat' + }, + { + 'experimentIds': [ + '11564051718' + ], + 'rolloutId': '11638870867', + 'variables': [ + { + 'defaultValue': 'x', + 'type': 'string', + 'id': '11535264366', + 'key': 'x' + } + ], + 'id': '11567102051', + 'key': 'feat_with_var' + } + ], + 'experiments': [ + { + 'status': 'Running', + 'key': 'feat_with_var_test', + 'layerId': '11504144555', + 'trafficAllocation': [ + { + 'entityId': '11617170975', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646', + '3468206647', '3468206644', '3468206643'], + 'variations': [ + { + 'variables': [ + { + 'id': '11535264366', + 'value': 'xyz' + } + ], + 'id': '11617170975', + 'key': 'variation_2', + 'featureEnabled': True + } + ], + 'forcedVariations': {}, + 'id': '11564051718' + }, + { + 'id': '1323241597', + 'key': 'typed_audience_experiment', + 'layerId': '1630555627', + 'status': 'Running', + 'variations': [ + { + 'id': '1423767503', + 'key': 'A', + 'variables': [] + } + ], + 'trafficAllocation': [ + { + 'entityId': '1423767503', + 'endOfRange': 10000 + } + ], + 'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646', + '3468206647', '3468206644', '3468206643'], + 'forcedVariations': {} + } + ], + 'audiences': [ + { + 'id': '3468206642', + 'name': 'exactString', + 'conditions': '["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "value": "Gryffindor"}]]]' + }, + { + 'id': '3988293898', + 'name': '$$dummySubstringString', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '3988293899', + 'name': '$$dummyExists', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '3468206646', + 'name': '$$dummyExactNumber', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '3468206647', + 'name': '$$dummyGtNumber', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '3468206644', + 'name': '$$dummyLtNumber', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + }, + { + 'id': '3468206643', + 'name': '$$dummyExactBoolean', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + } + ], + 'typedAudiences': [ + { + 'id': '3988293898', + 'name': 'substringString', + 'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute', + 'match': 'substring', 'value': 'Slytherin'}]]] + }, + { + 'id': '3988293899', + 'name': 'exists', + 'conditions': ['and', ['or', ['or', {'name': 'favorite_ice_cream', 'type': 'custom_attribute', + 'match': 'exists'}]]] + }, + { + 'id': '3468206646', + 'name': 'exactNumber', + 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', + 'match': 'exact', 'value': 45.5}]]] + }, + { + 'id': '3468206647', + 'name': 'gtNumber', + 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', + 'match': 'gt', 'value': 70}]]] + }, + { + 'id': '3468206644', + 'name': 'ltNumber', + 'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute', + 'match': 'lt', 'value': 1.0}]]] + }, + { + 'id': '3468206643', + 'name': 'exactBoolean', + 'conditions': ['and', ['or', ['or', {'name': 'should_do_it', 'type': 'custom_attribute', + 'match': 'exact', 'value': True}]]] + } + ], + 'groups': [], + 'attributes': [ + { + 'key': 'house', + 'id': '594015' + }, + { + 'key': 'lasers', + 'id': '594016' + }, + { + 'key': 'should_do_it', + 'id': '594017' + }, + { + 'key': 'favorite_ice_cream', + 'id': '594018' + } + ], + 'botFiltering': False, + 'accountId': '4879520872', + 'events': [ + { + 'key': 'item_bought', + 'id': '594089', + 'experimentIds': [ + '11564051718', + '1323241597' + ] + } + ], + 'revision': '3' + } + config = getattr(self, config_dict) self.optimizely = optimizely.Optimizely(json.dumps(config)) self.project_config = self.optimizely.config diff --git a/tests/helpers_tests/test_audience.py b/tests/helpers_tests/test_audience.py index 6302ad8a..eff2c9f4 100644 --- a/tests/helpers_tests/test_audience.py +++ b/tests/helpers_tests/test_audience.py @@ -54,14 +54,27 @@ def test_is_user_in_experiment__no_audience(self): self.assertTrue(audience.is_user_in_experiment(self.project_config, experiment, user_attributes)) def test_is_user_in_experiment__no_attributes(self): - """ Test that is_user_in_experiment returns True when experiment is using no audience. """ + """ Test that is_user_in_experiment defaults attributes to empty Dict and + is_match does get called with empty attributes. """ + + with mock.patch('optimizely.helpers.audience.is_match') as mock_is_match: + audience.is_user_in_experiment( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), None + ) - self.assertFalse(audience.is_user_in_experiment( - self.project_config, self.project_config.get_experiment_from_key('test_experiment'), None) + mock_is_match.assert_called_once_with( + self.optimizely.config.get_audience('11154'), {} ) - self.assertFalse(audience.is_user_in_experiment( - self.project_config, self.project_config.get_experiment_from_key('test_experiment'), {}) + with mock.patch('optimizely.helpers.audience.is_match') as mock_is_match: + audience.is_user_in_experiment( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), {} + ) + + mock_is_match.assert_called_once_with( + self.optimizely.config.get_audience('11154'), {} ) def test_is_user_in_experiment__audience_conditions_are_met(self): diff --git a/tests/helpers_tests/test_condition.py b/tests/helpers_tests/test_condition.py index 07cf1cbd..51021a02 100644 --- a/tests/helpers_tests/test_condition.py +++ b/tests/helpers_tests/test_condition.py @@ -12,122 +12,587 @@ # limitations under the License. import mock +from six import PY2, PY3 from optimizely.helpers import condition as condition_helper from tests import base +if PY3: + def long(a): + raise NotImplementedError('Tests should only call `long` if running in PY2') -class ConditionEvaluatorTests(base.BaseTest): +browserConditionSafari = ['browser_type', 'safari', 'custom_attribute', 'exact'] +booleanCondition = ['is_firefox', True, 'custom_attribute', 'exact'] +integerCondition = ['num_users', 10, 'custom_attribute', 'exact'] +doubleCondition = ['pi_value', 3.14, 'custom_attribute', 'exact'] + +exists_condition_list = [['input_value', None, 'custom_attribute', 'exists']] +exact_string_condition_list = [['favorite_constellation', 'Lacerta', 'custom_attribute', 'exact']] +exact_int_condition_list = [['lasers_count', 9000, 'custom_attribute', 'exact']] +exact_float_condition_list = [['lasers_count', 9000.0, 'custom_attribute', 'exact']] +exact_bool_condition_list = [['did_register_user', False, 'custom_attribute', 'exact']] +substring_condition_list = [['headline_text', 'buy now', 'custom_attribute', 'substring']] +gt_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'gt']] +gt_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'gt']] +lt_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'lt']] +lt_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'lt']] + + +class CustomAttributeConditionEvaluator(base.BaseTest): def setUp(self): base.BaseTest.setUp(self) - self.condition_structure, self.condition_list = condition_helper.loads( - self.config_dict['audiences'][0]['conditions'] + self.condition_list = [browserConditionSafari, booleanCondition, integerCondition, doubleCondition] + + def test_evaluate__returns_true__when_attributes_pass_audience_condition(self): + evaluator = condition_helper.CustomAttributeConditionEvaluator( + self.condition_list, {'browser_type': 'safari'} ) - attributes = { - 'test_attribute': 'test_value_1', - 'browser_type': 'firefox', - 'location': 'San Francisco' - } - self.condition_evaluator = condition_helper.ConditionEvaluator(self.condition_list, attributes) - - def test_evaluator__returns_true(self): - """ Test that evaluator correctly returns True when there is an exact match. - Also test that evaluator works for falsy values. """ - - # string attribute value - condition_list = [['test_attribute', '']] - condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'test_attribute': ''}) - self.assertTrue(self.condition_evaluator.evaluator(0)) - - # boolean attribute value - condition_list = [['boolean_key', False]] - condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'boolean_key': False}) - self.assertTrue(condition_evaluator.evaluator(0)) - - # integer attribute value - condition_list = [['integer_key', 0]] - condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'integer_key': 0}) - self.assertTrue(condition_evaluator.evaluator(0)) - - # double attribute value - condition_list = [['double_key', 0.0]] - condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'double_key': 0.0}) - self.assertTrue(condition_evaluator.evaluator(0)) - - def test_evaluator__returns_false(self): - """ Test that evaluator correctly returns False when there is no match. """ - - attributes = { - 'browser_type': 'chrome', - 'location': 'San Francisco' + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_false__when_attributes_fail_audience_condition(self): + evaluator = condition_helper.CustomAttributeConditionEvaluator( + self.condition_list, {'browser_type': 'chrome'} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__evaluates__different_typed_attributes(self): + userAttributes = { + 'browser_type': 'safari', + 'is_firefox': True, + 'num_users': 10, + 'pi_value': 3.14, } - self.condition_evaluator = condition_helper.ConditionEvaluator(self.condition_list, attributes) - self.assertFalse(self.condition_evaluator.evaluator(0)) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + self.condition_list, userAttributes + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + self.assertStrictTrue(evaluator.evaluate(1)) + self.assertStrictTrue(evaluator.evaluate(2)) + self.assertStrictTrue(evaluator.evaluate(3)) + + def test_evaluate__returns_null__when_condition_has_an_invalid_match_property(self): + + condition_list = [['weird_condition', 'hi', 'custom_attribute', 'weird_match']] + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + condition_list, {'weird_condition': 'hi'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_evaluate__assumes_exact__when_condition_match_property_is_none(self): + + condition_list = [['favorite_constellation', 'Lacerta', 'custom_attribute', None]] + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + condition_list, {'favorite_constellation': 'Lacerta'} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_null__when_condition_has_an_invalid_type_property(self): + + condition_list = [['weird_condition', 'hi', 'weird_type', 'exact']] + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + condition_list, {'weird_condition': 'hi'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exists__returns_false__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exists__returns_false__when_user_provided_value_is_null(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {'input_value': None} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exists__returns_true__when_user_provided_value_is_string(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {'input_value': 'hi'} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exists__returns_true__when_user_provided_value_is_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {'input_value': 10} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {'input_value': 10.0} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exists__returns_true__when_user_provided_value_is_boolean(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exists_condition_list, {'input_value': False} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exact_string__returns_true__when_user_provided_value_is_equal_to_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_string_condition_list, {'favorite_constellation': 'Lacerta'} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exact_string__returns_false__when_user_provided_value_is_not_equal_to_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_string_condition_list, {'favorite_constellation': 'The Big Dipper'} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exact_string__returns_null__when_user_provided_value_is_different_type_from_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_string_condition_list, {'favorite_constellation': False} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_string__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_string_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_int__returns_true__when_user_provided_value_is_equal_to_condition_value(self): + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': long(9000)} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': 9000} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': 9000.0} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exact_float__returns_true__when_user_provided_value_is_equal_to_condition_value(self): + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': long(9000)} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': 9000} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': 9000.0} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exact_int__returns_false__when_user_provided_value_is_not_equal_to_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': 8000} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exact_float__returns_false__when_user_provided_value_is_not_equal_to_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': 8000.0} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exact_int__returns_null__when_user_provided_value_is_different_type_from_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': 'hi'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {'lasers_count': True} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_float__returns_null__when_user_provided_value_is_different_type_from_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': 'hi'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {'lasers_count': True} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_int__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_int_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_float__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_float_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_bool__returns_true__when_user_provided_value_is_equal_to_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_bool_condition_list, {'did_register_user': False} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_exact_bool__returns_false__when_user_provided_value_is_not_equal_to_condition_value(self): - def test_and_evaluator__returns_true(self): - """ Test that and_evaluator returns True when all conditions evaluate to True. """ + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_bool_condition_list, {'did_register_user': True} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_exact_bool__returns_null__when_user_provided_value_is_different_type_from_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_bool_condition_list, {'did_register_user': 0} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_exact_bool__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + exact_bool_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_substring__returns_true__when_condition_value_is_substring_of_user_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + substring_condition_list, {'headline_text': 'Limited time, buy now!'} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_substring__returns_false__when_condition_value_is_not_a_substring_of_user_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + substring_condition_list, {'headline_text': 'Breaking news!'} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_substring__returns_null__when_user_provided_value_not_a_string(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + substring_condition_list, {'headline_text': 10} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_substring__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + substring_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_int__returns_true__when_user_value_greater_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': 48.1} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': 49} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': long(49)} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_greater_than_float__returns_true__when_user_value_greater_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': 48.3} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': 49} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': long(49)} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_greater_than_int__returns_false__when_user_value_not_greater_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': 47.9} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': 47} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': long(47)} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_greater_than_float__returns_false__when_user_value_not_greater_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': 48.2} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': 48} + ) - conditions = range(5) + self.assertStrictFalse(evaluator.evaluate(0)) - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', return_value=True): - self.assertTrue(self.condition_evaluator.and_evaluator(conditions)) + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': long(48)} + ) - def test_and_evaluator__returns_false(self): - """ Test that and_evaluator returns False when any one condition evaluates to False. """ + self.assertStrictFalse(evaluator.evaluate(0)) - conditions = range(5) + def test_greater_than_int__returns_null__when_user_value_is_not_a_number(self): - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', - side_effect=[True, True, False, True, True]): - self.assertFalse(self.condition_evaluator.and_evaluator(conditions)) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': 'a long way'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': False} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_float__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': 'a long way'} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {'meters_travelled': False} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_int__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {} + ) - def test_or_evaluator__returns_true(self): - """ Test that or_evaluator returns True when any one condition evaluates to True. """ + self.assertIsNone(evaluator.evaluate(0)) - conditions = range(5) + def test_greater_than_float__returns_null__when_no_user_provided_value(self): - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', - side_effect=[False, False, True, False, False]): - self.assertTrue(self.condition_evaluator.or_evaluator(conditions)) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_float_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_int__returns_true__when_user_value_less_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': 47.9} + ) - def test_or_evaluator__returns_false(self): - """ Test that or_evaluator returns False when all conditions evaluator to False. """ + self.assertStrictTrue(evaluator.evaluate(0)) - conditions = range(5) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': 47} + ) - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', return_value=False): - self.assertFalse(self.condition_evaluator.or_evaluator(conditions)) + self.assertStrictTrue(evaluator.evaluate(0)) - def test_not_evaluator__returns_true(self): - """ Test that not_evaluator returns True when condition evaluates to False. """ + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': long(47)} + ) - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', return_value=False): - self.assertTrue(self.condition_evaluator.not_evaluator([42])) + self.assertStrictTrue(evaluator.evaluate(0)) - def test_not_evaluator__returns_false(self): - """ Test that not_evaluator returns False when condition evaluates to True. """ + def test_less_than_float__returns_true__when_user_value_less_than_condition_value(self): - with mock.patch('optimizely.helpers.condition.ConditionEvaluator.evaluate', return_value=True): - self.assertFalse(self.condition_evaluator.not_evaluator([42])) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': 48.1} + ) - def test_not_evaluator__returns_false_more_than_one_condition(self): - """ Test that not_evaluator returns False when list has more than 1 condition. """ + self.assertStrictTrue(evaluator.evaluate(0)) - self.assertFalse(self.condition_evaluator.not_evaluator([42, 43])) + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': 48} + ) - def test_evaluate__returns_true(self): - """ Test that evaluate returns True when conditions evaluate to True. """ + self.assertStrictTrue(evaluator.evaluate(0)) - self.assertTrue(self.condition_evaluator.evaluate(self.condition_structure)) + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': long(48)} + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_less_than_int__returns_false__when_user_value_not_less_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': 48.1} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': 49} + ) - def test_evaluate__returns_false(self): - """ Test that evaluate returns False when conditions evaluate to False. """ + self.assertStrictFalse(evaluator.evaluate(0)) - condition_structure = ['and', ['or', ['not', 0]]] - self.assertFalse(self.condition_evaluator.evaluate(condition_structure)) + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': long(49)} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_less_than_float__returns_false__when_user_value_not_less_than_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': 48.2} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': 49} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': long(49)} + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_less_than_int__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {'meters_travelled': False} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_float__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {'meters_travelled': False} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_int__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_int_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_float__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_float_condition_list, {} + ) + + self.assertIsNone(evaluator.evaluate(0)) class ConditionDecoderTests(base.BaseTest): @@ -140,4 +605,15 @@ def test_loads(self): ) self.assertEqual(['and', ['or', ['or', 0]]], condition_structure) - self.assertEqual([['test_attribute', 'test_value_1']], condition_list) + self.assertEqual([['test_attribute', 'test_value_1', 'custom_attribute', None]], condition_list) + + def test_audience_condition_deserializer_defaults(self): + """ Test that audience_condition_deserializer defaults to None.""" + + browserConditionSafari = {} + + items = condition_helper._audience_condition_deserializer(browserConditionSafari) + self.assertIsNone(items[0]) + self.assertIsNone(items[1]) + self.assertIsNone(items[2]) + self.assertIsNone(items[3]) diff --git a/tests/helpers_tests/test_condition_tree_evaluator.py b/tests/helpers_tests/test_condition_tree_evaluator.py new file mode 100644 index 00000000..54aa7e92 --- /dev/null +++ b/tests/helpers_tests/test_condition_tree_evaluator.py @@ -0,0 +1,260 @@ +# Copyright 2018, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +from optimizely.helpers.condition_tree_evaluator import evaluate +from tests import base + +conditionA = { + 'name': 'browser_type', + 'value': 'safari', + 'type': 'custom_attribute', +} + +conditionB = { + 'name': 'device_model', + 'value': 'iphone6', + 'type': 'custom_attribute', +} + +conditionC = { + 'name': 'location', + 'match': 'exact', + 'type': 'custom_attribute', + 'value': 'CA', +} + + +class ConditionTreeEvaluatorTests(base.BaseTest): + + def test_evaluate__returns_true(self): + """ Test that evaluate returns True when the leaf condition evaluator returns True. """ + + self.assertStrictTrue(evaluate(conditionA, lambda a: True)) + + def test_evaluate__returns_false(self): + """ Test that evaluate returns False when the leaf condition evaluator returns False. """ + + self.assertStrictFalse(evaluate(conditionA, lambda a: False)) + + def test_and_evaluator__returns_true(self): + """ Test that and_evaluator returns True when all conditions evaluate to True. """ + + self.assertStrictTrue(evaluate( + ['and', conditionA, conditionB], + lambda a: True + )) + + def test_and_evaluator__returns_false(self): + """ Test that and_evaluator returns False when any one condition evaluates to False. """ + + leafEvaluator = mock.MagicMock(side_effect=[True, False]) + + self.assertStrictFalse(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_and_evaluator__returns_null__when_all_null(self): + """ Test that and_evaluator returns null when all operands evaluate to null. """ + + self.assertIsNone(evaluate( + ['and', conditionA, conditionB], + lambda a: None + )) + + def test_and_evaluator__returns_null__when_trues_and_null(self): + """ Test that and_evaluator returns when operands evaluate to trues and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[True, None]) + + self.assertIsNone(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[None, True]) + + self.assertIsNone(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_and_evaluator__returns_false__when_falses_and_null(self): + """ Test that and_evaluator returns False when when operands evaluate to falses and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[False, None]) + + self.assertStrictFalse(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[None, False]) + + self.assertStrictFalse(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_and_evaluator__returns_false__when_trues_falses_and_null(self): + """ Test that and_evaluator returns False when operands evaluate to trues, falses and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[True, False, None]) + + self.assertStrictFalse(evaluate( + ['and', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_or_evaluator__returns_true__when_any_true(self): + """ Test that or_evaluator returns True when any one condition evaluates to True. """ + + leafEvaluator = mock.MagicMock(side_effect=[False, True]) + + self.assertStrictTrue(evaluate( + ['or', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_or_evaluator__returns_false__when_all_false(self): + """ Test that or_evaluator returns False when all operands evaluate to False.""" + + self.assertStrictFalse(evaluate( + ['or', conditionA, conditionB], + lambda a: False + )) + + def test_or_evaluator__returns_null__when_all_null(self): + """ Test that or_evaluator returns null when all operands evaluate to null. """ + + self.assertIsNone(evaluate( + ['or', conditionA, conditionB], + lambda a: None + )) + + def test_or_evaluator__returns_true__when_trues_and_null(self): + """ Test that or_evaluator returns True when operands evaluate to trues and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[None, True]) + + self.assertStrictTrue(evaluate( + ['or', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[True, None]) + + self.assertStrictTrue(evaluate( + ['or', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_or_evaluator__returns_null__when_falses_and_null(self): + """ Test that or_evaluator returns null when operands evaluate to falses and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[False, None]) + + self.assertIsNone(evaluate( + ['or', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[None, False]) + + self.assertIsNone(evaluate( + ['or', conditionA, conditionB], + lambda a: leafEvaluator() + )) + + def test_or_evaluator__returns_true__when_trues_falses_and_null(self): + """ Test that or_evaluator returns True when operands evaluate to trues, falses and null. """ + + leafEvaluator = mock.MagicMock(side_effect=[False, None, True]) + + self.assertStrictTrue(evaluate( + ['or', conditionA, conditionB, conditionC], + lambda a: leafEvaluator() + )) + + def test_not_evaluator__returns_true(self): + """ Test that not_evaluator returns True when condition evaluates to False. """ + + self.assertStrictTrue(evaluate( + ['not', conditionA], + lambda a: False + )) + + def test_not_evaluator__returns_false(self): + """ Test that not_evaluator returns True when condition evaluates to False. """ + + self.assertStrictFalse(evaluate( + ['not', conditionA], + lambda a: True + )) + + def test_not_evaluator_negates_first_condition__ignores_rest(self): + """ Test that not_evaluator negates first condition and ignores rest. """ + leafEvaluator = mock.MagicMock(side_effect=[False, True, None]) + + self.assertStrictTrue(evaluate( + ['not', conditionA, conditionB, conditionC], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[True, False, None]) + + self.assertStrictFalse(evaluate( + ['not', conditionA, conditionB, conditionC], + lambda a: leafEvaluator() + )) + + leafEvaluator = mock.MagicMock(side_effect=[None, True, False]) + + self.assertIsNone(evaluate( + ['not', conditionA, conditionB, conditionC], + lambda a: leafEvaluator() + )) + + def test_not_evaluator__returns_null__when_null(self): + """ Test that not_evaluator returns null when condition evaluates to null. """ + + self.assertIsNone(evaluate( + ['not', conditionA], + lambda a: None + )) + + def test_not_evaluator__returns_null__when_there_are_no_operands(self): + """ Test that not_evaluator returns null when there are no conditions. """ + + self.assertIsNone(evaluate( + ['not'], + lambda a: True + )) + + def test_evaluate_assumes__OR_operator__when_first_item_in_array_not_recognized_operator(self): + """ Test that by default OR operator is assumed when the first item in conditions is not + a recognized operator. """ + + leafEvaluator = mock.MagicMock(side_effect=[False, True]) + + self.assertStrictTrue(evaluate( + [conditionA, conditionB], + lambda a: leafEvaluator() + )) + + self.assertStrictFalse(evaluate( + [conditionA, conditionB], + lambda a: False + )) diff --git a/tests/test_config.py b/tests/test_config.py index 83a8330a..1c40b846 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -120,13 +120,13 @@ def test_init(self): '11154', 'Test attribute users 1', '["and", ["or", ["or", {"name": "test_attribute", "type": "custom_attribute", "value": "test_value_1"}]]]', conditionStructure=['and', ['or', ['or', 0]]], - conditionList=[['test_attribute', 'test_value_1']] + conditionList=[['test_attribute', 'test_value_1', 'custom_attribute', None]] ), '11159': entities.Audience( '11159', 'Test attribute users 2', '["and", ["or", ["or", {"name": "test_attribute", "type": "custom_attribute", "value": "test_value_2"}]]]', conditionStructure=['and', ['or', ['or', 0]]], - conditionList=[['test_attribute', 'test_value_2']] + conditionList=[['test_attribute', 'test_value_2', 'custom_attribute', None]] ) } expected_variation_key_map = { @@ -521,7 +521,7 @@ def test_init__with_v4_datafile(self): '11154', 'Test attribute users', '["and", ["or", ["or", {"name": "test_attribute", "type": "custom_attribute", "value": "test_value"}]]]', conditionStructure=['and', ['or', ['or', 0]]], - conditionList=[['test_attribute', 'test_value']] + conditionList=[['test_attribute', 'test_value', 'custom_attribute', None]] ) } expected_variation_key_map = { @@ -764,6 +764,43 @@ def test_get_audience__invalid_id(self): self.assertIsNone(self.project_config.get_audience('42')) + def test_get_audience__prefers_typedAudiences_over_audiences(self): + opt = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + config = opt.config + + audiences = self.config_dict_with_typed_audiences['audiences'] + typed_audiences = self.config_dict_with_typed_audiences['typedAudiences'] + + audience_3988293898 = { + 'id': '3988293898', + 'name': '$$dummySubstringString', + 'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }' + } + + self.assertTrue(audience_3988293898 in audiences) + + typed_audience_3988293898 = { + 'id': '3988293898', + 'name': 'substringString', + 'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute', + 'match': 'substring', 'value': 'Slytherin'}]]] + } + + self.assertTrue(typed_audience_3988293898 in typed_audiences) + + audience = config.get_audience('3988293898') + + self.assertEqual('3988293898', audience.id) + self.assertEqual('substringString', audience.name) + + # compare parsed JSON as conditions for typedAudiences is generated via json.dumps + # which can be different for python versions. + self.assertEqual(json.loads( + '["and", ["or", ["or", {"match": "substring", "type": "custom_attribute",' + ' "name": "house", "value": "Slytherin"}]]]'), + json.loads(audience.conditions) + ) + def test_get_variation_from_key__valid_experiment_key(self): """ Test that variation is retrieved correctly when valid experiment key and variation key are provided. """ diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 933a9224..1b850e09 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -670,6 +670,53 @@ def test_activate__with_attributes_of_different_types(self): self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) + def test_activate__with_attributes__typed_audience_match(self): + """ Test that activate calls dispatch_event with right params and returns expected + variation when attributes are provided and typed audience conditions are met. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + # Should be included via exact match string audience with id '3468206642' + self.assertEqual('A', opt_obj.activate('typed_audience_experiment', 'test_user', + {'house': 'Gryffindor'})) + expected_attr = { + 'type': 'custom', + 'value': 'Gryffindor', + 'entity_id': '594015', + 'key': 'house' + } + + self.assertTrue( + expected_attr in mock_dispatch_event.call_args[0][0].params['visitors'][0]['attributes'] + ) + + mock_dispatch_event.reset() + + with mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + # Should be included via exact match number audience with id '3468206646' + self.assertEqual('A', opt_obj.activate('typed_audience_experiment', 'test_user', + {'lasers': 45.5})) + expected_attr = { + 'type': 'custom', + 'value': 45.5, + 'entity_id': '594016', + 'key': 'lasers' + } + + self.assertTrue( + expected_attr in mock_dispatch_event.call_args[0][0].params['visitors'][0]['attributes'] + ) + + def test_activate__with_attributes__typed_audience_mismatch(self): + """ Test that activate returns None when typed audience conditions do not match. """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + self.assertIsNone(opt_obj.activate('typed_audience_experiment', 'test_user', + {'house': 'Hufflepuff'})) + self.assertEqual(0, mock_dispatch_event.call_count) + def test_activate__with_attributes__audience_match__forced_bucketing(self): """ Test that activate calls dispatch_event with right params and returns expected variation when attributes are provided and audience conditions are met after a @@ -890,6 +937,39 @@ def test_track__with_attributes(self): self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) + def test_track__with_attributes__typed_audience_match(self): + """ Test that track calls dispatch_event with right params when attributes are provided + and it's a typed audience match. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + # Should be included via substring match string audience with id '3988293898' + opt_obj.track('item_bought', 'test_user', {'house': 'Welcome to Slytherin!'}) + + self.assertEqual(1, mock_dispatch_event.call_count) + + expected_attr = { + 'type': 'custom', + 'value': 'Welcome to Slytherin!', + 'entity_id': '594015', + 'key': 'house' + } + + self.assertTrue( + expected_attr in mock_dispatch_event.call_args[0][0].params['visitors'][0]['attributes'] + ) + + def test_track__with_attributes__typed_audience_mismatch(self): + """ Test that track does not call dispatch_event when typed audience conditions do not match. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + opt_obj.track('item_bought', 'test_user', {'house': 'Welcome to Hufflepuff!'}) + + self.assertEqual(0, mock_dispatch_event.call_count) + def test_track__with_attributes__bucketing_id_provided(self): """ Test that track calls dispatch_event with right params when attributes (including bucketing ID) are provided. """ @@ -1340,6 +1420,27 @@ def test_is_feature_enabled__returns_false_for__invalid_attributes(self): mock_validator.assert_called_once_with('invalid') mock_client_logging.error.assert_called_once_with('Provided attributes are in an invalid format.') + def test_is_feature_enabled__in_rollout__typed_audience_match(self): + """ Test that is_feature_enabled returns True for feature rollout with typed audience match. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + # Should be included via exists match audience with id '3988293899' + self.assertTrue(opt_obj.is_feature_enabled('feat', 'test_user', {'favorite_ice_cream': 'chocolate'})) + + # Should be included via less-than match audience with id '3468206644' + self.assertTrue(opt_obj.is_feature_enabled('feat', 'test_user', {'lasers': -3})) + + def test_is_feature_enabled__in_rollout__typed_audience_mismatch(self): + """ Test that is_feature_enabled returns False for feature rollout with typed audience mismatch. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + self.assertIs( + opt_obj.is_feature_enabled('feat', 'test_user', {}), + False + ) + def test_is_feature_enabled__returns_false_for_invalid_feature(self): """ Test that the feature is not enabled for the user if the provided feature key is invalid. """ @@ -2037,6 +2138,33 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): mock_client_logger.error.assert_called_with('Unable to cast value. Returning None.') + def test_get_feature_variable_returns__variable_value__typed_audience_match(self): + """ Test that get_feature_variable_* return variable value with typed audience match. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + # Should be included in the feature test via greater-than match audience with id '3468206647' + self.assertEqual( + 'xyz', + opt_obj.get_feature_variable_string('feat_with_var', 'x', 'user1', {'lasers': 71}) + ) + + # Should be included in the feature test via exact match boolean audience with id '3468206643' + self.assertEqual( + 'xyz', + opt_obj.get_feature_variable_string('feat_with_var', 'x', 'user1', {'should_do_it': True}) + ) + + def test_get_feature_variable_returns__default_value__typed_audience_match(self): + """ Test that get_feature_variable_* return default value with typed audience mismatch. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + self.assertEqual( + 'x', + opt_obj.get_feature_variable_string('feat_with_var', 'x', 'user1', {'lasers': 50}) + ) + class OptimizelyWithExceptionTest(base.BaseTest):