11b16049aSZach Atkinsfrom abc import ABC, abstractmethod 2*f36e7531SZach Atkinsfrom collections.abc import Iterable 31b16049aSZach Atkinsimport argparse 469ef23b6SZach Atkinsimport csv 5*f36e7531SZach Atkinsfrom dataclasses import dataclass, field, fields 61b16049aSZach Atkinsimport difflib 71b16049aSZach Atkinsfrom enum import Enum 81b16049aSZach Atkinsfrom math import isclose 91b16049aSZach Atkinsimport os 101b16049aSZach Atkinsfrom pathlib import Path 111b16049aSZach Atkinsimport re 121b16049aSZach Atkinsimport subprocess 1319868e18SZach Atkinsimport multiprocessing as mp 141b16049aSZach Atkinsimport sys 151b16049aSZach Atkinsimport time 16*f36e7531SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin 17*f36e7531SZach Atkinsimport shutil 181b16049aSZach Atkins 191b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml")) 201b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string # nopep8 211b16049aSZach Atkins 221b16049aSZach Atkins 23*f36e7531SZach Atkinsclass ParseError(RuntimeError): 24*f36e7531SZach Atkins """A custom exception for failed parsing.""" 25*f36e7531SZach Atkins 26*f36e7531SZach Atkins def __init__(self, message): 27*f36e7531SZach Atkins super().__init__(message) 28*f36e7531SZach Atkins 29*f36e7531SZach Atkins 301b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action): 311b16049aSZach Atkins """Action to convert input values to lower case prior to converting to an Enum type""" 321b16049aSZach Atkins 331b16049aSZach Atkins def __init__(self, option_strings, dest, type, default, **kwargs): 34*f36e7531SZach Atkins if not issubclass(type, Enum): 35*f36e7531SZach Atkins raise ValueError(f"{type} must be an Enum") 361b16049aSZach Atkins # store provided enum type 371b16049aSZach Atkins self.enum_type = type 38*f36e7531SZach Atkins if isinstance(default, self.enum_type): 39*f36e7531SZach Atkins pass 40*f36e7531SZach Atkins elif isinstance(default, str): 411b16049aSZach Atkins default = self.enum_type(default.lower()) 42*f36e7531SZach Atkins elif isinstance(default, Iterable): 431b16049aSZach Atkins default = [self.enum_type(v.lower()) for v in default] 44*f36e7531SZach Atkins else: 45*f36e7531SZach Atkins raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable") 461b16049aSZach Atkins # prevent automatic type conversion 471b16049aSZach Atkins super().__init__(option_strings, dest, default=default, **kwargs) 481b16049aSZach Atkins 491b16049aSZach Atkins def __call__(self, parser, namespace, values, option_string=None): 50*f36e7531SZach Atkins if isinstance(values, self.enum_type): 51*f36e7531SZach Atkins pass 52*f36e7531SZach Atkins elif isinstance(values, str): 531b16049aSZach Atkins values = self.enum_type(values.lower()) 54*f36e7531SZach Atkins elif isinstance(values, Iterable): 551b16049aSZach Atkins values = [self.enum_type(v.lower()) for v in values] 56*f36e7531SZach Atkins else: 57*f36e7531SZach Atkins raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable") 581b16049aSZach Atkins setattr(namespace, self.dest, values) 591b16049aSZach Atkins 601b16049aSZach Atkins 611b16049aSZach Atkins@dataclass 621b16049aSZach Atkinsclass TestSpec: 631b16049aSZach Atkins """Dataclass storing information about a single test case""" 64*f36e7531SZach Atkins name: str = field(default_factory=str) 65*f36e7531SZach Atkins csv_rtol: float = -1 66*f36e7531SZach Atkins csv_ztol: float = -1 67*f36e7531SZach Atkins cgns_tol: float = -1 688938a869SZach Atkins only: List = field(default_factory=list) 698938a869SZach Atkins args: List = field(default_factory=list) 70*f36e7531SZach Atkins key_values: Dict = field(default_factory=dict) 711b16049aSZach Atkins 721b16049aSZach Atkins 73*f36e7531SZach Atkinsclass RunMode(Enum): 741b16049aSZach Atkins """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 75*f36e7531SZach Atkins TAP = 'tap' 76*f36e7531SZach Atkins JUNIT = 'junit' 77*f36e7531SZach Atkins 78*f36e7531SZach Atkins def __str__(self): 79*f36e7531SZach Atkins return self.value 80*f36e7531SZach Atkins 81*f36e7531SZach Atkins def __repr__(self): 82*f36e7531SZach Atkins return self.value 831b16049aSZach Atkins 841b16049aSZach Atkins 851b16049aSZach Atkinsclass SuiteSpec(ABC): 861b16049aSZach Atkins """Abstract Base Class defining the required interface for running a test suite""" 871b16049aSZach Atkins @abstractmethod 881b16049aSZach Atkins def get_source_path(self, test: str) -> Path: 891b16049aSZach Atkins """Compute path to test source file 901b16049aSZach Atkins 911b16049aSZach Atkins Args: 921b16049aSZach Atkins test (str): Name of test 931b16049aSZach Atkins 941b16049aSZach Atkins Returns: 951b16049aSZach Atkins Path: Path to source file 961b16049aSZach Atkins """ 971b16049aSZach Atkins raise NotImplementedError 981b16049aSZach Atkins 991b16049aSZach Atkins @abstractmethod 1001b16049aSZach Atkins def get_run_path(self, test: str) -> Path: 1011b16049aSZach Atkins """Compute path to built test executable file 1021b16049aSZach Atkins 1031b16049aSZach Atkins Args: 1041b16049aSZach Atkins test (str): Name of test 1051b16049aSZach Atkins 1061b16049aSZach Atkins Returns: 1071b16049aSZach Atkins Path: Path to test executable 1081b16049aSZach Atkins """ 1091b16049aSZach Atkins raise NotImplementedError 1101b16049aSZach Atkins 1111b16049aSZach Atkins @abstractmethod 1121b16049aSZach Atkins def get_output_path(self, test: str, output_file: str) -> Path: 1131b16049aSZach Atkins """Compute path to expected output file 1141b16049aSZach Atkins 1151b16049aSZach Atkins Args: 1161b16049aSZach Atkins test (str): Name of test 1171b16049aSZach Atkins output_file (str): File name of output file 1181b16049aSZach Atkins 1191b16049aSZach Atkins Returns: 1201b16049aSZach Atkins Path: Path to expected output file 1211b16049aSZach Atkins """ 1221b16049aSZach Atkins raise NotImplementedError 1231b16049aSZach Atkins 124c0ad81e5SJeremy L Thompson @property 125*f36e7531SZach Atkins def test_failure_artifacts_path(self) -> Path: 126*f36e7531SZach Atkins """Path to test failure artifacts""" 127*f36e7531SZach Atkins return Path('build') / 'test_failure_artifacts' 128*f36e7531SZach Atkins 129*f36e7531SZach Atkins @property 130c0ad81e5SJeremy L Thompson def cgns_tol(self): 131c0ad81e5SJeremy L Thompson """Absolute tolerance for CGNS diff""" 132c0ad81e5SJeremy L Thompson return getattr(self, '_cgns_tol', 1.0e-12) 13383ebc4c4SJeremy L Thompson 134c0ad81e5SJeremy L Thompson @cgns_tol.setter 135c0ad81e5SJeremy L Thompson def cgns_tol(self, val): 136c0ad81e5SJeremy L Thompson self._cgns_tol = val 13783ebc4c4SJeremy L Thompson 13812235d7fSJames Wright @property 139*f36e7531SZach Atkins def csv_ztol(self): 14012235d7fSJames Wright """Keyword arguments to be passed to diff_csv()""" 141*f36e7531SZach Atkins return getattr(self, '_csv_ztol', 3e-10) 14212235d7fSJames Wright 143*f36e7531SZach Atkins @csv_ztol.setter 144*f36e7531SZach Atkins def csv_ztol(self, val): 145*f36e7531SZach Atkins self._csv_ztol = val 14612235d7fSJames Wright 147*f36e7531SZach Atkins @property 148*f36e7531SZach Atkins def csv_rtol(self): 149*f36e7531SZach Atkins """Keyword arguments to be passed to diff_csv()""" 150*f36e7531SZach Atkins return getattr(self, '_csv_rtol', 1e-6) 151*f36e7531SZach Atkins 152*f36e7531SZach Atkins @csv_rtol.setter 153*f36e7531SZach Atkins def csv_rtol(self, val): 154*f36e7531SZach Atkins self._csv_rtol = val 155*f36e7531SZach Atkins 156*f36e7531SZach Atkins def post_test_hook(self, test: str, spec: TestSpec, backend: str) -> None: 1571b16049aSZach Atkins """Function callback ran after each test case 1581b16049aSZach Atkins 1591b16049aSZach Atkins Args: 1601b16049aSZach Atkins test (str): Name of test 1611b16049aSZach Atkins spec (TestSpec): Test case specification 1621b16049aSZach Atkins """ 1631b16049aSZach Atkins pass 1641b16049aSZach Atkins 1651b16049aSZach Atkins def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]: 1661b16049aSZach Atkins """Check if a test case should be skipped prior to running, returning the reason for skipping 1671b16049aSZach Atkins 1681b16049aSZach Atkins Args: 1691b16049aSZach Atkins test (str): Name of test 1701b16049aSZach Atkins spec (TestSpec): Test case specification 1711b16049aSZach Atkins resource (str): libCEED backend 1721b16049aSZach Atkins nproc (int): Number of MPI processes to use when running test case 1731b16049aSZach Atkins 1741b16049aSZach Atkins Returns: 1751b16049aSZach Atkins Optional[str]: Skip reason, or `None` if test case should not be skipped 1761b16049aSZach Atkins """ 1771b16049aSZach Atkins return None 1781b16049aSZach Atkins 1791b16049aSZach Atkins def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]: 1801b16049aSZach Atkins """Check if a test case should be allowed to fail, based on its stderr output 1811b16049aSZach Atkins 1821b16049aSZach Atkins Args: 1831b16049aSZach Atkins test (str): Name of test 1841b16049aSZach Atkins spec (TestSpec): Test case specification 1851b16049aSZach Atkins resource (str): libCEED backend 1861b16049aSZach Atkins stderr (str): Standard error output from test case execution 1871b16049aSZach Atkins 1881b16049aSZach Atkins Returns: 18919868e18SZach Atkins Optional[str]: Skip reason, or `None` if unexpected error 1901b16049aSZach Atkins """ 1911b16049aSZach Atkins return None 1921b16049aSZach Atkins 19378cb100bSJames Wright def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]: 1941b16049aSZach Atkins """Check whether a test case is expected to fail and if it failed expectedly 1951b16049aSZach Atkins 1961b16049aSZach Atkins Args: 1971b16049aSZach Atkins test (str): Name of test 1981b16049aSZach Atkins spec (TestSpec): Test case specification 1991b16049aSZach Atkins resource (str): libCEED backend 2001b16049aSZach Atkins stderr (str): Standard error output from test case execution 2011b16049aSZach Atkins 2021b16049aSZach Atkins Returns: 2031b16049aSZach Atkins tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr` 2041b16049aSZach Atkins """ 2051b16049aSZach Atkins return '', True 2061b16049aSZach Atkins 2071b16049aSZach Atkins def check_allowed_stdout(self, test: str) -> bool: 2081b16049aSZach Atkins """Check whether a test is allowed to print console output 2091b16049aSZach Atkins 2101b16049aSZach Atkins Args: 2111b16049aSZach Atkins test (str): Name of test 2121b16049aSZach Atkins 2131b16049aSZach Atkins Returns: 2141b16049aSZach Atkins bool: True if the test is allowed to print console output 2151b16049aSZach Atkins """ 2161b16049aSZach Atkins return False 2171b16049aSZach Atkins 2181b16049aSZach Atkins 2191b16049aSZach Atkinsdef has_cgnsdiff() -> bool: 2201b16049aSZach Atkins """Check whether `cgnsdiff` is an executable program in the current environment 2211b16049aSZach Atkins 2221b16049aSZach Atkins Returns: 2231b16049aSZach Atkins bool: True if `cgnsdiff` is found 2241b16049aSZach Atkins """ 2251b16049aSZach Atkins my_env: dict = os.environ.copy() 2261b16049aSZach Atkins proc = subprocess.run('cgnsdiff', 2271b16049aSZach Atkins shell=True, 2281b16049aSZach Atkins stdout=subprocess.PIPE, 2291b16049aSZach Atkins stderr=subprocess.PIPE, 2301b16049aSZach Atkins env=my_env) 2311b16049aSZach Atkins return 'not found' not in proc.stderr.decode('utf-8') 2321b16049aSZach Atkins 2331b16049aSZach Atkins 23478cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool: 2351b16049aSZach Atkins """Helper function, checks if any of the substrings are included in the base string 2361b16049aSZach Atkins 2371b16049aSZach Atkins Args: 2381b16049aSZach Atkins base (str): Base string to search in 2398938a869SZach Atkins substrings (List[str]): List of potential substrings 2401b16049aSZach Atkins 2411b16049aSZach Atkins Returns: 2421b16049aSZach Atkins bool: True if any substrings are included in base string 2431b16049aSZach Atkins """ 2441b16049aSZach Atkins return any((sub in base for sub in substrings)) 2451b16049aSZach Atkins 2461b16049aSZach Atkins 24778cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool: 2481b16049aSZach Atkins """Helper function, checks if the base string is prefixed by any of `prefixes` 2491b16049aSZach Atkins 2501b16049aSZach Atkins Args: 2511b16049aSZach Atkins base (str): Base string to search 2528938a869SZach Atkins prefixes (List[str]): List of potential prefixes 2531b16049aSZach Atkins 2541b16049aSZach Atkins Returns: 2551b16049aSZach Atkins bool: True if base string is prefixed by any of the prefixes 2561b16049aSZach Atkins """ 2571b16049aSZach Atkins return any((base.startswith(prefix) for prefix in prefixes)) 2581b16049aSZach Atkins 2591b16049aSZach Atkins 260*f36e7531SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]: 261*f36e7531SZach Atkins """Find the start and end positions of the first outer paired delimeters 262*f36e7531SZach Atkins 263*f36e7531SZach Atkins Args: 264*f36e7531SZach Atkins line (str): Line to search 265*f36e7531SZach Atkins open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('. 266*f36e7531SZach Atkins close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'. 267*f36e7531SZach Atkins 268*f36e7531SZach Atkins Raises: 269*f36e7531SZach Atkins RuntimeError: If open or close is not a single character 270*f36e7531SZach Atkins RuntimeError: If open and close are the same characters 271*f36e7531SZach Atkins 272*f36e7531SZach Atkins Returns: 273*f36e7531SZach Atkins Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start. 274*f36e7531SZach Atkins """ 275*f36e7531SZach Atkins if len(open) != 1 or len(close) != 1: 276*f36e7531SZach Atkins raise RuntimeError("`open` and `close` must be single characters") 277*f36e7531SZach Atkins if open == close: 278*f36e7531SZach Atkins raise RuntimeError("`open` and `close` must be different characters") 279*f36e7531SZach Atkins start: int = line.find(open) 280*f36e7531SZach Atkins if start < 0: 281*f36e7531SZach Atkins return -1, -1 282*f36e7531SZach Atkins count: int = 1 283*f36e7531SZach Atkins for i in range(start + 1, len(line)): 284*f36e7531SZach Atkins if line[i] == open: 285*f36e7531SZach Atkins count += 1 286*f36e7531SZach Atkins if line[i] == close: 287*f36e7531SZach Atkins count -= 1 288*f36e7531SZach Atkins if count == 0: 289*f36e7531SZach Atkins return start, i 290*f36e7531SZach Atkins return start, -1 291*f36e7531SZach Atkins 292*f36e7531SZach Atkins 2931b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec: 2941b16049aSZach Atkins """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object 2951b16049aSZach Atkins 2961b16049aSZach Atkins Args: 2971b16049aSZach Atkins line (str): String containing TESTARGS specification and CLI arguments 2981b16049aSZach Atkins 2991b16049aSZach Atkins Returns: 3001b16049aSZach Atkins TestSpec: Parsed specification of test case 3011b16049aSZach Atkins """ 302*f36e7531SZach Atkins test_fields = fields(TestSpec) 303*f36e7531SZach Atkins field_names = [f.name for f in test_fields] 304*f36e7531SZach Atkins known: Dict = dict() 305*f36e7531SZach Atkins other: Dict = dict() 306*f36e7531SZach Atkins if line[0] == "(": 307*f36e7531SZach Atkins # have key/value pairs to parse 308*f36e7531SZach Atkins start, end = find_matching(line) 309*f36e7531SZach Atkins if end < start: 310*f36e7531SZach Atkins raise ParseError(f"Mismatched parentheses in TESTCASE: {line}") 311*f36e7531SZach Atkins 312*f36e7531SZach Atkins keyvalues_str = line[start:end + 1] 313*f36e7531SZach Atkins keyvalues_pattern = re.compile(r''' 314*f36e7531SZach Atkins (?:\(\s*|\s*,\s*) # start with open parentheses or comma, no capture 315*f36e7531SZach Atkins ([A-Za-z]+[\w\-]+) # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1 316*f36e7531SZach Atkins \s*=\s* # key is followed by = (whitespace ignored) 317*f36e7531SZach Atkins (?: # uncaptured group for OR 318*f36e7531SZach Atkins "((?:[^"]|\\")+)" # match quoted value (any internal " must be escaped as \"); captured as Group 2 319*f36e7531SZach Atkins | ([^=]+) # OR match unquoted value (no equals signs allowed); captured as Group 3 320*f36e7531SZach Atkins ) # end uncaptured group for OR 321*f36e7531SZach Atkins \s*(?=,|\)) # lookahead for either next comma or closing parentheses 322*f36e7531SZach Atkins ''', re.VERBOSE) 323*f36e7531SZach Atkins 324*f36e7531SZach Atkins for match in re.finditer(keyvalues_pattern, keyvalues_str): 325*f36e7531SZach Atkins if not match: # empty 326*f36e7531SZach Atkins continue 327*f36e7531SZach Atkins key = match.group(1) 328*f36e7531SZach Atkins value = match.group(2) if match.group(2) else match.group(3) 329*f36e7531SZach Atkins try: 330*f36e7531SZach Atkins index = field_names.index(key) 331*f36e7531SZach Atkins if key == "only": # weird bc only is a list 332*f36e7531SZach Atkins value = [constraint.strip() for constraint in value.split(',')] 333*f36e7531SZach Atkins try: 334*f36e7531SZach Atkins # TODO: stop supporting python <=3.8 335*f36e7531SZach Atkins known[key] = test_fields[index].type(value) # type: ignore 336*f36e7531SZach Atkins except TypeError: 337*f36e7531SZach Atkins # TODO: this is still liable to fail for complex types 338*f36e7531SZach Atkins known[key] = get_origin(test_fields[index].type)(value) # type: ignore 339*f36e7531SZach Atkins except ValueError: 340*f36e7531SZach Atkins other[key] = value 341*f36e7531SZach Atkins 342*f36e7531SZach Atkins line = line[end + 1:] 343*f36e7531SZach Atkins 344*f36e7531SZach Atkins args_pattern = re.compile(r''' 345*f36e7531SZach Atkins \s+( # remove leading space 346*f36e7531SZach Atkins (?:"[^"]+") # match quoted CLI option 347*f36e7531SZach Atkins | (?:[\S]+) # match anything else that is space separated 348*f36e7531SZach Atkins ) 349*f36e7531SZach Atkins ''', re.VERBOSE) 350*f36e7531SZach Atkins args: List[str] = re.findall(args_pattern, line) 351*f36e7531SZach Atkins for k, v in other.items(): 352*f36e7531SZach Atkins print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}") 353*f36e7531SZach Atkins return TestSpec(**known, key_values=other, args=args) 3541b16049aSZach Atkins 3551b16049aSZach Atkins 35678cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 3571b16049aSZach Atkins """Parse all test cases from a given source file 3581b16049aSZach Atkins 3591b16049aSZach Atkins Args: 3601b16049aSZach Atkins source_file (Path): Path to source file 3611b16049aSZach Atkins 3621b16049aSZach Atkins Raises: 3631b16049aSZach Atkins RuntimeError: Errors if source file extension is unsupported 3641b16049aSZach Atkins 3651b16049aSZach Atkins Returns: 3668938a869SZach Atkins List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 3671b16049aSZach Atkins """ 3681b16049aSZach Atkins comment_str: str = '' 3698c81f8b0SPeter Munch if source_file.suffix in ['.c', '.cc', '.cpp']: 3701b16049aSZach Atkins comment_str = '//' 3711b16049aSZach Atkins elif source_file.suffix in ['.py']: 3721b16049aSZach Atkins comment_str = '#' 3731b16049aSZach Atkins elif source_file.suffix in ['.usr']: 3741b16049aSZach Atkins comment_str = 'C_' 3751b16049aSZach Atkins elif source_file.suffix in ['.f90']: 3761b16049aSZach Atkins comment_str = '! ' 3771b16049aSZach Atkins else: 3781b16049aSZach Atkins raise RuntimeError(f'Unrecognized extension for file: {source_file}') 3791b16049aSZach Atkins 380*f36e7531SZach Atkins return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS")) 3811b16049aSZach Atkins for line in source_file.read_text().splitlines() 3821b16049aSZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])] 3831b16049aSZach Atkins 3841b16049aSZach Atkins 385*f36e7531SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float, 38612235d7fSJames Wright comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str: 3871b16049aSZach Atkins """Compare CSV results against an expected CSV file with tolerances 3881b16049aSZach Atkins 3891b16049aSZach Atkins Args: 3901b16049aSZach Atkins test_csv (Path): Path to output CSV results 3911b16049aSZach Atkins true_csv (Path): Path to expected CSV results 392*f36e7531SZach Atkins zero_tol (float): Tolerance below which values are considered to be zero. 393*f36e7531SZach Atkins rel_tol (float): Relative tolerance for comparing non-zero values. 39412235d7fSJames Wright comment_str (str, optional): String to denoting commented line 39512235d7fSJames Wright comment_func (Callable, optional): Function to determine if test and true line are different 3961b16049aSZach Atkins 3971b16049aSZach Atkins Returns: 3981b16049aSZach Atkins str: Diff output between result and expected CSVs 3991b16049aSZach Atkins """ 40078cb100bSJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 40178cb100bSJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 40269ef23b6SZach Atkins # Files should not be empty 40369ef23b6SZach Atkins if len(test_lines) == 0: 40469ef23b6SZach Atkins return f'No lines found in test output {test_csv}' 40569ef23b6SZach Atkins if len(true_lines) == 0: 40669ef23b6SZach Atkins return f'No lines found in test source {true_csv}' 40712235d7fSJames Wright if len(test_lines) != len(true_lines): 40812235d7fSJames Wright return f'Number of lines in {test_csv} and {true_csv} do not match' 40912235d7fSJames Wright 41012235d7fSJames Wright # Process commented lines 41112235d7fSJames Wright uncommented_lines: List[int] = [] 41212235d7fSJames Wright for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)): 41312235d7fSJames Wright if test_line[0] == comment_str and true_line[0] == comment_str: 41412235d7fSJames Wright if comment_func: 41512235d7fSJames Wright output = comment_func(test_line, true_line) 41612235d7fSJames Wright if output: 41712235d7fSJames Wright return output 41812235d7fSJames Wright elif test_line[0] == comment_str and true_line[0] != comment_str: 41912235d7fSJames Wright return f'Commented line found in {test_csv} at line {n} but not in {true_csv}' 42012235d7fSJames Wright elif test_line[0] != comment_str and true_line[0] == comment_str: 42112235d7fSJames Wright return f'Commented line found in {true_csv} at line {n} but not in {test_csv}' 42212235d7fSJames Wright else: 42312235d7fSJames Wright uncommented_lines.append(n) 42412235d7fSJames Wright 42512235d7fSJames Wright # Remove commented lines 42612235d7fSJames Wright test_lines = [test_lines[line] for line in uncommented_lines] 42712235d7fSJames Wright true_lines = [true_lines[line] for line in uncommented_lines] 4281b16049aSZach Atkins 42969ef23b6SZach Atkins test_reader: csv.DictReader = csv.DictReader(test_lines) 43069ef23b6SZach Atkins true_reader: csv.DictReader = csv.DictReader(true_lines) 431*f36e7531SZach Atkins if not test_reader.fieldnames: 432*f36e7531SZach Atkins return f'No CSV columns found in test output {test_csv}' 433*f36e7531SZach Atkins if not true_reader.fieldnames: 434*f36e7531SZach Atkins return f'No CSV columns found in test source {true_csv}' 43569ef23b6SZach Atkins if test_reader.fieldnames != true_reader.fieldnames: 4361b16049aSZach Atkins return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 4371b16049aSZach Atkins tofile='found CSV columns', fromfile='expected CSV columns')) 4381b16049aSZach Atkins 43978cb100bSJames Wright diff_lines: List[str] = list() 44069ef23b6SZach Atkins for test_line, true_line in zip(test_reader, true_reader): 44169ef23b6SZach Atkins for key in test_reader.fieldnames: 44269ef23b6SZach Atkins # Check if the value is numeric 44369ef23b6SZach Atkins try: 44469ef23b6SZach Atkins true_val: float = float(true_line[key]) 44569ef23b6SZach Atkins test_val: float = float(test_line[key]) 4461b16049aSZach Atkins true_zero: bool = abs(true_val) < zero_tol 4471b16049aSZach Atkins test_zero: bool = abs(test_val) < zero_tol 4481b16049aSZach Atkins fail: bool = False 4491b16049aSZach Atkins if true_zero: 4501b16049aSZach Atkins fail = not test_zero 4511b16049aSZach Atkins else: 4521b16049aSZach Atkins fail = not isclose(test_val, true_val, rel_tol=rel_tol) 4531b16049aSZach Atkins if fail: 45469ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 45569ef23b6SZach Atkins except ValueError: 45669ef23b6SZach Atkins if test_line[key] != true_line[key]: 45769ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 45869ef23b6SZach Atkins 4591b16049aSZach Atkins return '\n'.join(diff_lines) 4601b16049aSZach Atkins 4611b16049aSZach Atkins 462*f36e7531SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str: 4631b16049aSZach Atkins """Compare CGNS results against an expected CGSN file with tolerance 4641b16049aSZach Atkins 4651b16049aSZach Atkins Args: 4661b16049aSZach Atkins test_cgns (Path): Path to output CGNS file 4671b16049aSZach Atkins true_cgns (Path): Path to expected CGNS file 468*f36e7531SZach Atkins cgns_tol (float): Tolerance for comparing floating-point values 4691b16049aSZach Atkins 4701b16049aSZach Atkins Returns: 4711b16049aSZach Atkins str: Diff output between result and expected CGNS files 4721b16049aSZach Atkins """ 4731b16049aSZach Atkins my_env: dict = os.environ.copy() 4741b16049aSZach Atkins 47583ebc4c4SJeremy L Thompson run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 4761b16049aSZach Atkins proc = subprocess.run(' '.join(run_args), 4771b16049aSZach Atkins shell=True, 4781b16049aSZach Atkins stdout=subprocess.PIPE, 4791b16049aSZach Atkins stderr=subprocess.PIPE, 4801b16049aSZach Atkins env=my_env) 4811b16049aSZach Atkins 4821b16049aSZach Atkins return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 4831b16049aSZach Atkins 4841b16049aSZach Atkins 485*f36e7531SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str: 486*f36e7531SZach Atkins """Compare ASCII results against an expected ASCII file 487*f36e7531SZach Atkins 488*f36e7531SZach Atkins Args: 489*f36e7531SZach Atkins test_file (Path): Path to output ASCII file 490*f36e7531SZach Atkins true_file (Path): Path to expected ASCII file 491*f36e7531SZach Atkins 492*f36e7531SZach Atkins Returns: 493*f36e7531SZach Atkins str: Diff output between result and expected ASCII files 494*f36e7531SZach Atkins """ 495*f36e7531SZach Atkins tmp_backend: str = backend.replace('/', '-') 496*f36e7531SZach Atkins true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend) 497*f36e7531SZach Atkins diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True), 498*f36e7531SZach Atkins true_str.splitlines(keepends=True), 499*f36e7531SZach Atkins fromfile=str(test_file), 500*f36e7531SZach Atkins tofile=str(true_file))) 501*f36e7531SZach Atkins return ''.join(diff) 502*f36e7531SZach Atkins 503*f36e7531SZach Atkins 504e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 505*f36e7531SZach Atkins backend: str, test: str, index: int, verbose: bool) -> str: 506e17e35bbSJames Wright output_str = '' 507e17e35bbSJames Wright if mode is RunMode.TAP: 508e17e35bbSJames Wright # print incremental output if TAP mode 509e17e35bbSJames Wright if test_case.is_skipped(): 510e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 511e17e35bbSJames Wright elif test_case.is_failure() or test_case.is_error(): 512*f36e7531SZach Atkins output_str += f' not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 513e17e35bbSJames Wright else: 514*f36e7531SZach Atkins output_str += f' ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 515*f36e7531SZach Atkins if test_case.is_failure() or test_case.is_error() or verbose: 516e17e35bbSJames Wright output_str += f' ---\n' 517e17e35bbSJames Wright if spec.only: 518e17e35bbSJames Wright output_str += f' only: {",".join(spec.only)}\n' 519e17e35bbSJames Wright output_str += f' args: {test_case.args}\n' 520*f36e7531SZach Atkins if spec.csv_ztol > 0: 521*f36e7531SZach Atkins output_str += f' csv_ztol: {spec.csv_ztol}\n' 522*f36e7531SZach Atkins if spec.csv_rtol > 0: 523*f36e7531SZach Atkins output_str += f' csv_rtol: {spec.csv_rtol}\n' 524*f36e7531SZach Atkins if spec.cgns_tol > 0: 525*f36e7531SZach Atkins output_str += f' cgns_tol: {spec.cgns_tol}\n' 526*f36e7531SZach Atkins for k, v in spec.key_values.items(): 527*f36e7531SZach Atkins output_str += f' {k}: {v}\n' 528e17e35bbSJames Wright if test_case.is_error(): 529e17e35bbSJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 530e17e35bbSJames Wright if test_case.is_failure(): 531*f36e7531SZach Atkins output_str += f' failures:\n' 532e17e35bbSJames Wright for i, failure in enumerate(test_case.failures): 533*f36e7531SZach Atkins output_str += f' -\n' 534e17e35bbSJames Wright output_str += f' message: {failure["message"]}\n' 535e17e35bbSJames Wright if failure["output"]: 536e17e35bbSJames Wright out = failure["output"].strip().replace('\n', '\n ') 537e17e35bbSJames Wright output_str += f' output: |\n {out}\n' 538e17e35bbSJames Wright output_str += f' ...\n' 539e17e35bbSJames Wright else: 540e17e35bbSJames Wright # print error or failure information if JUNIT mode 541e17e35bbSJames Wright if test_case.is_error() or test_case.is_failure(): 542e17e35bbSJames Wright output_str += f'Test: {test} {spec.name}\n' 543e17e35bbSJames Wright output_str += f' $ {test_case.args}\n' 544e17e35bbSJames Wright if test_case.is_error(): 545e17e35bbSJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 546e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 547e17e35bbSJames Wright if test_case.is_failure(): 548e17e35bbSJames Wright for failure in test_case.failures: 549e17e35bbSJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 550e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 551e17e35bbSJames Wright return output_str 552e17e35bbSJames Wright 553e17e35bbSJames Wright 554*f36e7531SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path: 555*f36e7531SZach Atkins """Attach a file to a test case 556*f36e7531SZach Atkins 557*f36e7531SZach Atkins Args: 558*f36e7531SZach Atkins test_case (TestCase): Test case to attach the file to 559*f36e7531SZach Atkins file (Path): Path to the file to attach 560*f36e7531SZach Atkins """ 561*f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / file.name 562*f36e7531SZach Atkins shutil.copyfile(file, save_path) 563*f36e7531SZach Atkins return save_path 564*f36e7531SZach Atkins 565*f36e7531SZach Atkins 56619868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str, 567*f36e7531SZach Atkins mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase: 56819868e18SZach Atkins """Run a single test case and backend combination 5691b16049aSZach Atkins 5701b16049aSZach Atkins Args: 5718938a869SZach Atkins index (int): Index of backend for current spec 57219868e18SZach Atkins test (str): Path to test 57319868e18SZach Atkins spec (TestSpec): Specification of test case 57419868e18SZach Atkins backend (str): CEED backend 57519868e18SZach Atkins mode (RunMode): Output mode 57619868e18SZach Atkins nproc (int): Number of MPI processes to use when running test case 57719868e18SZach Atkins suite_spec (SuiteSpec): Specification of test suite 578*f36e7531SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 5791b16049aSZach Atkins 5801b16049aSZach Atkins Returns: 58119868e18SZach Atkins TestCase: Test case result 5821b16049aSZach Atkins """ 5831b16049aSZach Atkins source_path: Path = suite_spec.get_source_path(test) 5848938a869SZach Atkins run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 5851b16049aSZach Atkins 5861b16049aSZach Atkins if '{ceed_resource}' in run_args: 58719868e18SZach Atkins run_args[run_args.index('{ceed_resource}')] = backend 5888938a869SZach Atkins for i, arg in enumerate(run_args): 5898938a869SZach Atkins if '{ceed_resource}' in arg: 5908938a869SZach Atkins run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 5911b16049aSZach Atkins if '{nproc}' in run_args: 5921b16049aSZach Atkins run_args[run_args.index('{nproc}')] = f'{nproc}' 5931b16049aSZach Atkins elif nproc > 1 and source_path.suffix != '.py': 5941b16049aSZach Atkins run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 5951b16049aSZach Atkins 5961b16049aSZach Atkins # run test 597*f36e7531SZach Atkins skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc) 5981b16049aSZach Atkins if skip_reason: 59919868e18SZach Atkins test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6001b16049aSZach Atkins elapsed_sec=0, 6011b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 6021b16049aSZach Atkins stdout='', 6038938a869SZach Atkins stderr='', 6048938a869SZach Atkins category=spec.name,) 6051b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 6061b16049aSZach Atkins else: 6071b16049aSZach Atkins start: float = time.time() 6081b16049aSZach Atkins proc = subprocess.run(' '.join(str(arg) for arg in run_args), 6091b16049aSZach Atkins shell=True, 6101b16049aSZach Atkins stdout=subprocess.PIPE, 6111b16049aSZach Atkins stderr=subprocess.PIPE, 6121b16049aSZach Atkins env=my_env) 6131b16049aSZach Atkins 61419868e18SZach Atkins test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6151b16049aSZach Atkins classname=source_path.parent, 6161b16049aSZach Atkins elapsed_sec=time.time() - start, 6171b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 6181b16049aSZach Atkins stdout=proc.stdout.decode('utf-8'), 6191b16049aSZach Atkins stderr=proc.stderr.decode('utf-8'), 6208938a869SZach Atkins allow_multiple_subelements=True, 6218938a869SZach Atkins category=spec.name,) 62278cb100bSJames Wright ref_csvs: List[Path] = [] 623*f36e7531SZach Atkins ref_ascii: List[Path] = [] 6248938a869SZach Atkins output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg] 62597fab443SJeremy L Thompson if output_files: 626*f36e7531SZach Atkins ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1]) 627*f36e7531SZach Atkins for file in output_files if file.endswith('.csv')] 628*f36e7531SZach Atkins ref_ascii = [suite_spec.get_output_path(test, file.split(':')[1]) 629*f36e7531SZach Atkins for file in output_files if not file.endswith('.csv')] 63078cb100bSJames Wright ref_cgns: List[Path] = [] 6318938a869SZach Atkins output_files = [arg for arg in run_args if 'cgns:' in arg] 63297fab443SJeremy L Thompson if output_files: 6331b16049aSZach Atkins ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files] 6341b16049aSZach Atkins ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 635*f36e7531SZach Atkins suite_spec.post_test_hook(test, spec, backend) 6361b16049aSZach Atkins 6371b16049aSZach Atkins # check allowed failures 6381b16049aSZach Atkins if not test_case.is_skipped() and test_case.stderr: 639*f36e7531SZach Atkins skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 6401b16049aSZach Atkins if skip_reason: 6411b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 6421b16049aSZach Atkins 6431b16049aSZach Atkins # check required failures 6441b16049aSZach Atkins if not test_case.is_skipped(): 6452fee3251SSebastian Grimberg required_message, did_fail = suite_spec.check_required_failure( 64619868e18SZach Atkins test, spec, backend, test_case.stderr) 6471b16049aSZach Atkins if required_message and did_fail: 6481b16049aSZach Atkins test_case.status = f'fails with required: {required_message}' 6491b16049aSZach Atkins elif required_message: 6501b16049aSZach Atkins test_case.add_failure_info(f'required failure missing: {required_message}') 6511b16049aSZach Atkins 6521b16049aSZach Atkins # classify other results 6531b16049aSZach Atkins if not test_case.is_skipped() and not test_case.status: 6541b16049aSZach Atkins if test_case.stderr: 6551b16049aSZach Atkins test_case.add_failure_info('stderr', test_case.stderr) 6561b16049aSZach Atkins if proc.returncode != 0: 6571b16049aSZach Atkins test_case.add_error_info(f'returncode = {proc.returncode}') 6581b16049aSZach Atkins if ref_stdout.is_file(): 6591b16049aSZach Atkins diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 6601b16049aSZach Atkins test_case.stdout.splitlines(keepends=True), 6611b16049aSZach Atkins fromfile=str(ref_stdout), 6621b16049aSZach Atkins tofile='New')) 6631b16049aSZach Atkins if diff: 6641b16049aSZach Atkins test_case.add_failure_info('stdout', output=''.join(diff)) 6651b16049aSZach Atkins elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 6661b16049aSZach Atkins test_case.add_failure_info('stdout', output=test_case.stdout) 6671b16049aSZach Atkins # expected CSV output 6681b16049aSZach Atkins for ref_csv in ref_csvs: 6698938a869SZach Atkins csv_name = ref_csv.name 670*f36e7531SZach Atkins out_file = Path.cwd() / csv_name 6718938a869SZach Atkins if not ref_csv.is_file(): 6728938a869SZach Atkins # remove _{ceed_backend} from path name 6738938a869SZach Atkins ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 6741b16049aSZach Atkins if not ref_csv.is_file(): 6751b16049aSZach Atkins test_case.add_failure_info('csv', output=f'{ref_csv} not found') 676*f36e7531SZach Atkins elif not out_file.is_file(): 677*f36e7531SZach Atkins test_case.add_failure_info('csv', output=f'{out_file} not found') 6781b16049aSZach Atkins else: 679*f36e7531SZach Atkins csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol 680*f36e7531SZach Atkins csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol 681*f36e7531SZach Atkins diff = diff_csv(out_file, ref_csv, zero_tol=csv_ztol, rel_tol=csv_rtol) 6821b16049aSZach Atkins if diff: 683*f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / csv_name 684*f36e7531SZach Atkins shutil.move(out_file, save_path) 685*f36e7531SZach Atkins test_case.add_failure_info(f'csv: {save_path}', output=diff) 6861b16049aSZach Atkins else: 687*f36e7531SZach Atkins out_file.unlink() 6881b16049aSZach Atkins # expected CGNS output 6891b16049aSZach Atkins for ref_cgn in ref_cgns: 6908938a869SZach Atkins cgn_name = ref_cgn.name 691*f36e7531SZach Atkins out_file = Path.cwd() / cgn_name 6928938a869SZach Atkins if not ref_cgn.is_file(): 6938938a869SZach Atkins # remove _{ceed_backend} from path name 6948938a869SZach Atkins ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 6951b16049aSZach Atkins if not ref_cgn.is_file(): 6961b16049aSZach Atkins test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 697*f36e7531SZach Atkins elif not out_file.is_file(): 698*f36e7531SZach Atkins test_case.add_failure_info('cgns', output=f'{out_file} not found') 6991b16049aSZach Atkins else: 700*f36e7531SZach Atkins cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol 701*f36e7531SZach Atkins diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol) 7021b16049aSZach Atkins if diff: 703*f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name 704*f36e7531SZach Atkins shutil.move(out_file, save_path) 705*f36e7531SZach Atkins test_case.add_failure_info(f'cgns: {save_path}', output=diff) 7061b16049aSZach Atkins else: 707*f36e7531SZach Atkins out_file.unlink() 708*f36e7531SZach Atkins # expected ASCII output 709*f36e7531SZach Atkins for ref_file in ref_ascii: 710*f36e7531SZach Atkins ref_name = ref_file.name 711*f36e7531SZach Atkins out_file = Path.cwd() / ref_name 712*f36e7531SZach Atkins if not ref_file.is_file(): 713*f36e7531SZach Atkins # remove _{ceed_backend} from path name 714*f36e7531SZach Atkins ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix) 715*f36e7531SZach Atkins if not ref_file.is_file(): 716*f36e7531SZach Atkins test_case.add_failure_info('ascii', output=f'{ref_file} not found') 717*f36e7531SZach Atkins elif not out_file.is_file(): 718*f36e7531SZach Atkins test_case.add_failure_info('ascii', output=f'{out_file} not found') 719*f36e7531SZach Atkins else: 720*f36e7531SZach Atkins diff = diff_ascii(out_file, ref_file, backend) 721*f36e7531SZach Atkins if diff: 722*f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / ref_name 723*f36e7531SZach Atkins shutil.move(out_file, save_path) 724*f36e7531SZach Atkins test_case.add_failure_info(f'ascii: {save_path}', output=diff) 725*f36e7531SZach Atkins else: 726*f36e7531SZach Atkins out_file.unlink() 7271b16049aSZach Atkins 7281b16049aSZach Atkins # store result 7291b16049aSZach Atkins test_case.args = ' '.join(str(arg) for arg in run_args) 730*f36e7531SZach Atkins output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose) 73119868e18SZach Atkins 73219868e18SZach Atkins return test_case, output_str 73319868e18SZach Atkins 73419868e18SZach Atkins 73519868e18SZach Atkinsdef init_process(): 73619868e18SZach Atkins """Initialize multiprocessing process""" 73719868e18SZach Atkins # set up error handler 73819868e18SZach Atkins global my_env 73919868e18SZach Atkins my_env = os.environ.copy() 74019868e18SZach Atkins my_env['CEED_ERROR_HANDLER'] = 'exit' 74119868e18SZach Atkins 74219868e18SZach Atkins 74378cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 744*f36e7531SZach Atkins suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite: 74519868e18SZach Atkins """Run all test cases for `test` with each of the provided `ceed_backends` 74619868e18SZach Atkins 74719868e18SZach Atkins Args: 74819868e18SZach Atkins test (str): Name of test 7498938a869SZach Atkins ceed_backends (List[str]): List of libCEED backends 75019868e18SZach Atkins mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 75119868e18SZach Atkins nproc (int): Number of MPI processes to use when running each test case 75219868e18SZach Atkins suite_spec (SuiteSpec): Object defining required methods for running tests 75319868e18SZach Atkins pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 754*f36e7531SZach Atkins search (str, optional): Regular expression used to match tests. Defaults to ".*". 755*f36e7531SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 75619868e18SZach Atkins 75719868e18SZach Atkins Returns: 75819868e18SZach Atkins TestSuite: JUnit `TestSuite` containing results of all test cases 75919868e18SZach Atkins """ 760*f36e7531SZach Atkins test_specs: List[TestSpec] = [ 761*f36e7531SZach Atkins t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE) 762*f36e7531SZach Atkins ] 763*f36e7531SZach Atkins suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True) 76419868e18SZach Atkins if mode is RunMode.TAP: 7658938a869SZach Atkins print('TAP version 13') 7668938a869SZach Atkins print(f'1..{len(test_specs)}') 76719868e18SZach Atkins 76819868e18SZach Atkins with mp.Pool(processes=pool_size, initializer=init_process) as pool: 769*f36e7531SZach Atkins async_outputs: List[List[mp.pool.AsyncResult]] = [ 770*f36e7531SZach Atkins [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose)) 7718938a869SZach Atkins for (i, backend) in enumerate(ceed_backends, start=1)] 7728938a869SZach Atkins for spec in test_specs 7738938a869SZach Atkins ] 77419868e18SZach Atkins 77519868e18SZach Atkins test_cases = [] 7768938a869SZach Atkins for (i, subtest) in enumerate(async_outputs, start=1): 7778938a869SZach Atkins is_new_subtest = True 7788938a869SZach Atkins subtest_ok = True 7798938a869SZach Atkins for async_output in subtest: 78019868e18SZach Atkins test_case, print_output = async_output.get() 78119868e18SZach Atkins test_cases.append(test_case) 7828938a869SZach Atkins if is_new_subtest and mode == RunMode.TAP: 7838938a869SZach Atkins is_new_subtest = False 7848938a869SZach Atkins print(f'# Subtest: {test_case.category}') 7858938a869SZach Atkins print(f' 1..{len(ceed_backends)}') 78619868e18SZach Atkins print(print_output, end='') 7878938a869SZach Atkins if test_case.is_failure() or test_case.is_error(): 7888938a869SZach Atkins subtest_ok = False 7898938a869SZach Atkins if mode == RunMode.TAP: 7908938a869SZach Atkins print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 7911b16049aSZach Atkins 7921b16049aSZach Atkins return TestSuite(test, test_cases) 7931b16049aSZach Atkins 7941b16049aSZach Atkins 7951b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None: 7961b16049aSZach Atkins """Write a JUnit XML file containing the results of a `TestSuite` 7971b16049aSZach Atkins 7981b16049aSZach Atkins Args: 7991b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to write 8001b16049aSZach Atkins output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 8011b16049aSZach Atkins batch (str): Name of JUnit batch, defaults to empty string 8021b16049aSZach Atkins """ 803*f36e7531SZach Atkins output_file = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 8041b16049aSZach Atkins output_file.write_text(to_xml_report_string([test_suite])) 8051b16049aSZach Atkins 8061b16049aSZach Atkins 8071b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool: 8081b16049aSZach Atkins """Check whether any test cases in a `TestSuite` failed 8091b16049aSZach Atkins 8101b16049aSZach Atkins Args: 8111b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to check 8121b16049aSZach Atkins 8131b16049aSZach Atkins Returns: 8141b16049aSZach Atkins bool: True if any test cases failed 8151b16049aSZach Atkins """ 8161b16049aSZach Atkins return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 817