Source code for pyEDAA.Reports.Unittesting.OpenTestReporting

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²)                                                  #
#                                                                                                                      #
# 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.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
A data model of Open Test Reporting's XML formats, which converts to the unified test entity hierarchy.

`Open Test Reporting <https://github.com/ota4j-team/open-test-reporting>`__ (OTR) defines XML formats for test results,
independent of a test framework or programming language. The *event-based* format is written while the tests run, one
event per state change of a test or container. The *hierarchical* format nests tests in their containers. The JUnit
Platform writes the event-based format, OTR's command line tool converts it into the hierarchical format.

Both formats describe a test or container by the same elements: metadata, sources, attachments and a result. This
package models them as a tree of :class:`Node` objects within an :class:`Execution`. A document reader per format,
derived from :class:`Document`, fills the tree and converts it into the unified test entity hierarchy of
:mod:`pyEDAA.Reports.Unittesting`.

The records of the tree's elements - the infrastructure and a result - are in
:mod:`~pyEDAA.Reports.Unittesting.OpenTestReporting.Records`.

.. rubric:: Example

.. code-block:: Python

   from pathlib import Path
   from pyEDAA.Reports.Unittesting.OpenTestReporting.Events import Document

   document = Document(Path("build/test-results/test/open-test-report.xml"), analyzeAndConvert=True)
   summary = document.ToTestsuiteSummary()
   for testcase in summary.IterateTestcases():
     print(f"{testcase.Name}: {testcase.Status.name}")
