<div dir="ltr">Adding Luca to this thread because it applies to him as well:<br><br>Hi Tomáš,<div><br></div><div>This all looks great, one thing I did notice when running locally is that the "test_suites" section of results.json contains null values instead of the names of the suites that were run. Could just be something strange happening on my side, but I would double check on your end just in case. Otherwise:</div><div><br></div><div>Reviewed-by: Dean Marx <<a href="mailto:dmarx@iol.unh.edu" target="_blank">dmarx@iol.unh.edu</a>></div></div><br><div class="gmail_quote"><div dir="ltr" class="gmail_attr">On Fri, Oct 25, 2024 at 1:59 PM Dean Marx <<a href="mailto:dmarx@iol.unh.edu">dmarx@iol.unh.edu</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div dir="ltr">Hi Tomáš,<div><br></div><div>This all looks great, one thing I did notice when running locally is that the "test_suites" section of results.json contains null values instead of the names of the suites that were run. Could just be something strange happening on my side, but I would double check on your end just in case. Otherwise:</div><div><br></div><div>Reviewed-by: Dean Marx <<a href="mailto:dmarx@iol.unh.edu" target="_blank">dmarx@iol.unh.edu</a>></div></div><br><div class="gmail_quote"><div dir="ltr" class="gmail_attr">On Mon, Sep 30, 2024 at 12:26 PM Tomáš Ďurovec <tomas.durovec@pantheon.tech> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">The previous version of statistics store only the last test run<br>
attribute and result. In this patch we are adding header for each<br>
test run and overall summary of test runs at the end. This is<br>
represented as textual summaries with basic information and json<br>
result with more detailed information.<br>
<br>
Signed-off-by: Tomáš Ďurovec <tomas.durovec@pantheon.tech><br>
<br>
Depends-on: series-33184 ("DTS external DPDK build")<br>
---<br>
 dts/framework/runner.py      |   7 +-<br>
 dts/framework/test_result.py | 409 ++++++++++++++++++++++++++++-------<br>
 2 files changed, 332 insertions(+), 84 deletions(-)<br>
<br>
diff --git a/dts/framework/runner.py b/dts/framework/runner.py<br>
index 7d463c1fa1..be615ccace 100644<br>
--- a/dts/framework/runner.py<br>
+++ b/dts/framework/runner.py<br>
@@ -84,7 +84,7 @@ def __init__(self):<br>
         if not os.path.exists(SETTINGS.output_dir):<br>
             os.makedirs(SETTINGS.output_dir)<br>
         self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, SETTINGS.output_dir)<br>
-        self._result = DTSResult(self._logger)<br>
+        self._result = DTSResult(SETTINGS.output_dir, self._logger)<br>
         self._test_suite_class_prefix = "Test"<br>
         self._test_suite_module_prefix = "tests.TestSuite_"<br>
         self._func_test_case_regex = r"test_(?!perf_)"<br>
