11b16049aSZach Atkinsfrom abc import ABC, abstractmethod 2f36e7531SZach Atkinsfrom collections.abc import Iterable 31b16049aSZach Atkinsimport argparse 469ef23b6SZach Atkinsimport csv 5f36e7531SZach 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 16f36e7531SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin 17f36e7531SZach 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 23f36e7531SZach Atkinsclass ParseError(RuntimeError): 24f36e7531SZach Atkins """A custom exception for failed parsing.""" 25f36e7531SZach Atkins 26f36e7531SZach Atkins def __init__(self, message): 27f36e7531SZach Atkins super().__init__(message) 28f36e7531SZach Atkins 29f36e7531SZach 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): 34f36e7531SZach Atkins if not issubclass(type, Enum): 35f36e7531SZach Atkins raise ValueError(f"{type} must be an Enum") 361b16049aSZach Atkins # store provided enum type 371b16049aSZach Atkins self.enum_type = type 38f36e7531SZach Atkins if isinstance(default, self.enum_type): 39f36e7531SZach Atkins pass 40f36e7531SZach Atkins elif isinstance(default, str): 411b16049aSZach Atkins default = self.enum_type(default.lower()) 42f36e7531SZach Atkins elif isinstance(default, Iterable): 431b16049aSZach Atkins default = [self.enum_type(v.lower()) for v in default] 44f36e7531SZach Atkins else: 45f36e7531SZach 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): 50f36e7531SZach Atkins if isinstance(values, self.enum_type): 51f36e7531SZach Atkins pass 52f36e7531SZach Atkins elif isinstance(values, str): 531b16049aSZach Atkins values = self.enum_type(values.lower()) 54f36e7531SZach Atkins elif isinstance(values, Iterable): 551b16049aSZach Atkins values = [self.enum_type(v.lower()) for v in values] 56f36e7531SZach Atkins else: 57f36e7531SZach 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""" 64f36e7531SZach Atkins name: str = field(default_factory=str) 65f36e7531SZach Atkins csv_rtol: float = -1 66f36e7531SZach Atkins csv_ztol: float = -1 67f36e7531SZach Atkins cgns_tol: float = -1 688938a869SZach Atkins only: List = field(default_factory=list) 698938a869SZach Atkins args: List = field(default_factory=list) 70f36e7531SZach Atkins key_values: Dict = field(default_factory=dict) 711b16049aSZach Atkins 721b16049aSZach Atkins 73f36e7531SZach Atkinsclass RunMode(Enum): 741b16049aSZach Atkins """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 75f36e7531SZach Atkins TAP = 'tap' 76f36e7531SZach Atkins JUNIT = 'junit' 77f36e7531SZach Atkins 78f36e7531SZach Atkins def __str__(self): 79f36e7531SZach Atkins return self.value 80f36e7531SZach Atkins 81f36e7531SZach Atkins def __repr__(self): 82f36e7531SZach 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 125f36e7531SZach Atkins def test_failure_artifacts_path(self) -> Path: 126f36e7531SZach Atkins """Path to test failure artifacts""" 127f36e7531SZach Atkins return Path('build') / 'test_failure_artifacts' 128f36e7531SZach Atkins 129f36e7531SZach 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 139f36e7531SZach Atkins def csv_ztol(self): 14012235d7fSJames Wright """Keyword arguments to be passed to diff_csv()""" 141f36e7531SZach Atkins return getattr(self, '_csv_ztol', 3e-10) 14212235d7fSJames Wright 143f36e7531SZach Atkins @csv_ztol.setter 144f36e7531SZach Atkins def csv_ztol(self, val): 145f36e7531SZach Atkins self._csv_ztol = val 14612235d7fSJames Wright 147f36e7531SZach Atkins @property 148f36e7531SZach Atkins def csv_rtol(self): 149f36e7531SZach Atkins """Keyword arguments to be passed to diff_csv()""" 150f36e7531SZach Atkins return getattr(self, '_csv_rtol', 1e-6) 151f36e7531SZach Atkins 152f36e7531SZach Atkins @csv_rtol.setter 153f36e7531SZach Atkins def csv_rtol(self, val): 154f36e7531SZach Atkins self._csv_rtol = val 155f36e7531SZach Atkins 156f36e7531SZach 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 260f36e7531SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]: 261f36e7531SZach Atkins """Find the start and end positions of the first outer paired delimeters 262f36e7531SZach Atkins 263f36e7531SZach Atkins Args: 264f36e7531SZach Atkins line (str): Line to search 265f36e7531SZach Atkins open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('. 266f36e7531SZach Atkins close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'. 267f36e7531SZach Atkins 268f36e7531SZach Atkins Raises: 269f36e7531SZach Atkins RuntimeError: If open or close is not a single character 270f36e7531SZach Atkins RuntimeError: If open and close are the same characters 271f36e7531SZach Atkins 272f36e7531SZach Atkins Returns: 273f36e7531SZach Atkins Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start. 274f36e7531SZach Atkins """ 275f36e7531SZach Atkins if len(open) != 1 or len(close) != 1: 276f36e7531SZach Atkins raise RuntimeError("`open` and `close` must be single characters") 277f36e7531SZach Atkins if open == close: 278f36e7531SZach Atkins raise RuntimeError("`open` and `close` must be different characters") 279f36e7531SZach Atkins start: int = line.find(open) 280f36e7531SZach Atkins if start < 0: 281f36e7531SZach Atkins return -1, -1 282f36e7531SZach Atkins count: int = 1 283f36e7531SZach Atkins for i in range(start + 1, len(line)): 284f36e7531SZach Atkins if line[i] == open: 285f36e7531SZach Atkins count += 1 286f36e7531SZach Atkins if line[i] == close: 287f36e7531SZach Atkins count -= 1 288f36e7531SZach Atkins if count == 0: 289f36e7531SZach Atkins return start, i 290f36e7531SZach Atkins return start, -1 291f36e7531SZach Atkins 292f36e7531SZach Atkins 293*7b1ec880SZach Atkinsdef parse_test_line(line: str, fallback_name: 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 """ 302f36e7531SZach Atkins test_fields = fields(TestSpec) 303f36e7531SZach Atkins field_names = [f.name for f in test_fields] 304f36e7531SZach Atkins known: Dict = dict() 305f36e7531SZach Atkins other: Dict = dict() 306f36e7531SZach Atkins if line[0] == "(": 307f36e7531SZach Atkins # have key/value pairs to parse 308f36e7531SZach Atkins start, end = find_matching(line) 309f36e7531SZach Atkins if end < start: 310f36e7531SZach Atkins raise ParseError(f"Mismatched parentheses in TESTCASE: {line}") 311f36e7531SZach Atkins 312f36e7531SZach Atkins keyvalues_str = line[start:end + 1] 313f36e7531SZach Atkins keyvalues_pattern = re.compile(r''' 314f36e7531SZach Atkins (?:\(\s*|\s*,\s*) # start with open parentheses or comma, no capture 315f36e7531SZach Atkins ([A-Za-z]+[\w\-]+) # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1 316f36e7531SZach Atkins \s*=\s* # key is followed by = (whitespace ignored) 317f36e7531SZach Atkins (?: # uncaptured group for OR 318f36e7531SZach Atkins "((?:[^"]|\\")+)" # match quoted value (any internal " must be escaped as \"); captured as Group 2 319f36e7531SZach Atkins | ([^=]+) # OR match unquoted value (no equals signs allowed); captured as Group 3 320f36e7531SZach Atkins ) # end uncaptured group for OR 321f36e7531SZach Atkins \s*(?=,|\)) # lookahead for either next comma or closing parentheses 322f36e7531SZach Atkins ''', re.VERBOSE) 323f36e7531SZach Atkins 324f36e7531SZach Atkins for match in re.finditer(keyvalues_pattern, keyvalues_str): 325f36e7531SZach Atkins if not match: # empty 326f36e7531SZach Atkins continue 327f36e7531SZach Atkins key = match.group(1) 328f36e7531SZach Atkins value = match.group(2) if match.group(2) else match.group(3) 329f36e7531SZach Atkins try: 330f36e7531SZach Atkins index = field_names.index(key) 331f36e7531SZach Atkins if key == "only": # weird bc only is a list 332f36e7531SZach Atkins value = [constraint.strip() for constraint in value.split(',')] 333f36e7531SZach Atkins try: 334f36e7531SZach Atkins # TODO: stop supporting python <=3.8 335f36e7531SZach Atkins known[key] = test_fields[index].type(value) # type: ignore 336f36e7531SZach Atkins except TypeError: 337f36e7531SZach Atkins # TODO: this is still liable to fail for complex types 338f36e7531SZach Atkins known[key] = get_origin(test_fields[index].type)(value) # type: ignore 339f36e7531SZach Atkins except ValueError: 340f36e7531SZach Atkins other[key] = value 341f36e7531SZach Atkins 342f36e7531SZach Atkins line = line[end + 1:] 343f36e7531SZach Atkins 344*7b1ec880SZach Atkins if not 'name' in known.keys(): 345*7b1ec880SZach Atkins known['name'] = fallback_name 346*7b1ec880SZach Atkins 347f36e7531SZach Atkins args_pattern = re.compile(r''' 348f36e7531SZach Atkins \s+( # remove leading space 349f36e7531SZach Atkins (?:"[^"]+") # match quoted CLI option 350f36e7531SZach Atkins | (?:[\S]+) # match anything else that is space separated 351f36e7531SZach Atkins ) 352f36e7531SZach Atkins ''', re.VERBOSE) 353f36e7531SZach Atkins args: List[str] = re.findall(args_pattern, line) 354f36e7531SZach Atkins for k, v in other.items(): 355f36e7531SZach Atkins print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}") 356f36e7531SZach Atkins return TestSpec(**known, key_values=other, args=args) 3571b16049aSZach Atkins 3581b16049aSZach Atkins 35978cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 3601b16049aSZach Atkins """Parse all test cases from a given source file 3611b16049aSZach Atkins 3621b16049aSZach Atkins Args: 3631b16049aSZach Atkins source_file (Path): Path to source file 3641b16049aSZach Atkins 3651b16049aSZach Atkins Raises: 3661b16049aSZach Atkins RuntimeError: Errors if source file extension is unsupported 3671b16049aSZach Atkins 3681b16049aSZach Atkins Returns: 3698938a869SZach Atkins List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 3701b16049aSZach Atkins """ 3711b16049aSZach Atkins comment_str: str = '' 3728c81f8b0SPeter Munch if source_file.suffix in ['.c', '.cc', '.cpp']: 3731b16049aSZach Atkins comment_str = '//' 3741b16049aSZach Atkins elif source_file.suffix in ['.py']: 3751b16049aSZach Atkins comment_str = '#' 3761b16049aSZach Atkins elif source_file.suffix in ['.usr']: 3771b16049aSZach Atkins comment_str = 'C_' 3781b16049aSZach Atkins elif source_file.suffix in ['.f90']: 3791b16049aSZach Atkins comment_str = '! ' 3801b16049aSZach Atkins else: 3811b16049aSZach Atkins raise RuntimeError(f'Unrecognized extension for file: {source_file}') 3821b16049aSZach Atkins 383*7b1ec880SZach Atkins return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem) 3841b16049aSZach Atkins for line in source_file.read_text().splitlines() 385*7b1ec880SZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])] 3861b16049aSZach Atkins 3871b16049aSZach Atkins 388f36e7531SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float, 38912235d7fSJames Wright comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str: 3901b16049aSZach Atkins """Compare CSV results against an expected CSV file with tolerances 3911b16049aSZach Atkins 3921b16049aSZach Atkins Args: 3931b16049aSZach Atkins test_csv (Path): Path to output CSV results 3941b16049aSZach Atkins true_csv (Path): Path to expected CSV results 395f36e7531SZach Atkins zero_tol (float): Tolerance below which values are considered to be zero. 396f36e7531SZach Atkins rel_tol (float): Relative tolerance for comparing non-zero values. 39712235d7fSJames Wright comment_str (str, optional): String to denoting commented line 39812235d7fSJames Wright comment_func (Callable, optional): Function to determine if test and true line are different 3991b16049aSZach Atkins 4001b16049aSZach Atkins Returns: 4011b16049aSZach Atkins str: Diff output between result and expected CSVs 4021b16049aSZach Atkins """ 40378cb100bSJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 40478cb100bSJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 40569ef23b6SZach Atkins # Files should not be empty 40669ef23b6SZach Atkins if len(test_lines) == 0: 40769ef23b6SZach Atkins return f'No lines found in test output {test_csv}' 40869ef23b6SZach Atkins if len(true_lines) == 0: 40969ef23b6SZach Atkins return f'No lines found in test source {true_csv}' 41012235d7fSJames Wright if len(test_lines) != len(true_lines): 41112235d7fSJames Wright return f'Number of lines in {test_csv} and {true_csv} do not match' 41212235d7fSJames Wright 41312235d7fSJames Wright # Process commented lines 41412235d7fSJames Wright uncommented_lines: List[int] = [] 41512235d7fSJames Wright for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)): 41612235d7fSJames Wright if test_line[0] == comment_str and true_line[0] == comment_str: 41712235d7fSJames Wright if comment_func: 41812235d7fSJames Wright output = comment_func(test_line, true_line) 41912235d7fSJames Wright if output: 42012235d7fSJames Wright return output 42112235d7fSJames Wright elif test_line[0] == comment_str and true_line[0] != comment_str: 42212235d7fSJames Wright return f'Commented line found in {test_csv} at line {n} but not in {true_csv}' 42312235d7fSJames Wright elif test_line[0] != comment_str and true_line[0] == comment_str: 42412235d7fSJames Wright return f'Commented line found in {true_csv} at line {n} but not in {test_csv}' 42512235d7fSJames Wright else: 42612235d7fSJames Wright uncommented_lines.append(n) 42712235d7fSJames Wright 42812235d7fSJames Wright # Remove commented lines 42912235d7fSJames Wright test_lines = [test_lines[line] for line in uncommented_lines] 43012235d7fSJames Wright true_lines = [true_lines[line] for line in uncommented_lines] 4311b16049aSZach Atkins 43269ef23b6SZach Atkins test_reader: csv.DictReader = csv.DictReader(test_lines) 43369ef23b6SZach Atkins true_reader: csv.DictReader = csv.DictReader(true_lines) 434f36e7531SZach Atkins if not test_reader.fieldnames: 435f36e7531SZach Atkins return f'No CSV columns found in test output {test_csv}' 436f36e7531SZach Atkins if not true_reader.fieldnames: 437f36e7531SZach Atkins return f'No CSV columns found in test source {true_csv}' 43869ef23b6SZach Atkins if test_reader.fieldnames != true_reader.fieldnames: 4391b16049aSZach Atkins return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 4401b16049aSZach Atkins tofile='found CSV columns', fromfile='expected CSV columns')) 4411b16049aSZach Atkins 44278cb100bSJames Wright diff_lines: List[str] = list() 44369ef23b6SZach Atkins for test_line, true_line in zip(test_reader, true_reader): 44469ef23b6SZach Atkins for key in test_reader.fieldnames: 44569ef23b6SZach Atkins # Check if the value is numeric 44669ef23b6SZach Atkins try: 44769ef23b6SZach Atkins true_val: float = float(true_line[key]) 44869ef23b6SZach Atkins test_val: float = float(test_line[key]) 4491b16049aSZach Atkins true_zero: bool = abs(true_val) < zero_tol 4501b16049aSZach Atkins test_zero: bool = abs(test_val) < zero_tol 4511b16049aSZach Atkins fail: bool = False 4521b16049aSZach Atkins if true_zero: 4531b16049aSZach Atkins fail = not test_zero 4541b16049aSZach Atkins else: 4551b16049aSZach Atkins fail = not isclose(test_val, true_val, rel_tol=rel_tol) 4561b16049aSZach Atkins if fail: 45769ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 45869ef23b6SZach Atkins except ValueError: 45969ef23b6SZach Atkins if test_line[key] != true_line[key]: 46069ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 46169ef23b6SZach Atkins 4621b16049aSZach Atkins return '\n'.join(diff_lines) 4631b16049aSZach Atkins 4641b16049aSZach Atkins 465f36e7531SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str: 4661b16049aSZach Atkins """Compare CGNS results against an expected CGSN file with tolerance 4671b16049aSZach Atkins 4681b16049aSZach Atkins Args: 4691b16049aSZach Atkins test_cgns (Path): Path to output CGNS file 4701b16049aSZach Atkins true_cgns (Path): Path to expected CGNS file 471f36e7531SZach Atkins cgns_tol (float): Tolerance for comparing floating-point values 4721b16049aSZach Atkins 4731b16049aSZach Atkins Returns: 4741b16049aSZach Atkins str: Diff output between result and expected CGNS files 4751b16049aSZach Atkins """ 4761b16049aSZach Atkins my_env: dict = os.environ.copy() 4771b16049aSZach Atkins 47883ebc4c4SJeremy L Thompson run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 4791b16049aSZach Atkins proc = subprocess.run(' '.join(run_args), 4801b16049aSZach Atkins shell=True, 4811b16049aSZach Atkins stdout=subprocess.PIPE, 4821b16049aSZach Atkins stderr=subprocess.PIPE, 4831b16049aSZach Atkins env=my_env) 4841b16049aSZach Atkins 4851b16049aSZach Atkins return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 4861b16049aSZach Atkins 4871b16049aSZach Atkins 488f36e7531SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str: 489f36e7531SZach Atkins """Compare ASCII results against an expected ASCII file 490f36e7531SZach Atkins 491f36e7531SZach Atkins Args: 492f36e7531SZach Atkins test_file (Path): Path to output ASCII file 493f36e7531SZach Atkins true_file (Path): Path to expected ASCII file 494f36e7531SZach Atkins 495f36e7531SZach Atkins Returns: 496f36e7531SZach Atkins str: Diff output between result and expected ASCII files 497f36e7531SZach Atkins """ 498f36e7531SZach Atkins tmp_backend: str = backend.replace('/', '-') 499f36e7531SZach Atkins true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend) 500f36e7531SZach Atkins diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True), 501f36e7531SZach Atkins true_str.splitlines(keepends=True), 502f36e7531SZach Atkins fromfile=str(test_file), 503f36e7531SZach Atkins tofile=str(true_file))) 504f36e7531SZach Atkins return ''.join(diff) 505f36e7531SZach Atkins 506f36e7531SZach Atkins 507e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 508f36e7531SZach Atkins backend: str, test: str, index: int, verbose: bool) -> str: 509e17e35bbSJames Wright output_str = '' 510e17e35bbSJames Wright if mode is RunMode.TAP: 511e17e35bbSJames Wright # print incremental output if TAP mode 512e17e35bbSJames Wright if test_case.is_skipped(): 513e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 514e17e35bbSJames Wright elif test_case.is_failure() or test_case.is_error(): 515f36e7531SZach Atkins output_str += f' not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 516e17e35bbSJames Wright else: 517f36e7531SZach Atkins output_str += f' ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 518f36e7531SZach Atkins if test_case.is_failure() or test_case.is_error() or verbose: 519e17e35bbSJames Wright output_str += f' ---\n' 520e17e35bbSJames Wright if spec.only: 521e17e35bbSJames Wright output_str += f' only: {",".join(spec.only)}\n' 522e17e35bbSJames Wright output_str += f' args: {test_case.args}\n' 523f36e7531SZach Atkins if spec.csv_ztol > 0: 524f36e7531SZach Atkins output_str += f' csv_ztol: {spec.csv_ztol}\n' 525f36e7531SZach Atkins if spec.csv_rtol > 0: 526f36e7531SZach Atkins output_str += f' csv_rtol: {spec.csv_rtol}\n' 527f36e7531SZach Atkins if spec.cgns_tol > 0: 528f36e7531SZach Atkins output_str += f' cgns_tol: {spec.cgns_tol}\n' 529f36e7531SZach Atkins for k, v in spec.key_values.items(): 530f36e7531SZach Atkins output_str += f' {k}: {v}\n' 531e17e35bbSJames Wright if test_case.is_error(): 532e17e35bbSJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 533e17e35bbSJames Wright if test_case.is_failure(): 534f36e7531SZach Atkins output_str += f' failures:\n' 535e17e35bbSJames Wright for i, failure in enumerate(test_case.failures): 536f36e7531SZach Atkins output_str += f' -\n' 537e17e35bbSJames Wright output_str += f' message: {failure["message"]}\n' 538e17e35bbSJames Wright if failure["output"]: 539e17e35bbSJames Wright out = failure["output"].strip().replace('\n', '\n ') 540e17e35bbSJames Wright output_str += f' output: |\n {out}\n' 541e17e35bbSJames Wright output_str += f' ...\n' 542e17e35bbSJames Wright else: 543e17e35bbSJames Wright # print error or failure information if JUNIT mode 544e17e35bbSJames Wright if test_case.is_error() or test_case.is_failure(): 545e17e35bbSJames Wright output_str += f'Test: {test} {spec.name}\n' 546e17e35bbSJames Wright output_str += f' $ {test_case.args}\n' 547e17e35bbSJames Wright if test_case.is_error(): 548e17e35bbSJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 549e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 550e17e35bbSJames Wright if test_case.is_failure(): 551e17e35bbSJames Wright for failure in test_case.failures: 552e17e35bbSJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 553e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 554e17e35bbSJames Wright return output_str 555e17e35bbSJames Wright 556e17e35bbSJames Wright 557f36e7531SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path: 558f36e7531SZach Atkins """Attach a file to a test case 559f36e7531SZach Atkins 560f36e7531SZach Atkins Args: 561f36e7531SZach Atkins test_case (TestCase): Test case to attach the file to 562f36e7531SZach Atkins file (Path): Path to the file to attach 563f36e7531SZach Atkins """ 564f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / file.name 565f36e7531SZach Atkins shutil.copyfile(file, save_path) 566f36e7531SZach Atkins return save_path 567f36e7531SZach Atkins 568f36e7531SZach Atkins 56919868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str, 570f36e7531SZach Atkins mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase: 57119868e18SZach Atkins """Run a single test case and backend combination 5721b16049aSZach Atkins 5731b16049aSZach Atkins Args: 5748938a869SZach Atkins index (int): Index of backend for current spec 57519868e18SZach Atkins test (str): Path to test 57619868e18SZach Atkins spec (TestSpec): Specification of test case 57719868e18SZach Atkins backend (str): CEED backend 57819868e18SZach Atkins mode (RunMode): Output mode 57919868e18SZach Atkins nproc (int): Number of MPI processes to use when running test case 58019868e18SZach Atkins suite_spec (SuiteSpec): Specification of test suite 581f36e7531SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 5821b16049aSZach Atkins 5831b16049aSZach Atkins Returns: 58419868e18SZach Atkins TestCase: Test case result 5851b16049aSZach Atkins """ 5861b16049aSZach Atkins source_path: Path = suite_spec.get_source_path(test) 5878938a869SZach Atkins run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 5881b16049aSZach Atkins 5891b16049aSZach Atkins if '{ceed_resource}' in run_args: 59019868e18SZach Atkins run_args[run_args.index('{ceed_resource}')] = backend 5918938a869SZach Atkins for i, arg in enumerate(run_args): 5928938a869SZach Atkins if '{ceed_resource}' in arg: 5938938a869SZach Atkins run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 5941b16049aSZach Atkins if '{nproc}' in run_args: 5951b16049aSZach Atkins run_args[run_args.index('{nproc}')] = f'{nproc}' 5961b16049aSZach Atkins elif nproc > 1 and source_path.suffix != '.py': 5971b16049aSZach Atkins run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 5981b16049aSZach Atkins 5991b16049aSZach Atkins # run test 600f36e7531SZach Atkins skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc) 6011b16049aSZach Atkins if skip_reason: 60219868e18SZach Atkins test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6031b16049aSZach Atkins elapsed_sec=0, 6041b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 6051b16049aSZach Atkins stdout='', 6068938a869SZach Atkins stderr='', 6078938a869SZach Atkins category=spec.name,) 6081b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 6091b16049aSZach Atkins else: 6101b16049aSZach Atkins start: float = time.time() 6111b16049aSZach Atkins proc = subprocess.run(' '.join(str(arg) for arg in run_args), 6121b16049aSZach Atkins shell=True, 6131b16049aSZach Atkins stdout=subprocess.PIPE, 6141b16049aSZach Atkins stderr=subprocess.PIPE, 6151b16049aSZach Atkins env=my_env) 6161b16049aSZach Atkins 61719868e18SZach Atkins test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6181b16049aSZach Atkins classname=source_path.parent, 6191b16049aSZach Atkins elapsed_sec=time.time() - start, 6201b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 6211b16049aSZach Atkins stdout=proc.stdout.decode('utf-8'), 6221b16049aSZach Atkins stderr=proc.stderr.decode('utf-8'), 6238938a869SZach Atkins allow_multiple_subelements=True, 6248938a869SZach Atkins category=spec.name,) 62578cb100bSJames Wright ref_csvs: List[Path] = [] 626f36e7531SZach Atkins ref_ascii: List[Path] = [] 6278938a869SZach Atkins output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg] 62897fab443SJeremy L Thompson if output_files: 629f36e7531SZach Atkins ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1]) 630f36e7531SZach Atkins for file in output_files if file.endswith('.csv')] 631f36e7531SZach Atkins ref_ascii = [suite_spec.get_output_path(test, file.split(':')[1]) 632f36e7531SZach Atkins for file in output_files if not file.endswith('.csv')] 63378cb100bSJames Wright ref_cgns: List[Path] = [] 6348938a869SZach Atkins output_files = [arg for arg in run_args if 'cgns:' in arg] 63597fab443SJeremy L Thompson if output_files: 6361b16049aSZach Atkins ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files] 6371b16049aSZach Atkins ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 638f36e7531SZach Atkins suite_spec.post_test_hook(test, spec, backend) 6391b16049aSZach Atkins 6401b16049aSZach Atkins # check allowed failures 6411b16049aSZach Atkins if not test_case.is_skipped() and test_case.stderr: 642f36e7531SZach Atkins skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 6431b16049aSZach Atkins if skip_reason: 6441b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 6451b16049aSZach Atkins 6461b16049aSZach Atkins # check required failures 6471b16049aSZach Atkins if not test_case.is_skipped(): 6482fee3251SSebastian Grimberg required_message, did_fail = suite_spec.check_required_failure( 64919868e18SZach Atkins test, spec, backend, test_case.stderr) 6501b16049aSZach Atkins if required_message and did_fail: 6511b16049aSZach Atkins test_case.status = f'fails with required: {required_message}' 6521b16049aSZach Atkins elif required_message: 6531b16049aSZach Atkins test_case.add_failure_info(f'required failure missing: {required_message}') 6541b16049aSZach Atkins 6551b16049aSZach Atkins # classify other results 6561b16049aSZach Atkins if not test_case.is_skipped() and not test_case.status: 6571b16049aSZach Atkins if test_case.stderr: 6581b16049aSZach Atkins test_case.add_failure_info('stderr', test_case.stderr) 6591b16049aSZach Atkins if proc.returncode != 0: 6601b16049aSZach Atkins test_case.add_error_info(f'returncode = {proc.returncode}') 6611b16049aSZach Atkins if ref_stdout.is_file(): 6621b16049aSZach Atkins diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 6631b16049aSZach Atkins test_case.stdout.splitlines(keepends=True), 6641b16049aSZach Atkins fromfile=str(ref_stdout), 6651b16049aSZach Atkins tofile='New')) 6661b16049aSZach Atkins if diff: 6671b16049aSZach Atkins test_case.add_failure_info('stdout', output=''.join(diff)) 6681b16049aSZach Atkins elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 6691b16049aSZach Atkins test_case.add_failure_info('stdout', output=test_case.stdout) 6701b16049aSZach Atkins # expected CSV output 6711b16049aSZach Atkins for ref_csv in ref_csvs: 6728938a869SZach Atkins csv_name = ref_csv.name 673f36e7531SZach Atkins out_file = Path.cwd() / csv_name 6748938a869SZach Atkins if not ref_csv.is_file(): 6758938a869SZach Atkins # remove _{ceed_backend} from path name 6768938a869SZach Atkins ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 6771b16049aSZach Atkins if not ref_csv.is_file(): 6781b16049aSZach Atkins test_case.add_failure_info('csv', output=f'{ref_csv} not found') 679f36e7531SZach Atkins elif not out_file.is_file(): 680f36e7531SZach Atkins test_case.add_failure_info('csv', output=f'{out_file} not found') 6811b16049aSZach Atkins else: 682f36e7531SZach Atkins csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol 683f36e7531SZach Atkins csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol 684f36e7531SZach Atkins diff = diff_csv(out_file, ref_csv, zero_tol=csv_ztol, rel_tol=csv_rtol) 6851b16049aSZach Atkins if diff: 686f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / csv_name 687f36e7531SZach Atkins shutil.move(out_file, save_path) 688f36e7531SZach Atkins test_case.add_failure_info(f'csv: {save_path}', output=diff) 6891b16049aSZach Atkins else: 690f36e7531SZach Atkins out_file.unlink() 6911b16049aSZach Atkins # expected CGNS output 6921b16049aSZach Atkins for ref_cgn in ref_cgns: 6938938a869SZach Atkins cgn_name = ref_cgn.name 694f36e7531SZach Atkins out_file = Path.cwd() / cgn_name 6958938a869SZach Atkins if not ref_cgn.is_file(): 6968938a869SZach Atkins # remove _{ceed_backend} from path name 6978938a869SZach Atkins ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 6981b16049aSZach Atkins if not ref_cgn.is_file(): 6991b16049aSZach Atkins test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 700f36e7531SZach Atkins elif not out_file.is_file(): 701f36e7531SZach Atkins test_case.add_failure_info('cgns', output=f'{out_file} not found') 7021b16049aSZach Atkins else: 703f36e7531SZach Atkins cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol 704f36e7531SZach Atkins diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol) 7051b16049aSZach Atkins if diff: 706f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name 707f36e7531SZach Atkins shutil.move(out_file, save_path) 708f36e7531SZach Atkins test_case.add_failure_info(f'cgns: {save_path}', output=diff) 7091b16049aSZach Atkins else: 710f36e7531SZach Atkins out_file.unlink() 711f36e7531SZach Atkins # expected ASCII output 712f36e7531SZach Atkins for ref_file in ref_ascii: 713f36e7531SZach Atkins ref_name = ref_file.name 714f36e7531SZach Atkins out_file = Path.cwd() / ref_name 715f36e7531SZach Atkins if not ref_file.is_file(): 716f36e7531SZach Atkins # remove _{ceed_backend} from path name 717f36e7531SZach Atkins ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix) 718f36e7531SZach Atkins if not ref_file.is_file(): 719f36e7531SZach Atkins test_case.add_failure_info('ascii', output=f'{ref_file} not found') 720f36e7531SZach Atkins elif not out_file.is_file(): 721f36e7531SZach Atkins test_case.add_failure_info('ascii', output=f'{out_file} not found') 722f36e7531SZach Atkins else: 723f36e7531SZach Atkins diff = diff_ascii(out_file, ref_file, backend) 724f36e7531SZach Atkins if diff: 725f36e7531SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / ref_name 726f36e7531SZach Atkins shutil.move(out_file, save_path) 727f36e7531SZach Atkins test_case.add_failure_info(f'ascii: {save_path}', output=diff) 728f36e7531SZach Atkins else: 729f36e7531SZach Atkins out_file.unlink() 7301b16049aSZach Atkins 7311b16049aSZach Atkins # store result 7321b16049aSZach Atkins test_case.args = ' '.join(str(arg) for arg in run_args) 733f36e7531SZach Atkins output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose) 73419868e18SZach Atkins 73519868e18SZach Atkins return test_case, output_str 73619868e18SZach Atkins 73719868e18SZach Atkins 73819868e18SZach Atkinsdef init_process(): 73919868e18SZach Atkins """Initialize multiprocessing process""" 74019868e18SZach Atkins # set up error handler 74119868e18SZach Atkins global my_env 74219868e18SZach Atkins my_env = os.environ.copy() 74319868e18SZach Atkins my_env['CEED_ERROR_HANDLER'] = 'exit' 74419868e18SZach Atkins 74519868e18SZach Atkins 74678cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 747f36e7531SZach Atkins suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite: 74819868e18SZach Atkins """Run all test cases for `test` with each of the provided `ceed_backends` 74919868e18SZach Atkins 75019868e18SZach Atkins Args: 75119868e18SZach Atkins test (str): Name of test 7528938a869SZach Atkins ceed_backends (List[str]): List of libCEED backends 75319868e18SZach Atkins mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 75419868e18SZach Atkins nproc (int): Number of MPI processes to use when running each test case 75519868e18SZach Atkins suite_spec (SuiteSpec): Object defining required methods for running tests 75619868e18SZach Atkins pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 757f36e7531SZach Atkins search (str, optional): Regular expression used to match tests. Defaults to ".*". 758f36e7531SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 75919868e18SZach Atkins 76019868e18SZach Atkins Returns: 76119868e18SZach Atkins TestSuite: JUnit `TestSuite` containing results of all test cases 76219868e18SZach Atkins """ 763f36e7531SZach Atkins test_specs: List[TestSpec] = [ 764f36e7531SZach Atkins t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE) 765f36e7531SZach Atkins ] 766f36e7531SZach Atkins suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True) 76719868e18SZach Atkins if mode is RunMode.TAP: 7688938a869SZach Atkins print('TAP version 13') 7698938a869SZach Atkins print(f'1..{len(test_specs)}') 77019868e18SZach Atkins 77119868e18SZach Atkins with mp.Pool(processes=pool_size, initializer=init_process) as pool: 772f36e7531SZach Atkins async_outputs: List[List[mp.pool.AsyncResult]] = [ 773f36e7531SZach Atkins [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose)) 7748938a869SZach Atkins for (i, backend) in enumerate(ceed_backends, start=1)] 7758938a869SZach Atkins for spec in test_specs 7768938a869SZach Atkins ] 77719868e18SZach Atkins 77819868e18SZach Atkins test_cases = [] 7798938a869SZach Atkins for (i, subtest) in enumerate(async_outputs, start=1): 7808938a869SZach Atkins is_new_subtest = True 7818938a869SZach Atkins subtest_ok = True 7828938a869SZach Atkins for async_output in subtest: 78319868e18SZach Atkins test_case, print_output = async_output.get() 78419868e18SZach Atkins test_cases.append(test_case) 7858938a869SZach Atkins if is_new_subtest and mode == RunMode.TAP: 7868938a869SZach Atkins is_new_subtest = False 7878938a869SZach Atkins print(f'# Subtest: {test_case.category}') 7888938a869SZach Atkins print(f' 1..{len(ceed_backends)}') 78919868e18SZach Atkins print(print_output, end='') 7908938a869SZach Atkins if test_case.is_failure() or test_case.is_error(): 7918938a869SZach Atkins subtest_ok = False 7928938a869SZach Atkins if mode == RunMode.TAP: 7938938a869SZach Atkins print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 7941b16049aSZach Atkins 7951b16049aSZach Atkins return TestSuite(test, test_cases) 7961b16049aSZach Atkins 7971b16049aSZach Atkins 7981b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None: 7991b16049aSZach Atkins """Write a JUnit XML file containing the results of a `TestSuite` 8001b16049aSZach Atkins 8011b16049aSZach Atkins Args: 8021b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to write 8031b16049aSZach Atkins output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 8041b16049aSZach Atkins batch (str): Name of JUnit batch, defaults to empty string 8051b16049aSZach Atkins """ 806f36e7531SZach Atkins output_file = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 8071b16049aSZach Atkins output_file.write_text(to_xml_report_string([test_suite])) 8081b16049aSZach Atkins 8091b16049aSZach Atkins 8101b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool: 8111b16049aSZach Atkins """Check whether any test cases in a `TestSuite` failed 8121b16049aSZach Atkins 8131b16049aSZach Atkins Args: 8141b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to check 8151b16049aSZach Atkins 8161b16049aSZach Atkins Returns: 8171b16049aSZach Atkins bool: True if any test cases failed 8181b16049aSZach Atkins """ 8191b16049aSZach Atkins return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 820