xref: /honee/tests/junit_common.py (revision 3a98a9868128cddbae29bd879e7c98a077a4f2e8)
10006be33SJames Wrightfrom abc import ABC, abstractmethod
2e45c6f40SZach Atkinsfrom collections.abc import Iterable
30006be33SJames Wrightimport argparse
40006be33SJames Wrightimport csv
5e45c6f40SZach Atkinsfrom dataclasses import dataclass, field, fields
60006be33SJames Wrightimport difflib
70006be33SJames Wrightfrom enum import Enum
80006be33SJames Wrightfrom math import isclose
90006be33SJames Wrightimport os
100006be33SJames Wrightfrom pathlib import Path
110006be33SJames Wrightimport re
120006be33SJames Wrightimport subprocess
130006be33SJames Wrightimport multiprocessing as mp
140006be33SJames Wrightimport sys
150006be33SJames Wrightimport time
16e45c6f40SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin
17e45c6f40SZach Atkinsimport shutil
180006be33SJames Wright
190006be33SJames Wrightsys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
200006be33SJames Wrightfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
210006be33SJames Wright
220006be33SJames Wright
23e45c6f40SZach Atkinsclass ParseError(RuntimeError):
24e45c6f40SZach Atkins    """A custom exception for failed parsing."""
25e45c6f40SZach Atkins
26e45c6f40SZach Atkins    def __init__(self, message):
27e45c6f40SZach Atkins        super().__init__(message)
28e45c6f40SZach Atkins
29e45c6f40SZach Atkins
300006be33SJames Wrightclass CaseInsensitiveEnumAction(argparse.Action):
310006be33SJames Wright    """Action to convert input values to lower case prior to converting to an Enum type"""
320006be33SJames Wright
330006be33SJames Wright    def __init__(self, option_strings, dest, type, default, **kwargs):
34e45c6f40SZach Atkins        if not issubclass(type, Enum):
35e45c6f40SZach Atkins            raise ValueError(f"{type} must be an Enum")
360006be33SJames Wright        # store provided enum type
370006be33SJames Wright        self.enum_type = type
38e45c6f40SZach Atkins        if isinstance(default, self.enum_type):
39e45c6f40SZach Atkins            pass
40e45c6f40SZach Atkins        elif isinstance(default, str):
410006be33SJames Wright            default = self.enum_type(default.lower())
42e45c6f40SZach Atkins        elif isinstance(default, Iterable):
430006be33SJames Wright            default = [self.enum_type(v.lower()) for v in default]
44e45c6f40SZach Atkins        else:
45e45c6f40SZach Atkins            raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable")
460006be33SJames Wright        # prevent automatic type conversion
470006be33SJames Wright        super().__init__(option_strings, dest, default=default, **kwargs)
480006be33SJames Wright
490006be33SJames Wright    def __call__(self, parser, namespace, values, option_string=None):
50e45c6f40SZach Atkins        if isinstance(values, self.enum_type):
51e45c6f40SZach Atkins            pass
52e45c6f40SZach Atkins        elif isinstance(values, str):
530006be33SJames Wright            values = self.enum_type(values.lower())
54e45c6f40SZach Atkins        elif isinstance(values, Iterable):
550006be33SJames Wright            values = [self.enum_type(v.lower()) for v in values]
56e45c6f40SZach Atkins        else:
57e45c6f40SZach Atkins            raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable")
580006be33SJames Wright        setattr(namespace, self.dest, values)
590006be33SJames Wright
600006be33SJames Wright
610006be33SJames Wright@dataclass
620006be33SJames Wrightclass TestSpec:
630006be33SJames Wright    """Dataclass storing information about a single test case"""
64e45c6f40SZach Atkins    name: str = field(default_factory=str)
65e45c6f40SZach Atkins    csv_rtol: float = -1
66e45c6f40SZach Atkins    csv_ztol: float = -1
67e45c6f40SZach Atkins    cgns_tol: float = -1
680006be33SJames Wright    only: List = field(default_factory=list)
690006be33SJames Wright    args: List = field(default_factory=list)
70e45c6f40SZach Atkins    key_values: Dict = field(default_factory=dict)
710006be33SJames Wright
720006be33SJames Wright
73e45c6f40SZach Atkinsclass RunMode(Enum):
740006be33SJames Wright    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
75e45c6f40SZach Atkins    TAP = 'tap'
76e45c6f40SZach Atkins    JUNIT = 'junit'
77e45c6f40SZach Atkins
78e45c6f40SZach Atkins    def __str__(self):
79e45c6f40SZach Atkins        return self.value
80e45c6f40SZach Atkins
81e45c6f40SZach Atkins    def __repr__(self):
82e45c6f40SZach Atkins        return self.value
830006be33SJames Wright
840006be33SJames Wright
850006be33SJames Wrightclass SuiteSpec(ABC):
860006be33SJames Wright    """Abstract Base Class defining the required interface for running a test suite"""
870006be33SJames Wright    @abstractmethod
880006be33SJames Wright    def get_source_path(self, test: str) -> Path:
890006be33SJames Wright        """Compute path to test source file
900006be33SJames Wright
910006be33SJames Wright        Args:
920006be33SJames Wright            test (str): Name of test
930006be33SJames Wright
940006be33SJames Wright        Returns:
950006be33SJames Wright            Path: Path to source file
960006be33SJames Wright        """
970006be33SJames Wright        raise NotImplementedError
980006be33SJames Wright
990006be33SJames Wright    @abstractmethod
1000006be33SJames Wright    def get_run_path(self, test: str) -> Path:
1010006be33SJames Wright        """Compute path to built test executable file
1020006be33SJames Wright
1030006be33SJames Wright        Args:
1040006be33SJames Wright            test (str): Name of test
1050006be33SJames Wright
1060006be33SJames Wright        Returns:
1070006be33SJames Wright            Path: Path to test executable
1080006be33SJames Wright        """
1090006be33SJames Wright        raise NotImplementedError
1100006be33SJames Wright
1110006be33SJames Wright    @abstractmethod
1120006be33SJames Wright    def get_output_path(self, test: str, output_file: str) -> Path:
1130006be33SJames Wright        """Compute path to expected output file
1140006be33SJames Wright
1150006be33SJames Wright        Args:
1160006be33SJames Wright            test (str): Name of test
1170006be33SJames Wright            output_file (str): File name of output file
1180006be33SJames Wright
1190006be33SJames Wright        Returns:
1200006be33SJames Wright            Path: Path to expected output file
1210006be33SJames Wright        """
1220006be33SJames Wright        raise NotImplementedError
1230006be33SJames Wright
1240006be33SJames Wright    @property
125e45c6f40SZach Atkins    def test_failure_artifacts_path(self) -> Path:
126e45c6f40SZach Atkins        """Path to test failure artifacts"""
127e45c6f40SZach Atkins        return Path('build') / 'test_failure_artifacts'
128e45c6f40SZach Atkins
129e45c6f40SZach Atkins    @property
1300006be33SJames Wright    def cgns_tol(self):
1310006be33SJames Wright        """Absolute tolerance for CGNS diff"""
1320006be33SJames Wright        return getattr(self, '_cgns_tol', 1.0e-12)
1330006be33SJames Wright
1340006be33SJames Wright    @cgns_tol.setter
1350006be33SJames Wright    def cgns_tol(self, val):
1360006be33SJames Wright        self._cgns_tol = val
1370006be33SJames Wright
138e941b1e9SJames Wright    @property
139e45c6f40SZach Atkins    def csv_ztol(self):
140e941b1e9SJames Wright        """Keyword arguments to be passed to diff_csv()"""
141e45c6f40SZach Atkins        return getattr(self, '_csv_ztol', 3e-10)
142e941b1e9SJames Wright
143e45c6f40SZach Atkins    @csv_ztol.setter
144e45c6f40SZach Atkins    def csv_ztol(self, val):
145e45c6f40SZach Atkins        self._csv_ztol = val
146e941b1e9SJames Wright
147e45c6f40SZach Atkins    @property
148e45c6f40SZach Atkins    def csv_rtol(self):
149e45c6f40SZach Atkins        """Keyword arguments to be passed to diff_csv()"""
150e45c6f40SZach Atkins        return getattr(self, '_csv_rtol', 1e-6)
151e45c6f40SZach Atkins
152e45c6f40SZach Atkins    @csv_rtol.setter
153e45c6f40SZach Atkins    def csv_rtol(self, val):
154e45c6f40SZach Atkins        self._csv_rtol = val
155e45c6f40SZach Atkins
156e45c6f40SZach Atkins    @property
157e45c6f40SZach Atkins    def csv_comment_diff_fn(self):  # -> Any | Callable[..., None]:
158e45c6f40SZach Atkins        return getattr(self, '_csv_comment_diff_fn', None)
159e45c6f40SZach Atkins
160e45c6f40SZach Atkins    @csv_comment_diff_fn.setter
161e45c6f40SZach Atkins    def csv_comment_diff_fn(self, test_fn):
162e45c6f40SZach Atkins        self._csv_comment_diff_fn = test_fn
163e45c6f40SZach Atkins
164e45c6f40SZach Atkins    @property
165e45c6f40SZach Atkins    def csv_comment_str(self):
166e45c6f40SZach Atkins        return getattr(self, '_csv_comment_str', '#')
167e45c6f40SZach Atkins
168e45c6f40SZach Atkins    @csv_comment_str.setter
169e45c6f40SZach Atkins    def csv_comment_str(self, comment_str):
170e45c6f40SZach Atkins        self._csv_comment_str = comment_str
171e45c6f40SZach Atkins
172e45c6f40SZach Atkins    def post_test_hook(self, test: str, spec: TestSpec, backend: str) -> None:
1730006be33SJames Wright        """Function callback ran after each test case
1740006be33SJames Wright
1750006be33SJames Wright        Args:
1760006be33SJames Wright            test (str): Name of test
1770006be33SJames Wright            spec (TestSpec): Test case specification
1780006be33SJames Wright        """
1790006be33SJames Wright        pass
1800006be33SJames Wright
1810006be33SJames Wright    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1820006be33SJames Wright        """Check if a test case should be skipped prior to running, returning the reason for skipping
1830006be33SJames Wright
1840006be33SJames Wright        Args:
1850006be33SJames Wright            test (str): Name of test
1860006be33SJames Wright            spec (TestSpec): Test case specification
1870006be33SJames Wright            resource (str): libCEED backend
1880006be33SJames Wright            nproc (int): Number of MPI processes to use when running test case
1890006be33SJames Wright
1900006be33SJames Wright        Returns:
1910006be33SJames Wright            Optional[str]: Skip reason, or `None` if test case should not be skipped
1920006be33SJames Wright        """
1930006be33SJames Wright        return None
1940006be33SJames Wright
1950006be33SJames Wright    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1960006be33SJames Wright        """Check if a test case should be allowed to fail, based on its stderr output
1970006be33SJames Wright
1980006be33SJames Wright        Args:
1990006be33SJames Wright            test (str): Name of test
2000006be33SJames Wright            spec (TestSpec): Test case specification
2010006be33SJames Wright            resource (str): libCEED backend
2020006be33SJames Wright            stderr (str): Standard error output from test case execution
2030006be33SJames Wright
2040006be33SJames Wright        Returns:
2050006be33SJames Wright            Optional[str]: Skip reason, or `None` if unexpected error
2060006be33SJames Wright        """
2070006be33SJames Wright        return None
2080006be33SJames Wright
2090006be33SJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
2100006be33SJames Wright        """Check whether a test case is expected to fail and if it failed expectedly
2110006be33SJames Wright
2120006be33SJames Wright        Args:
2130006be33SJames Wright            test (str): Name of test
2140006be33SJames Wright            spec (TestSpec): Test case specification
2150006be33SJames Wright            resource (str): libCEED backend
2160006be33SJames Wright            stderr (str): Standard error output from test case execution
2170006be33SJames Wright
2180006be33SJames Wright        Returns:
2190006be33SJames Wright            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
2200006be33SJames Wright        """
2210006be33SJames Wright        return '', True
2220006be33SJames Wright
2230006be33SJames Wright    def check_allowed_stdout(self, test: str) -> bool:
2240006be33SJames Wright        """Check whether a test is allowed to print console output
2250006be33SJames Wright
2260006be33SJames Wright        Args:
2270006be33SJames Wright            test (str): Name of test
2280006be33SJames Wright
2290006be33SJames Wright        Returns:
2300006be33SJames Wright            bool: True if the test is allowed to print console output
2310006be33SJames Wright        """
2320006be33SJames Wright        return False
2330006be33SJames Wright
2340006be33SJames Wright
2350006be33SJames Wrightdef has_cgnsdiff() -> bool:
2360006be33SJames Wright    """Check whether `cgnsdiff` is an executable program in the current environment
2370006be33SJames Wright
2380006be33SJames Wright    Returns:
2390006be33SJames Wright        bool: True if `cgnsdiff` is found
2400006be33SJames Wright    """
2410006be33SJames Wright    my_env: dict = os.environ.copy()
2420006be33SJames Wright    proc = subprocess.run('cgnsdiff',
2430006be33SJames Wright                          shell=True,
2440006be33SJames Wright                          stdout=subprocess.PIPE,
2450006be33SJames Wright                          stderr=subprocess.PIPE,
2460006be33SJames Wright                          env=my_env)
2470006be33SJames Wright    return 'not found' not in proc.stderr.decode('utf-8')
2480006be33SJames Wright
2490006be33SJames Wright
2500006be33SJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
2510006be33SJames Wright    """Helper function, checks if any of the substrings are included in the base string
2520006be33SJames Wright
2530006be33SJames Wright    Args:
2540006be33SJames Wright        base (str): Base string to search in
2550006be33SJames Wright        substrings (List[str]): List of potential substrings
2560006be33SJames Wright
2570006be33SJames Wright    Returns:
2580006be33SJames Wright        bool: True if any substrings are included in base string
2590006be33SJames Wright    """
2600006be33SJames Wright    return any((sub in base for sub in substrings))
2610006be33SJames Wright
2620006be33SJames Wright
2630006be33SJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
2640006be33SJames Wright    """Helper function, checks if the base string is prefixed by any of `prefixes`
2650006be33SJames Wright
2660006be33SJames Wright    Args:
2670006be33SJames Wright        base (str): Base string to search
2680006be33SJames Wright        prefixes (List[str]): List of potential prefixes
2690006be33SJames Wright
2700006be33SJames Wright    Returns:
2710006be33SJames Wright        bool: True if base string is prefixed by any of the prefixes
2720006be33SJames Wright    """
2730006be33SJames Wright    return any((base.startswith(prefix) for prefix in prefixes))
2740006be33SJames Wright
2750006be33SJames Wright
276e45c6f40SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]:
277e45c6f40SZach Atkins    """Find the start and end positions of the first outer paired delimeters
278e45c6f40SZach Atkins
279e45c6f40SZach Atkins    Args:
280e45c6f40SZach Atkins        line (str): Line to search
281e45c6f40SZach Atkins        open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('.
282e45c6f40SZach Atkins        close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'.
283e45c6f40SZach Atkins
284e45c6f40SZach Atkins    Raises:
285e45c6f40SZach Atkins        RuntimeError: If open or close is not a single character
286e45c6f40SZach Atkins        RuntimeError: If open and close are the same characters
287e45c6f40SZach Atkins
288e45c6f40SZach Atkins    Returns:
289e45c6f40SZach Atkins        Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start.
290e45c6f40SZach Atkins    """
291e45c6f40SZach Atkins    if len(open) != 1 or len(close) != 1:
292e45c6f40SZach Atkins        raise RuntimeError("`open` and `close` must be single characters")
293e45c6f40SZach Atkins    if open == close:
294e45c6f40SZach Atkins        raise RuntimeError("`open` and `close` must be different characters")
295e45c6f40SZach Atkins    start: int = line.find(open)
296e45c6f40SZach Atkins    if start < 0:
297e45c6f40SZach Atkins        return -1, -1
298e45c6f40SZach Atkins    count: int = 1
299e45c6f40SZach Atkins    for i in range(start + 1, len(line)):
300e45c6f40SZach Atkins        if line[i] == open:
301e45c6f40SZach Atkins            count += 1
302e45c6f40SZach Atkins        if line[i] == close:
303e45c6f40SZach Atkins            count -= 1
304e45c6f40SZach Atkins            if count == 0:
305e45c6f40SZach Atkins                return start, i
306e45c6f40SZach Atkins    return start, -1
307e45c6f40SZach Atkins
308e45c6f40SZach Atkins
309e45c6f40SZach Atkinsdef parse_test_line(line: str, fallback_name: str = '') -> TestSpec:
3100006be33SJames Wright    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
3110006be33SJames Wright
3120006be33SJames Wright    Args:
3130006be33SJames Wright        line (str): String containing TESTARGS specification and CLI arguments
3140006be33SJames Wright
3150006be33SJames Wright    Returns:
3160006be33SJames Wright        TestSpec: Parsed specification of test case
3170006be33SJames Wright    """
318e45c6f40SZach Atkins    test_fields = fields(TestSpec)
319e45c6f40SZach Atkins    field_names = [f.name for f in test_fields]
320e45c6f40SZach Atkins    known: Dict = dict()
321e45c6f40SZach Atkins    other: Dict = dict()
322e45c6f40SZach Atkins    if line[0] == "(":
323e45c6f40SZach Atkins        # have key/value pairs to parse
324e45c6f40SZach Atkins        start, end = find_matching(line)
325e45c6f40SZach Atkins        if end < start:
326e45c6f40SZach Atkins            raise ParseError(f"Mismatched parentheses in TESTCASE: {line}")
327e45c6f40SZach Atkins
328e45c6f40SZach Atkins        keyvalues_str = line[start:end + 1]
329e45c6f40SZach Atkins        keyvalues_pattern = re.compile(r'''
330e45c6f40SZach Atkins            (?:\(\s*|\s*,\s*)   # start with open parentheses or comma, no capture
331e45c6f40SZach Atkins            ([A-Za-z]+[\w\-]+)  # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1
332e45c6f40SZach Atkins            \s*=\s*             # key is followed by = (whitespace ignored)
333e45c6f40SZach Atkins            (?:                 # uncaptured group for OR
334e45c6f40SZach Atkins              "((?:[^"]|\\")+)" #   match quoted value (any internal " must be escaped as \"); captured as Group 2
335e45c6f40SZach Atkins            | ([^=]+)           #   OR match unquoted value (no equals signs allowed); captured as Group 3
336e45c6f40SZach Atkins            )                   # end uncaptured group for OR
337e45c6f40SZach Atkins            \s*(?=,|\))         # lookahead for either next comma or closing parentheses
338e45c6f40SZach Atkins        ''', re.VERBOSE)
339e45c6f40SZach Atkins
340e45c6f40SZach Atkins        for match in re.finditer(keyvalues_pattern, keyvalues_str):
341e45c6f40SZach Atkins            if not match:  # empty
342e45c6f40SZach Atkins                continue
343e45c6f40SZach Atkins            key = match.group(1)
344e45c6f40SZach Atkins            value = match.group(2) if match.group(2) else match.group(3)
345e45c6f40SZach Atkins            try:
346e45c6f40SZach Atkins                index = field_names.index(key)
347e45c6f40SZach Atkins                if key == "only":  # weird bc only is a list
348e45c6f40SZach Atkins                    value = [constraint.strip() for constraint in value.split(',')]
349e45c6f40SZach Atkins                try:
350e45c6f40SZach Atkins                    # TODO: stop supporting python <=3.8
351e45c6f40SZach Atkins                    known[key] = test_fields[index].type(value)  # type: ignore
352e45c6f40SZach Atkins                except TypeError:
353e45c6f40SZach Atkins                    # TODO: this is still liable to fail for complex types
354e45c6f40SZach Atkins                    known[key] = get_origin(test_fields[index].type)(value)  # type: ignore
355e45c6f40SZach Atkins            except ValueError:
356e45c6f40SZach Atkins                other[key] = value
357e45c6f40SZach Atkins
358e45c6f40SZach Atkins        line = line[end + 1:]
359e45c6f40SZach Atkins
360e45c6f40SZach Atkins    if not 'name' in known.keys():
361e45c6f40SZach Atkins        known['name'] = fallback_name
362e45c6f40SZach Atkins
363e45c6f40SZach Atkins    args_pattern = re.compile(r'''
364e45c6f40SZach Atkins        \s+(            # remove leading space
365e45c6f40SZach Atkins            (?:"[^"]+") # match quoted CLI option
366e45c6f40SZach Atkins          | (?:[\S]+)   # match anything else that is space separated
367e45c6f40SZach Atkins        )
368e45c6f40SZach Atkins    ''', re.VERBOSE)
369e45c6f40SZach Atkins    args: List[str] = re.findall(args_pattern, line)
370e45c6f40SZach Atkins    for k, v in other.items():
371e45c6f40SZach Atkins        print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}")
372e45c6f40SZach Atkins    return TestSpec(**known, key_values=other, args=args)
3730006be33SJames Wright
3740006be33SJames Wright
3750006be33SJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
3760006be33SJames Wright    """Parse all test cases from a given source file
3770006be33SJames Wright
3780006be33SJames Wright    Args:
3790006be33SJames Wright        source_file (Path): Path to source file
3800006be33SJames Wright
3810006be33SJames Wright    Raises:
3820006be33SJames Wright        RuntimeError: Errors if source file extension is unsupported
3830006be33SJames Wright
3840006be33SJames Wright    Returns:
3850006be33SJames Wright        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
3860006be33SJames Wright    """
3870006be33SJames Wright    comment_str: str = ''
3880006be33SJames Wright    if source_file.suffix in ['.c', '.cc', '.cpp']:
3890006be33SJames Wright        comment_str = '//'
3900006be33SJames Wright    elif source_file.suffix in ['.py']:
3910006be33SJames Wright        comment_str = '#'
3920006be33SJames Wright    elif source_file.suffix in ['.usr']:
3930006be33SJames Wright        comment_str = 'C_'
3940006be33SJames Wright    elif source_file.suffix in ['.f90']:
3950006be33SJames Wright        comment_str = '! '
3960006be33SJames Wright    else:
3970006be33SJames Wright        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
3980006be33SJames Wright
399e45c6f40SZach Atkins    return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem)
4000006be33SJames Wright            for line in source_file.read_text().splitlines()
401e45c6f40SZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])]
4020006be33SJames Wright
4030006be33SJames Wright
404e45c6f40SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float,
405e941b1e9SJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
4060006be33SJames Wright    """Compare CSV results against an expected CSV file with tolerances
4070006be33SJames Wright
4080006be33SJames Wright    Args:
4090006be33SJames Wright        test_csv (Path): Path to output CSV results
4100006be33SJames Wright        true_csv (Path): Path to expected CSV results
411e45c6f40SZach Atkins        zero_tol (float): Tolerance below which values are considered to be zero.
412e45c6f40SZach Atkins        rel_tol (float): Relative tolerance for comparing non-zero values.
413e941b1e9SJames Wright        comment_str (str, optional): String to denoting commented line
414e941b1e9SJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
4150006be33SJames Wright
4160006be33SJames Wright    Returns:
4170006be33SJames Wright        str: Diff output between result and expected CSVs
4180006be33SJames Wright    """
4190006be33SJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
4200006be33SJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
4210006be33SJames Wright    # Files should not be empty
4220006be33SJames Wright    if len(test_lines) == 0:
4230006be33SJames Wright        return f'No lines found in test output {test_csv}'
4240006be33SJames Wright    if len(true_lines) == 0:
4250006be33SJames Wright        return f'No lines found in test source {true_csv}'
426e941b1e9SJames Wright    if len(test_lines) != len(true_lines):
427e941b1e9SJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
428e941b1e9SJames Wright
429e941b1e9SJames Wright    # Process commented lines
430e941b1e9SJames Wright    uncommented_lines: List[int] = []
431e941b1e9SJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
432e941b1e9SJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
433e941b1e9SJames Wright            if comment_func:
434e941b1e9SJames Wright                output = comment_func(test_line, true_line)
435e941b1e9SJames Wright                if output:
436e941b1e9SJames Wright                    return output
437e941b1e9SJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
438e941b1e9SJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
439e941b1e9SJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
440e941b1e9SJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
441e941b1e9SJames Wright        else:
442e941b1e9SJames Wright            uncommented_lines.append(n)
443e941b1e9SJames Wright
444e941b1e9SJames Wright    # Remove commented lines
445e941b1e9SJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
446e941b1e9SJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
4470006be33SJames Wright
4480006be33SJames Wright    test_reader: csv.DictReader = csv.DictReader(test_lines)
4490006be33SJames Wright    true_reader: csv.DictReader = csv.DictReader(true_lines)
450e45c6f40SZach Atkins    if not test_reader.fieldnames:
451e45c6f40SZach Atkins        return f'No CSV columns found in test output {test_csv}'
452e45c6f40SZach Atkins    if not true_reader.fieldnames:
453e45c6f40SZach Atkins        return f'No CSV columns found in test source {true_csv}'
4540006be33SJames Wright    if test_reader.fieldnames != true_reader.fieldnames:
4550006be33SJames Wright        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
4560006be33SJames Wright                       tofile='found CSV columns', fromfile='expected CSV columns'))
4570006be33SJames Wright
4580006be33SJames Wright    diff_lines: List[str] = list()
4590006be33SJames Wright    for test_line, true_line in zip(test_reader, true_reader):
4600006be33SJames Wright        for key in test_reader.fieldnames:
4610006be33SJames Wright            # Check if the value is numeric
4620006be33SJames Wright            try:
4630006be33SJames Wright                true_val: float = float(true_line[key])
4640006be33SJames Wright                test_val: float = float(test_line[key])
4650006be33SJames Wright                true_zero: bool = abs(true_val) < zero_tol
4660006be33SJames Wright                test_zero: bool = abs(test_val) < zero_tol
4670006be33SJames Wright                fail: bool = False
4680006be33SJames Wright                if true_zero:
4690006be33SJames Wright                    fail = not test_zero
4700006be33SJames Wright                else:
4710006be33SJames Wright                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
4720006be33SJames Wright                if fail:
4730006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
4740006be33SJames Wright            except ValueError:
4750006be33SJames Wright                if test_line[key] != true_line[key]:
4760006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
4770006be33SJames Wright
4780006be33SJames Wright    return '\n'.join(diff_lines)
4790006be33SJames Wright
4800006be33SJames Wright
481e45c6f40SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str:
4820006be33SJames Wright    """Compare CGNS results against an expected CGSN file with tolerance
4830006be33SJames Wright
4840006be33SJames Wright    Args:
4850006be33SJames Wright        test_cgns (Path): Path to output CGNS file
4860006be33SJames Wright        true_cgns (Path): Path to expected CGNS file
487e45c6f40SZach Atkins        cgns_tol (float): Tolerance for comparing floating-point values
4880006be33SJames Wright
4890006be33SJames Wright    Returns:
4900006be33SJames Wright        str: Diff output between result and expected CGNS files
4910006be33SJames Wright    """
4920006be33SJames Wright    my_env: dict = os.environ.copy()
4930006be33SJames Wright
4940006be33SJames Wright    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
4950006be33SJames Wright    proc = subprocess.run(' '.join(run_args),
4960006be33SJames Wright                          shell=True,
4970006be33SJames Wright                          stdout=subprocess.PIPE,
4980006be33SJames Wright                          stderr=subprocess.PIPE,
4990006be33SJames Wright                          env=my_env)
5000006be33SJames Wright
5010006be33SJames Wright    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
5020006be33SJames Wright
5030006be33SJames Wright
504e45c6f40SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str:
505e45c6f40SZach Atkins    """Compare ASCII results against an expected ASCII file
506e45c6f40SZach Atkins
507e45c6f40SZach Atkins    Args:
508e45c6f40SZach Atkins        test_file (Path): Path to output ASCII file
509e45c6f40SZach Atkins        true_file (Path): Path to expected ASCII file
510e45c6f40SZach Atkins
511e45c6f40SZach Atkins    Returns:
512e45c6f40SZach Atkins        str: Diff output between result and expected ASCII files
513e45c6f40SZach Atkins    """
514e45c6f40SZach Atkins    tmp_backend: str = backend.replace('/', '-')
515e45c6f40SZach Atkins    true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend)
516e45c6f40SZach Atkins    diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True),
517e45c6f40SZach Atkins                                     true_str.splitlines(keepends=True),
518e45c6f40SZach Atkins                                     fromfile=str(test_file),
519e45c6f40SZach Atkins                                     tofile=str(true_file)))
520e45c6f40SZach Atkins    return ''.join(diff)
521e45c6f40SZach Atkins
522e45c6f40SZach Atkins
5230006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
524e45c6f40SZach Atkins                            backend: str, test: str, index: int, verbose: bool) -> str:
5250006be33SJames Wright    output_str = ''
5260006be33SJames Wright    if mode is RunMode.TAP:
5270006be33SJames Wright        # print incremental output if TAP mode
5280006be33SJames Wright        if test_case.is_skipped():
5290006be33SJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
5300006be33SJames Wright        elif test_case.is_failure() or test_case.is_error():
531e45c6f40SZach Atkins            output_str += f'    not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
5320006be33SJames Wright        else:
533e45c6f40SZach Atkins            output_str += f'    ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
534e45c6f40SZach Atkins        if test_case.is_failure() or test_case.is_error() or verbose:
5350006be33SJames Wright            output_str += f'      ---\n'
5360006be33SJames Wright            if spec.only:
5370006be33SJames Wright                output_str += f'      only: {",".join(spec.only)}\n'
5380006be33SJames Wright            output_str += f'      args: {test_case.args}\n'
539e45c6f40SZach Atkins            if spec.csv_ztol > 0:
540e45c6f40SZach Atkins                output_str += f'      csv_ztol: {spec.csv_ztol}\n'
541e45c6f40SZach Atkins            if spec.csv_rtol > 0:
542e45c6f40SZach Atkins                output_str += f'      csv_rtol: {spec.csv_rtol}\n'
543e45c6f40SZach Atkins            if spec.cgns_tol > 0:
544e45c6f40SZach Atkins                output_str += f'      cgns_tol: {spec.cgns_tol}\n'
545e45c6f40SZach Atkins            for k, v in spec.key_values.items():
546e45c6f40SZach Atkins                output_str += f'      {k}: {v}\n'
5470006be33SJames Wright            if test_case.is_error():
5480006be33SJames Wright                output_str += f'      error: {test_case.errors[0]["message"]}\n'
5490006be33SJames Wright            if test_case.is_failure():
550e45c6f40SZach Atkins                output_str += f'      failures:\n'
5510006be33SJames Wright                for i, failure in enumerate(test_case.failures):
552e45c6f40SZach Atkins                    output_str += f'        -\n'
5530006be33SJames Wright                    output_str += f'          message: {failure["message"]}\n'
5540006be33SJames Wright                    if failure["output"]:
5550006be33SJames Wright                        out = failure["output"].strip().replace('\n', '\n            ')
5560006be33SJames Wright                        output_str += f'          output: |\n            {out}\n'
5570006be33SJames Wright            output_str += f'      ...\n'
5580006be33SJames Wright    else:
5590006be33SJames Wright        # print error or failure information if JUNIT mode
5600006be33SJames Wright        if test_case.is_error() or test_case.is_failure():
5610006be33SJames Wright            output_str += f'Test: {test} {spec.name}\n'
5620006be33SJames Wright            output_str += f'  $ {test_case.args}\n'
5630006be33SJames Wright            if test_case.is_error():
5640006be33SJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
5650006be33SJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
5660006be33SJames Wright            if test_case.is_failure():
5670006be33SJames Wright                for failure in test_case.failures:
5680006be33SJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
5690006be33SJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
5700006be33SJames Wright    return output_str
5710006be33SJames Wright
5720006be33SJames Wright
573e45c6f40SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path:
574e45c6f40SZach Atkins    """Attach a file to a test case
575e45c6f40SZach Atkins
576e45c6f40SZach Atkins    Args:
577e45c6f40SZach Atkins        test_case (TestCase): Test case to attach the file to
578e45c6f40SZach Atkins        file (Path): Path to the file to attach
579e45c6f40SZach Atkins    """
580e45c6f40SZach Atkins    save_path: Path = suite_spec.test_failure_artifacts_path / file.name
581e45c6f40SZach Atkins    shutil.copyfile(file, save_path)
582e45c6f40SZach Atkins    return save_path
583e45c6f40SZach Atkins
584e45c6f40SZach Atkins
5850006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str,
586e45c6f40SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase:
5870006be33SJames Wright    """Run a single test case and backend combination
5880006be33SJames Wright
5890006be33SJames Wright    Args:
5900006be33SJames Wright        index (int): Index of backend for current spec
5910006be33SJames Wright        test (str): Path to test
5920006be33SJames Wright        spec (TestSpec): Specification of test case
5930006be33SJames Wright        backend (str): CEED backend
5940006be33SJames Wright        mode (RunMode): Output mode
5950006be33SJames Wright        nproc (int): Number of MPI processes to use when running test case
5960006be33SJames Wright        suite_spec (SuiteSpec): Specification of test suite
597e45c6f40SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
5980006be33SJames Wright
5990006be33SJames Wright    Returns:
6000006be33SJames Wright        TestCase: Test case result
6010006be33SJames Wright    """
6020006be33SJames Wright    source_path: Path = suite_spec.get_source_path(test)
6030006be33SJames Wright    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
6040006be33SJames Wright
6050006be33SJames Wright    if '{ceed_resource}' in run_args:
6060006be33SJames Wright        run_args[run_args.index('{ceed_resource}')] = backend
6070006be33SJames Wright    for i, arg in enumerate(run_args):
6080006be33SJames Wright        if '{ceed_resource}' in arg:
6090006be33SJames Wright            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
6100006be33SJames Wright    if '{nproc}' in run_args:
6110006be33SJames Wright        run_args[run_args.index('{nproc}')] = f'{nproc}'
6120006be33SJames Wright    elif nproc > 1 and source_path.suffix != '.py':
6130006be33SJames Wright        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
6140006be33SJames Wright
6150006be33SJames Wright    # run test
616e45c6f40SZach Atkins    skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc)
6170006be33SJames Wright    if skip_reason:
6180006be33SJames Wright        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6190006be33SJames Wright                                       elapsed_sec=0,
6200006be33SJames Wright                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
6210006be33SJames Wright                                       stdout='',
6220006be33SJames Wright                                       stderr='',
6230006be33SJames Wright                                       category=spec.name,)
6240006be33SJames Wright        test_case.add_skipped_info(skip_reason)
6250006be33SJames Wright    else:
6260006be33SJames Wright        start: float = time.time()
6270006be33SJames Wright        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
6280006be33SJames Wright                              shell=True,
6290006be33SJames Wright                              stdout=subprocess.PIPE,
6300006be33SJames Wright                              stderr=subprocess.PIPE,
6310006be33SJames Wright                              env=my_env)
6320006be33SJames Wright
6330006be33SJames Wright        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6340006be33SJames Wright                             classname=source_path.parent,
6350006be33SJames Wright                             elapsed_sec=time.time() - start,
6360006be33SJames Wright                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
6370006be33SJames Wright                             stdout=proc.stdout.decode('utf-8'),
6380006be33SJames Wright                             stderr=proc.stderr.decode('utf-8'),
6390006be33SJames Wright                             allow_multiple_subelements=True,
6400006be33SJames Wright                             category=spec.name,)
6410006be33SJames Wright        ref_csvs: List[Path] = []
642e45c6f40SZach Atkins        ref_ascii: List[Path] = []
643e45c6f40SZach Atkins        output_files: List[str] = [arg.split(':')[1] for arg in run_args if arg.startswith('ascii:')]
6440006be33SJames Wright        if output_files:
645e45c6f40SZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file)
646e45c6f40SZach Atkins                        for file in output_files if file.endswith('.csv')]
647e45c6f40SZach Atkins            ref_ascii = [suite_spec.get_output_path(test, file)
648e45c6f40SZach Atkins                         for file in output_files if not file.endswith('.csv')]
6490006be33SJames Wright        ref_cgns: List[Path] = []
650e45c6f40SZach Atkins        output_files = [arg.split(':')[1] for arg in run_args if arg.startswith('cgns:')]
6510006be33SJames Wright        if output_files:
652e45c6f40SZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file) for file in output_files]
6530006be33SJames Wright        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
654e45c6f40SZach Atkins        suite_spec.post_test_hook(test, spec, backend)
6550006be33SJames Wright
6560006be33SJames Wright    # check allowed failures
6570006be33SJames Wright    if not test_case.is_skipped() and test_case.stderr:
658e45c6f40SZach Atkins        skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
6590006be33SJames Wright        if skip_reason:
6600006be33SJames Wright            test_case.add_skipped_info(skip_reason)
6610006be33SJames Wright
6620006be33SJames Wright    # check required failures
6630006be33SJames Wright    if not test_case.is_skipped():
6640006be33SJames Wright        required_message, did_fail = suite_spec.check_required_failure(
6650006be33SJames Wright            test, spec, backend, test_case.stderr)
6660006be33SJames Wright        if required_message and did_fail:
6670006be33SJames Wright            test_case.status = f'fails with required: {required_message}'
6680006be33SJames Wright        elif required_message:
6690006be33SJames Wright            test_case.add_failure_info(f'required failure missing: {required_message}')
6700006be33SJames Wright
6710006be33SJames Wright    # classify other results
6720006be33SJames Wright    if not test_case.is_skipped() and not test_case.status:
6730006be33SJames Wright        if test_case.stderr:
6740006be33SJames Wright            test_case.add_failure_info('stderr', test_case.stderr)
6750006be33SJames Wright        if proc.returncode != 0:
6760006be33SJames Wright            test_case.add_error_info(f'returncode = {proc.returncode}')
6770006be33SJames Wright        if ref_stdout.is_file():
6780006be33SJames Wright            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
6790006be33SJames Wright                                             test_case.stdout.splitlines(keepends=True),
6800006be33SJames Wright                                             fromfile=str(ref_stdout),
6810006be33SJames Wright                                             tofile='New'))
6820006be33SJames Wright            if diff:
6830006be33SJames Wright                test_case.add_failure_info('stdout', output=''.join(diff))
6840006be33SJames Wright        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
6850006be33SJames Wright            test_case.add_failure_info('stdout', output=test_case.stdout)
6860006be33SJames Wright        # expected CSV output
6870006be33SJames Wright        for ref_csv in ref_csvs:
6880006be33SJames Wright            csv_name = ref_csv.name
689e45c6f40SZach Atkins            out_file = Path.cwd() / csv_name
6900006be33SJames Wright            if not ref_csv.is_file():
6910006be33SJames Wright                # remove _{ceed_backend} from path name
6920006be33SJames Wright                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
6930006be33SJames Wright            if not ref_csv.is_file():
6940006be33SJames Wright                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
695e45c6f40SZach Atkins            elif not out_file.is_file():
696e45c6f40SZach Atkins                test_case.add_failure_info('csv', output=f'{out_file} not found')
6970006be33SJames Wright            else:
698e45c6f40SZach Atkins                csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol
699e45c6f40SZach Atkins                csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol
700e45c6f40SZach Atkins                diff = diff_csv(
701e45c6f40SZach Atkins                    out_file,
702e45c6f40SZach Atkins                    ref_csv,
703e45c6f40SZach Atkins                    csv_ztol,
704e45c6f40SZach Atkins                    csv_rtol,
705e45c6f40SZach Atkins                    suite_spec.csv_comment_str,
706e45c6f40SZach Atkins                    suite_spec.csv_comment_diff_fn)
7070006be33SJames Wright                if diff:
708e45c6f40SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / csv_name
709e45c6f40SZach Atkins                    shutil.move(out_file, save_path)
710e45c6f40SZach Atkins                    test_case.add_failure_info(f'csv: {save_path}', output=diff)
7110006be33SJames Wright                else:
712e45c6f40SZach Atkins                    out_file.unlink()
7130006be33SJames Wright        # expected CGNS output
7140006be33SJames Wright        for ref_cgn in ref_cgns:
7150006be33SJames Wright            cgn_name = ref_cgn.name
716e45c6f40SZach Atkins            out_file = Path.cwd() / cgn_name
7170006be33SJames Wright            if not ref_cgn.is_file():
7180006be33SJames Wright                # remove _{ceed_backend} from path name
7190006be33SJames Wright                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
7200006be33SJames Wright            if not ref_cgn.is_file():
7210006be33SJames Wright                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
722e45c6f40SZach Atkins            elif not out_file.is_file():
723e45c6f40SZach Atkins                test_case.add_failure_info('cgns', output=f'{out_file} not found')
7240006be33SJames Wright            else:
725e45c6f40SZach Atkins                cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol
726e45c6f40SZach Atkins                diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol)
7270006be33SJames Wright                if diff:
728e45c6f40SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name
729e45c6f40SZach Atkins                    shutil.move(out_file, save_path)
730e45c6f40SZach Atkins                    test_case.add_failure_info(f'cgns: {save_path}', output=diff)
7310006be33SJames Wright                else:
732e45c6f40SZach Atkins                    out_file.unlink()
733e45c6f40SZach Atkins        # expected ASCII output
734e45c6f40SZach Atkins        for ref_file in ref_ascii:
735e45c6f40SZach Atkins            ref_name = ref_file.name
736e45c6f40SZach Atkins            out_file = Path.cwd() / ref_name
737e45c6f40SZach Atkins            if not ref_file.is_file():
738e45c6f40SZach Atkins                # remove _{ceed_backend} from path name
739e45c6f40SZach Atkins                ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix)
740e45c6f40SZach Atkins            if not ref_file.is_file():
741e45c6f40SZach Atkins                test_case.add_failure_info('ascii', output=f'{ref_file} not found')
742e45c6f40SZach Atkins            elif not out_file.is_file():
743e45c6f40SZach Atkins                test_case.add_failure_info('ascii', output=f'{out_file} not found')
744e45c6f40SZach Atkins            else:
745e45c6f40SZach Atkins                diff = diff_ascii(out_file, ref_file, backend)
746e45c6f40SZach Atkins                if diff:
747e45c6f40SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / ref_name
748e45c6f40SZach Atkins                    shutil.move(out_file, save_path)
749e45c6f40SZach Atkins                    test_case.add_failure_info(f'ascii: {save_path}', output=diff)
750e45c6f40SZach Atkins                else:
751e45c6f40SZach Atkins                    out_file.unlink()
7520006be33SJames Wright
7530006be33SJames Wright    # store result
7540006be33SJames Wright    test_case.args = ' '.join(str(arg) for arg in run_args)
755e45c6f40SZach Atkins    output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose)
7560006be33SJames Wright
7570006be33SJames Wright    return test_case, output_str
7580006be33SJames Wright
7590006be33SJames Wright
7600006be33SJames Wrightdef init_process():
7610006be33SJames Wright    """Initialize multiprocessing process"""
7620006be33SJames Wright    # set up error handler
7630006be33SJames Wright    global my_env
7640006be33SJames Wright    my_env = os.environ.copy()
7650006be33SJames Wright    my_env['CEED_ERROR_HANDLER'] = 'exit'
7660006be33SJames Wright
7670006be33SJames Wright
7680006be33SJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
769e45c6f40SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite:
7700006be33SJames Wright    """Run all test cases for `test` with each of the provided `ceed_backends`
7710006be33SJames Wright
7720006be33SJames Wright    Args:
7730006be33SJames Wright        test (str): Name of test
7740006be33SJames Wright        ceed_backends (List[str]): List of libCEED backends
7750006be33SJames Wright        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
7760006be33SJames Wright        nproc (int): Number of MPI processes to use when running each test case
7770006be33SJames Wright        suite_spec (SuiteSpec): Object defining required methods for running tests
7780006be33SJames Wright        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
779e45c6f40SZach Atkins        search (str, optional): Regular expression used to match tests. Defaults to ".*".
780e45c6f40SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
7810006be33SJames Wright
7820006be33SJames Wright    Returns:
7830006be33SJames Wright        TestSuite: JUnit `TestSuite` containing results of all test cases
7840006be33SJames Wright    """
785e45c6f40SZach Atkins    test_specs: List[TestSpec] = [
786e45c6f40SZach Atkins        t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE)
787e45c6f40SZach Atkins    ]
788e45c6f40SZach Atkins    suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True)
7890006be33SJames Wright    if mode is RunMode.TAP:
7900006be33SJames Wright        print('TAP version 13')
7910006be33SJames Wright        print(f'1..{len(test_specs)}')
7920006be33SJames Wright
7930006be33SJames Wright    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
794e45c6f40SZach Atkins        async_outputs: List[List[mp.pool.AsyncResult]] = [
795e45c6f40SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose))
7960006be33SJames Wright             for (i, backend) in enumerate(ceed_backends, start=1)]
7970006be33SJames Wright            for spec in test_specs
7980006be33SJames Wright        ]
7990006be33SJames Wright
8000006be33SJames Wright        test_cases = []
8010006be33SJames Wright        for (i, subtest) in enumerate(async_outputs, start=1):
8020006be33SJames Wright            is_new_subtest = True
8030006be33SJames Wright            subtest_ok = True
8040006be33SJames Wright            for async_output in subtest:
8050006be33SJames Wright                test_case, print_output = async_output.get()
8060006be33SJames Wright                test_cases.append(test_case)
8070006be33SJames Wright                if is_new_subtest and mode == RunMode.TAP:
8080006be33SJames Wright                    is_new_subtest = False
8090006be33SJames Wright                    print(f'# Subtest: {test_case.category}')
8100006be33SJames Wright                    print(f'    1..{len(ceed_backends)}')
8110006be33SJames Wright                print(print_output, end='')
8120006be33SJames Wright                if test_case.is_failure() or test_case.is_error():
8130006be33SJames Wright                    subtest_ok = False
8140006be33SJames Wright            if mode == RunMode.TAP:
8150006be33SJames Wright                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
8160006be33SJames Wright
8170006be33SJames Wright    return TestSuite(test, test_cases)
8180006be33SJames Wright
8190006be33SJames Wright
820*f5d9ab20SJames Wrightdef write_junit_xml(test_suite: TestSuite, batch: str = '') -> None:
8210006be33SJames Wright    """Write a JUnit XML file containing the results of a `TestSuite`
8220006be33SJames Wright
8230006be33SJames Wright    Args:
8240006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to write
8250006be33SJames Wright        batch (str): Name of JUnit batch, defaults to empty string
8260006be33SJames Wright    """
827*f5d9ab20SJames Wright    output_file = Path('build') / (f'{test_suite.name}{batch}.junit')
8280006be33SJames Wright    output_file.write_text(to_xml_report_string([test_suite]))
8290006be33SJames Wright
8300006be33SJames Wright
8310006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool:
8320006be33SJames Wright    """Check whether any test cases in a `TestSuite` failed
8330006be33SJames Wright
8340006be33SJames Wright    Args:
8350006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to check
8360006be33SJames Wright
8370006be33SJames Wright    Returns:
8380006be33SJames Wright        bool: True if any test cases failed
8390006be33SJames Wright    """
8400006be33SJames Wright    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
841