@@ -421,11 +421,12 @@ def _run_test_run(<br>
         self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>(<br>
             f"Running test run with SUT '{<a href="http://test_run_config.system_under_test_node.name" rel="noreferrer" target="_blank">test_run_config.system_under_test_node.name</a>}'."<br>
         )<br>
-        test_run_result.add_sut_info(sut_node.node_info)<br>
+        test_run_result.ports = sut_node.ports<br>
+        test_run_result.sut_info = sut_node.node_info<br>
         try:<br>
             dpdk_location = SETTINGS.dpdk_location or test_run_config.dpdk_config.dpdk_location<br>
             sut_node.set_up_test_run(test_run_config, dpdk_location)<br>
-            test_run_result.add_dpdk_build_info(sut_node.get_dpdk_build_info())<br>
+            test_run_result.dpdk_build_info = sut_node.get_dpdk_build_info()<br>
             tg_node.set_up_test_run(test_run_config, dpdk_location)<br>
             test_run_result.update_setup(Result.PASS)<br>
         except Exception as e:<br>
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py<br>
index 0a10723098..bf148a6b45 100644<br>
--- a/dts/framework/test_result.py<br>
+++ b/dts/framework/test_result.py<br>
@@ -22,18 +22,19 @@<br>
 variable modify the directory where the files with results will be stored.<br>
 """<br>
<br>
-import os.path<br>
+import json<br>
 from collections.abc import MutableSequence<br>
-from dataclasses import dataclass<br>
+from dataclasses import asdict, dataclass<br>
 from enum import Enum, auto<br>
+from pathlib import Path<br>
 from types import FunctionType<br>
-from typing import Union<br>
+from typing import Any, Callable, TypedDict<br>
<br>
 from .config import DPDKBuildInfo, NodeInfo, TestRunConfiguration, TestSuiteConfig<br>
 from .exception import DTSError, ErrorSeverity<br>
 from .logger import DTSLogger<br>
-from .settings import SETTINGS<br>
 from .test_suite import TestSuite<br>
+from .testbed_model.port import Port<br>
<br>
<br>
 @dataclass(slots=True, frozen=True)<br>
@@ -85,6 +86,60 @@ def __bool__(self) -> bool:<br>
         return self is self.PASS<br>
<br>
<br>
+class TestCaseResultDict(TypedDict):<br>
+    """Represents the `TestCaseResult` results.<br>
+<br>
+    Attributes:<br>
+        test_case_name: The name of the test case.<br>
+        result: The result name of the test case.<br>
+    """<br>
+<br>
+    test_case_name: str<br>
+    result: str<br>
+<br>
+<br>
+class TestSuiteResultDict(TypedDict):<br>
+    """Represents the `TestSuiteResult` results.<br>
+<br>
+    Attributes:<br>
+        test_suite_name: The name of the test suite.<br>
+        test_cases: A list of test case results contained in this test suite.<br>
+    """<br>
+<br>
+    test_suite_name: str<br>
+    test_cases: list[TestCaseResultDict]<br>
+<br>
+<br>
+class TestRunResultDict(TypedDict, total=False):<br>
+    """Represents the `TestRunResult` results.<br>
+<br>
+    Attributes:<br>
+        compiler_version: The version of the compiler used for the DPDK build.<br>
+        dpdk_version: The version of DPDK being tested.<br>
+        ports: A list of ports associated with the test run.<br>
+        test_suites: A list of test suite results included in this test run.<br>
+        summary: A dictionary containing overall results, such as pass/fail counts.<br>
+    """<br>
+<br>
+    compiler_version: str | None<br>
+    dpdk_version: str | None<br>
+    ports: list[dict[str, Any]]<br>
+    test_suites: list[TestSuiteResultDict]<br>
+    summary: dict[str, int | float]<br>
+<br>
+<br>
+class DtsRunResultDict(TypedDict):<br>
+    """Represents the `DtsRunResult` results.<br>
+<br>
+    Attributes:<br>
+        test_runs: A list of test run results.<br>
+        summary: A summary dictionary containing overall statistics for the test runs.<br>
+    """<br>
+<br>
+    test_runs: list[TestRunResultDict]<br>
+    summary: dict[str, int | float]<br>
+<br>
+<br>
 class FixtureResult:<br>
     """A record that stores the result of a setup or a teardown.<br>
<br>
@@ -198,14 +253,34 @@ def get_errors(self) -> list[Exception]:<br>
         """<br>
         return self._get_setup_teardown_errors() + self._get_child_errors()<br>
<br>
-    def add_stats(self, statistics: "Statistics") -> None:<br>
-        """Collate stats from the whole result hierarchy.<br>
+    def to_dict(self):<br>
+        """Convert the results hierarchy into a dictionary representation."""<br>
+<br>
+    def add_result(self, results: dict[str, int]):<br>
+        """Collate the test case result from the result hierarchy.<br>
<br>
         Args:<br>
-            statistics: The :class:`Statistics` object where the stats will be collated.<br>
+            results: The dictionary to which results will be collated.<br>
         """<br>
         for child_result in self.child_results:<br>
-            child_result.add_stats(statistics)<br>
+            child_result.add_result(results)<br>
+<br>
+    def generate_pass_rate_dict(self, test_run_summary) -> dict[str, float]:<br>
+        """Generate a dictionary with the FAIL/PASS ratio of all test cases.<br>
+<br>
+        Args:<br>
+            test_run_summary: The summary dictionary containing test result counts.<br>
+<br>
+        Returns:<br>
+            A dictionary with the FAIL/PASS ratio of all test cases.<br>
+        """<br>
+        return {<br>
+            "PASS_RATE": (<br>
+                float(test_run_summary[<a href="http://Result.PASS.name" rel="noreferrer" target="_blank">Result.PASS.name</a>])<br>
+                * 100<br>
+                / sum(test_run_summary[<a href="http://result.name" rel="noreferrer" target="_blank">result.name</a>] for result in Result)<br>
+            )<br>
+        }<br>
<br>
<br>
 class DTSResult(BaseResult):<br>
@@ -220,31 +295,25 @@ class DTSResult(BaseResult):<br>
     and as such is where the data form the whole hierarchy is collated or processed.<br>
<br>
     The internal list stores the results of all test runs.<br>
-<br>
-    Attributes:<br>
-        dpdk_version: The DPDK version to record.<br>
     """<br>
<br>
-    dpdk_version: str | None<br>
+    _output_dir: str<br>
     _logger: DTSLogger<br>
     _errors: list[Exception]<br>
     _return_code: ErrorSeverity<br>
-    _stats_result: Union["Statistics", None]<br>
-    _stats_filename: str<br>
<br>
-    def __init__(self, logger: DTSLogger):<br>
+    def __init__(self, output_dir: str, logger: DTSLogger):<br>
         """Extend the constructor with top-level specifics.<br>
<br>
         Args:<br>
+            output_dir: The directory where DTS logs and results are saved.<br>
             logger: The logger instance the whole result will use.<br>
         """<br>
         super().__init__()<br>
-        self.dpdk_version = None<br>
+        self._output_dir = output_dir<br>
         self._logger = logger<br>
         self._errors = []<br>
         self._return_code = ErrorSeverity.NO_ERR<br>
-        self._stats_result = None<br>
-        self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")<br>
<br>
     def add_test_run(self, test_run_config: TestRunConfiguration) -> "TestRunResult":<br>
         """Add and return the child result (test run).<br>
@@ -281,10 +350,8 @@ def process(self) -> None:<br>
             for error in self._errors:<br>
                 self._logger.debug(repr(error))<br>
<br>
-        self._stats_result = Statistics(self.dpdk_version)<br>
-        self.add_stats(self._stats_result)<br>
-        with open(self._stats_filename, "w+") as stats_file:<br>
-            stats_file.write(str(self._stats_result))<br>
+        TextSummary(self).save(Path(self._output_dir, "results_summary.txt"))<br>
+        JsonResults(self).save(Path(self._output_dir, "results.json"))<br>
<br>
     def get_return_code(self) -> int:<br>
         """Go through all stored Exceptions and return the final DTS error code.<br>
@@ -302,6 +369,37 @@ def get_return_code(self) -> int:<br>
<br>
         return int(self._return_code)<br>
<br>
+    def to_dict(self) -> DtsRunResultDict:<br>
+        """Convert DTS result into a dictionary format.<br>
+<br>
+        The dictionary contains test runs and summary of test runs.<br>
+<br>
+        Returns:<br>
+            A dictionary representation of the DTS result<br>
+        """<br>
+<br>
+        def merge_test_run_summaries(test_run_summaries: list[dict[str, int]]) -> dict[str, int]:<br>
+            """Merge multiple test run summaries into one dictionary.<br>
+<br>
+            Args:<br>
+                test_run_summaries: List of test run summary dictionaries.<br>
+<br>
+            Returns:<br>
+                A merged dictionary containing the aggregated summary.<br>
+            """<br>
+            return {<br>
+                <a href="http://key.name" rel="noreferrer" target="_blank">key.name</a>: sum(test_run_summary[<a href="http://key.name" rel="noreferrer" target="_blank">key.name</a>] for test_run_summary in test_run_summaries)<br>
+                for key in Result<br>
+            }<br>
+<br>
+        test_runs = [child.to_dict() for child in self.child_results]<br>
+        test_run_summary = merge_test_run_summaries([test_run["summary"] for test_run in test_runs])<br>
+<br>
+        return {<br>
+            "test_runs": test_runs,<br>
+            "summary": test_run_summary | self.generate_pass_rate_dict(test_run_summary),<br>
+        }<br>
+<br>
<br>
 class TestRunResult(BaseResult):<br>
     """The test run specific result.<br>
@@ -316,13 +414,11 @@ class TestRunResult(BaseResult):<br>
         sut_kernel_version: The operating system kernel version of the SUT node.<br>
     """<br>
<br>
-    compiler_version: str | None<br>
-    dpdk_version: str | None<br>
-    sut_os_name: str<br>
-    sut_os_version: str<br>
-    sut_kernel_version: str<br>
     _config: TestRunConfiguration<br>
     _test_suites_with_cases: list[TestSuiteWithCases]<br>
+    _ports: list[Port]<br>
+    _sut_info: NodeInfo | None<br>
+    _dpdk_build_info: DPDKBuildInfo | None<br>
<br>
     def __init__(self, test_run_config: TestRunConfiguration):<br>
         """Extend the constructor with the test run's config.<br>
@@ -331,10 +427,11 @@ def __init__(self, test_run_config: TestRunConfiguration):<br>
             test_run_config: A test run configuration.<br>
         """<br>
         super().__init__()<br>
-        self.compiler_version = None<br>
-        self.dpdk_version = None<br>
         self._config = test_run_config<br>
         self._test_suites_with_cases = []<br>
+        self._ports = []<br>
+        self._sut_info = None<br>
+        self._dpdk_build_info = None<br>
<br>
     def add_test_suite(<br>
         self,<br>
@@ -374,24 +471,96 @@ def test_suites_with_cases(self, test_suites_with_cases: list[TestSuiteWithCases<br>
             )<br>
         self._test_suites_with_cases = test_suites_with_cases<br>
<br>
-    def add_sut_info(self, sut_info: NodeInfo) -> None:<br>
-        """Add SUT information gathered at runtime.<br>
+    @property<br>
+    def ports(self) -> list[Port]:<br>
+        """Get the list of ports associated with this test run."""<br>
+        return self._ports<br>
+<br>
+    @ports.setter<br>
+    def ports(self, ports: list[Port]) -> None:<br>
+        """Set the list of ports associated with this test run.<br>
+<br>
+        Args:<br>
+            ports: The list of ports to associate with this test run.<br>
+<br>
+        Raises:<br>
+            ValueError: If the ports have already been assigned to this test run.<br>
+        """<br>
+        if self._ports:<br>
+            raise ValueError(<br>
+                "Attempted to assign `ports` to a test run result which already has `ports`."<br>
+            )<br>
+        self._ports = ports<br>
+<br>
+    @property<br>
+    def sut_info(self) -> NodeInfo | None:<br>
+        """Get the SUT node information associated with this test run."""<br>
+        return self._sut_info<br>
+<br>
+    @sut_info.setter<br>
+    def sut_info(self, sut_info: NodeInfo) -> None:<br>
+        """Set the SUT node information associated with this test run.<br>
<br>
         Args:<br>
-            sut_info: The additional SUT node information.<br>
+            sut_info: The SUT node information to associate with this test run.<br>
+<br>
+        Raises:<br>
+            ValueError: If the SUT information has already been assigned to this test run.<br>
         """<br>
-        self.sut_os_name = sut_info.os_name<br>
-        self.sut_os_version = sut_info.os_version<br>
-        self.sut_kernel_version = sut_info.kernel_version<br>
+        if self._sut_info:<br>
+            raise ValueError(<br>
+                "Attempted to assign `sut_info` to a test run result which already has `sut_info`."<br>
+            )<br>
+        self._sut_info = sut_info<br>
<br>
-    def add_dpdk_build_info(self, versions: DPDKBuildInfo) -> None:<br>
-        """Add information about the DPDK build gathered at runtime.<br>
+    @property<br>
+    def dpdk_build_info(self) -> DPDKBuildInfo | None:<br>
+        """Get the DPDK build information associated with this test run."""<br>
+        return self._dpdk_build_info<br>
+<br>
+    @dpdk_build_info.setter<br>
+    def dpdk_build_info(self, dpdk_build_info: DPDKBuildInfo) -> None:<br>
+        """Set the DPDK build information associated with this test run.<br>
<br>
         Args:<br>
-            versions: The additional information.<br>
+            dpdk_build_info: The DPDK build information to associate with this test run.<br>
+<br>
+        Raises:<br>
+            ValueError: If the DPDK build information has already been assigned to this test run.<br>
         """<br>
-        self.compiler_version = versions.compiler_version<br>
-        self.dpdk_version = versions.dpdk_version<br>
+        if self._dpdk_build_info:<br>
+            raise ValueError(<br>
+                "Attempted to assign `dpdk_build_info` to a test run result which already "<br>
+                "has `dpdk_build_info`."<br>
+            )<br>
+        self._dpdk_build_info = dpdk_build_info<br>
+<br>
+    def to_dict(self) -> TestRunResultDict:<br>
+        """Convert the test run result into a dictionary.<br>
+<br>
+        The dictionary contains test suites in this test run, and a summary of the test run and<br>
+        information about the DPDK version, compiler version and associated ports.<br>
+<br>
+        Returns:<br>
+            TestRunResultDict: A dictionary representation of the test run result.<br>
+        """<br>
+        results = {<a href="http://result.name" rel="noreferrer" target="_blank">result.name</a>: 0 for result in Result}<br>
+        self.add_result(results)<br>
+<br>
+        compiler_version = None<br>
+        dpdk_version = None<br>
+<br>
+        if self.dpdk_build_info:<br>
+            compiler_version = self.dpdk_build_info.compiler_version<br>
+            dpdk_version = self.dpdk_build_info.dpdk_version<br>
+<br>
+        return {<br>
+            "compiler_version": compiler_version,<br>
+            "dpdk_version": dpdk_version,<br>
+            "ports": [asdict(port) for port in self.ports],<br>
+            "test_suites": [child.to_dict() for child in self.child_results],<br>
+            "summary": results | self.generate_pass_rate_dict(results),<br>
+        }<br>
<br>
     def _block_result(self) -> None:<br>
         r"""Mark the result as :attr:`~Result.BLOCK`\ed."""<br>
@@ -436,6 +605,16 @@ def add_test_case(self, test_case_name: str) -> "TestCaseResult":<br>
         self.child_results.append(result)<br>
         return result<br>
<br>
+    def to_dict(self) -> TestSuiteResultDict:<br>
+        """Convert the test suite result into a dictionary.<br>
+<br>
+        The dictionary contains a test suite name and test cases given in this test suite.<br>
+        """<br>
+        return {<br>
+            "test_suite_name": self.test_suite_name,<br>
+            "test_cases": [child.to_dict() for child in self.child_results],<br>
+        }<br>
+<br>
     def _block_result(self) -> None:<br>
         r"""Mark the result as :attr:`~Result.BLOCK`\ed."""<br>
         for test_case_method in self._test_suite_with_cases.test_cases:<br>
@@ -483,16 +662,23 @@ def _get_child_errors(self) -> list[Exception]:<br>
             return [self.error]<br>
         return []<br>
<br>
-    def add_stats(self, statistics: "Statistics") -> None:<br>
-        r"""Add the test case result to statistics.<br>
+    def to_dict(self) -> TestCaseResultDict:<br>
+        """Convert the test case result into a dictionary.<br>
+<br>
+        The dictionary contains a test case name and the result name.<br>
+        """<br>
+        return {"test_case_name": self.test_case_name, "result": <a href="http://self.result.name" rel="noreferrer" target="_blank">self.result.name</a>}<br>
+<br>
+    def add_result(self, results: dict[str, int]):<br>
+        r"""Add the test case result to the results.<br>
<br>
         The base method goes through the hierarchy recursively and this method is here to stop<br>
-        the recursion, as the :class:`TestCaseResult`\s are the leaves of the hierarchy tree.<br>
+        the recursion, as the :class:`TestCaseResult` are the leaves of the hierarchy tree.<br>
<br>
         Args:<br>
-            statistics: The :class:`Statistics` object where the stats will be added.<br>
+            results: The dictionary to which results will be collated.<br>
         """<br>
-        statistics += self.result<br>
+        results[<a href="http://self.result.name" rel="noreferrer" target="_blank">self.result.name</a>] += 1<br>
<br>
     def _block_result(self) -> None:<br>
         r"""Mark the result as :attr:`~Result.BLOCK`\ed."""<br>
@@ -503,53 +689,114 @@ def __bool__(self) -> bool:<br>
         return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)<br>