"""
from __future__                                           import annotations

from datetime                                             import datetime, timedelta
from enum                                                 import Enum
from io                                                   import BytesIO
from pathlib                                              import Path
from typing                                               import ClassVar, Dict, Generic, List, Optional as Nullable, \
                                                                 Tuple, TypeVar

from lxml.etree                                           import QName, XMLParser, XMLSchema, XMLSchemaParseError, \
                                                                 XMLSyntaxError, _Element, _ElementTree, parse
from pyTooling.Common                                     import getFullyQualifiedName, getResourceFile, StringEnum
from pyTooling.Decorators                                 import export, readonly
from pyTooling.Exceptions                                 import ToolingException
from pyTooling.MetaClasses                                import ExtendedType, abstractmethod
from pyTooling.Stopwatch                                  import Stopwatch

from pyEDAA.Reports.Resources                             import OpenTestReporting as OpenTestReportingResources
from pyEDAA.Reports.Resources.OpenTestReporting           import SPECIFICATIONS, Specification
from pyEDAA.Reports.Unittesting                           import UnittestError, TestcaseStatus, TestsuiteKind, \
                                                                 Document as ut_Document, \
                                                                 TestsuiteSummary as ut_TestsuiteSummary, \
                                                                 Testsuite as ut_Testsuite, Testcase as ut_Testcase
from pyEDAA.Reports.Unittesting.OpenTestReporting.Records import Infrastructure, Result, ResultStatus


__all__ = ["STATUS_MAP"]


[docs] @export class FormatVersion(StringEnum): """ Version of Open Test Reporting, as the URIs of its XML namespaces state it. The XML namespaces and schemas of each version are in :data:`~pyEDAA.Reports.Resources.OpenTestReporting.SPECIFICATIONS`, keyed by the version. """ Version0_2_0 = "0.2.0" #: Open Test Reporting 0.2.0.
ParentType = TypeVar("ParentType", bound="Node | Execution") """A type variable for the parent of a :class:`Node`: a :class:`Node`, or the :class:`Execution` of a root node."""
[docs] @export class NodeType(Enum): """An enumeration of the kinds of a node, as the JUnit Platform states them (``<junit:type>``).""" Container = "CONTAINER" #: A container of tests or containers, e.g. a test class. Test = "TEST" #: A test, e.g. a test method or an invocation of a parameterized test. ContainerAndTest = "CONTAINER_AND_TEST" #: A test, which contains tests or containers too.
STATUS_MAP: Dict[ResultStatus, TestcaseStatus] = { ResultStatus.Successful: TestcaseStatus.Passed, ResultStatus.Skipped: TestcaseStatus.Skipped, ResultStatus.Aborted: TestcaseStatus.Skipped, ResultStatus.Failed: TestcaseStatus.Failed, ResultStatus.Errored: TestcaseStatus.Errored, } #: Mapping of a result's status to a test case status.
[docs] @export class Node(Generic[ParentType], metaclass=ExtendedType, slots=True): """ A test or a container of tests. Test engines, test classes and parameterized tests are containers. A node is named by its display name. Its metadata, sources, attachments and result are filled by a document reader. The JUnit Platform states additional metadata: a unique ID, a legacy reporting name and the node's type. """ _parent: Nullable[ParentType] #: The parent node, or the execution of a root node. _children: List[Node[Node]] #: Child nodes in the order they were started. _name: str #: Display name. _startTime: datetime #: Time when the node was started. _duration: Nullable[timedelta] #: Duration from start to finish. _tags: List[str] #: Tags. _uniqueID: Nullable[str] #: JUnit Platform's unique ID. _legacyReportingName: Nullable[str] #: JUnit Platform's name for tools not showing display names. _type: Nullable[NodeType] #: JUnit Platform's kind of node. _className: Nullable[str] #: Name of the Java class (Java extension). _methodName: Nullable[str] #: Name of the Java method (Java extension). _methodParameterTypes: Nullable[str] #: Parameter types of the Java method (Java extension). _data: List[Tuple[str, str]] #: Key-value pairs of the data attachments in reported order. _output: Dict[str, str] #: Output attachments by source, e.g. ``stdout``. _files: List[Path] #: Paths of the file attachments. _result: Nullable[Result] #: Result, or ``None`` if the node didn't finish.
[docs] def __init__( self, name: str, startTime: datetime, duration: Nullable[timedelta] = None, *, parent: Nullable[ParentType] = None ) -> None: """ Initializes a node, and adds it to the children of its parent node or the root nodes of its execution. :param name: Display name of the node. :param startTime: Time when the node was started. :param duration: Optional, duration from start to finish. :param parent: Optional, the parent node, or the execution of a root node. Default: ``None``. :raises ValueError: If parameter 'name' is None. :raises TypeError: If parameter 'name' is not of type :class:`str`. :raises ValueError: If parameter 'startTime' is None. :raises TypeError: If parameter 'startTime' is not of type :class:`~datetime.datetime`. :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`. :raises TypeError: If parameter 'parent' is not of type :class:`Node` or :class:`Execution`. """ if parent is not None and not isinstance(parent, (Node, Execution)): ex = TypeError("Parameter 'parent' is not of type 'Node' or 'Execution'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex if name is None: raise ValueError("Parameter 'name' is None.") elif not isinstance(name, str): ex = TypeError("Parameter 'name' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") raise ex if startTime is None: raise ValueError("Parameter 'startTime' is None.") elif not isinstance(startTime, datetime): ex = TypeError("Parameter 'startTime' is not of type 'datetime'.") ex.add_note(f"Got type '{getFullyQualifiedName(startTime)}'.") raise ex if duration is not None and not isinstance(duration, timedelta): ex = TypeError("Parameter 'duration' is not of type 'timedelta'.") ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.") raise ex self._parent = parent self._children = [] self._name = name self._startTime = startTime self._duration = duration self._tags = [] self._uniqueID = None self._legacyReportingName = None self._type = None self._className = None self._methodName = None self._methodParameterTypes = None self._data = [] self._output = {} self._files = [] self._result = None if isinstance(parent, Execution): parent._roots.append(self) elif parent is not None: parent._children.append(self)
@readonly def Parent(self) -> Nullable[ParentType]: """ Read-only property to access the parent node, or the execution of a root node (:attr:`_parent`). :returns: The parent :class:`Node`, or the :class:`Execution` of a root node; ``None`` if the node belongs to neither. """ return self._parent @readonly def Children(self) -> List[Node[Node]]: """ Read-only property to access the child nodes (:attr:`_children`). :returns: The list of child nodes in the order they were started. """ return self._children @readonly def Name(self) -> str: """ Read-only property to access the display name (:attr:`_name`). The display name is given by the test, e.g. ``Tests of MyClass``, or generated, e.g. ``[1] "5", "5"`` for a parameterized test's invocation. :returns: The display name. """ return self._name @readonly def StartTime(self) -> datetime: """ Read-only property to access the time when the node was started (:attr:`_startTime`). :returns: The start time. """ return self._startTime @readonly def Duration(self) -> Nullable[timedelta]: """ Read-only property to access the duration from start to finish (:attr:`_duration`). :returns: The duration, or ``None`` if the node didn't finish. """ return self._duration @readonly def Tags(self) -> List[str]: """ Read-only property to access the tags (:attr:`_tags`). :returns: The list of tags. """ return self._tags @readonly def UniqueID(self) -> Nullable[str]: """ Read-only property to access the JUnit Platform's unique ID (:attr:`_uniqueID`). A test class run by JUnit Jupiter has the unique ID ``[engine:junit-jupiter]/[class:my.pack.MyClassTest]``. :returns: The unique ID, or ``None`` if it wasn't reported. """ return self._uniqueID @readonly def LegacyReportingName(self) -> Nullable[str]: """ Read-only property to access the JUnit Platform's legacy reporting name (:attr:`_legacyReportingName`). A test class has its qualified name, e.g. ``my.pack.MyClassTest``, a test method its name and parameters, e.g. ``testReturnTrue()``. :returns: The legacy reporting name, or ``None`` if it wasn't reported. """ return self._legacyReportingName @readonly def Type(self) -> Nullable[NodeType]: """ Read-only property to access the JUnit Platform's kind of node (:attr:`_type`). :returns: The kind of node, or ``None`` if it wasn't reported. """ return self._type @readonly def ClassName(self) -> Nullable[str]: """ Read-only property to access the name of the Java class (:attr:`_className`). :returns: The fully qualified class name, or ``None`` if no Java class or method source was reported. """ return self._className @readonly def MethodName(self) -> Nullable[str]: """ Read-only property to access the name of the Java method (:attr:`_methodName`). :returns: The method name, or ``None`` if no Java method source was reported. """ return self._methodName @readonly def MethodParameterTypes(self) -> Nullable[str]: """ Read-only property to access the parameter types of the Java method (:attr:`_methodParameterTypes`). :returns: The comma-separated parameter types, or ``None`` if they weren't reported. """ return self._methodParameterTypes @readonly def Data(self) -> List[Tuple[str, str]]: """ Read-only property to access the key-value pairs of the data attachments (:attr:`_data`). :returns: The list of key-value pairs in the order they were reported. """ return self._data @readonly def Output(self) -> Dict[str, str]: """ Read-only property to access the output attachments (:attr:`_output`). An output's source is e.g. ``stdout`` or ``stderr``. Output of the same source is concatenated in the order it was reported. :returns: The dictionary of output by source. """ return self._output @readonly def Files(self) -> List[Path]: """ Read-only property to access the paths of the file attachments (:attr:`_files`). :returns: The list of paths. """ return self._files @readonly def Result(self) -> Nullable[Result]: """ Read-only property to access the result (:attr:`_result`). :returns: The result, or ``None`` if the node didn't finish. """ return self._result @readonly def IsTest(self) -> bool: """ Read-only property to return whether the node is a test. A node is a test, if the JUnit Platform states it as ``TEST``. A node of another or no stated type is a test, if it has no child nodes. :returns: True, if the node is a test. """ if self._type is NodeType.Test: return True elif self._type is NodeType.Container: return False return len(self._children) == 0
[docs] def ToTestsuite(self, hostname: Nullable[str] = None) -> ut_Testsuite: """ Convert this container to a test suite of the unified data model. The test suite is named by the legacy reporting name, if the JUnit Platform reported one, else by the display name. Then, the display name becomes the test suite's title. A container of a Java class becomes a test suite of kind ``Class``. Child containers become test suites, child tests become test cases. :param hostname: Optional, name of the host the test suite was executed on. :returns: A test suite of the unified test entity data model. :raises DuplicateTestsuiteError: If two child containers have the same name. :raises DuplicateTestcaseError: If two child tests have the same name. """ return ut_Testsuite( self._name if self._legacyReportingName is None else self._legacyReportingName, title=None if self._legacyReportingName is None else self._name, kind=TestsuiteKind.Class if self._className is not None and self._methodName is None else TestsuiteKind.Logical, hostname=hostname, startTime=self._startTime, totalDuration=self._duration, testsuites=[child.ToTestsuite() for child in self._children if not child.IsTest], testcases=[child.ToTestcase() for child in self._children if child.IsTest] )
[docs] def ToTestcase(self) -> ut_Testcase: """ Convert this test to a test case of the unified data model. The test case is named by the legacy reporting name, if the JUnit Platform reported one, else by the display name. Then, the display name becomes the test case's title. The status is mapped by :data:`STATUS_MAP`; a test without a result status is ``Inconsistent``. The result's message and stack trace become the test case's message and details, the output on ``stdout`` and ``stderr`` its captured output streams, and the data attachments its key-value pairs. :returns: A test case of the unified test entity data model. """ if self._result is None: status = TestcaseStatus.Inconsistent message = None details = None else: status = TestcaseStatus.Inconsistent if self._result._status is None else STATUS_MAP[self._result._status] message = self._result.Message details = self._result._stackTrace testcase = ut_Testcase( self._name if self._legacyReportingName is None else self._legacyReportingName, title=None if self._legacyReportingName is None else self._name, startTime=self._startTime, totalDuration=self._duration, status=status, message=message, details=details, standardOutput=self._output.get("stdout", None), standardError=self._output.get("stderr", None) ) for key, value in self._data: testcase[key] = value return testcase
[docs] @export class Execution(metaclass=ExtendedType, mixin=True): """ A mixin-class representing a test run: the infrastructure the tests were run on and the tree of tests and containers. A document reader fills the infrastructure and adds a root node per node without parent, e.g. per test engine. Both are added by creating them with the execution as their parent. """ _infrastructure: Nullable[Infrastructure] #: Infrastructure the tests were run on. _roots: List[Node[Execution]] #: Root nodes in the order they were started.
[docs] def __init__(self) -> None: """Initializes an execution without infrastructure and nodes.""" self._infrastructure = None self._roots = []
@readonly def Infrastructure(self) -> Nullable[Infrastructure]: """ Read-only property to access the infrastructure the tests were run on (:attr:`_infrastructure`). :returns: The infrastructure, or ``None`` if it wasn't reported. """ return self._infrastructure @readonly def Roots(self) -> List[Node[Execution]]: """ Read-only property to access the root nodes (:attr:`_roots`). A JUnit Platform report's root nodes are its test engines. :returns: The list of root nodes in the order they were started. """ return self._roots
# 'Document' has properties named 'FormatVersion' and 'Specification', which a field's or a property's annotation in the # class body would name instead of the classes. _FormatVersion = FormatVersion _Specification = Specification
[docs] @export class Document(ut_Document, Execution, slots=True): """ Base-class of the document readers for Open Test Reporting's XML formats. The format version is read from the root element's namespace. The file is validated against the XML schema of that version's :class:`~pyEDAA.Reports.Resources.OpenTestReporting.Specification`, which imports the schemas of all namespaces: the core namespace, both formats, and the Java, Git and JUnit Platform extensions. Both formats describe a test or container by the same elements of the core namespace, which this class converts. A derived class converts the format's structure into the tree of :class:`Node` objects. """ _ROOT_NAME: ClassVar[str] #: Prefixed name of the format's root element, e.g. ``e:events``. _xmlDocument: Nullable[_ElementTree] #: Parsed and validated XML document. _formatVersion: Nullable[_FormatVersion] #: Version of Open Test Reporting the file states, after :meth:`Analyze`.
[docs] def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None: """ Initializes the document reader. :param xmlReportFile: Path to the XML report file. :param analyzeAndConvert: Optional, if true, analyze (parse and validate) the file and convert its content. """ super().__init__(xmlReportFile) Execution.__init__(self) self._xmlDocument = None self._formatVersion = None if analyzeAndConvert: self.Analyze() self.Convert()
@readonly def FormatVersion(self) -> Nullable[_FormatVersion]: """ Read-only property to access the version of Open Test Reporting the file states (:attr:`_formatVersion`). :returns: The format version, or ``None`` before :meth:`Analyze`. """ return self._formatVersion @readonly def Specification(self) -> _Specification: """ Read-only property to return the XML namespaces and schemas of the file's format version. :returns: The specification of :attr:`FormatVersion`. :raises UnittestError: If the file wasn't analyzed yet. """ if self._formatVersion is None: raise UnittestError(f"Format version of Open Test Reporting file '{self._path}' is known after 'Analyze()'.") return SPECIFICATIONS[self._formatVersion]
[docs] @abstractmethod def _RootTag(self, specification: _Specification) -> str: """ Return the qualified tag of the format's root element in a version of Open Test Reporting. :param specification: The XML namespaces and schemas of a version. :returns: The tag of the root element, qualified by the format's namespace URI. """
[docs] def Analyze(self) -> None: """ Analyze the XML file: parse it, check its root element and validate it against the XML schema. .. hint:: The time spent for analysis will be made available via property :data:`AnalysisDuration`. :raises UnittestError: If the file doesn't exist. :raises UnittestError: If the file can't be read. :raises UnittestError: If the file isn't well-formed XML. :raises UnittestError: If the root element isn't the format's root element of a supported version of Open Test Reporting. |br| The exception notes the supported versions. :raises UnittestError: If the XML schema can't be located or parsed. :raises UnittestError: If the file isn't valid according to the XML schema. """ if not self._path.exists(): raise UnittestError(f"Open Test Reporting file '{self._path}' does not exist.") \ from FileNotFoundError(f"File '{self._path}' not found.") with Stopwatch() as sw: try: content = self._path.read_bytes() except OSError as ex: raise UnittestError(f"Couldn't read Open Test Reporting file '{self._path}'.") from ex try: xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path)) except XMLSyntaxError as ex: raise UnittestError(f"XML syntax error in Open Test Reporting file '{self._path}'.") from ex rootElement: _Element = xmlDocument.getroot() for formatVersion in FormatVersion: if rootElement.tag == self._RootTag(SPECIFICATIONS[formatVersion]): break else: ex = UnittestError(f"Root element of '{self._path}' is not '<{self._ROOT_NAME}>' of Open Test Reporting.") ex.add_note(f"Got root element '{rootElement.tag}'.") ex.add_note(f"Supported versions: {', '.join(FormatVersion)}.") raise ex schemaFile = SPECIFICATIONS[formatVersion].SchemaFile try: schemaResourceFile = getResourceFile(OpenTestReportingResources, schemaFile) except ToolingException as ex: raise UnittestError(f"Couldn't locate XML Schema '{schemaFile}' in package resources.") from ex try: xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True))) except (XMLSyntaxError, XMLSchemaParseError) as ex: raise UnittestError(f"Error while parsing XML Schema '{schemaFile}'.") from ex if not xmlSchema.validate(xmlDocument): ex = UnittestError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.") for logEntry in xmlSchema.error_log: ex.add_note(str(logEntry)) raise ex self._xmlDocument = xmlDocument self._formatVersion = formatVersion self._analysisDuration = sw.Duration
[docs] @abstractmethod def Convert(self) -> None: """Convert the parsed and validated XML data structure into a tree of nodes."""
[docs] def _ConvertInfrastructure(self, infrastructureElement: _Element) -> None: """ Convert the ``<infrastructure>`` element. :param infrastructureElement: The XML element node describing the infrastructure. """ core = self.Specification.Core.URI infrastructure = Infrastructure(parent=self) for element in infrastructureElement.iterchildren("*"): # type: _Element if element.tag == f"{{{core}}}hostName": infrastructure._hostName = element.text elif element.tag == f"{{{core}}}userName": infrastructure._userName = element.text elif element.tag == f"{{{core}}}operatingSystem": infrastructure._operatingSystem = element.text elif element.tag == f"{{{core}}}cpuCores": infrastructure._cpuCoreCount = int(element.text) else: localName = QName(element).localname if element.text is not None: infrastructure._extensions[localName] = element.text for attributeName, value in element.attrib.items(): infrastructure._extensions[f"{localName}.{attributeName}"] = value
[docs] def _ConvertTestInfo(self, element: _Element, node: Node) -> None: """ Convert the metadata, sources, attachments and result of an event. :param element: The XML element node of an event. :param node: The node to update. """ core = self.Specification.Core.URI java = self.Specification.Java.URI junit = self.Specification.JUnit.URI for child in element.iterchildren("*"): # type: _Element if child.tag == f"{{{core}}}metadata": for metadataElement in child.iterchildren("*"): # type: _Element if metadataElement.tag == f"{{{core}}}tags": for tagElement in metadataElement.iterchildren(f"{{{core}}}tag"): # type: _Element node._tags.append("" if tagElement.text is None else tagElement.text) elif metadataElement.tag == f"{{{junit}}}uniqueId": node._uniqueID = metadataElement.text elif metadataElement.tag == f"{{{junit}}}legacyReportingName": node._legacyReportingName = metadataElement.text elif metadataElement.tag == f"{{{junit}}}type": node._type = NodeType(metadataElement.text) elif child.tag == f"{{{core}}}sources": sourceTags = (f"{{{java}}}classSource", f"{{{java}}}methodSource") for sourceElement in child.iterchildren(*sourceTags): # type: _Element node._className = sourceElement.attrib["className"] node._methodName = sourceElement.attrib.get("methodName", None) node._methodParameterTypes = sourceElement.attrib.get("methodParameterTypes", None) elif child.tag == f"{{{core}}}attachments": for attachmentElement in child.iterchildren("*"): # type: _Element if attachmentElement.tag == f"{{{core}}}data": for entryElement in attachmentElement.iterchildren(f"{{{core}}}entry"): # type: _Element value = "" if entryElement.text is None else entryElement.text node._data.append((entryElement.attrib["key"], value)) elif attachmentElement.tag == f"{{{core}}}output": source = attachmentElement.attrib["source"] text = "" if attachmentElement.text is None else attachmentElement.text node._output[source] = node._output.get(source, "") + text elif attachmentElement.tag == f"{{{core}}}file": node._files.append(Path(attachmentElement.attrib["path"])) elif child.tag == f"{{{core}}}result": status = child.attrib.get("status", None) reasonElement = child.find(f"{{{core}}}reason") throwableElement = child.find(f"{{{java}}}throwable") Result( None if status is None else ResultStatus(status), None if reasonElement is None else reasonElement.text, None if throwableElement is None else throwableElement.attrib["type"], None if throwableElement is None else throwableElement.attrib["assertionError"] in ("true", "1"), None if throwableElement is None else throwableElement.text, parent=node )
[docs] def ToTestsuiteSummary(self) -> ut_TestsuiteSummary: """ Convert the tree of nodes to a test suite summary of the unified data model. The test suite summary is named after the report file (without extension). Every root node becomes a test suite, executed on the reported host. The summary starts with the first root node and lasts until the last one finished. Afterwards, the hierarchy is aggregated, so all counters and states are computed. :returns: A test suite summary of the unified test entity data model. :raises UnittestError: If a root node is a test. """ hostname = None if self._infrastructure is None else self._infrastructure._hostName testsuites: List[ut_Testsuite] = [] for root in self._roots: if root.IsTest: ex = UnittestError(f"Test '{root._name}' in '{self._path}' has no container.") ex.add_note("A test case of the unified data model needs a test suite as parent.") raise ex testsuites.append(root.ToTestsuite(hostname)) startTime = min((root._startTime for root in self._roots), default=None) finishTime = max( (root._startTime + root._duration for root in self._roots if root._duration is not None), default=None ) summary = ut_TestsuiteSummary( self._path.stem, startTime=startTime, totalDuration=None if finishTime is None else finishTime - startTime, testsuites=testsuites ) summary.Aggregate() return summary