diff --git a/doc/newsfragments/15721_new.testcase_parameter_filter.rst b/doc/newsfragments/15721_new.testcase_parameter_filter.rst
new file mode 100644
index 000000000..0a46044d4
--- /dev/null
+++ b/doc/newsfragments/15721_new.testcase_parameter_filter.rst
@@ -0,0 +1 @@
+Added an Extended Search button for filtering and navigating parametrized testcase permutations in reports.
diff --git a/testplan/common/serialization/fields.py b/testplan/common/serialization/fields.py
index d80560b3c..2b0e83f87 100644
--- a/testplan/common/serialization/fields.py
+++ b/testplan/common/serialization/fields.py
@@ -7,7 +7,17 @@
import pprint
from datetime import timezone, datetime
-from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Tuple,
+ Type,
+ Union,
+ cast,
+)
from boltons.iterutils import is_scalar, remap
from lxml import etree
@@ -24,6 +34,7 @@
# orjson's native int range; outside this it raises
_INT64_MIN = -(2**63)
_UINT64_MAX = 2**64 - 1
+_MAX_TEXT_LENGTH = 1000
# pylint: disable=unused-argument
@@ -140,7 +151,9 @@ def _repr_obj(obj: object) -> str:
return object.__repr__(obj)
-def native_or_pformat(value: Any) -> Any:
+def native_or_pformat(
+ value: Any, formatter: Callable[[Any], Any] = pprint.pformat
+) -> Any:
"""Generic serialization compatible value formatter."""
if comparison.is_regex(value):
value = "REGEX({})".format(value.pattern)
@@ -159,11 +172,24 @@ def native_or_pformat(value: Any) -> Any:
elif type(value) in JSON_SAFE_SCALARS:
result = value
else:
- result = pprint.pformat(value)
+ result = formatter(value)
return result
+def native_or_text(value: Any) -> Any:
+ """Preserve primitive values and render other values as bounded text."""
+ try:
+ if isinstance(value, (set, frozenset)):
+ value = sorted(value, key=repr)
+ result = native_or_pformat(value, formatter=str)
+ except Exception: # pylint: disable=broad-except
+ return "<{}>".format(type(value).__name__)
+ if isinstance(result, str) and len(result) > _MAX_TEXT_LENGTH:
+ return result[:_MAX_TEXT_LENGTH] + "..."
+ return result
+
+
def native_or_pformat_dict(value: Dict[str, Any]) -> Dict[str, Any]:
"""
Converter utility for dictionaries,
diff --git a/testplan/report/testing/base.py b/testplan/report/testing/base.py
index a4d167680..67242e93c 100644
--- a/testplan/report/testing/base.py
+++ b/testplan/report/testing/base.py
@@ -41,7 +41,6 @@
import copy
import getpass
-import hashlib
import itertools
import platform
import re
@@ -61,6 +60,7 @@
Status,
)
from testplan.common.report.base import ExceptionLoggerBase
+from testplan.common.serialization.fields import native_or_text
from testplan.common.utils.timing import iana_tz
from testplan.testing import tagging
from testplan.testing.common import TEST_PART_PATTERN_FORMAT_STRING
@@ -524,6 +524,7 @@ def __init__(
name: str,
tags: Optional[Union[Dict[str, Any], str]] = None,
category: str = ReportCategories.TESTCASE,
+ parametrization_kwargs: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> None:
super(TestCaseReport, self).__init__(name=name, **kwargs)
@@ -533,6 +534,14 @@ def __init__(
self.attachments: List[Any] = []
self.category = category
self.covered_lines: Optional[dict] = None
+ self.parametrization_kwargs = (
+ {
+ key: native_or_text(value)
+ for key, value in parametrization_kwargs.items()
+ }
+ if parametrization_kwargs
+ else None
+ )
def _get_comparison_attrs(self) -> List[str]:
return super(TestCaseReport, self)._get_comparison_attrs() + [
diff --git a/testplan/report/testing/schemas.py b/testplan/report/testing/schemas.py
index cbdaf21eb..fcae8aae3 100644
--- a/testplan/report/testing/schemas.py
+++ b/testplan/report/testing/schemas.py
@@ -63,6 +63,16 @@ class TestCaseReportSchema(ReportSchema):
category = fields.String()
counter = fields.Dict(dump_only=True)
tags = TagField()
+ parametrization_kwargs = fields.Dict(allow_none=True)
+
+ @post_dump
+ def strip_empty_parametrization_kwargs(
+ self, data: Dict[str, Any], **kwargs: Any
+ ) -> Dict[str, Any]:
+ """Omit parametrization data from ordinary testcase reports."""
+ if data.get("parametrization_kwargs") is None:
+ data.pop("parametrization_kwargs", None)
+ return data
@post_load
def make_report(
diff --git a/testplan/testing/multitest/base.py b/testplan/testing/multitest/base.py
index 0fbcb04fe..853983c50 100644
--- a/testplan/testing/multitest/base.py
+++ b/testplan/testing/multitest/base.py
@@ -771,6 +771,9 @@ def _new_testcase_report(self, testcase: Any) -> TestCaseReport:
definition_name=testcase.name,
uid=testcase.__name__,
tags=testcase.__tags__,
+ parametrization_kwargs=getattr(
+ testcase, "_parametrization_kwargs", None
+ ),
)
def _new_parametrized_group_report(
diff --git a/testplan/web_ui/testing/src/Report/BatchReport.js b/testplan/web_ui/testing/src/Report/BatchReport.js
index d61a64335..eb6900b0d 100644
--- a/testplan/web_ui/testing/src/Report/BatchReport.js
+++ b/testplan/web_ui/testing/src/Report/BatchReport.js
@@ -65,6 +65,9 @@ class BatchReportComponent extends BaseReport {
this.updateTagsDisplay = this.updateTagsDisplay.bind(this);
this.updateFilter = this.updateFilter.bind(this);
this.updatePanelView = this.updatePanelView.bind(this);
+ this.handleExtendedSearchNavigate = this.handleExtendedSearchNavigate.bind(
+ this
+ );
this.state = {
...this.state,
@@ -243,6 +246,15 @@ class BatchReportComponent extends BaseReport {
this.props.history.push(newUrl);
}
+ /**
+ * Handle navigation from extended search.
+ * @param {Array} uids - Array of UIDs from report root to target entry
+ */
+ handleExtendedSearchNavigate(uids) {
+ if (!uids || uids.length === 0) return;
+ const newPath = generateSelectionPath(this.props.match.path, uids);
+ this.props.history.push(newPath);
+ }
getSelectedUIDsFromPath() {
const { uid, selection } = this.props.match.params;
@@ -310,6 +322,7 @@ class BatchReportComponent extends BaseReport {
updateTagsDisplayFunc={this.updateTagsDisplay}
current_pannel={this.state.currentPanelView}
switchPanelViewFunc={this.updatePanelView}
+ onExtendedSearchNavigate={this.handleExtendedSearchNavigate}
/>