<br>
<br>
-class Statistics(dict):<br>
-    """How many test cases ended in which result state along some other basic information.<br>
+class TextSummary:<br>
+    """Generates and saves textual summaries of DTS run results.<br>
<br>
-    Subclassing :class:`dict` provides a convenient way to format the data.<br>
+    The summary includes:<br>
+    * Results of test run test cases,<br>
+    * Compiler version of the DPDK build,<br>
+    * DPDK version of the DPDK source tree,<br>
+    * Overall summary of results when multiple test runs are present.<br>
+    """<br>
<br>
-    The data are stored in the following keys:<br>
+    _dict_result: DtsRunResultDict<br>
+    _summary: dict[str, int | float]<br>
+    _text: str<br>
<br>
-    * **PASS RATE** (:class:`int`) -- The FAIL/PASS ratio of all test cases.<br>
-    * **DPDK VERSION** (:class:`str`) -- The tested DPDK version.<br>
-    """<br>
+    def __init__(self, dts_run_result: DTSResult):<br>
+        """Initializes with a DTSResult object and converts it to a dictionary format.<br>
<br>
-    def __init__(self, dpdk_version: str | None):<br>
-        """Extend the constructor with keys in which the data are stored.<br>
+        Args:<br>
+            dts_run_result: The DTS result.<br>
+        """<br>
+        self._dict_result = dts_run_result.to_dict()<br>
+        self._summary = self._dict_result["summary"]<br>
+        self._text = ""<br>
+<br>
+    @property<br>
+    def _outdent(self) -> str:<br>
+        """Appropriate indentation based on multiple test run results."""<br>
+        return "\t" if len(self._dict_result["test_runs"]) > 1 else ""<br>
+<br>
+    def save(self, output_path: Path):<br>
+        """Generate and save text statistics to a file.<br>
<br>
         Args:<br>
