diff --git a/scripts/py_matter_yamltests/matter_yamltests/definitions.py b/scripts/py_matter_yamltests/matter_yamltests/definitions.py index a9602c7024f1a8..9cf5786dca7a76 100644 --- a/scripts/py_matter_yamltests/matter_yamltests/definitions.py +++ b/scripts/py_matter_yamltests/matter_yamltests/definitions.py @@ -141,11 +141,19 @@ def get_type_by_name(self, cluster_name: str, target_name: str): if struct: return struct + event = self.get_event_by_name(cluster_name, target_name) + if event: + return event + return None def is_fabric_scoped(self, target) -> bool: - if hasattr(target, 'qualities'): + if isinstance(target, Event): + return bool(target.is_fabric_sensitive) + + if isinstance(target, Struct) and hasattr(target, 'qualities'): return bool(target.qualities & StructQuality.FABRIC_SCOPED) + return False def is_nullable(self, target) -> bool: diff --git a/scripts/py_matter_yamltests/matter_yamltests/fixes.py b/scripts/py_matter_yamltests/matter_yamltests/fixes.py index d9630f723d5e64..a488d83bfb87d7 100644 --- a/scripts/py_matter_yamltests/matter_yamltests/fixes.py +++ b/scripts/py_matter_yamltests/matter_yamltests/fixes.py @@ -130,10 +130,10 @@ def try_update_yaml_node_id_test_runner_state(tests, config): if test.cluster == 'CommissionerCommands' or test.cluster == 'DelayCommands': if test.command == 'PairWithCode' or test.command == 'WaitForCommissionee': - if test.response_with_placeholders: + if test.responses_with_placeholders: # It the test expects an error, we should not update the # nodeId of the identity. - error = test.response_with_placeholders.get('error') + error = test.responses_with_placeholders[0].get('error') if error: continue diff --git a/scripts/py_matter_yamltests/matter_yamltests/parser.py b/scripts/py_matter_yamltests/matter_yamltests/parser.py index 97cc0dc51e8c19..c3620029e008b1 100644 --- a/scripts/py_matter_yamltests/matter_yamltests/parser.py +++ b/scripts/py_matter_yamltests/matter_yamltests/parser.py @@ -36,6 +36,8 @@ 'cluster', 'command', 'disabled', + 'event', + 'eventNumber', 'endpoint', 'identity', 'fabricFiltered', @@ -81,6 +83,7 @@ _EVENT_COMMANDS = [ 'readEvent', 'subscribeEvent', + 'waitForReport', ] @@ -99,6 +102,7 @@ class PostProcessCheckType(Enum): CONSTRAINT_VALIDATION = auto() SAVE_AS_VARIABLE = auto() WAIT_VALIDATION = auto() + OPTIONAL = auto() class PostProcessCheck: @@ -206,6 +210,7 @@ def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_ self.cluster = _value_or_config(test, 'cluster', config) self.command = _value_or_config(test, 'command', config) self.attribute = _value_or_none(test, 'attribute') + self.event = _value_or_none(test, 'event') self.endpoint = _value_or_config(test, 'endpoint', config) self.is_pics_enabled = pics_checker.check(_value_or_none(test, 'PICS')) @@ -217,20 +222,35 @@ def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_ test, 'timedInteractionTimeoutMs') self.busy_wait_ms = _value_or_none(test, 'busyWaitMs') self.wait_for = _value_or_none(test, 'wait') - - self.is_attribute = self.command in _ATTRIBUTE_COMMANDS or self.wait_for in _ATTRIBUTE_COMMANDS - self.is_event = self.command in _EVENT_COMMANDS or self.wait_for in _EVENT_COMMANDS - - self.arguments_with_placeholders = _value_or_none(test, 'arguments') - self.response_with_placeholders = _value_or_none(test, 'response') - - _check_valid_keys(self.arguments_with_placeholders, - _TEST_ARGUMENTS_SECTION) - _check_valid_keys(self.response_with_placeholders, - _TEST_RESPONSE_SECTION) - - self._convert_single_value_to_values(self.arguments_with_placeholders) - self._convert_single_value_to_values(self.response_with_placeholders) + self.event_number = _value_or_none(test, 'eventNumber') + + self.is_attribute = self.attribute and ( + self.command in _ATTRIBUTE_COMMANDS or self.wait_for in _ATTRIBUTE_COMMANDS) + self.is_event = self.event and ( + self.command in _EVENT_COMMANDS or self.wait_for in _EVENT_COMMANDS) + + arguments = _value_or_none(test, 'arguments') + _check_valid_keys(arguments, _TEST_ARGUMENTS_SECTION) + self._convert_single_value_to_values(arguments) + self.arguments_with_placeholders = arguments + + responses = _value_or_none(test, 'response') + # Test may expect multiple responses. For example reading events may + # trigger multiple event responses. Or reading multiple attributes + # at the same time, may trigger multiple responses too. + if responses is None: + # If no response is specified at all, it implies that the step expect + # a success with any associatied value(s). So the empty response is effectively + # replace by an array that contains an empty object to represent that. + responses = [{}] + elif not isinstance(responses, list): + # If a single response is specified, it is converted to a list of responses. + responses = [responses] + + for response in responses: + _check_valid_keys(response, _TEST_RESPONSE_SECTION) + self._convert_single_value_to_values(response) + self.responses_with_placeholders = responses argument_mapping = None response_mapping = None @@ -245,6 +265,15 @@ def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_ argument_mapping = attribute_mapping response_mapping = attribute_mapping response_mapping_name = attribute.definition.data_type.name + elif self.is_event: + event = definitions.get_event_by_name( + self.cluster, self.event) + if event: + event_mapping = self._as_mapping(definitions, self.cluster, + event.name) + argument_mapping = event_mapping + response_mapping = event_mapping + response_mapping_name = event.name else: command = definitions.get_command_by_name( self.cluster, self.command) @@ -259,15 +288,25 @@ def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_ self.response_mapping = response_mapping self.response_mapping_name = response_mapping_name self.update_arguments(self.arguments_with_placeholders) - self.update_response(self.response_with_placeholders) + self.update_responses(self.responses_with_placeholders) + + # Some keywords, such as "optional" and "wait_for" do not support multiple + # responses. + if len(responses) > 1: + if self.optional: + raise Exception( + 'The "optional" keyword can not be used with multiple expected responses') + elif self.wait_for: + raise Exception( + 'The "wait_for" keyword can not be used with multiple expected responses') # This performs a very basic sanity parse time check of constraints. This parsing happens # again inside post processing response since at that time we will have required variables # to substitute in. This parsing check here has value since some test can take a really # long time to run so knowing earlier on that the test step would have failed at parsing # time before the test step run occurs save developer time that building yaml tests. - if self.response_with_placeholders: - for value in self.response_with_placeholders['values']: + for response in self.responses_with_placeholders: + for value in response: if 'constraints' not in value: continue get_constraints(value['constraints']) @@ -313,17 +352,22 @@ def update_arguments(self, arguments_with_placeholders): self._update_with_definition( arguments_with_placeholders, self.argument_mapping) - def update_response(self, response_with_placeholders): - self._update_with_definition( - response_with_placeholders, self.response_mapping) + def update_responses(self, responses_with_placeholders): + for response in responses_with_placeholders: + self._update_with_definition( + response, self.response_mapping) def _update_with_definition(self, container: dict, mapping_type): if not container or not mapping_type: return - for value in list(container['values']): + values = container['values'] + if values is None: + return + + for value in list(values): for key, item_value in list(value.items()): - if self.is_attribute: + if self.is_attribute or self.is_event: mapping = mapping_type else: target_key = value['name'] @@ -405,14 +449,16 @@ def __init__(self, test: _TestStepWithPlaceholders, runtime_config_variable_stor self._test = test self._runtime_config_variable_storage = runtime_config_variable_storage self.arguments = copy.deepcopy(test.arguments_with_placeholders) - self.response = copy.deepcopy(test.response_with_placeholders) + self.responses = copy.deepcopy(test.responses_with_placeholders) if test.is_pics_enabled: self._update_placeholder_values(self.arguments) - self._update_placeholder_values(self.response) + self._update_placeholder_values(self.responses) self._test.node_id = self._config_variable_substitution( self._test.node_id) + self._test.event_number = self._config_variable_substitution( + self._test.event_number) test.update_arguments(self.arguments) - test.update_response(self.response) + test.update_responses(self.responses) @property def is_enabled(self): @@ -458,6 +504,10 @@ def command(self): def attribute(self): return self._test.attribute + @property + def event(self): + return self._test.event + @property def endpoint(self): return self._test.endpoint @@ -490,42 +540,86 @@ def busy_wait_ms(self): def wait_for(self): return self._test.wait_for - def post_process_response(self, response: dict): + @property + def event_number(self): + return self._test.event_number + + def post_process_response(self, received_responses): result = PostProcessResponseResult() + # A list of responses is what is expected, but for legacy, if the response + # does not comes up as a list, it is converted here. + # TODO It should be removed once all decoders returns a list. + if not isinstance(received_responses, list): + received_responses = [received_responses] + if self.wait_for is not None: - self._response_cluster_wait_validation(response, result) + self._response_cluster_wait_validation(received_responses, result) return result - if self._skip_post_processing(response, result): + if self._skip_post_processing(received_responses, result): return result - self._response_error_validation(response, result) - if self.response: - self._response_cluster_error_validation(response, result) - self._response_values_validation(response, result) - self._response_constraints_validation(response, result) - self._maybe_save_as(response, result) + check_type = PostProcessCheckType.RESPONSE_VALIDATION + error_failure_wrong_response_number = f'The test expects {len(self.responses)} responses but got {len(received_responses)} responses.' + + received_responses_copy = copy.deepcopy(received_responses) + for expected_response in self.responses: + if len(received_responses_copy) == 0: + result.error(check_type, error_failure_wrong_response_number) + return result + received_response = received_responses_copy.pop(0) + self._response_error_validation( + expected_response, received_response, result) + self._response_cluster_error_validation( + expected_response, received_response, result) + self._response_values_validation( + expected_response, received_response, result) + self._response_constraints_validation( + expected_response, received_response, result) + self._maybe_save_as(expected_response, received_response, result) + + # An empty response array in a test step (responses: []) implies that the test step does expect a response + # but without any associated value. + if self.responses == [] and received_responses_copy == [{}]: + # if the received responses is a simple success ([{}]), that is valid. + return result + # This is different from the case where no response is specified at all, which implies that the step expect + # a success with any associatied value(s). + elif self.responses == [{'values': [{}]}] and len(received_responses_copy): + # if there are multiple responses and the test specifies that it does not really care + # about which values are returned, that is valid too. + return result + # Anything more complex where the response field as been defined with some values and the number + # of expected responses differs from the number of received responses is an error. + elif len(received_responses_copy) != 0: + result.error(check_type, error_failure_wrong_response_number) return result - def _response_cluster_wait_validation(self, response, result): + def _response_cluster_wait_validation(self, received_responses, result): """Check if the response concrete path matches the configuration of the test step and validate that the response type (e.g readAttribute/writeAttribute/...) matches the expectation from the test step.""" check_type = PostProcessCheckType.WAIT_VALIDATION error_success = 'The test expectation "{wait_for}" for "{cluster}.{wait_type}" on endpoint {endpoint} is true' error_failure = 'The test expectation "{expected} == {received}" is false' + error_failure_multiple_responses = 'The test expects a single response but got {len(received_responses)} responses.' + + if len(received_responses) > 1: + result.error(check_type, error_failure.multiple_responses) + return + received_response = received_responses[0] if self.is_attribute: expected_wait_type = self.attribute - received_wait_type = response.get('attribute') + received_wait_type = received_response.get('attribute') elif self.is_event: expected_wait_type = self.event - received_wait_type = response.get('event') + received_wait_type = received_response.get('event') else: expected_wait_type = self.command - received_wait_type = response.get('command') + received_wait_type = receive_response.get('command') expected_values = [ self.wait_for, @@ -536,9 +630,9 @@ def _response_cluster_wait_validation(self, response, result): ] received_values = [ - response.get('wait_for'), - response.get('endpoint'), - response.get('cluster'), + received_response.get('wait_for'), + received_response.get('endpoint'), + received_response.get('cluster'), received_wait_type ] @@ -555,7 +649,7 @@ def _response_cluster_wait_validation(self, response, result): result.success(check_type, error_success.format( wait_for=self.wait_for, cluster=self.cluster, wait_type=expected_wait_type, endpoint=self.endpoint)) - def _skip_post_processing(self, response: dict, result) -> bool: + def _skip_post_processing(self, received_responses, result) -> bool: '''Should we skip perform post processing. Currently we only skip post processing if the test step indicates that sent test step @@ -566,17 +660,26 @@ def _skip_post_processing(self, response: dict, result) -> bool: if not self.optional: return False - received_error = response.get('error', None) + check_type = PostProcessCheckType.OPTIONAL + error_failure_multiple_responses = 'The test expects a single response but got {len(received_responses)} responses.' + + if len(received_responses) > 1: + result.error(check_type, error_failure.multiple_responses) + return False + received_response = received_responses[0] + + received_error = received_response.get('error', None) if received_error is None: return False if received_error == 'UNSUPPORTED_ATTRIBUTE' or received_error == 'UNSUPPORTED_COMMAND': - # result.warning(PostProcessCheckType.Optional, f'The response contains the error: "{error}".') + result.warning(PostProcessCheckType.OPTIONAL, + f'The response contains the error: "{received_error}".') return True return False - def _response_error_validation(self, response, result): + def _response_error_validation(self, expected_response, received_response, result): check_type = PostProcessCheckType.IM_STATUS error_success = 'The test expects the "{error}" error which occured successfully.' error_success_no_error = 'The test expects no error and no error occurred.' @@ -584,9 +687,9 @@ def _response_error_validation(self, response, result): error_unexpected_error = 'The test expects no error but the "{error}" error occured.' error_unexpected_success = 'The test expects the "{error}" error but no error occured.' - expected_error = self.response.get('error') if self.response else None - - received_error = response.get('error') + expected_error = expected_response.get( + 'error') if expected_response else None + received_error = received_response.get('error') if expected_error and received_error and expected_error == received_error: result.success(check_type, error_success.format( @@ -606,14 +709,14 @@ def _response_error_validation(self, response, result): # This should not happens raise AssertionError('This should not happens.') - def _response_cluster_error_validation(self, response, result): + def _response_cluster_error_validation(self, expected_response, received_response, result): check_type = PostProcessCheckType.CLUSTER_STATUS error_success = 'The test expects the "{error}" error which occured successfully.' error_unexpected_success = 'The test expects the "{error}" error but no error occured.' error_wrong_error = 'The test expects the "{error}" error but the "{value}" error occured.' - expected_error = self.response.get('clusterError') - received_error = response.get('clusterError') + expected_error = expected_response.get('clusterError') + received_error = received_response.get('clusterError') if expected_error: if received_error and expected_error == received_error: @@ -629,21 +732,27 @@ def _response_cluster_error_validation(self, response, result): # Nothing is logged here to not be redundant with the generic error checking code. pass - def _response_values_validation(self, response, result): + def _response_values_validation(self, expected_response, received_response, result): check_type = PostProcessCheckType.RESPONSE_VALIDATION error_success = 'The test expectation "{name} == {value}" is true' error_failure = 'The test expectation "{name} == {value}" is false' error_name_does_not_exist = 'The test expects a value named "{name}" but it does not exists in the response."' + error_value_does_not_exist = 'The test expects a value but it does not exists in the response."' - for value in self.response['values']: + for value in expected_response['values']: if 'value' not in value: continue expected_name = 'value' - received_value = response.get('value') - if not self.is_attribute: + if expected_name not in received_response: + result.error( + check_type, error_value_does_not_exist) + break + + received_value = received_response.get('value') + if not self.is_attribute and not self.is_event: expected_name = value.get('name') - if received_value is None or expected_name not in received_value: + if expected_name not in received_value: result.error(check_type, error_name_does_not_exist.format( name=expected_name)) continue @@ -678,18 +787,18 @@ def _response_value_validation(self, expected_value, received_value): else: return expected_value == received_value - def _response_constraints_validation(self, response, result): + def _response_constraints_validation(self, expected_response, received_response, result): check_type = PostProcessCheckType.CONSTRAINT_VALIDATION error_success = 'Constraints check passed' error_failure = 'Constraints check failed' response_type_name = self._test.response_mapping_name - for value in self.response['values']: + for value in expected_response['values']: if 'constraints' not in value: continue - received_value = response.get('value') - if not self.is_attribute: + received_value = received_response.get('value') + if not self.is_attribute and not self.is_event: expected_name = value.get('name') if received_value is None or expected_name not in received_value: received_value = None @@ -714,17 +823,17 @@ def _response_constraints_validation(self, response, result): # TODO would be helpful to be more verbose here result.error(check_type, error_failure) - def _maybe_save_as(self, response, result): + def _maybe_save_as(self, expected_response, received_response, result): check_type = PostProcessCheckType.SAVE_AS_VARIABLE error_success = 'The test save the value "{value}" as {name}.' error_name_does_not_exist = 'The test expects a value named "{name}" but it does not exists in the response."' - for value in self.response['values']: + for value in expected_response['values']: if 'saveAs' not in value: continue - received_value = response.get('value') - if not self.is_attribute: + received_value = received_response.get('value') + if not self.is_attribute and not self.is_event: expected_name = value.get('name') if received_value is None or expected_name not in received_value: result.error(check_type, error_name_does_not_exist.format( @@ -739,23 +848,26 @@ def _maybe_save_as(self, response, result): result.success(check_type, error_success.format( value=received_value, name=save_as)) - def _update_placeholder_values(self, container): - if not container: + def _update_placeholder_values(self, containers): + if not containers: return - values = container['values'] + if not isinstance(containers, list): + containers = [containers] - for idx, item in enumerate(values): - if 'value' in item: - values[idx]['value'] = self._config_variable_substitution( - item['value']) + for container in containers: + values = container['values'] + for idx, item in enumerate(values): + if 'value' in item: + values[idx]['value'] = self._config_variable_substitution( + item['value']) - if 'constraints' in item: - for constraint, constraint_value in item['constraints'].items(): - values[idx]['constraints'][constraint] = self._config_variable_substitution( - constraint_value) + if 'constraints' in item: + for constraint, constraint_value in item['constraints'].items(): + values[idx]['constraints'][constraint] = self._config_variable_substitution( + constraint_value) - container['values'] = values + container['values'] = values def _config_variable_substitution(self, value): if type(value) is list: diff --git a/scripts/py_matter_yamltests/test_spec_definitions.py b/scripts/py_matter_yamltests/test_spec_definitions.py index 1f32670ced962d..4ef8653dfded96 100644 --- a/scripts/py_matter_yamltests/test_spec_definitions.py +++ b/scripts/py_matter_yamltests/test_spec_definitions.py @@ -94,6 +94,8 @@ + + ''' @@ -232,8 +234,10 @@ def test_event_name(self): definitions = SpecDefinitions( [ParseSource(source=io.StringIO(source_event), name='source_event')]) self.assertIsNone(definitions.get_event_name(0x4321, 0x0)) - self.assertIsNone(definitions.get_event_name(0x1234, 0x1)) + self.assertIsNone(definitions.get_event_name(0x1234, 0x2)) self.assertEqual(definitions.get_event_name(0x1234, 0x0), 'TestEvent') + self.assertEqual(definitions.get_event_name( + 0x1234, 0x1), 'TestEventFabricScoped') def test_get_command_by_name(self): definitions = SpecDefinitions( @@ -358,7 +362,8 @@ def test_get_type_by_name(self): definitions = SpecDefinitions( [ParseSource(source=io.StringIO(source_event), name='source_event')]) - self.assertIsNone(definitions.get_type_by_name('Test', 'TestEvent')) + self.assertIsInstance( + definitions.get_type_by_name('Test', 'TestEvent'), Event) definitions = SpecDefinitions( [ParseSource(source=io.StringIO(source_bitmap), name='source_bitmap')]) @@ -375,7 +380,7 @@ def test_get_type_by_name(self): self.assertIsInstance(definitions.get_type_by_name( 'Test', 'TestStruct'), Struct) - def test_is_fabric_scoped(self): + def test_struct_is_fabric_scoped(self): definitions = SpecDefinitions( [ParseSource(source=io.StringIO(source_struct), name='source_struct')]) @@ -386,6 +391,17 @@ def test_is_fabric_scoped(self): 'Test', 'TestStructFabricScoped') self.assertTrue(definitions.is_fabric_scoped(struct)) + def test_event_is_fabric_scoped(self): + definitions = SpecDefinitions( + [ParseSource(source=io.StringIO(source_event), name='source_event')]) + + event = definitions.get_event_by_name('Test', 'TestEvent') + self.assertFalse(definitions.is_fabric_scoped(event)) + + event = definitions.get_event_by_name( + 'Test', 'TestEventFabricScoped') + self.assertTrue(definitions.is_fabric_scoped(event)) + if __name__ == '__main__': unittest.main() diff --git a/src/app/tests/suites/TestEvents.yaml b/src/app/tests/suites/TestEvents.yaml index 9fbdfb466fb6f6..e43dc0f5423c75 100644 --- a/src/app/tests/suites/TestEvents.yaml +++ b/src/app/tests/suites/TestEvents.yaml @@ -31,11 +31,13 @@ tests: - label: "Check there is no event on the target endpoint" command: "readEvent" event: "TestEvent" + response: [] - label: "Check reading events from an invalid endpoint" command: "readEvent" event: "TestEvent" endpoint: 0 + response: [] - label: "Generate an event on the accessory" command: "TestEmitTestEventRequest" @@ -69,6 +71,7 @@ tests: command: "readEvent" event: "TestEvent" eventNumber: eventNumber + 1 + response: [] - label: "Generate a second event on the accessory" command: "TestEmitTestEventRequest"