xref: /honee/tests/junit_common.py (revision e941b1e9c029a850a20e06efe1e514154e2a4e67)
10006be33SJames Wrightfrom abc import ABC, abstractmethod
20006be33SJames Wrightimport argparse
30006be33SJames Wrightimport csv
40006be33SJames Wrightfrom dataclasses import dataclass, field
50006be33SJames Wrightimport difflib
60006be33SJames Wrightfrom enum import Enum
70006be33SJames Wrightfrom math import isclose
80006be33SJames Wrightimport os
90006be33SJames Wrightfrom pathlib import Path
100006be33SJames Wrightimport re
110006be33SJames Wrightimport subprocess
120006be33SJames Wrightimport multiprocessing as mp
130006be33SJames Wrightfrom itertools import product
140006be33SJames Wrightimport sys
150006be33SJames Wrightimport time
16*e941b1e9SJames Wrightfrom typing import Optional, Tuple, List, Callable
170006be33SJames Wright
180006be33SJames Wrightsys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
190006be33SJames Wrightfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
200006be33SJames Wright
210006be33SJames Wright
220006be33SJames Wrightclass CaseInsensitiveEnumAction(argparse.Action):
230006be33SJames Wright    """Action to convert input values to lower case prior to converting to an Enum type"""
240006be33SJames Wright
250006be33SJames Wright    def __init__(self, option_strings, dest, type, default, **kwargs):
260006be33SJames Wright        if not (issubclass(type, Enum) and issubclass(type, str)):
270006be33SJames Wright            raise ValueError(f"{type} must be a StrEnum or str and Enum")
280006be33SJames Wright        # store provided enum type
290006be33SJames Wright        self.enum_type = type
300006be33SJames Wright        if isinstance(default, str):
310006be33SJames Wright            default = self.enum_type(default.lower())
320006be33SJames Wright        else:
330006be33SJames Wright            default = [self.enum_type(v.lower()) for v in default]
340006be33SJames Wright        # prevent automatic type conversion
350006be33SJames Wright        super().__init__(option_strings, dest, default=default, **kwargs)
360006be33SJames Wright
370006be33SJames Wright    def __call__(self, parser, namespace, values, option_string=None):
380006be33SJames Wright        if isinstance(values, str):
390006be33SJames Wright            values = self.enum_type(values.lower())
400006be33SJames Wright        else:
410006be33SJames Wright            values = [self.enum_type(v.lower()) for v in values]
420006be33SJames Wright        setattr(namespace, self.dest, values)
430006be33SJames Wright
440006be33SJames Wright
450006be33SJames Wright@dataclass
460006be33SJames Wrightclass TestSpec:
470006be33SJames Wright    """Dataclass storing information about a single test case"""
480006be33SJames Wright    name: str
490006be33SJames Wright    only: List = field(default_factory=list)
500006be33SJames Wright    args: List = field(default_factory=list)
510006be33SJames Wright
520006be33SJames Wright
530006be33SJames Wrightclass RunMode(str, Enum):
540006be33SJames Wright    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
550006be33SJames Wright    __str__ = str.__str__
560006be33SJames Wright    __format__ = str.__format__
570006be33SJames Wright    TAP: str = 'tap'
580006be33SJames Wright    JUNIT: str = 'junit'
590006be33SJames Wright
600006be33SJames Wright
610006be33SJames Wrightclass SuiteSpec(ABC):
620006be33SJames Wright    """Abstract Base Class defining the required interface for running a test suite"""
630006be33SJames Wright    @abstractmethod
640006be33SJames Wright    def get_source_path(self, test: str) -> Path:
650006be33SJames Wright        """Compute path to test source file
660006be33SJames Wright
670006be33SJames Wright        Args:
680006be33SJames Wright            test (str): Name of test
690006be33SJames Wright
700006be33SJames Wright        Returns:
710006be33SJames Wright            Path: Path to source file
720006be33SJames Wright        """
730006be33SJames Wright        raise NotImplementedError
740006be33SJames Wright
750006be33SJames Wright    @abstractmethod
760006be33SJames Wright    def get_run_path(self, test: str) -> Path:
770006be33SJames Wright        """Compute path to built test executable file
780006be33SJames Wright
790006be33SJames Wright        Args:
800006be33SJames Wright            test (str): Name of test
810006be33SJames Wright
820006be33SJames Wright        Returns:
830006be33SJames Wright            Path: Path to test executable
840006be33SJames Wright        """
850006be33SJames Wright        raise NotImplementedError
860006be33SJames Wright
870006be33SJames Wright    @abstractmethod
880006be33SJames Wright    def get_output_path(self, test: str, output_file: str) -> Path:
890006be33SJames Wright        """Compute path to expected output file
900006be33SJames Wright
910006be33SJames Wright        Args:
920006be33SJames Wright            test (str): Name of test
930006be33SJames Wright            output_file (str): File name of output file
940006be33SJames Wright
950006be33SJames Wright        Returns:
960006be33SJames Wright            Path: Path to expected output file
970006be33SJames Wright        """
980006be33SJames Wright        raise NotImplementedError
990006be33SJames Wright
1000006be33SJames Wright    @property
1010006be33SJames Wright    def cgns_tol(self):
1020006be33SJames Wright        """Absolute tolerance for CGNS diff"""
1030006be33SJames Wright        return getattr(self, '_cgns_tol', 1.0e-12)
1040006be33SJames Wright
1050006be33SJames Wright    @cgns_tol.setter
1060006be33SJames Wright    def cgns_tol(self, val):
1070006be33SJames Wright        self._cgns_tol = val
1080006be33SJames Wright
109*e941b1e9SJames Wright    @property
110*e941b1e9SJames Wright    def diff_csv_kwargs(self):
111*e941b1e9SJames Wright        """Keyword arguments to be passed to diff_csv()"""
112*e941b1e9SJames Wright        return getattr(self, '_diff_csv_kwargs', {})
113*e941b1e9SJames Wright
114*e941b1e9SJames Wright    @diff_csv_kwargs.setter
115*e941b1e9SJames Wright    def diff_csv_kwargs(self, val):
116*e941b1e9SJames Wright        self._diff_csv_kwargs = val
117*e941b1e9SJames Wright
1180006be33SJames Wright    def post_test_hook(self, test: str, spec: TestSpec) -> None:
1190006be33SJames Wright        """Function callback ran after each test case
1200006be33SJames Wright
1210006be33SJames Wright        Args:
1220006be33SJames Wright            test (str): Name of test
1230006be33SJames Wright            spec (TestSpec): Test case specification
1240006be33SJames Wright        """
1250006be33SJames Wright        pass
1260006be33SJames Wright
1270006be33SJames Wright    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1280006be33SJames Wright        """Check if a test case should be skipped prior to running, returning the reason for skipping
1290006be33SJames Wright
1300006be33SJames Wright        Args:
1310006be33SJames Wright            test (str): Name of test
1320006be33SJames Wright            spec (TestSpec): Test case specification
1330006be33SJames Wright            resource (str): libCEED backend
1340006be33SJames Wright            nproc (int): Number of MPI processes to use when running test case
1350006be33SJames Wright
1360006be33SJames Wright        Returns:
1370006be33SJames Wright            Optional[str]: Skip reason, or `None` if test case should not be skipped
1380006be33SJames Wright        """
1390006be33SJames Wright        return None
1400006be33SJames Wright
1410006be33SJames Wright    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1420006be33SJames Wright        """Check if a test case should be allowed to fail, based on its stderr output
1430006be33SJames Wright
1440006be33SJames Wright        Args:
1450006be33SJames Wright            test (str): Name of test
1460006be33SJames Wright            spec (TestSpec): Test case specification
1470006be33SJames Wright            resource (str): libCEED backend
1480006be33SJames Wright            stderr (str): Standard error output from test case execution
1490006be33SJames Wright
1500006be33SJames Wright        Returns:
1510006be33SJames Wright            Optional[str]: Skip reason, or `None` if unexpected error
1520006be33SJames Wright        """
1530006be33SJames Wright        return None
1540006be33SJames Wright
1550006be33SJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
1560006be33SJames Wright        """Check whether a test case is expected to fail and if it failed expectedly
1570006be33SJames Wright
1580006be33SJames Wright        Args:
1590006be33SJames Wright            test (str): Name of test
1600006be33SJames Wright            spec (TestSpec): Test case specification
1610006be33SJames Wright            resource (str): libCEED backend
1620006be33SJames Wright            stderr (str): Standard error output from test case execution
1630006be33SJames Wright
1640006be33SJames Wright        Returns:
1650006be33SJames Wright            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
1660006be33SJames Wright        """
1670006be33SJames Wright        return '', True
1680006be33SJames Wright
1690006be33SJames Wright    def check_allowed_stdout(self, test: str) -> bool:
1700006be33SJames Wright        """Check whether a test is allowed to print console output
1710006be33SJames Wright
1720006be33SJames Wright        Args:
1730006be33SJames Wright            test (str): Name of test
1740006be33SJames Wright
1750006be33SJames Wright        Returns:
1760006be33SJames Wright            bool: True if the test is allowed to print console output
1770006be33SJames Wright        """
1780006be33SJames Wright        return False
1790006be33SJames Wright
1800006be33SJames Wright
1810006be33SJames Wrightdef has_cgnsdiff() -> bool:
1820006be33SJames Wright    """Check whether `cgnsdiff` is an executable program in the current environment
1830006be33SJames Wright
1840006be33SJames Wright    Returns:
1850006be33SJames Wright        bool: True if `cgnsdiff` is found
1860006be33SJames Wright    """
1870006be33SJames Wright    my_env: dict = os.environ.copy()
1880006be33SJames Wright    proc = subprocess.run('cgnsdiff',
1890006be33SJames Wright                          shell=True,
1900006be33SJames Wright                          stdout=subprocess.PIPE,
1910006be33SJames Wright                          stderr=subprocess.PIPE,
1920006be33SJames Wright                          env=my_env)
1930006be33SJames Wright    return 'not found' not in proc.stderr.decode('utf-8')
1940006be33SJames Wright
1950006be33SJames Wright
1960006be33SJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
1970006be33SJames Wright    """Helper function, checks if any of the substrings are included in the base string
1980006be33SJames Wright
1990006be33SJames Wright    Args:
2000006be33SJames Wright        base (str): Base string to search in
2010006be33SJames Wright        substrings (List[str]): List of potential substrings
2020006be33SJames Wright
2030006be33SJames Wright    Returns:
2040006be33SJames Wright        bool: True if any substrings are included in base string
2050006be33SJames Wright    """
2060006be33SJames Wright    return any((sub in base for sub in substrings))
2070006be33SJames Wright
2080006be33SJames Wright
2090006be33SJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
2100006be33SJames Wright    """Helper function, checks if the base string is prefixed by any of `prefixes`
2110006be33SJames Wright
2120006be33SJames Wright    Args:
2130006be33SJames Wright        base (str): Base string to search
2140006be33SJames Wright        prefixes (List[str]): List of potential prefixes
2150006be33SJames Wright
2160006be33SJames Wright    Returns:
2170006be33SJames Wright        bool: True if base string is prefixed by any of the prefixes
2180006be33SJames Wright    """
2190006be33SJames Wright    return any((base.startswith(prefix) for prefix in prefixes))
2200006be33SJames Wright
2210006be33SJames Wright
2220006be33SJames Wrightdef parse_test_line(line: str) -> TestSpec:
2230006be33SJames Wright    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2240006be33SJames Wright
2250006be33SJames Wright    Args:
2260006be33SJames Wright        line (str): String containing TESTARGS specification and CLI arguments
2270006be33SJames Wright
2280006be33SJames Wright    Returns:
2290006be33SJames Wright        TestSpec: Parsed specification of test case
2300006be33SJames Wright    """
2310006be33SJames Wright    args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
2320006be33SJames Wright    if args[0] == 'TESTARGS':
2330006be33SJames Wright        return TestSpec(name='', args=args[1:])
2340006be33SJames Wright    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
2350006be33SJames Wright    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
2360006be33SJames Wright    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
2370006be33SJames Wright    name: str = test_args.get('name', '')
2380006be33SJames Wright    constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else []
2390006be33SJames Wright    if len(args) > 1:
2400006be33SJames Wright        return TestSpec(name=name, only=constraints, args=args[1:])
2410006be33SJames Wright    else:
2420006be33SJames Wright        return TestSpec(name=name, only=constraints)
2430006be33SJames Wright
2440006be33SJames Wright
2450006be33SJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
2460006be33SJames Wright    """Parse all test cases from a given source file
2470006be33SJames Wright
2480006be33SJames Wright    Args:
2490006be33SJames Wright        source_file (Path): Path to source file
2500006be33SJames Wright
2510006be33SJames Wright    Raises:
2520006be33SJames Wright        RuntimeError: Errors if source file extension is unsupported
2530006be33SJames Wright
2540006be33SJames Wright    Returns:
2550006be33SJames Wright        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
2560006be33SJames Wright    """
2570006be33SJames Wright    comment_str: str = ''
2580006be33SJames Wright    if source_file.suffix in ['.c', '.cc', '.cpp']:
2590006be33SJames Wright        comment_str = '//'
2600006be33SJames Wright    elif source_file.suffix in ['.py']:
2610006be33SJames Wright        comment_str = '#'
2620006be33SJames Wright    elif source_file.suffix in ['.usr']:
2630006be33SJames Wright        comment_str = 'C_'
2640006be33SJames Wright    elif source_file.suffix in ['.f90']:
2650006be33SJames Wright        comment_str = '! '
2660006be33SJames Wright    else:
2670006be33SJames Wright        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
2680006be33SJames Wright
2690006be33SJames Wright    return [parse_test_line(line.strip(comment_str))
2700006be33SJames Wright            for line in source_file.read_text().splitlines()
2710006be33SJames Wright            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
2720006be33SJames Wright
2730006be33SJames Wright
274*e941b1e9SJames Wrightdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2,
275*e941b1e9SJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
2760006be33SJames Wright    """Compare CSV results against an expected CSV file with tolerances
2770006be33SJames Wright
2780006be33SJames Wright    Args:
2790006be33SJames Wright        test_csv (Path): Path to output CSV results
2800006be33SJames Wright        true_csv (Path): Path to expected CSV results
2810006be33SJames Wright        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
2820006be33SJames Wright        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
283*e941b1e9SJames Wright        comment_str (str, optional): String to denoting commented line
284*e941b1e9SJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
2850006be33SJames Wright
2860006be33SJames Wright    Returns:
2870006be33SJames Wright        str: Diff output between result and expected CSVs
2880006be33SJames Wright    """
2890006be33SJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
2900006be33SJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
2910006be33SJames Wright    # Files should not be empty
2920006be33SJames Wright    if len(test_lines) == 0:
2930006be33SJames Wright        return f'No lines found in test output {test_csv}'
2940006be33SJames Wright    if len(true_lines) == 0:
2950006be33SJames Wright        return f'No lines found in test source {true_csv}'
296*e941b1e9SJames Wright    if len(test_lines) != len(true_lines):
297*e941b1e9SJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
298*e941b1e9SJames Wright
299*e941b1e9SJames Wright    # Process commented lines
300*e941b1e9SJames Wright    uncommented_lines: List[int] = []
301*e941b1e9SJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
302*e941b1e9SJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
303*e941b1e9SJames Wright            if comment_func:
304*e941b1e9SJames Wright                output = comment_func(test_line, true_line)
305*e941b1e9SJames Wright                if output:
306*e941b1e9SJames Wright                    return output
307*e941b1e9SJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
308*e941b1e9SJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
309*e941b1e9SJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
310*e941b1e9SJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
311*e941b1e9SJames Wright        else:
312*e941b1e9SJames Wright            uncommented_lines.append(n)
313*e941b1e9SJames Wright
314*e941b1e9SJames Wright    # Remove commented lines
315*e941b1e9SJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
316*e941b1e9SJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
3170006be33SJames Wright
3180006be33SJames Wright    test_reader: csv.DictReader = csv.DictReader(test_lines)
3190006be33SJames Wright    true_reader: csv.DictReader = csv.DictReader(true_lines)
3200006be33SJames Wright    if test_reader.fieldnames != true_reader.fieldnames:
3210006be33SJames Wright        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
3220006be33SJames Wright                       tofile='found CSV columns', fromfile='expected CSV columns'))
3230006be33SJames Wright
3240006be33SJames Wright    diff_lines: List[str] = list()
3250006be33SJames Wright    for test_line, true_line in zip(test_reader, true_reader):
3260006be33SJames Wright        for key in test_reader.fieldnames:
3270006be33SJames Wright            # Check if the value is numeric
3280006be33SJames Wright            try:
3290006be33SJames Wright                true_val: float = float(true_line[key])
3300006be33SJames Wright                test_val: float = float(test_line[key])
3310006be33SJames Wright                true_zero: bool = abs(true_val) < zero_tol
3320006be33SJames Wright                test_zero: bool = abs(test_val) < zero_tol
3330006be33SJames Wright                fail: bool = False
3340006be33SJames Wright                if true_zero:
3350006be33SJames Wright                    fail = not test_zero
3360006be33SJames Wright                else:
3370006be33SJames Wright                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
3380006be33SJames Wright                if fail:
3390006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
3400006be33SJames Wright            except ValueError:
3410006be33SJames Wright                if test_line[key] != true_line[key]:
3420006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
3430006be33SJames Wright
3440006be33SJames Wright    return '\n'.join(diff_lines)
3450006be33SJames Wright
3460006be33SJames Wright
3470006be33SJames Wrightdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str:
3480006be33SJames Wright    """Compare CGNS results against an expected CGSN file with tolerance
3490006be33SJames Wright
3500006be33SJames Wright    Args:
3510006be33SJames Wright        test_cgns (Path): Path to output CGNS file
3520006be33SJames Wright        true_cgns (Path): Path to expected CGNS file
3530006be33SJames Wright        cgns_tol (float, optional): Tolerance for comparing floating-point values
3540006be33SJames Wright
3550006be33SJames Wright    Returns:
3560006be33SJames Wright        str: Diff output between result and expected CGNS files
3570006be33SJames Wright    """
3580006be33SJames Wright    my_env: dict = os.environ.copy()
3590006be33SJames Wright
3600006be33SJames Wright    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
3610006be33SJames Wright    proc = subprocess.run(' '.join(run_args),
3620006be33SJames Wright                          shell=True,
3630006be33SJames Wright                          stdout=subprocess.PIPE,
3640006be33SJames Wright                          stderr=subprocess.PIPE,
3650006be33SJames Wright                          env=my_env)
3660006be33SJames Wright
3670006be33SJames Wright    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
3680006be33SJames Wright
3690006be33SJames Wright
3700006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
3710006be33SJames Wright                            backend: str, test: str, index: int) -> str:
3720006be33SJames Wright    output_str = ''
3730006be33SJames Wright    if mode is RunMode.TAP:
3740006be33SJames Wright        # print incremental output if TAP mode
3750006be33SJames Wright        if test_case.is_skipped():
3760006be33SJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
3770006be33SJames Wright        elif test_case.is_failure() or test_case.is_error():
3780006be33SJames Wright            output_str += f'    not ok {index} - {spec.name}, {backend}\n'
3790006be33SJames Wright        else:
3800006be33SJames Wright            output_str += f'    ok {index} - {spec.name}, {backend}\n'
3810006be33SJames Wright        output_str += f'      ---\n'
3820006be33SJames Wright        if spec.only:
3830006be33SJames Wright            output_str += f'      only: {",".join(spec.only)}\n'
3840006be33SJames Wright        output_str += f'      args: {test_case.args}\n'
3850006be33SJames Wright        if test_case.is_error():
3860006be33SJames Wright            output_str += f'      error: {test_case.errors[0]["message"]}\n'
3870006be33SJames Wright        if test_case.is_failure():
3880006be33SJames Wright            output_str += f'      num_failures: {len(test_case.failures)}\n'
3890006be33SJames Wright            for i, failure in enumerate(test_case.failures):
3900006be33SJames Wright                output_str += f'      failure_{i}: {failure["message"]}\n'
3910006be33SJames Wright                output_str += f'        message: {failure["message"]}\n'
3920006be33SJames Wright                if failure["output"]:
3930006be33SJames Wright                    out = failure["output"].strip().replace('\n', '\n          ')
3940006be33SJames Wright                    output_str += f'        output: |\n          {out}\n'
3950006be33SJames Wright        output_str += f'      ...\n'
3960006be33SJames Wright    else:
3970006be33SJames Wright        # print error or failure information if JUNIT mode
3980006be33SJames Wright        if test_case.is_error() or test_case.is_failure():
3990006be33SJames Wright            output_str += f'Test: {test} {spec.name}\n'
4000006be33SJames Wright            output_str += f'  $ {test_case.args}\n'
4010006be33SJames Wright            if test_case.is_error():
4020006be33SJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
4030006be33SJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
4040006be33SJames Wright            if test_case.is_failure():
4050006be33SJames Wright                for failure in test_case.failures:
4060006be33SJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
4070006be33SJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
4080006be33SJames Wright    return output_str
4090006be33SJames Wright
4100006be33SJames Wright
4110006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str,
4120006be33SJames Wright             mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase:
4130006be33SJames Wright    """Run a single test case and backend combination
4140006be33SJames Wright
4150006be33SJames Wright    Args:
4160006be33SJames Wright        index (int): Index of backend for current spec
4170006be33SJames Wright        test (str): Path to test
4180006be33SJames Wright        spec (TestSpec): Specification of test case
4190006be33SJames Wright        backend (str): CEED backend
4200006be33SJames Wright        mode (RunMode): Output mode
4210006be33SJames Wright        nproc (int): Number of MPI processes to use when running test case
4220006be33SJames Wright        suite_spec (SuiteSpec): Specification of test suite
4230006be33SJames Wright
4240006be33SJames Wright    Returns:
4250006be33SJames Wright        TestCase: Test case result
4260006be33SJames Wright    """
4270006be33SJames Wright    source_path: Path = suite_spec.get_source_path(test)
4280006be33SJames Wright    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
4290006be33SJames Wright
4300006be33SJames Wright    if '{ceed_resource}' in run_args:
4310006be33SJames Wright        run_args[run_args.index('{ceed_resource}')] = backend
4320006be33SJames Wright    for i, arg in enumerate(run_args):
4330006be33SJames Wright        if '{ceed_resource}' in arg:
4340006be33SJames Wright            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
4350006be33SJames Wright    if '{nproc}' in run_args:
4360006be33SJames Wright        run_args[run_args.index('{nproc}')] = f'{nproc}'
4370006be33SJames Wright    elif nproc > 1 and source_path.suffix != '.py':
4380006be33SJames Wright        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
4390006be33SJames Wright
4400006be33SJames Wright    # run test
4410006be33SJames Wright    skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc)
4420006be33SJames Wright    if skip_reason:
4430006be33SJames Wright        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4440006be33SJames Wright                                       elapsed_sec=0,
4450006be33SJames Wright                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
4460006be33SJames Wright                                       stdout='',
4470006be33SJames Wright                                       stderr='',
4480006be33SJames Wright                                       category=spec.name,)
4490006be33SJames Wright        test_case.add_skipped_info(skip_reason)
4500006be33SJames Wright    else:
4510006be33SJames Wright        start: float = time.time()
4520006be33SJames Wright        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
4530006be33SJames Wright                              shell=True,
4540006be33SJames Wright                              stdout=subprocess.PIPE,
4550006be33SJames Wright                              stderr=subprocess.PIPE,
4560006be33SJames Wright                              env=my_env)
4570006be33SJames Wright
4580006be33SJames Wright        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4590006be33SJames Wright                             classname=source_path.parent,
4600006be33SJames Wright                             elapsed_sec=time.time() - start,
4610006be33SJames Wright                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
4620006be33SJames Wright                             stdout=proc.stdout.decode('utf-8'),
4630006be33SJames Wright                             stderr=proc.stderr.decode('utf-8'),
4640006be33SJames Wright                             allow_multiple_subelements=True,
4650006be33SJames Wright                             category=spec.name,)
4660006be33SJames Wright        ref_csvs: List[Path] = []
4670006be33SJames Wright        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
4680006be33SJames Wright        if output_files:
469*e941b1e9SJames Wright            ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1]) for file in output_files]
4700006be33SJames Wright        ref_cgns: List[Path] = []
4710006be33SJames Wright        output_files = [arg for arg in run_args if 'cgns:' in arg]
4720006be33SJames Wright        if output_files:
4730006be33SJames Wright            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
4740006be33SJames Wright        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
4750006be33SJames Wright        suite_spec.post_test_hook(test, spec)
4760006be33SJames Wright
4770006be33SJames Wright    # check allowed failures
4780006be33SJames Wright    if not test_case.is_skipped() and test_case.stderr:
4790006be33SJames Wright        skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
4800006be33SJames Wright        if skip_reason:
4810006be33SJames Wright            test_case.add_skipped_info(skip_reason)
4820006be33SJames Wright
4830006be33SJames Wright    # check required failures
4840006be33SJames Wright    if not test_case.is_skipped():
4850006be33SJames Wright        required_message, did_fail = suite_spec.check_required_failure(
4860006be33SJames Wright            test, spec, backend, test_case.stderr)
4870006be33SJames Wright        if required_message and did_fail:
4880006be33SJames Wright            test_case.status = f'fails with required: {required_message}'
4890006be33SJames Wright        elif required_message:
4900006be33SJames Wright            test_case.add_failure_info(f'required failure missing: {required_message}')
4910006be33SJames Wright
4920006be33SJames Wright    # classify other results
4930006be33SJames Wright    if not test_case.is_skipped() and not test_case.status:
4940006be33SJames Wright        if test_case.stderr:
4950006be33SJames Wright            test_case.add_failure_info('stderr', test_case.stderr)
4960006be33SJames Wright        if proc.returncode != 0:
4970006be33SJames Wright            test_case.add_error_info(f'returncode = {proc.returncode}')
4980006be33SJames Wright        if ref_stdout.is_file():
4990006be33SJames Wright            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
5000006be33SJames Wright                                             test_case.stdout.splitlines(keepends=True),
5010006be33SJames Wright                                             fromfile=str(ref_stdout),
5020006be33SJames Wright                                             tofile='New'))
5030006be33SJames Wright            if diff:
5040006be33SJames Wright                test_case.add_failure_info('stdout', output=''.join(diff))
5050006be33SJames Wright        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
5060006be33SJames Wright            test_case.add_failure_info('stdout', output=test_case.stdout)
5070006be33SJames Wright        # expected CSV output
5080006be33SJames Wright        for ref_csv in ref_csvs:
5090006be33SJames Wright            csv_name = ref_csv.name
5100006be33SJames Wright            if not ref_csv.is_file():
5110006be33SJames Wright                # remove _{ceed_backend} from path name
5120006be33SJames Wright                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
5130006be33SJames Wright            if not ref_csv.is_file():
5140006be33SJames Wright                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
5150006be33SJames Wright            else:
516*e941b1e9SJames Wright                diff: str = diff_csv(Path.cwd() / csv_name, ref_csv, **suite_spec.diff_csv_kwargs)
5170006be33SJames Wright                if diff:
5180006be33SJames Wright                    test_case.add_failure_info('csv', output=diff)
5190006be33SJames Wright                else:
5200006be33SJames Wright                    (Path.cwd() / csv_name).unlink()
5210006be33SJames Wright        # expected CGNS output
5220006be33SJames Wright        for ref_cgn in ref_cgns:
5230006be33SJames Wright            cgn_name = ref_cgn.name
5240006be33SJames Wright            if not ref_cgn.is_file():
5250006be33SJames Wright                # remove _{ceed_backend} from path name
5260006be33SJames Wright                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
5270006be33SJames Wright            if not ref_cgn.is_file():
5280006be33SJames Wright                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
5290006be33SJames Wright            else:
5300006be33SJames Wright                diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol)
5310006be33SJames Wright                if diff:
5320006be33SJames Wright                    test_case.add_failure_info('cgns', output=diff)
5330006be33SJames Wright                else:
5340006be33SJames Wright                    (Path.cwd() / cgn_name).unlink()
5350006be33SJames Wright
5360006be33SJames Wright    # store result
5370006be33SJames Wright    test_case.args = ' '.join(str(arg) for arg in run_args)
5380006be33SJames Wright    output_str = test_case_output_string(test_case, spec, mode, backend, test, index)
5390006be33SJames Wright
5400006be33SJames Wright    return test_case, output_str
5410006be33SJames Wright
5420006be33SJames Wright
5430006be33SJames Wrightdef init_process():
5440006be33SJames Wright    """Initialize multiprocessing process"""
5450006be33SJames Wright    # set up error handler
5460006be33SJames Wright    global my_env
5470006be33SJames Wright    my_env = os.environ.copy()
5480006be33SJames Wright    my_env['CEED_ERROR_HANDLER'] = 'exit'
5490006be33SJames Wright
5500006be33SJames Wright
5510006be33SJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
5520006be33SJames Wright              suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite:
5530006be33SJames Wright    """Run all test cases for `test` with each of the provided `ceed_backends`
5540006be33SJames Wright
5550006be33SJames Wright    Args:
5560006be33SJames Wright        test (str): Name of test
5570006be33SJames Wright        ceed_backends (List[str]): List of libCEED backends
5580006be33SJames Wright        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
5590006be33SJames Wright        nproc (int): Number of MPI processes to use when running each test case
5600006be33SJames Wright        suite_spec (SuiteSpec): Object defining required methods for running tests
5610006be33SJames Wright        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
5620006be33SJames Wright
5630006be33SJames Wright    Returns:
5640006be33SJames Wright        TestSuite: JUnit `TestSuite` containing results of all test cases
5650006be33SJames Wright    """
5660006be33SJames Wright    test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test))
5670006be33SJames Wright    if mode is RunMode.TAP:
5680006be33SJames Wright        print('TAP version 13')
5690006be33SJames Wright        print(f'1..{len(test_specs)}')
5700006be33SJames Wright
5710006be33SJames Wright    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
5720006be33SJames Wright        async_outputs: List[List[mp.AsyncResult]] = [
5730006be33SJames Wright            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec))
5740006be33SJames Wright             for (i, backend) in enumerate(ceed_backends, start=1)]
5750006be33SJames Wright            for spec in test_specs
5760006be33SJames Wright        ]
5770006be33SJames Wright
5780006be33SJames Wright        test_cases = []
5790006be33SJames Wright        for (i, subtest) in enumerate(async_outputs, start=1):
5800006be33SJames Wright            is_new_subtest = True
5810006be33SJames Wright            subtest_ok = True
5820006be33SJames Wright            for async_output in subtest:
5830006be33SJames Wright                test_case, print_output = async_output.get()
5840006be33SJames Wright                test_cases.append(test_case)
5850006be33SJames Wright                if is_new_subtest and mode == RunMode.TAP:
5860006be33SJames Wright                    is_new_subtest = False
5870006be33SJames Wright                    print(f'# Subtest: {test_case.category}')
5880006be33SJames Wright                    print(f'    1..{len(ceed_backends)}')
5890006be33SJames Wright                print(print_output, end='')
5900006be33SJames Wright                if test_case.is_failure() or test_case.is_error():
5910006be33SJames Wright                    subtest_ok = False
5920006be33SJames Wright            if mode == RunMode.TAP:
5930006be33SJames Wright                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
5940006be33SJames Wright
5950006be33SJames Wright    return TestSuite(test, test_cases)
5960006be33SJames Wright
5970006be33SJames Wright
5980006be33SJames Wrightdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
5990006be33SJames Wright    """Write a JUnit XML file containing the results of a `TestSuite`
6000006be33SJames Wright
6010006be33SJames Wright    Args:
6020006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to write
6030006be33SJames Wright        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
6040006be33SJames Wright        batch (str): Name of JUnit batch, defaults to empty string
6050006be33SJames Wright    """
6060006be33SJames Wright    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
6070006be33SJames Wright    output_file.write_text(to_xml_report_string([test_suite]))
6080006be33SJames Wright
6090006be33SJames Wright
6100006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool:
6110006be33SJames Wright    """Check whether any test cases in a `TestSuite` failed
6120006be33SJames Wright
6130006be33SJames Wright    Args:
6140006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to check
6150006be33SJames Wright
6160006be33SJames Wright    Returns:
6170006be33SJames Wright        bool: True if any test cases failed
6180006be33SJames Wright    """
6190006be33SJames Wright    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
620