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