219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
# Copyright 2020 The Pigweed Authors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
|
# use this file except in compliance with the License. You may obtain a copy of
|
|
# the License at
|
|
#
|
|
# https://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations under
|
|
# the License.
|
|
"""Tools for generating Pigweed tests that execute in C++ and Python."""
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
import unittest
|
|
|
|
from typing import (Any, Callable, Dict, Generic, Iterable, Iterator, List,
|
|
Sequence, TextIO, TypeVar, Union)
|
|
|
|
_COPYRIGHT = f"""\
|
|
// Copyright {datetime.now().year} The Pigweed Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
|
// use this file except in compliance with the License. You may obtain a copy of
|
|
// the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
// License for the specific language governing permissions and limitations under
|
|
// the License.
|
|
|
|
// AUTOGENERATED - DO NOT EDIT
|
|
//
|
|
// Generated at {datetime.now().isoformat()}
|
|
"""
|
|
|
|
_HEADER_CPP = _COPYRIGHT + """\
|
|
// clang-format off
|
|
"""
|
|
|
|
_HEADER_JS = _COPYRIGHT + """\
|
|
/* eslint-env browser, jasmine */
|
|
"""
|
|
|
|
|
|
class Error(Exception):
|
|
"""Something went wrong when generating tests."""
|
|
|
|
|
|
T = TypeVar('T')
|
|
|
|
|
|
@dataclass
|
|
class Context(Generic[T]):
|
|
"""Info passed into test generator functions for each test case."""
|
|
group: str
|
|
count: int
|
|
total: int
|
|
test_case: T
|
|
|
|
def cc_name(self) -> str:
|
|
name = ''.join(w.capitalize()
|
|
for w in self.group.replace('-', ' ').split(' '))
|
|
name = ''.join(c if c.isalnum() else '_' for c in name)
|
|
return f'{name}_{self.count}' if self.total > 1 else name
|
|
|
|
def py_name(self) -> str:
|
|
name = 'test_' + ''.join(c if c.isalnum() else '_'
|
|
for c in self.group.lower())
|
|
return f'{name}_{self.count}' if self.total > 1 else name
|
|
|
|
def ts_name(self) -> str:
|
|
name = ''.join(c if c.isalnum() else ' ' for c in self.group.lower())
|
|
return f'{name} {self.count}' if self.total > 1 else name
|
|
|
|
|
|
# Test cases are specified as a sequence of strings or test case instances. The
|
|
# strings are used to separate the tests into named groups. For example:
|
|
#
|
|
# STR_SPLIT_TEST_CASES = (
|
|
# 'Empty input',
|
|
# MyTestCase('', '', []),
|
|
# MyTestCase('', 'foo', []),
|
|
# 'Split on single character',
|
|
# MyTestCase('abcde', 'c', ['ab', 'de']),
|
|
# ...
|
|
# )
|
|
#
|
|
GroupOrTest = Union[str, T]
|
|
|
|
# Python tests are generated by a function that returns a function usable as a
|
|
# unittest.TestCase method.
|
|
PyTest = Callable[[unittest.TestCase], None]
|
|
PyTestGenerator = Callable[[Context[T]], PyTest]
|
|
|
|
# C++ tests are generated with a function that returns or yields lines of C++
|
|
# code for the given test case.
|
|
CcTestGenerator = Callable[[Context[T]], Iterable[str]]
|
|
|
|
JsTestGenerator = Callable[[Context[T]], Iterable[str]]
|
|
|
|
|
|
class TestGenerator(Generic[T]):
|
|
"""Generates tests for multiple languages from a series of test cases."""
|
|
def __init__(self, test_cases: Sequence[GroupOrTest[T]]):
|
|
self._cases: Dict[str, List[T]] = defaultdict(list)
|
|
message = ''
|
|
|
|
if len(test_cases) < 2:
|
|
raise Error('At least one test case must be provided')
|
|
|
|
if not isinstance(test_cases[0], str):
|
|
raise Error(
|
|
'The first item in the test cases must be a group name string')
|
|
|
|
for case in test_cases:
|
|
if isinstance(case, str):
|
|
message = case
|
|
else:
|
|
self._cases[message].append(case)
|
|
|
|
if '' in self._cases:
|
|
raise Error('Empty test group names are not permitted')
|
|
|
|
def _test_contexts(self) -> Iterator[Context[T]]:
|
|
for group, test_list in self._cases.items():
|
|
for i, test_case in enumerate(test_list, 1):
|
|
yield Context(group, i, len(test_list), test_case)
|
|
|
|
def _generate_python_tests(self, define_py_test: PyTestGenerator):
|
|
tests: Dict[str, Callable[[Any], None]] = {}
|
|
|
|
for ctx in self._test_contexts():
|
|
test = define_py_test(ctx)
|
|
test.__name__ = ctx.py_name()
|
|
|
|
if test.__name__ in tests:
|
|
raise Error(
|
|
f'Multiple Python tests are named {test.__name__}!')
|
|
|
|
tests[test.__name__] = test
|
|
|
|
return tests
|
|
|
|
def python_tests(self, name: str, define_py_test: PyTestGenerator) -> type:
|
|
"""Returns a Python unittest.TestCase class with tests for each case."""
|
|
return type(name, (unittest.TestCase, ),
|
|
self._generate_python_tests(define_py_test))
|
|
|
|
def _generate_cc_tests(self, define_cpp_test: CcTestGenerator, header: str,
|
|
footer: str) -> Iterator[str]:
|
|
yield _HEADER_CPP
|
|
yield header
|
|
|
|
for ctx in self._test_contexts():
|
|
yield from define_cpp_test(ctx)
|
|
yield ''
|
|
|
|
yield footer
|
|
|
|
def cc_tests(self, output: TextIO, define_cpp_test: CcTestGenerator,
|
|
header: str, footer: str):
|
|
"""Writes C++ unit tests for each test case to the given file."""
|
|
for line in self._generate_cc_tests(define_cpp_test, header, footer):
|
|
output.write(line)
|
|
output.write('\n')
|
|
|
|
def _generate_ts_tests(self, define_ts_test: JsTestGenerator, header: str,
|
|
footer: str) -> Iterator[str]:
|
|
yield _HEADER_JS
|
|
yield header
|
|
|
|
for ctx in self._test_contexts():
|
|
yield from define_ts_test(ctx)
|
|
yield footer
|
|
|
|
def ts_tests(self, output: TextIO, define_js_test: JsTestGenerator,
|
|
header: str, footer: str):
|
|
"""Writes JS unit tests for each test case to the given file."""
|
|
for line in self._generate_ts_tests(define_js_test, header, footer):
|
|
output.write(line)
|
|
output.write('\n')
|
|
|
|
|
|
def _to_chars(data: bytes) -> Iterator[str]:
|
|
for i, byte in enumerate(data):
|
|
try:
|
|
char = data[i:i + 1].decode()
|
|
yield char if char.isprintable() else fr'\x{byte:02x}'
|
|
except UnicodeDecodeError:
|
|
yield fr'\x{byte:02x}'
|
|
|
|
|
|
def cc_string(data: Union[str, bytes]) -> str:
|
|
"""Returns a C++ string literal version of a byte string or UTF-8 string."""
|
|
if isinstance(data, str):
|
|
data = data.encode()
|
|
|
|
return '"' + ''.join(_to_chars(data)) + '"'
|
|
|
|
|
|
def parse_test_generation_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description='Generate unit test files')
|
|
parser.add_argument('--generate-cc-test',
|
|
type=argparse.FileType('w'),
|
|
help='Generate the C++ test file')
|
|
parser.add_argument('--generate-ts-test',
|
|
type=argparse.FileType('w'),
|
|
help='Generate the JS test file')
|
|
return parser.parse_known_args()[0]
|