-            dpdk_version: The version of tested DPDK.<br>
+            output_path: The path where the text file will be saved.<br>
         """<br>
-        super().__init__()<br>
-        for result in Result:<br>
-            self[<a href="http://result.name" rel="noreferrer" target="_blank">result.name</a>] = 0<br>
-        self["PASS RATE"] = 0.0<br>
-        self["DPDK VERSION"] = dpdk_version<br>
+        if self._dict_result["test_runs"]:<br>
+            with open(f"{output_path}", "w") as fp:<br>
+                self._add_test_runs_dict_decorator(self._add_test_run_dict)<br>
+                fp.write(self._text)<br>
<br>
-    def __iadd__(self, other: Result) -> "Statistics":<br>
-        """Add a Result to the final count.<br>
+    def _add_test_runs_dict_decorator(self, func: Callable):<br>
+        """Handles multiple test runs and appends results to the summary.<br>
<br>
-        Example:<br>
-            stats: Statistics = Statistics()  # empty Statistics<br>
-            stats += Result.PASS  # add a Result to `stats`<br>
+        Adds headers for each test run and overall result when multiple<br>
+        test runs are provided.<br>
<br>
         Args:<br>
-            other: The Result to add to this statistics object.<br>
+            func: Function to process and add results from each test run.<br>
+        """<br>
+        if len(self._dict_result["test_runs"]) > 1:<br>
+            for idx, test_run_result in enumerate(self._dict_result["test_runs"]):<br>
+                self._text += f"TEST_RUN_{idx}\n"<br>
+                func(test_run_result)<br>
<br>
-        Returns:<br>
-            The modified statistics object.<br>
+            self._add_overall_results()<br>
+        else:<br>
+            func(self._dict_result["test_runs"][0])<br>
+<br>
+    def _add_test_run_dict(self, test_run_dict: TestRunResultDict):<br>
+        """Adds the results and the test run attributes of a single test run to the summary.<br>
+<br>
+        Args:<br>
+            test_run_dict: Dictionary containing the test run results.<br>
         """<br>
