xref: /honee/tests/junit_common.py (revision 0006be33bc3a095c7a40443db044019362bd3e1c)
1*0006be33SJames Wrightfrom abc import ABC, abstractmethod
2*0006be33SJames Wrightimport argparse
3*0006be33SJames Wrightimport csv
4*0006be33SJames Wrightfrom dataclasses import dataclass, field
5*0006be33SJames Wrightimport difflib
6*0006be33SJames Wrightfrom enum import Enum
7*0006be33SJames Wrightfrom math import isclose
8*0006be33SJames Wrightimport os
9*0006be33SJames Wrightfrom pathlib import Path
10*0006be33SJames Wrightimport re
11*0006be33SJames Wrightimport subprocess
12*0006be33SJames Wrightimport multiprocessing as mp
13*0006be33SJames Wrightfrom itertools import product
14*0006be33SJames Wrightimport sys
15*0006be33SJames Wrightimport time
16*0006be33SJames Wrightfrom typing import Optional, Tuple, List
17*0006be33SJames Wright
18*0006be33SJames Wrightsys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
19*0006be33SJames Wrightfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
20*0006be33SJames Wright
21*0006be33SJames Wright
22*0006be33SJames Wrightclass CaseInsensitiveEnumAction(argparse.Action):
23*0006be33SJames Wright    """Action to convert input values to lower case prior to converting to an Enum type"""
24*0006be33SJames Wright
25*0006be33SJames Wright    def __init__(self, option_strings, dest, type, default, **kwargs):
26*0006be33SJames Wright        if not (issubclass(type, Enum) and issubclass(type, str)):
27*0006be33SJames Wright            raise ValueError(f"{type} must be a StrEnum or str and Enum")
28*0006be33SJames Wright        # store provided enum type
29*0006be33SJames Wright        self.enum_type = type
30*0006be33SJames Wright        if isinstance(default, str):
31*0006be33SJames Wright            default = self.enum_type(default.lower())
32*0006be33SJames Wright        else:
33*0006be33SJames Wright            default = [self.enum_type(v.lower()) for v in default]
34*0006be33SJames Wright        # prevent automatic type conversion
35*0006be33SJames Wright        super().__init__(option_strings, dest, default=default, **kwargs)
36*0006be33SJames Wright
37*0006be33SJames Wright    def __call__(self, parser, namespace, values, option_string=None):
38*0006be33SJames Wright        if isinstance(values, str):
39*0006be33SJames Wright            values = self.enum_type(values.lower())
40*0006be33SJames Wright        else:
41*0006be33SJames Wright            values = [self.enum_type(v.lower()) for v in values]
42*0006be33SJames Wright        setattr(namespace, self.dest, values)
43*0006be33SJames Wright
44*0006be33SJames Wright
45*0006be33SJames Wright@dataclass
46*0006be33SJames Wrightclass TestSpec:
47*0006be33SJames Wright    """Dataclass storing information about a single test case"""
48*0006be33SJames Wright    name: str
49*0006be33SJames Wright    only: List = field(default_factory=list)
50*0006be33SJames Wright    args: List = field(default_factory=list)
51*0006be33SJames Wright
52*0006be33SJames Wright
53*0006be33SJames Wrightclass RunMode(str, Enum):
54*0006be33SJames Wright    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
55*0006be33SJames Wright    __str__ = str.__str__
56*0006be33SJames Wright    __format__ = str.__format__
57*0006be33SJames Wright    TAP: str = 'tap'
58*0006be33SJames Wright    JUNIT: str = 'junit'
59*0006be33SJames Wright
60*0006be33SJames Wright
61*0006be33SJames Wrightclass SuiteSpec(ABC):
62*0006be33SJames Wright    """Abstract Base Class defining the required interface for running a test suite"""
63*0006be33SJames Wright    @abstractmethod
64*0006be33SJames Wright    def get_source_path(self, test: str) -> Path:
65*0006be33SJames Wright        """Compute path to test source file
66*0006be33SJames Wright
67*0006be33SJames Wright        Args:
68*0006be33SJames Wright            test (str): Name of test
69*0006be33SJames Wright
70*0006be33SJames Wright        Returns:
71*0006be33SJames Wright            Path: Path to source file
72*0006be33SJames Wright        """
73*0006be33SJames Wright        raise NotImplementedError
74*0006be33SJames Wright
75*0006be33SJames Wright    @abstractmethod
76*0006be33SJames Wright    def get_run_path(self, test: str) -> Path:
77*0006be33SJames Wright        """Compute path to built test executable file
78*0006be33SJames Wright
79*0006be33SJames Wright        Args:
80*0006be33SJames Wright            test (str): Name of test
81*0006be33SJames Wright
82*0006be33SJames Wright        Returns:
83*0006be33SJames Wright            Path: Path to test executable
84*0006be33SJames Wright        """
85*0006be33SJames Wright        raise NotImplementedError
86*0006be33SJames Wright
87*0006be33SJames Wright    @abstractmethod
88*0006be33SJames Wright    def get_output_path(self, test: str, output_file: str) -> Path:
89*0006be33SJames Wright        """Compute path to expected output file
90*0006be33SJames Wright
91*0006be33SJames Wright        Args:
92*0006be33SJames Wright            test (str): Name of test
93*0006be33SJames Wright            output_file (str): File name of output file
94*0006be33SJames Wright
95*0006be33SJames Wright        Returns:
96*0006be33SJames Wright            Path: Path to expected output file
97*0006be33SJames Wright        """
98*0006be33SJames Wright        raise NotImplementedError
99*0006be33SJames Wright
100*0006be33SJames Wright    @property
101*0006be33SJames Wright    def cgns_tol(self):
102*0006be33SJames Wright        """Absolute tolerance for CGNS diff"""
103*0006be33SJames Wright        return getattr(self, '_cgns_tol', 1.0e-12)
104*0006be33SJames Wright
105*0006be33SJames Wright    @cgns_tol.setter
106*0006be33SJames Wright    def cgns_tol(self, val):
107*0006be33SJames Wright        self._cgns_tol = val
108*0006be33SJames Wright
109*0006be33SJames Wright    def post_test_hook(self, test: str, spec: TestSpec) -> None:
110*0006be33SJames Wright        """Function callback ran after each test case
111*0006be33SJames Wright
112*0006be33SJames Wright        Args:
113*0006be33SJames Wright            test (str): Name of test
114*0006be33SJames Wright            spec (TestSpec): Test case specification
115*0006be33SJames Wright        """
116*0006be33SJames Wright        pass
117*0006be33SJames Wright
118*0006be33SJames Wright    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
119*0006be33SJames Wright        """Check if a test case should be skipped prior to running, returning the reason for skipping
120*0006be33SJames Wright
121*0006be33SJames Wright        Args:
122*0006be33SJames Wright            test (str): Name of test
123*0006be33SJames Wright            spec (TestSpec): Test case specification
124*0006be33SJames Wright            resource (str): libCEED backend
125*0006be33SJames Wright            nproc (int): Number of MPI processes to use when running test case
126*0006be33SJames Wright
127*0006be33SJames Wright        Returns:
128*0006be33SJames Wright            Optional[str]: Skip reason, or `None` if test case should not be skipped
129*0006be33SJames Wright        """
130*0006be33SJames Wright        return None
131*0006be33SJames Wright
132*0006be33SJames Wright    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
133*0006be33SJames Wright        """Check if a test case should be allowed to fail, based on its stderr output
134*0006be33SJames Wright
135*0006be33SJames Wright        Args:
136*0006be33SJames Wright            test (str): Name of test
137*0006be33SJames Wright            spec (TestSpec): Test case specification
138*0006be33SJames Wright            resource (str): libCEED backend
139*0006be33SJames Wright            stderr (str): Standard error output from test case execution
140*0006be33SJames Wright
141*0006be33SJames Wright        Returns:
142*0006be33SJames Wright            Optional[str]: Skip reason, or `None` if unexpected error
143*0006be33SJames Wright        """
144*0006be33SJames Wright        return None
145*0006be33SJames Wright
146*0006be33SJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
147*0006be33SJames Wright        """Check whether a test case is expected to fail and if it failed expectedly
148*0006be33SJames Wright
149*0006be33SJames Wright        Args:
150*0006be33SJames Wright            test (str): Name of test
151*0006be33SJames Wright            spec (TestSpec): Test case specification
152*0006be33SJames Wright            resource (str): libCEED backend
153*0006be33SJames Wright            stderr (str): Standard error output from test case execution
154*0006be33SJames Wright
155*0006be33SJames Wright        Returns:
156*0006be33SJames Wright            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
157*0006be33SJames Wright        """
158*0006be33SJames Wright        return '', True
159*0006be33SJames Wright
160*0006be33SJames Wright    def check_allowed_stdout(self, test: str) -> bool:
161*0006be33SJames Wright        """Check whether a test is allowed to print console output
162*0006be33SJames Wright
163*0006be33SJames Wright        Args:
164*0006be33SJames Wright            test (str): Name of test
165*0006be33SJames Wright
166*0006be33SJames Wright        Returns:
167*0006be33SJames Wright            bool: True if the test is allowed to print console output
168*0006be33SJames Wright        """
169*0006be33SJames Wright        return False
170*0006be33SJames Wright
171*0006be33SJames Wright
172*0006be33SJames Wrightdef has_cgnsdiff() -> bool:
173*0006be33SJames Wright    """Check whether `cgnsdiff` is an executable program in the current environment
174*0006be33SJames Wright
175*0006be33SJames Wright    Returns:
176*0006be33SJames Wright        bool: True if `cgnsdiff` is found
177*0006be33SJames Wright    """
178*0006be33SJames Wright    my_env: dict = os.environ.copy()
179*0006be33SJames Wright    proc = subprocess.run('cgnsdiff',
180*0006be33SJames Wright                          shell=True,
181*0006be33SJames Wright                          stdout=subprocess.PIPE,
182*0006be33SJames Wright                          stderr=subprocess.PIPE,
183*0006be33SJames Wright                          env=my_env)
184*0006be33SJames Wright    return 'not found' not in proc.stderr.decode('utf-8')
185*0006be33SJames Wright
186*0006be33SJames Wright
187*0006be33SJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
188*0006be33SJames Wright    """Helper function, checks if any of the substrings are included in the base string
189*0006be33SJames Wright
190*0006be33SJames Wright    Args:
191*0006be33SJames Wright        base (str): Base string to search in
192*0006be33SJames Wright        substrings (List[str]): List of potential substrings
193*0006be33SJames Wright
194*0006be33SJames Wright    Returns:
195*0006be33SJames Wright        bool: True if any substrings are included in base string
196*0006be33SJames Wright    """
197*0006be33SJames Wright    return any((sub in base for sub in substrings))
198*0006be33SJames Wright
199*0006be33SJames Wright
200*0006be33SJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
201*0006be33SJames Wright    """Helper function, checks if the base string is prefixed by any of `prefixes`
202*0006be33SJames Wright
203*0006be33SJames Wright    Args:
204*0006be33SJames Wright        base (str): Base string to search
205*0006be33SJames Wright        prefixes (List[str]): List of potential prefixes
206*0006be33SJames Wright
207*0006be33SJames Wright    Returns:
208*0006be33SJames Wright        bool: True if base string is prefixed by any of the prefixes
209*0006be33SJames Wright    """
210*0006be33SJames Wright    return any((base.startswith(prefix) for prefix in prefixes))
211*0006be33SJames Wright
212*0006be33SJames Wright
213*0006be33SJames Wrightdef parse_test_line(line: str) -> TestSpec:
214*0006be33SJames Wright    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
215*0006be33SJames Wright
216*0006be33SJames Wright    Args:
217*0006be33SJames Wright        line (str): String containing TESTARGS specification and CLI arguments
218*0006be33SJames Wright
219*0006be33SJames Wright    Returns:
220*0006be33SJames Wright        TestSpec: Parsed specification of test case
221*0006be33SJames Wright    """
222*0006be33SJames Wright    args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
223*0006be33SJames Wright    if args[0] == 'TESTARGS':
224*0006be33SJames Wright        return TestSpec(name='', args=args[1:])
225*0006be33SJames Wright    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
226*0006be33SJames Wright    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
227*0006be33SJames Wright    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
228*0006be33SJames Wright    name: str = test_args.get('name', '')
229*0006be33SJames Wright    constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else []
230*0006be33SJames Wright    if len(args) > 1:
231*0006be33SJames Wright        return TestSpec(name=name, only=constraints, args=args[1:])
232*0006be33SJames Wright    else:
233*0006be33SJames Wright        return TestSpec(name=name, only=constraints)
234*0006be33SJames Wright
235*0006be33SJames Wright
236*0006be33SJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
237*0006be33SJames Wright    """Parse all test cases from a given source file
238*0006be33SJames Wright
239*0006be33SJames Wright    Args:
240*0006be33SJames Wright        source_file (Path): Path to source file
241*0006be33SJames Wright
242*0006be33SJames Wright    Raises:
243*0006be33SJames Wright        RuntimeError: Errors if source file extension is unsupported
244*0006be33SJames Wright
245*0006be33SJames Wright    Returns:
246*0006be33SJames Wright        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
247*0006be33SJames Wright    """
248*0006be33SJames Wright    comment_str: str = ''
249*0006be33SJames Wright    if source_file.suffix in ['.c', '.cc', '.cpp']:
250*0006be33SJames Wright        comment_str = '//'
251*0006be33SJames Wright    elif source_file.suffix in ['.py']:
252*0006be33SJames Wright        comment_str = '#'
253*0006be33SJames Wright    elif source_file.suffix in ['.usr']:
254*0006be33SJames Wright        comment_str = 'C_'
255*0006be33SJames Wright    elif source_file.suffix in ['.f90']:
256*0006be33SJames Wright        comment_str = '! '
257*0006be33SJames Wright    else:
258*0006be33SJames Wright        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
259*0006be33SJames Wright
260*0006be33SJames Wright    return [parse_test_line(line.strip(comment_str))
261*0006be33SJames Wright            for line in source_file.read_text().splitlines()
262*0006be33SJames Wright            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
263*0006be33SJames Wright
264*0006be33SJames Wright
265*0006be33SJames Wrightdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str:
266*0006be33SJames Wright    """Compare CSV results against an expected CSV file with tolerances
267*0006be33SJames Wright
268*0006be33SJames Wright    Args:
269*0006be33SJames Wright        test_csv (Path): Path to output CSV results
270*0006be33SJames Wright        true_csv (Path): Path to expected CSV results
271*0006be33SJames Wright        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
272*0006be33SJames Wright        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
273*0006be33SJames Wright
274*0006be33SJames Wright    Returns:
275*0006be33SJames Wright        str: Diff output between result and expected CSVs
276*0006be33SJames Wright    """
277*0006be33SJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
278*0006be33SJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
279*0006be33SJames Wright    # Files should not be empty
280*0006be33SJames Wright    if len(test_lines) == 0:
281*0006be33SJames Wright        return f'No lines found in test output {test_csv}'
282*0006be33SJames Wright    if len(true_lines) == 0:
283*0006be33SJames Wright        return f'No lines found in test source {true_csv}'
284*0006be33SJames Wright
285*0006be33SJames Wright    test_reader: csv.DictReader = csv.DictReader(test_lines)
286*0006be33SJames Wright    true_reader: csv.DictReader = csv.DictReader(true_lines)
287*0006be33SJames Wright    if test_reader.fieldnames != true_reader.fieldnames:
288*0006be33SJames Wright        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
289*0006be33SJames Wright                       tofile='found CSV columns', fromfile='expected CSV columns'))
290*0006be33SJames Wright
291*0006be33SJames Wright    if len(test_lines) != len(true_lines):
292*0006be33SJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
293*0006be33SJames Wright    diff_lines: List[str] = list()
294*0006be33SJames Wright    for test_line, true_line in zip(test_reader, true_reader):
295*0006be33SJames Wright        for key in test_reader.fieldnames:
296*0006be33SJames Wright            # Check if the value is numeric
297*0006be33SJames Wright            try:
298*0006be33SJames Wright                true_val: float = float(true_line[key])
299*0006be33SJames Wright                test_val: float = float(test_line[key])
300*0006be33SJames Wright                true_zero: bool = abs(true_val) < zero_tol
301*0006be33SJames Wright                test_zero: bool = abs(test_val) < zero_tol
302*0006be33SJames Wright                fail: bool = False
303*0006be33SJames Wright                if true_zero:
304*0006be33SJames Wright                    fail = not test_zero
305*0006be33SJames Wright                else:
306*0006be33SJames Wright                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
307*0006be33SJames Wright                if fail:
308*0006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
309*0006be33SJames Wright            except ValueError:
310*0006be33SJames Wright                if test_line[key] != true_line[key]:
311*0006be33SJames Wright                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
312*0006be33SJames Wright
313*0006be33SJames Wright    return '\n'.join(diff_lines)
314*0006be33SJames Wright
315*0006be33SJames Wright
316*0006be33SJames Wrightdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str:
317*0006be33SJames Wright    """Compare CGNS results against an expected CGSN file with tolerance
318*0006be33SJames Wright
319*0006be33SJames Wright    Args:
320*0006be33SJames Wright        test_cgns (Path): Path to output CGNS file
321*0006be33SJames Wright        true_cgns (Path): Path to expected CGNS file
322*0006be33SJames Wright        cgns_tol (float, optional): Tolerance for comparing floating-point values
323*0006be33SJames Wright
324*0006be33SJames Wright    Returns:
325*0006be33SJames Wright        str: Diff output between result and expected CGNS files
326*0006be33SJames Wright    """
327*0006be33SJames Wright    my_env: dict = os.environ.copy()
328*0006be33SJames Wright
329*0006be33SJames Wright    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
330*0006be33SJames Wright    proc = subprocess.run(' '.join(run_args),
331*0006be33SJames Wright                          shell=True,
332*0006be33SJames Wright                          stdout=subprocess.PIPE,
333*0006be33SJames Wright                          stderr=subprocess.PIPE,
334*0006be33SJames Wright                          env=my_env)
335*0006be33SJames Wright
336*0006be33SJames Wright    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
337*0006be33SJames Wright
338*0006be33SJames Wright
339*0006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
340*0006be33SJames Wright                            backend: str, test: str, index: int) -> str:
341*0006be33SJames Wright    output_str = ''
342*0006be33SJames Wright    if mode is RunMode.TAP:
343*0006be33SJames Wright        # print incremental output if TAP mode
344*0006be33SJames Wright        if test_case.is_skipped():
345*0006be33SJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
346*0006be33SJames Wright        elif test_case.is_failure() or test_case.is_error():
347*0006be33SJames Wright            output_str += f'    not ok {index} - {spec.name}, {backend}\n'
348*0006be33SJames Wright        else:
349*0006be33SJames Wright            output_str += f'    ok {index} - {spec.name}, {backend}\n'
350*0006be33SJames Wright        output_str += f'      ---\n'
351*0006be33SJames Wright        if spec.only:
352*0006be33SJames Wright            output_str += f'      only: {",".join(spec.only)}\n'
353*0006be33SJames Wright        output_str += f'      args: {test_case.args}\n'
354*0006be33SJames Wright        if test_case.is_error():
355*0006be33SJames Wright            output_str += f'      error: {test_case.errors[0]["message"]}\n'
356*0006be33SJames Wright        if test_case.is_failure():
357*0006be33SJames Wright            output_str += f'      num_failures: {len(test_case.failures)}\n'
358*0006be33SJames Wright            for i, failure in enumerate(test_case.failures):
359*0006be33SJames Wright                output_str += f'      failure_{i}: {failure["message"]}\n'
360*0006be33SJames Wright                output_str += f'        message: {failure["message"]}\n'
361*0006be33SJames Wright                if failure["output"]:
362*0006be33SJames Wright                    out = failure["output"].strip().replace('\n', '\n          ')
363*0006be33SJames Wright                    output_str += f'        output: |\n          {out}\n'
364*0006be33SJames Wright        output_str += f'      ...\n'
365*0006be33SJames Wright    else:
366*0006be33SJames Wright        # print error or failure information if JUNIT mode
367*0006be33SJames Wright        if test_case.is_error() or test_case.is_failure():
368*0006be33SJames Wright            output_str += f'Test: {test} {spec.name}\n'
369*0006be33SJames Wright            output_str += f'  $ {test_case.args}\n'
370*0006be33SJames Wright            if test_case.is_error():
371*0006be33SJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
372*0006be33SJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
373*0006be33SJames Wright            if test_case.is_failure():
374*0006be33SJames Wright                for failure in test_case.failures:
375*0006be33SJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
376*0006be33SJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
377*0006be33SJames Wright    return output_str
378*0006be33SJames Wright
379*0006be33SJames Wright
380*0006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str,
381*0006be33SJames Wright             mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase:
382*0006be33SJames Wright    """Run a single test case and backend combination
383*0006be33SJames Wright
384*0006be33SJames Wright    Args:
385*0006be33SJames Wright        index (int): Index of backend for current spec
386*0006be33SJames Wright        test (str): Path to test
387*0006be33SJames Wright        spec (TestSpec): Specification of test case
388*0006be33SJames Wright        backend (str): CEED backend
389*0006be33SJames Wright        mode (RunMode): Output mode
390*0006be33SJames Wright        nproc (int): Number of MPI processes to use when running test case
391*0006be33SJames Wright        suite_spec (SuiteSpec): Specification of test suite
392*0006be33SJames Wright
393*0006be33SJames Wright    Returns:
394*0006be33SJames Wright        TestCase: Test case result
395*0006be33SJames Wright    """
396*0006be33SJames Wright    source_path: Path = suite_spec.get_source_path(test)
397*0006be33SJames Wright    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
398*0006be33SJames Wright
399*0006be33SJames Wright    if '{ceed_resource}' in run_args:
400*0006be33SJames Wright        run_args[run_args.index('{ceed_resource}')] = backend
401*0006be33SJames Wright    for i, arg in enumerate(run_args):
402*0006be33SJames Wright        if '{ceed_resource}' in arg:
403*0006be33SJames Wright            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
404*0006be33SJames Wright    if '{nproc}' in run_args:
405*0006be33SJames Wright        run_args[run_args.index('{nproc}')] = f'{nproc}'
406*0006be33SJames Wright    elif nproc > 1 and source_path.suffix != '.py':
407*0006be33SJames Wright        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
408*0006be33SJames Wright
409*0006be33SJames Wright    # run test
410*0006be33SJames Wright    skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc)
411*0006be33SJames Wright    if skip_reason:
412*0006be33SJames Wright        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
413*0006be33SJames Wright                                       elapsed_sec=0,
414*0006be33SJames Wright                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
415*0006be33SJames Wright                                       stdout='',
416*0006be33SJames Wright                                       stderr='',
417*0006be33SJames Wright                                       category=spec.name,)
418*0006be33SJames Wright        test_case.add_skipped_info(skip_reason)
419*0006be33SJames Wright    else:
420*0006be33SJames Wright        start: float = time.time()
421*0006be33SJames Wright        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
422*0006be33SJames Wright                              shell=True,
423*0006be33SJames Wright                              stdout=subprocess.PIPE,
424*0006be33SJames Wright                              stderr=subprocess.PIPE,
425*0006be33SJames Wright                              env=my_env)
426*0006be33SJames Wright
427*0006be33SJames Wright        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
428*0006be33SJames Wright                             classname=source_path.parent,
429*0006be33SJames Wright                             elapsed_sec=time.time() - start,
430*0006be33SJames Wright                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
431*0006be33SJames Wright                             stdout=proc.stdout.decode('utf-8'),
432*0006be33SJames Wright                             stderr=proc.stderr.decode('utf-8'),
433*0006be33SJames Wright                             allow_multiple_subelements=True,
434*0006be33SJames Wright                             category=spec.name,)
435*0006be33SJames Wright        ref_csvs: List[Path] = []
436*0006be33SJames Wright        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
437*0006be33SJames Wright        if output_files:
438*0006be33SJames Wright            ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files]
439*0006be33SJames Wright        ref_cgns: List[Path] = []
440*0006be33SJames Wright        output_files = [arg for arg in run_args if 'cgns:' in arg]
441*0006be33SJames Wright        if output_files:
442*0006be33SJames Wright            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
443*0006be33SJames Wright        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
444*0006be33SJames Wright        suite_spec.post_test_hook(test, spec)
445*0006be33SJames Wright
446*0006be33SJames Wright    # check allowed failures
447*0006be33SJames Wright    if not test_case.is_skipped() and test_case.stderr:
448*0006be33SJames Wright        skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
449*0006be33SJames Wright        if skip_reason:
450*0006be33SJames Wright            test_case.add_skipped_info(skip_reason)
451*0006be33SJames Wright
452*0006be33SJames Wright    # check required failures
453*0006be33SJames Wright    if not test_case.is_skipped():
454*0006be33SJames Wright        required_message, did_fail = suite_spec.check_required_failure(
455*0006be33SJames Wright            test, spec, backend, test_case.stderr)
456*0006be33SJames Wright        if required_message and did_fail:
457*0006be33SJames Wright            test_case.status = f'fails with required: {required_message}'
458*0006be33SJames Wright        elif required_message:
459*0006be33SJames Wright            test_case.add_failure_info(f'required failure missing: {required_message}')
460*0006be33SJames Wright
461*0006be33SJames Wright    # classify other results
462*0006be33SJames Wright    if not test_case.is_skipped() and not test_case.status:
463*0006be33SJames Wright        if test_case.stderr:
464*0006be33SJames Wright            test_case.add_failure_info('stderr', test_case.stderr)
465*0006be33SJames Wright        if proc.returncode != 0:
466*0006be33SJames Wright            test_case.add_error_info(f'returncode = {proc.returncode}')
467*0006be33SJames Wright        if ref_stdout.is_file():
468*0006be33SJames Wright            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
469*0006be33SJames Wright                                             test_case.stdout.splitlines(keepends=True),
470*0006be33SJames Wright                                             fromfile=str(ref_stdout),
471*0006be33SJames Wright                                             tofile='New'))
472*0006be33SJames Wright            if diff:
473*0006be33SJames Wright                test_case.add_failure_info('stdout', output=''.join(diff))
474*0006be33SJames Wright        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
475*0006be33SJames Wright            test_case.add_failure_info('stdout', output=test_case.stdout)
476*0006be33SJames Wright        # expected CSV output
477*0006be33SJames Wright        for ref_csv in ref_csvs:
478*0006be33SJames Wright            csv_name = ref_csv.name
479*0006be33SJames Wright            if not ref_csv.is_file():
480*0006be33SJames Wright                # remove _{ceed_backend} from path name
481*0006be33SJames Wright                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
482*0006be33SJames Wright            if not ref_csv.is_file():
483*0006be33SJames Wright                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
484*0006be33SJames Wright            else:
485*0006be33SJames Wright                diff: str = diff_csv(Path.cwd() / csv_name, ref_csv)
486*0006be33SJames Wright                if diff:
487*0006be33SJames Wright                    test_case.add_failure_info('csv', output=diff)
488*0006be33SJames Wright                else:
489*0006be33SJames Wright                    (Path.cwd() / csv_name).unlink()
490*0006be33SJames Wright        # expected CGNS output
491*0006be33SJames Wright        for ref_cgn in ref_cgns:
492*0006be33SJames Wright            cgn_name = ref_cgn.name
493*0006be33SJames Wright            if not ref_cgn.is_file():
494*0006be33SJames Wright                # remove _{ceed_backend} from path name
495*0006be33SJames Wright                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
496*0006be33SJames Wright            if not ref_cgn.is_file():
497*0006be33SJames Wright                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
498*0006be33SJames Wright            else:
499*0006be33SJames Wright                diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol)
500*0006be33SJames Wright                if diff:
501*0006be33SJames Wright                    test_case.add_failure_info('cgns', output=diff)
502*0006be33SJames Wright                else:
503*0006be33SJames Wright                    (Path.cwd() / cgn_name).unlink()
504*0006be33SJames Wright
505*0006be33SJames Wright    # store result
506*0006be33SJames Wright    test_case.args = ' '.join(str(arg) for arg in run_args)
507*0006be33SJames Wright    output_str = test_case_output_string(test_case, spec, mode, backend, test, index)
508*0006be33SJames Wright
509*0006be33SJames Wright    return test_case, output_str
510*0006be33SJames Wright
511*0006be33SJames Wright
512*0006be33SJames Wrightdef init_process():
513*0006be33SJames Wright    """Initialize multiprocessing process"""
514*0006be33SJames Wright    # set up error handler
515*0006be33SJames Wright    global my_env
516*0006be33SJames Wright    my_env = os.environ.copy()
517*0006be33SJames Wright    my_env['CEED_ERROR_HANDLER'] = 'exit'
518*0006be33SJames Wright
519*0006be33SJames Wright
520*0006be33SJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
521*0006be33SJames Wright              suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite:
522*0006be33SJames Wright    """Run all test cases for `test` with each of the provided `ceed_backends`
523*0006be33SJames Wright
524*0006be33SJames Wright    Args:
525*0006be33SJames Wright        test (str): Name of test
526*0006be33SJames Wright        ceed_backends (List[str]): List of libCEED backends
527*0006be33SJames Wright        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
528*0006be33SJames Wright        nproc (int): Number of MPI processes to use when running each test case
529*0006be33SJames Wright        suite_spec (SuiteSpec): Object defining required methods for running tests
530*0006be33SJames Wright        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
531*0006be33SJames Wright
532*0006be33SJames Wright    Returns:
533*0006be33SJames Wright        TestSuite: JUnit `TestSuite` containing results of all test cases
534*0006be33SJames Wright    """
535*0006be33SJames Wright    test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test))
536*0006be33SJames Wright    if mode is RunMode.TAP:
537*0006be33SJames Wright        print('TAP version 13')
538*0006be33SJames Wright        print(f'1..{len(test_specs)}')
539*0006be33SJames Wright
540*0006be33SJames Wright    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
541*0006be33SJames Wright        async_outputs: List[List[mp.AsyncResult]] = [
542*0006be33SJames Wright            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec))
543*0006be33SJames Wright             for (i, backend) in enumerate(ceed_backends, start=1)]
544*0006be33SJames Wright            for spec in test_specs
545*0006be33SJames Wright        ]
546*0006be33SJames Wright
547*0006be33SJames Wright        test_cases = []
548*0006be33SJames Wright        for (i, subtest) in enumerate(async_outputs, start=1):
549*0006be33SJames Wright            is_new_subtest = True
550*0006be33SJames Wright            subtest_ok = True
551*0006be33SJames Wright            for async_output in subtest:
552*0006be33SJames Wright                test_case, print_output = async_output.get()
553*0006be33SJames Wright                test_cases.append(test_case)
554*0006be33SJames Wright                if is_new_subtest and mode == RunMode.TAP:
555*0006be33SJames Wright                    is_new_subtest = False
556*0006be33SJames Wright                    print(f'# Subtest: {test_case.category}')
557*0006be33SJames Wright                    print(f'    1..{len(ceed_backends)}')
558*0006be33SJames Wright                print(print_output, end='')
559*0006be33SJames Wright                if test_case.is_failure() or test_case.is_error():
560*0006be33SJames Wright                    subtest_ok = False
561*0006be33SJames Wright            if mode == RunMode.TAP:
562*0006be33SJames Wright                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
563*0006be33SJames Wright
564*0006be33SJames Wright    return TestSuite(test, test_cases)
565*0006be33SJames Wright
566*0006be33SJames Wright
567*0006be33SJames Wrightdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
568*0006be33SJames Wright    """Write a JUnit XML file containing the results of a `TestSuite`
569*0006be33SJames Wright
570*0006be33SJames Wright    Args:
571*0006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to write
572*0006be33SJames Wright        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
573*0006be33SJames Wright        batch (str): Name of JUnit batch, defaults to empty string
574*0006be33SJames Wright    """
575*0006be33SJames Wright    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
576*0006be33SJames Wright    output_file.write_text(to_xml_report_string([test_suite]))
577*0006be33SJames Wright
578*0006be33SJames Wright
579*0006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool:
580*0006be33SJames Wright    """Check whether any test cases in a `TestSuite` failed
581*0006be33SJames Wright
582*0006be33SJames Wright    Args:
583*0006be33SJames Wright        test_suite (TestSuite): JUnit `TestSuite` to check
584*0006be33SJames Wright
585*0006be33SJames Wright    Returns:
586*0006be33SJames Wright        bool: True if any test cases failed
587*0006be33SJames Wright    """
588*0006be33SJames Wright    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
589