10006be33SJames Wrightfrom abc import ABC, abstractmethod 2*e45c6f40SZach Atkinsfrom collections.abc import Iterable 30006be33SJames Wrightimport argparse 40006be33SJames Wrightimport csv 5*e45c6f40SZach 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 16*e45c6f40SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin 17*e45c6f40SZach 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 23*e45c6f40SZach Atkinsclass ParseError(RuntimeError): 24*e45c6f40SZach Atkins """A custom exception for failed parsing.""" 25*e45c6f40SZach Atkins 26*e45c6f40SZach Atkins def __init__(self, message): 27*e45c6f40SZach Atkins super().__init__(message) 28*e45c6f40SZach Atkins 29*e45c6f40SZach 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): 34*e45c6f40SZach Atkins if not issubclass(type, Enum): 35*e45c6f40SZach Atkins raise ValueError(f"{type} must be an Enum") 360006be33SJames Wright # store provided enum type 370006be33SJames Wright self.enum_type = type 38*e45c6f40SZach Atkins if isinstance(default, self.enum_type): 39*e45c6f40SZach Atkins pass 40*e45c6f40SZach Atkins elif isinstance(default, str): 410006be33SJames Wright default = self.enum_type(default.lower()) 42*e45c6f40SZach Atkins elif isinstance(default, Iterable): 430006be33SJames Wright default = [self.enum_type(v.lower()) for v in default] 44*e45c6f40SZach Atkins else: 45*e45c6f40SZach 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): 50*e45c6f40SZach Atkins if isinstance(values, self.enum_type): 51*e45c6f40SZach Atkins pass 52*e45c6f40SZach Atkins elif isinstance(values, str): 530006be33SJames Wright values = self.enum_type(values.lower()) 54*e45c6f40SZach Atkins elif isinstance(values, Iterable): 550006be33SJames Wright values = [self.enum_type(v.lower()) for v in values] 56*e45c6f40SZach Atkins else: 57*e45c6f40SZach 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""" 64*e45c6f40SZach Atkins name: str = field(default_factory=str) 65*e45c6f40SZach Atkins csv_rtol: float = -1 66*e45c6f40SZach Atkins csv_ztol: float = -1 67*e45c6f40SZach Atkins cgns_tol: float = -1 680006be33SJames Wright only: List = field(default_factory=list) 690006be33SJames Wright args: List = field(default_factory=list) 70*e45c6f40SZach Atkins key_values: Dict = field(default_factory=dict) 710006be33SJames Wright 720006be33SJames Wright 73*e45c6f40SZach Atkinsclass RunMode(Enum): 740006be33SJames Wright """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 75*e45c6f40SZach Atkins TAP = 'tap' 76*e45c6f40SZach Atkins JUNIT = 'junit' 77*e45c6f40SZach Atkins 78*e45c6f40SZach Atkins def __str__(self): 79*e45c6f40SZach Atkins return self.value 80*e45c6f40SZach Atkins 81*e45c6f40SZach Atkins def __repr__(self): 82*e45c6f40SZach 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 125*e45c6f40SZach Atkins def test_failure_artifacts_path(self) -> Path: 126*e45c6f40SZach Atkins """Path to test failure artifacts""" 127*e45c6f40SZach Atkins return Path('build') / 'test_failure_artifacts' 128*e45c6f40SZach Atkins 129*e45c6f40SZach 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 139*e45c6f40SZach Atkins def csv_ztol(self): 140e941b1e9SJames Wright """Keyword arguments to be passed to diff_csv()""" 141*e45c6f40SZach Atkins return getattr(self, '_csv_ztol', 3e-10) 142e941b1e9SJames Wright 143*e45c6f40SZach Atkins @csv_ztol.setter 144*e45c6f40SZach Atkins def csv_ztol(self, val): 145*e45c6f40SZach Atkins self._csv_ztol = val 146e941b1e9SJames Wright 147*e45c6f40SZach Atkins @property 148*e45c6f40SZach Atkins def csv_rtol(self): 149*e45c6f40SZach Atkins """Keyword arguments to be passed to diff_csv()""" 150*e45c6f40SZach Atkins return getattr(self, '_csv_rtol', 1e-6) 151*e45c6f40SZach Atkins 152*e45c6f40SZach Atkins @csv_rtol.setter 153*e45c6f40SZach Atkins def csv_rtol(self, val): 154*e45c6f40SZach Atkins self._csv_rtol = val 155*e45c6f40SZach Atkins 156*e45c6f40SZach Atkins @property 157*e45c6f40SZach Atkins def csv_comment_diff_fn(self): # -> Any | Callable[..., None]: 158*e45c6f40SZach Atkins return getattr(self, '_csv_comment_diff_fn', None) 159*e45c6f40SZach Atkins 160*e45c6f40SZach Atkins @csv_comment_diff_fn.setter 161*e45c6f40SZach Atkins def csv_comment_diff_fn(self, test_fn): 162*e45c6f40SZach Atkins self._csv_comment_diff_fn = test_fn 163*e45c6f40SZach Atkins 164*e45c6f40SZach Atkins @property 165*e45c6f40SZach Atkins def csv_comment_str(self): 166*e45c6f40SZach Atkins return getattr(self, '_csv_comment_str', '#') 167*e45c6f40SZach Atkins 168*e45c6f40SZach Atkins @csv_comment_str.setter 169*e45c6f40SZach Atkins def csv_comment_str(self, comment_str): 170*e45c6f40SZach Atkins self._csv_comment_str = comment_str 171*e45c6f40SZach Atkins 172*e45c6f40SZach 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 276*e45c6f40SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]: 277*e45c6f40SZach Atkins """Find the start and end positions of the first outer paired delimeters 278*e45c6f40SZach Atkins 279*e45c6f40SZach Atkins Args: 280*e45c6f40SZach Atkins line (str): Line to search 281*e45c6f40SZach Atkins open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('. 282*e45c6f40SZach Atkins close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'. 283*e45c6f40SZach Atkins 284*e45c6f40SZach Atkins Raises: 285*e45c6f40SZach Atkins RuntimeError: If open or close is not a single character 286*e45c6f40SZach Atkins RuntimeError: If open and close are the same characters 287*e45c6f40SZach Atkins 288*e45c6f40SZach Atkins Returns: 289*e45c6f40SZach Atkins Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start. 290*e45c6f40SZach Atkins """ 291*e45c6f40SZach Atkins if len(open) != 1 or len(close) != 1: 292*e45c6f40SZach Atkins raise RuntimeError("`open` and `close` must be single characters") 293*e45c6f40SZach Atkins if open == close: 294*e45c6f40SZach Atkins raise RuntimeError("`open` and `close` must be different characters") 295*e45c6f40SZach Atkins start: int = line.find(open) 296*e45c6f40SZach Atkins if start < 0: 297*e45c6f40SZach Atkins return -1, -1 298*e45c6f40SZach Atkins count: int = 1 299*e45c6f40SZach Atkins for i in range(start + 1, len(line)): 300*e45c6f40SZach Atkins if line[i] == open: 301*e45c6f40SZach Atkins count += 1 302*e45c6f40SZach Atkins if line[i] == close: 303*e45c6f40SZach Atkins count -= 1 304*e45c6f40SZach Atkins if count == 0: 305*e45c6f40SZach Atkins return start, i 306*e45c6f40SZach Atkins return start, -1 307*e45c6f40SZach Atkins 308*e45c6f40SZach Atkins 309*e45c6f40SZach 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 """ 318*e45c6f40SZach Atkins test_fields = fields(TestSpec) 319*e45c6f40SZach Atkins field_names = [f.name for f in test_fields] 320*e45c6f40SZach Atkins known: Dict = dict() 321*e45c6f40SZach Atkins other: Dict = dict() 322*e45c6f40SZach Atkins if line[0] == "(": 323*e45c6f40SZach Atkins # have key/value pairs to parse 324*e45c6f40SZach Atkins start, end = find_matching(line) 325*e45c6f40SZach Atkins if end < start: 326*e45c6f40SZach Atkins raise ParseError(f"Mismatched parentheses in TESTCASE: {line}") 327*e45c6f40SZach Atkins 328*e45c6f40SZach Atkins keyvalues_str = line[start:end + 1] 329*e45c6f40SZach Atkins keyvalues_pattern = re.compile(r''' 330*e45c6f40SZach Atkins (?:\(\s*|\s*,\s*) # start with open parentheses or comma, no capture 331*e45c6f40SZach Atkins ([A-Za-z]+[\w\-]+) # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1 332*e45c6f40SZach Atkins \s*=\s* # key is followed by = (whitespace ignored) 333*e45c6f40SZach Atkins (?: # uncaptured group for OR 334*e45c6f40SZach Atkins "((?:[^"]|\\")+)" # match quoted value (any internal " must be escaped as \"); captured as Group 2 335*e45c6f40SZach Atkins | ([^=]+) # OR match unquoted value (no equals signs allowed); captured as Group 3 336*e45c6f40SZach Atkins ) # end uncaptured group for OR 337*e45c6f40SZach Atkins \s*(?=,|\)) # lookahead for either next comma or closing parentheses 338*e45c6f40SZach Atkins ''', re.VERBOSE) 339*e45c6f40SZach Atkins 340*e45c6f40SZach Atkins for match in re.finditer(keyvalues_pattern, keyvalues_str): 341*e45c6f40SZach Atkins if not match: # empty 342*e45c6f40SZach Atkins continue 343*e45c6f40SZach Atkins key = match.group(1) 344*e45c6f40SZach Atkins value = match.group(2) if match.group(2) else match.group(3) 345*e45c6f40SZach Atkins try: 346*e45c6f40SZach Atkins index = field_names.index(key) 347*e45c6f40SZach Atkins if key == "only": # weird bc only is a list 348*e45c6f40SZach Atkins value = [constraint.strip() for constraint in value.split(',')] 349*e45c6f40SZach Atkins try: 350*e45c6f40SZach Atkins # TODO: stop supporting python <=3.8 351*e45c6f40SZach Atkins known[key] = test_fields[index].type(value) # type: ignore 352*e45c6f40SZach Atkins except TypeError: 353*e45c6f40SZach Atkins # TODO: this is still liable to fail for complex types 354*e45c6f40SZach Atkins known[key] = get_origin(test_fields[index].type)(value) # type: ignore 355*e45c6f40SZach Atkins except ValueError: 356*e45c6f40SZach Atkins other[key] = value 357*e45c6f40SZach Atkins 358*e45c6f40SZach Atkins line = line[end + 1:] 359*e45c6f40SZach Atkins 360*e45c6f40SZach Atkins if not 'name' in known.keys(): 361*e45c6f40SZach Atkins known['name'] = fallback_name 362*e45c6f40SZach Atkins 363*e45c6f40SZach Atkins args_pattern = re.compile(r''' 364*e45c6f40SZach Atkins \s+( # remove leading space 365*e45c6f40SZach Atkins (?:"[^"]+") # match quoted CLI option 366*e45c6f40SZach Atkins | (?:[\S]+) # match anything else that is space separated 367*e45c6f40SZach Atkins ) 368*e45c6f40SZach Atkins ''', re.VERBOSE) 369*e45c6f40SZach Atkins args: List[str] = re.findall(args_pattern, line) 370*e45c6f40SZach Atkins for k, v in other.items(): 371*e45c6f40SZach Atkins print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}") 372*e45c6f40SZach 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 399*e45c6f40SZach Atkins return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem) 4000006be33SJames Wright for line in source_file.read_text().splitlines() 401*e45c6f40SZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])] 4020006be33SJames Wright 4030006be33SJames Wright 404*e45c6f40SZach 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 411*e45c6f40SZach Atkins zero_tol (float): Tolerance below which values are considered to be zero. 412*e45c6f40SZach 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) 450*e45c6f40SZach Atkins if not test_reader.fieldnames: 451*e45c6f40SZach Atkins return f'No CSV columns found in test output {test_csv}' 452*e45c6f40SZach Atkins if not true_reader.fieldnames: 453*e45c6f40SZach 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 481*e45c6f40SZach 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 487*e45c6f40SZach 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 504*e45c6f40SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str: 505*e45c6f40SZach Atkins """Compare ASCII results against an expected ASCII file 506*e45c6f40SZach Atkins 507*e45c6f40SZach Atkins Args: 508*e45c6f40SZach Atkins test_file (Path): Path to output ASCII file 509*e45c6f40SZach Atkins true_file (Path): Path to expected ASCII file 510*e45c6f40SZach Atkins 511*e45c6f40SZach Atkins Returns: 512*e45c6f40SZach Atkins str: Diff output between result and expected ASCII files 513*e45c6f40SZach Atkins """ 514*e45c6f40SZach Atkins tmp_backend: str = backend.replace('/', '-') 515*e45c6f40SZach Atkins true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend) 516*e45c6f40SZach Atkins diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True), 517*e45c6f40SZach Atkins true_str.splitlines(keepends=True), 518*e45c6f40SZach Atkins fromfile=str(test_file), 519*e45c6f40SZach Atkins tofile=str(true_file))) 520*e45c6f40SZach Atkins return ''.join(diff) 521*e45c6f40SZach Atkins 522*e45c6f40SZach Atkins 5230006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 524*e45c6f40SZach 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(): 531*e45c6f40SZach Atkins output_str += f' not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 5320006be33SJames Wright else: 533*e45c6f40SZach Atkins output_str += f' ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 534*e45c6f40SZach 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' 539*e45c6f40SZach Atkins if spec.csv_ztol > 0: 540*e45c6f40SZach Atkins output_str += f' csv_ztol: {spec.csv_ztol}\n' 541*e45c6f40SZach Atkins if spec.csv_rtol > 0: 542*e45c6f40SZach Atkins output_str += f' csv_rtol: {spec.csv_rtol}\n' 543*e45c6f40SZach Atkins if spec.cgns_tol > 0: 544*e45c6f40SZach Atkins output_str += f' cgns_tol: {spec.cgns_tol}\n' 545*e45c6f40SZach Atkins for k, v in spec.key_values.items(): 546*e45c6f40SZach 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(): 550*e45c6f40SZach Atkins output_str += f' failures:\n' 5510006be33SJames Wright for i, failure in enumerate(test_case.failures): 552*e45c6f40SZach 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 573*e45c6f40SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path: 574*e45c6f40SZach Atkins """Attach a file to a test case 575*e45c6f40SZach Atkins 576*e45c6f40SZach Atkins Args: 577*e45c6f40SZach Atkins test_case (TestCase): Test case to attach the file to 578*e45c6f40SZach Atkins file (Path): Path to the file to attach 579*e45c6f40SZach Atkins """ 580*e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / file.name 581*e45c6f40SZach Atkins shutil.copyfile(file, save_path) 582*e45c6f40SZach Atkins return save_path 583*e45c6f40SZach Atkins 584*e45c6f40SZach Atkins 5850006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str, 586*e45c6f40SZach 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 597*e45c6f40SZach 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 616*e45c6f40SZach 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] = [] 642*e45c6f40SZach Atkins ref_ascii: List[Path] = [] 643*e45c6f40SZach Atkins output_files: List[str] = [arg.split(':')[1] for arg in run_args if arg.startswith('ascii:')] 6440006be33SJames Wright if output_files: 645*e45c6f40SZach Atkins ref_csvs = [suite_spec.get_output_path(test, file) 646*e45c6f40SZach Atkins for file in output_files if file.endswith('.csv')] 647*e45c6f40SZach Atkins ref_ascii = [suite_spec.get_output_path(test, file) 648*e45c6f40SZach Atkins for file in output_files if not file.endswith('.csv')] 6490006be33SJames Wright ref_cgns: List[Path] = [] 650*e45c6f40SZach Atkins output_files = [arg.split(':')[1] for arg in run_args if arg.startswith('cgns:')] 6510006be33SJames Wright if output_files: 652*e45c6f40SZach 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') 654*e45c6f40SZach 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: 658*e45c6f40SZach 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 689*e45c6f40SZach 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') 695*e45c6f40SZach Atkins elif not out_file.is_file(): 696*e45c6f40SZach Atkins test_case.add_failure_info('csv', output=f'{out_file} not found') 6970006be33SJames Wright else: 698*e45c6f40SZach Atkins csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol 699*e45c6f40SZach Atkins csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol 700*e45c6f40SZach Atkins diff = diff_csv( 701*e45c6f40SZach Atkins out_file, 702*e45c6f40SZach Atkins ref_csv, 703*e45c6f40SZach Atkins csv_ztol, 704*e45c6f40SZach Atkins csv_rtol, 705*e45c6f40SZach Atkins suite_spec.csv_comment_str, 706*e45c6f40SZach Atkins suite_spec.csv_comment_diff_fn) 7070006be33SJames Wright if diff: 708*e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / csv_name 709*e45c6f40SZach Atkins shutil.move(out_file, save_path) 710*e45c6f40SZach Atkins test_case.add_failure_info(f'csv: {save_path}', output=diff) 7110006be33SJames Wright else: 712*e45c6f40SZach Atkins out_file.unlink() 7130006be33SJames Wright # expected CGNS output 7140006be33SJames Wright for ref_cgn in ref_cgns: 7150006be33SJames Wright cgn_name = ref_cgn.name 716*e45c6f40SZach 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') 722*e45c6f40SZach Atkins elif not out_file.is_file(): 723*e45c6f40SZach Atkins test_case.add_failure_info('cgns', output=f'{out_file} not found') 7240006be33SJames Wright else: 725*e45c6f40SZach Atkins cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol 726*e45c6f40SZach Atkins diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol) 7270006be33SJames Wright if diff: 728*e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name 729*e45c6f40SZach Atkins shutil.move(out_file, save_path) 730*e45c6f40SZach Atkins test_case.add_failure_info(f'cgns: {save_path}', output=diff) 7310006be33SJames Wright else: 732*e45c6f40SZach Atkins out_file.unlink() 733*e45c6f40SZach Atkins # expected ASCII output 734*e45c6f40SZach Atkins for ref_file in ref_ascii: 735*e45c6f40SZach Atkins ref_name = ref_file.name 736*e45c6f40SZach Atkins out_file = Path.cwd() / ref_name 737*e45c6f40SZach Atkins if not ref_file.is_file(): 738*e45c6f40SZach Atkins # remove _{ceed_backend} from path name 739*e45c6f40SZach Atkins ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix) 740*e45c6f40SZach Atkins if not ref_file.is_file(): 741*e45c6f40SZach Atkins test_case.add_failure_info('ascii', output=f'{ref_file} not found') 742*e45c6f40SZach Atkins elif not out_file.is_file(): 743*e45c6f40SZach Atkins test_case.add_failure_info('ascii', output=f'{out_file} not found') 744*e45c6f40SZach Atkins else: 745*e45c6f40SZach Atkins diff = diff_ascii(out_file, ref_file, backend) 746*e45c6f40SZach Atkins if diff: 747*e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / ref_name 748*e45c6f40SZach Atkins shutil.move(out_file, save_path) 749*e45c6f40SZach Atkins test_case.add_failure_info(f'ascii: {save_path}', output=diff) 750*e45c6f40SZach Atkins else: 751*e45c6f40SZach Atkins out_file.unlink() 7520006be33SJames Wright 7530006be33SJames Wright # store result 7540006be33SJames Wright test_case.args = ' '.join(str(arg) for arg in run_args) 755*e45c6f40SZach 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, 769*e45c6f40SZach 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. 779*e45c6f40SZach Atkins search (str, optional): Regular expression used to match tests. Defaults to ".*". 780*e45c6f40SZach 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 """ 785*e45c6f40SZach Atkins test_specs: List[TestSpec] = [ 786*e45c6f40SZach Atkins t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE) 787*e45c6f40SZach Atkins ] 788*e45c6f40SZach 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: 794*e45c6f40SZach Atkins async_outputs: List[List[mp.pool.AsyncResult]] = [ 795*e45c6f40SZach 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 8200006be33SJames Wrightdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], 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 output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 8260006be33SJames Wright batch (str): Name of JUnit batch, defaults to empty string 8270006be33SJames Wright """ 828*e45c6f40SZach Atkins output_file = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 8290006be33SJames Wright output_file.write_text(to_xml_report_string([test_suite])) 8300006be33SJames Wright 8310006be33SJames Wright 8320006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool: 8330006be33SJames Wright """Check whether any test cases in a `TestSuite` failed 8340006be33SJames Wright 8350006be33SJames Wright Args: 8360006be33SJames Wright test_suite (TestSuite): JUnit `TestSuite` to check 8370006be33SJames Wright 8380006be33SJames Wright Returns: 8390006be33SJames Wright bool: True if any test cases failed 8400006be33SJames Wright """ 8410006be33SJames Wright return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 842