-        self[<a href="http://other.name" rel="noreferrer" target="_blank">other.name</a>] += 1<br>
-        self["PASS RATE"] = (<br>
-            float(self[<a href="http://Result.PASS.name" rel="noreferrer" target="_blank">Result.PASS.name</a>]) * 100 / sum(self[<a href="http://result.name" rel="noreferrer" target="_blank">result.name</a>] for result in Result)<br>
+        self._add_column(<br>
+            DPDK_VERSION=test_run_dict["dpdk_version"],<br>
+            COMPILER_VERSION=test_run_dict["compiler_version"],<br>
+            **test_run_dict["summary"],<br>
         )<br>
-        return self<br>
-<br>
-    def __str__(self) -> str:<br>
-        """Each line contains the formatted key = value pair."""<br>
-        stats_str = ""<br>
-        for key, value in self.items():<br>
-            stats_str += f"{key:<12} = {value}\n"<br>
-            # according to docs, we should use \n when writing to text files<br>
-            # on all platforms<br>
-        return stats_str<br>
+        self._text += "\n"<br>
+<br>
+    def _add_column(self, **rows):<br>
+        """Formats and adds key-value pairs to the summary text.<br>
+<br>
+        Handles cases where values might be None by replacing them with "N/A".<br>
+<br>
+        Args:<br>
+            **rows: Arbitrary key-value pairs representing the result data.<br>
+        """<br>
+        rows = {k: "N/A" if v is None else v for k, v in rows.items()}<br>
+        max_length = len(max(rows, key=len))<br>
+        for key, value in rows.items():<br>
+            self._text += f"{self._outdent}{key:<{max_length}} = {value}\n"<br>
+<br>
+    def _add_overall_results(self):<br>
+        """Add overall summary of test runs."""<br>
+        self._text += "OVERALL\n"<br>
+        self._add_column(**self._summary)<br>
+<br>
+<br>
+class JsonResults:<br>
+    """Save DTS run result in JSON format."""<br>
+<br>
+    _dict_result: DtsRunResultDict<br>
+<br>
+    def __init__(self, dts_run_result: DTSResult):<br>
+        """Initializes with a DTSResult object and converts it to a dictionary format.<br>
+<br>
+        Args:<br>
+            dts_run_result: The DTS result.<br>
+        """<br>
+        self._dict_result = dts_run_result.to_dict()<br>
+<br>
+    def save(self, output_path: Path):<br>
+        """Save the result to a file as JSON.<br>
+<br>
+        Args:<br>
+            output_path: The path where the JSON file will be saved.<br>
+        """<br>
+        with open(f"{output_path}", "w") as fp:<br>
+            json.dump(self._dict_result, fp, indent=4)<br>
-- <br>
2.46.1<br>
<br>
</blockquote></div>
</blockquote></div>