Skip to content

Commit f6366d4

Browse files
Add support for minRevision/maxRevision to YAML test framework (project-chip#41706)
* Add support for minRevision/maxRevision to YAML test framework Changes done: - Add minRevision and maxRevision fields to YAML tests processing - Add code to auto-augment the ClusterRevision reading in the test execution set of steps if any other steps have minRevision, maxRevision. - Update runner to honor skipping on min/maxRevision - Update docs to show usage. Testing done: - Added necessary unit tests to existing test_yaml_parser.py - Added an integration test in TestBasicInformation.yaml - All other unit and integration tests pass * Restyled by prettier-yaml * Restyled by autopep8 * Restyled by isort * Fix return statement * Fix lint * Fix parser error, add docs * Restyled by prettier-markdown * Address review comments * Restyled by autopep8 * Restyled by isort --------- Co-authored-by: Restyled.io <[email protected]>
1 parent a3d3ee5 commit f6366d4

File tree

8 files changed

+541
-13
lines changed

8 files changed

+541
-13
lines changed

docs/testing/yaml.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,33 @@ the test harness. Note that full-test gating is not currently implemented in the
271271
local runner or in the CI.
272272

273273
Some test steps need to be gated on values from earlier in the test. In these
274-
cases, PICS cannot be used. Instead, the runIf: tag can be used. This tag
274+
cases, PICS cannot be used. Instead, the `runIf` tag can be used. This tag
275275
requires a boolean value. To convert values to booleans, the TestEqualities
276276
function can be use. See
277277
[TestEqualities](https://github.com/project-chip/connectedhomeip/blob/master/src/app/tests/suites/TestEqualities.yaml)
278278
for an example of how to use this pseudo-cluster.
279279

280+
In addition to the `PICS` and `runIf` tags, there are the `minRevision` and
281+
`maxRevision` tags which use the step's `cluster`'s `ClusterRevision` attribute
282+
to determine if a step should be skipped because it only applies to older or
283+
newer versions. A step will be skipped if the cluster's `ClusterRevision`
284+
attribute is < `minRevision` (if present), or > `maxRevision` (if present).
285+
286+
Here is an example of only checking for the value of a given attribute if
287+
`ClusterRevision` >= 3 using `minRevision`.
288+
289+
```yaml
290+
- label: "Verify the minimum-to-support Max Paths Per Invoke value"
291+
command: "readAttribute"
292+
attribute: "MaxPathsPerInvoke"
293+
minRevision:
294+
3 # Attribute was added in revision 3, so this step applies
295+
# to revision >= 3.
296+
response:
297+
constraints:
298+
minValue: 1
299+
```
300+
280301
#### Setting step timeouts
281302

282303
The timeout argument can be used for each individual test step to set the time

docs/testing/yaml_schema.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,5 @@ YAML schema
7676
|&emsp; dataVersion |list,int|Y|
7777
|&emsp; busyWaitMs |int||
7878
|&emsp; wait |str||
79+
|&emsp; minRevision |int|Y|
80+
|&emsp; maxRevision |int|Y|

scripts/py_matter_yamltests/matter/yamltests/parser.py

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import re
1919
from dataclasses import dataclass, field
2020
from enum import Enum, auto
21-
from typing import Optional
21+
from typing import Any, Optional
2222

2323
from . import fixes
2424
from .constraints import get_constraints, is_typed_constraint, is_variable_aware_constraint
@@ -46,6 +46,11 @@
4646
STRICT_ENUM_VALUE_CHECK = False
4747

4848

49+
def build_revision_var_name(endpoint, cluster) -> str:
50+
"""Helper to create a unique variable name for the cluster revision read-out."""
51+
return f'__cluster_revision_read_EP_{endpoint}_CL_{cluster}'
52+
53+
4954
class UnknownPathQualifierError(TestStepError):
5055
"""Raise when an attribute/command/event name is not found in the definitions."""
5156

@@ -301,6 +306,8 @@ def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_
301306

302307
self.identity = _value_or_none(test, 'identity')
303308
self.fabric_filtered = _value_or_none(test, 'fabricFiltered')
309+
self.min_revision = _value_or_none(test, 'minRevision')
310+
self.max_revision = _value_or_none(test, 'maxRevision')
304311
self.min_interval = _value_or_none(test, 'minInterval')
305312
self.max_interval = _value_or_none(test, 'maxInterval')
306313
self.keep_subscriptions = _value_or_none(test, 'keepSubscriptions')
@@ -748,6 +755,10 @@ def __init__(self, test: _TestStepWithPlaceholders, step_index: int, runtime_con
748755
self._test.group_id)
749756
self._test.node_id = self._config_variable_substitution(
750757
self._test.node_id)
758+
self._test.min_revision = self._config_variable_substitution(
759+
self._test.min_revision)
760+
self._test.max_revision = self._config_variable_substitution(
761+
self._test.max_revision)
751762
test.update_arguments(self.arguments)
752763
test.update_responses(self.responses)
753764

@@ -855,6 +866,60 @@ def event_number(self, value):
855866
def pics(self):
856867
return self._test.pics
857868

869+
@property
870+
def min_revision(self):
871+
return self._test.min_revision
872+
873+
@property
874+
def max_revision(self):
875+
return self._test.max_revision
876+
877+
@property
878+
def is_revision_condition_passed(self) -> bool:
879+
"""Checks if the revision conditions passed properly based on min/maxRevision.
880+
881+
This is evaluated at runtime by the runner.
882+
883+
Returns True if step can be run, False if it cannot be run, or raises an exception
884+
if there was an error processing revision (i.e. internal error).
885+
"""
886+
# If no revision checks are defined, the step is OK to run.
887+
if self.min_revision is None and self.max_revision is None:
888+
return True
889+
890+
endpoint = self.endpoint
891+
cluster = self.cluster
892+
893+
# The runner will have executed a prior step to read the revision, based
894+
# on the parser having injected that step (search for minRevision in
895+
# `YamlTests` class).
896+
var_name = build_revision_var_name(endpoint, cluster)
897+
898+
cluster_revision = self.get_runtime_variable(var_name)
899+
900+
if cluster_revision is None:
901+
raise KeyError(
902+
f"Step '{self.label}': Cannot check min/maxRevision. "
903+
f"ClusterRevision variable '{var_name}' is not set (read step may have been skipped or failed)."
904+
)
905+
906+
# Perform the checks
907+
try:
908+
# Ensure values are integers for comparison
909+
cluster_revision = int(cluster_revision)
910+
911+
return all([
912+
(self.min_revision is None) or (cluster_revision >= int(self.min_revision)),
913+
(self.max_revision is None) or (cluster_revision <= int(self.max_revision)),
914+
])
915+
except (ValueError, TypeError) as e:
916+
# Failed to convert revision to int. This can happen with malformed YAML.
917+
raise ValueError(
918+
f"Step '{self.label}': Error checking min/maxRevision. "
919+
f"cluster_revision='{cluster_revision}', min='{self.min_revision}', max='{self.max_revision}'. "
920+
f"Error: {e}."
921+
)
922+
858923
def _get_last_event_number(self, responses) -> Optional[int]:
859924
if not self.is_event:
860925
return None
@@ -877,6 +942,10 @@ def _get_last_event_number(self, responses) -> Optional[int]:
877942

878943
return event_number
879944

945+
def get_runtime_variable(self, name: str) -> Any:
946+
"""Gets a runtime variable from the test context, or None if missing."""
947+
return self._runtime_config_variable_storage.get(name)
948+
880949
def post_process_response(self, received_responses):
881950
result = PostProcessResponseResult()
882951

@@ -1159,7 +1228,8 @@ def _response_constraints_validation(self, expected_response, received_response,
11591228

11601229
for constraint in constraints:
11611230
try:
1162-
constraint.validate(received_value, response_type_name, self._runtime_config_variable_storage)
1231+
constraint.validate(
1232+
received_value, response_type_name, self._runtime_config_variable_storage)
11631233
result.success(check_type, error_success)
11641234
except TestStepError as e:
11651235
e.update_context(expected_response, self.step_index)
@@ -1265,9 +1335,19 @@ class YamlTests:
12651335

12661336
def __init__(self, parsing_config_variable_storage: dict, definitions: SpecDefinitions, pics_checker: PICSChecker, tests: dict):
12671337
self._parsing_config_variable_storage = parsing_config_variable_storage
1338+
self._runtime_config_variable_storage = copy.deepcopy(
1339+
parsing_config_variable_storage)
1340+
self._definitions = definitions
1341+
self._pics_checker = pics_checker
1342+
1343+
tests_with_cluster_revision_injections = self._tests_with_cluster_revision_checks(
1344+
tests)
1345+
1346+
# Build list of enabled tests from the starting point where pseudo steps are added/injected for
1347+
# things like min/maxRevision tests.
12681348
enabled_tests = []
12691349
try:
1270-
for step_index, step in enumerate(tests):
1350+
for step_index, step in enumerate(tests_with_cluster_revision_injections):
12711351
test_with_placeholders = _TestStepWithPlaceholders(
12721352
step, self._parsing_config_variable_storage, definitions, pics_checker)
12731353
if test_with_placeholders.is_enabled:
@@ -1278,12 +1358,93 @@ def __init__(self, parsing_config_variable_storage: dict, definitions: SpecDefin
12781358

12791359
fixes.try_update_yaml_node_id_test_runner_state(
12801360
enabled_tests, self._parsing_config_variable_storage)
1281-
self._runtime_config_variable_storage = copy.deepcopy(
1282-
parsing_config_variable_storage)
1361+
12831362
self._tests = enabled_tests
12841363
self._index = 0
12851364
self.count = len(self._tests)
12861365

1366+
def _tests_with_cluster_revision_checks(self, tests: dict) -> list[TestStep]:
1367+
"""Injection Logic for synthetic steps needed to process cluster revision checks."""
1368+
1369+
# Pre-pass: Find all (endpoint, cluster) pairs that *need* a revision check.
1370+
default_endpoint = self._get_config_value(
1371+
self._parsing_config_variable_storage, 'endpoint')
1372+
default_cluster = self._get_config_value(
1373+
self._parsing_config_variable_storage, 'cluster')
1374+
1375+
needed_revisions = set() # Set of (endpoint, cluster) tuples
1376+
for step in tests:
1377+
# A step needs a revision check if it has min/maxRevision and is not disabled.
1378+
if 'disabled' in step and step['disabled']:
1379+
continue
1380+
1381+
if 'minRevision' in step or 'maxRevision' in step:
1382+
endpoint = step.get('endpoint', default_endpoint)
1383+
cluster = step.get('cluster', default_cluster)
1384+
1385+
if endpoint is not None and cluster is not None:
1386+
needed_revisions.add((endpoint, cluster))
1387+
1388+
# Main pass: Inject steps
1389+
injected_tests = []
1390+
seen_injections = set() # Set of (endpoint, cluster) tuples
1391+
1392+
for step in tests:
1393+
endpoint = step.get('endpoint', default_endpoint)
1394+
cluster = step.get('cluster', default_cluster)
1395+
key = (endpoint, cluster)
1396+
is_disabled = 'disabled' in step and step['disabled']
1397+
step_has_revision_condition = 'minRevision' in step or 'maxRevision' in step
1398+
1399+
# Determine if an enabled step is the first that would need a cluster revision not yet read.
1400+
# Prepend the read to such steps.
1401+
if step_has_revision_condition and (key in needed_revisions) and (key not in seen_injections) and (not is_disabled):
1402+
# Inject the step *before* the current step.
1403+
revision_var_name = build_revision_var_name(endpoint, cluster)
1404+
1405+
# Add to config storage so it's a known variable
1406+
if revision_var_name not in self._parsing_config_variable_storage:
1407+
self._parsing_config_variable_storage[revision_var_name] = None
1408+
1409+
# Also update the runtime storage, since it was already copied.
1410+
if revision_var_name not in self._runtime_config_variable_storage:
1411+
self._runtime_config_variable_storage[revision_var_name] = None
1412+
1413+
injected_step = {
1414+
'label': f'Read ClusterRevision for conditions (EP: {endpoint}, CL: {cluster})',
1415+
'cluster': cluster,
1416+
'endpoint': endpoint,
1417+
'command': 'readAttribute',
1418+
'attribute': 'ClusterRevision',
1419+
'response': {
1420+
'saveAs': revision_var_name,
1421+
'constraints': {
1422+
'type': 'int16u'
1423+
}
1424+
}
1425+
}
1426+
1427+
injected_tests.append(injected_step)
1428+
seen_injections.add(key)
1429+
1430+
injected_tests.append(step)
1431+
1432+
return injected_tests
1433+
1434+
def _get_config_value(self, config_storage, key):
1435+
value = config_storage.get(key)
1436+
if isinstance(value, dict) and 'defaultValue' in value:
1437+
return value['defaultValue']
1438+
return value
1439+
1440+
def set_runtime_variable(self, name: str, value: Any) -> None:
1441+
"""Sets a runtime variable in the test context."""
1442+
self._runtime_config_variable_storage[name] = value
1443+
1444+
def get_runtime_variable(self, name: str) -> Any:
1445+
"""Gets a runtime variable from the test context, or None if missing."""
1446+
return self._runtime_config_variable_storage.get(name)
1447+
12871448
def __iter__(self):
12881449
return self
12891450

scripts/py_matter_yamltests/matter/yamltests/runner.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from .adapter import TestAdapter
2424
from .hooks import TestRunnerHooks
25-
from .parser import TestParser
25+
from .parser import TestParser, build_revision_var_name
2626
from .parser_builder import TestParserBuilder, TestParserBuilderConfig
2727
from .pseudo_clusters.pseudo_clusters import PseudoClusters
2828

@@ -188,10 +188,29 @@ async def _run(self, parser: TestParser, config: TestRunnerConfig):
188188

189189
test_duration = 0
190190
for idx, request in enumerate(parser.tests):
191+
# Handle skipping tests where PICS do not apply.
191192
if not request.is_pics_enabled:
192193
hooks.step_skipped(request.label, request.pics)
193194
continue
194-
elif not config.adapter:
195+
196+
# Handle skipping steps where ClusterRevision does not apply.
197+
if not request.is_revision_condition_passed:
198+
# Try to get the var name and value for a more informative message
199+
try:
200+
var_name = build_revision_var_name(
201+
request.endpoint, request.cluster)
202+
current_val = request.get_runtime_variable(var_name)
203+
except (ValueError, IndexError, KeyError):
204+
current_val = "unknown"
205+
206+
reason = (f"Step skipped due to ClusterRevision range not matching (val={current_val}, "
207+
f"min={request.min_revision}, "
208+
f"max={request.max_revision})")
209+
hooks.step_skipped(request.label, reason)
210+
continue
211+
212+
# Handle normal flows of execution after condition skipping above.
213+
if not config.adapter:
195214
hooks.step_start(request)
196215
hooks.step_unknown()
197216
continue

scripts/py_matter_yamltests/matter/yamltests/yaml_loader.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,18 @@
5656
'verification': str,
5757
'PICS': str,
5858
'arguments': dict,
59-
'response': (dict, list, str), # Can be a variable
59+
'response': (dict, list, str), # Can be a variable.
6060
'saveResponseAs': str,
6161
'minInterval': int,
6262
'maxInterval': int,
6363
'keepSubscriptions': bool,
6464
'timeout': int,
6565
'timedInteractionTimeoutMs': int,
66-
'dataVersion': (list, int, str), # Can be a variable
66+
'dataVersion': (list, int, str), # Can be a variable.
6767
'busyWaitMs': int,
6868
'wait': str,
69+
'minRevision': (int, str), # Can be a variable.
70+
'maxRevision': (int, str), # Can be a variable.
6971
}
7072

7173
_TEST_STEP_ARGUMENTS_SCHEMA = {

0 commit comments

Comments
 (0)