移除了对scc_abi包的依赖,将相关头文件从libs/abi移动到libs/ast2ir目录下。 重构了基本类型解析功能,将parse_base_type函数提取为独立的 scc_ast2ir_parse_base_type实现,并支持有符号/无符号类型区分。 feat(ast2ir): 实现整数常量表达式求值器 新增了完整的整数常量表达式求值功能,支持C11标准中的常量表达式规则, 包括字面量、标识符、sizeof/_Alignof、一元/二元运算、条件表达式和 类型转换等操作。该功能用于数组大小和枚举值的编译期计算验证。 refactor(ast2ir): 完善类型提升和算术转换机制 改进了整数提升和寻常算术转换的实现,修复了移位操作的符号处理问题, 添加了无符号比较操作的支持,增强了类型安全检查,统一了错误处理流程。 fix(ast2ir): 修复赋值表达式返回值和数组大小计算问题 修正了赋值表达式的返回值处理,确保返回右侧值而不是存储指令引用。 使用新的常量表达式求值器替代原有的硬编码数组大小计算,提高了 数组声明的正确性。
381 lines
12 KiB
Python
381 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Integration test runner for scc compiler.
|
|
|
|
Reads test expectations from `expect.toml`, compiles each C source file with scc,
|
|
executes the resulting binary, and validates the process return code or stdout.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import difflib
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import tomllib
|
|
import uuid
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
WORKSPACE = Path(__file__).resolve().parent
|
|
CC_ROOT = WORKSPACE / "../.."
|
|
CC_PATH = CC_ROOT / "build/dev/scc"
|
|
CONFIG_PATH = WORKSPACE / "expect.toml"
|
|
DEFAULT_TIMEOUT = 1 # seconds
|
|
|
|
logger = logging.getLogger("scc-test")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data models
|
|
# ---------------------------------------------------------------------------
|
|
@dataclass
|
|
class TestCase:
|
|
"""A single test case defined in expect.toml."""
|
|
source: Path
|
|
expected: int | str # return code (int) or stdout (str)
|
|
test_type: str # "return" or "stdout"
|
|
origin_key: str # original TOML key for user-friendly display
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return f"{self.test_type.upper():6s} {self.source}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test runner
|
|
# ---------------------------------------------------------------------------
|
|
class Runner:
|
|
"""Compiles and executes test cases."""
|
|
|
|
def __init__(
|
|
self,
|
|
cc: Path,
|
|
workspace: Path,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
keep_temps: bool = False,
|
|
) -> None:
|
|
self.cc = cc.resolve()
|
|
self.workspace = workspace.resolve()
|
|
self.timeout = timeout
|
|
self.keep_temps = keep_temps
|
|
self._temp_dir: tempfile.TemporaryDirectory | None = None
|
|
|
|
@property
|
|
def temp_dir(self) -> Path:
|
|
if self._temp_dir is None:
|
|
self._temp_dir = tempfile.TemporaryDirectory(
|
|
prefix="scc-test-", dir=self.workspace
|
|
)
|
|
return Path(self._temp_dir.name)
|
|
|
|
def cleanup(self) -> None:
|
|
if self._temp_dir is not None:
|
|
self._temp_dir.cleanup()
|
|
self._temp_dir = None
|
|
|
|
def _unique_exe_path(self) -> Path:
|
|
"""Generate a unique executable path inside the temporary directory."""
|
|
return self.temp_dir / f"test_{uuid.uuid4().hex[:8]}.exe"
|
|
|
|
def compile(self, source: Path, output: Path) -> tuple[bool, str]:
|
|
"""Compile *source* into *output*. Returns (success, stderr)."""
|
|
cmd = [
|
|
str(self.cc),
|
|
str(source),
|
|
"-o",
|
|
str(output),
|
|
"--entry-point-symbol", "main",
|
|
]
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
cwd=self.workspace,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=self.timeout,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
# e.stdout, e.stderr 为已捕获的部分输出(字符串)
|
|
captured_stderr = e.stderr.strip() if e.stderr else ""
|
|
captured_stdout = e.stdout.strip() if e.stdout else ""
|
|
err_msg = f"""Compilation timed out.
|
|
stderr: {captured_stderr or '(no output)'}
|
|
stdout: {captured_stdout or '(no output)'}
|
|
cmd: {" ".join(cmd)}"""
|
|
return False, err_msg
|
|
except OSError as exc:
|
|
return False, f"Failed to invoke compiler: {exc}"
|
|
|
|
if proc.returncode != 0 or not output.exists():
|
|
return False, proc.stderr.strip() or "(no error message)"
|
|
return True, proc.stderr.strip()
|
|
|
|
def run_exe(self, exe: Path) -> tuple[int, str, str]:
|
|
"""Execute a binary. Returns (returncode, stdout, stderr)."""
|
|
# Make sure the file is executable (Unix)
|
|
exe.chmod(exe.stat().st_mode | 0o111)
|
|
try:
|
|
proc = subprocess.run(
|
|
[str(exe)],
|
|
cwd=self.workspace,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=self.timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return -1, "", "Execution timed out"
|
|
except OSError as exc:
|
|
return -1, "", f"Failed to run executable: {exc}"
|
|
|
|
return proc.returncode, proc.stdout, proc.stderr.strip()
|
|
|
|
def _print_diff(self, expected: str, actual: str) -> None:
|
|
"""Print unified diff between expected and actual stdout."""
|
|
expected_lines = expected.splitlines()
|
|
actual_lines = actual.splitlines()
|
|
diff = difflib.unified_diff(
|
|
expected_lines, actual_lines,
|
|
fromfile='expected', tofile='actual',
|
|
lineterm=''
|
|
)
|
|
# diff = difflib.ndiff(
|
|
# expected_lines, actual_lines,
|
|
# )
|
|
diff_lines = list(diff)
|
|
if not diff_lines:
|
|
logger.error(" Strings differ but no diff generated?")
|
|
return
|
|
logger.error(" Diff (expected vs actual):")
|
|
for line in diff_lines:
|
|
logger.error(" %s", line)
|
|
|
|
def run_one(self, test: TestCase) -> bool:
|
|
"""Run a single test case. Returns True if passed."""
|
|
logger.info("Testing %s", test.source)
|
|
|
|
# 1. Compile
|
|
exe_path = self._unique_exe_path()
|
|
ok, stderr = self.compile(test.source, exe_path)
|
|
if not ok:
|
|
logger.error(" Compilation FAILED: %s", stderr)
|
|
return False
|
|
|
|
# 2. Execute
|
|
returncode, stdout, run_err = self.run_exe(exe_path)
|
|
|
|
# Runtime error detection: negative return code (signal) or timeout
|
|
if run_err and returncode < 0:
|
|
logger.error(" Runtime error: %s", run_err)
|
|
self._remove(exe_path)
|
|
return False
|
|
|
|
# 3. Validate
|
|
if test.test_type == "return":
|
|
actual = returncode
|
|
else:
|
|
actual = stdout
|
|
|
|
passed = actual == test.expected
|
|
if passed:
|
|
logger.info(" PASSED")
|
|
else:
|
|
if test.test_type == "stdout":
|
|
logger.error(" FAILED: stdout mismatch")
|
|
self._print_diff(test.expected, actual)
|
|
else:
|
|
logger.error(
|
|
" FAILED: expected return code %r, got %r",
|
|
test.expected, actual
|
|
)
|
|
|
|
# 4. Cleanup
|
|
self._remove(exe_path)
|
|
return passed
|
|
|
|
def _remove(self, path: Path) -> None:
|
|
"""Remove *path* unless keep_temps is set."""
|
|
if self.keep_temps:
|
|
return
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
def run_all(self, tests: Sequence[TestCase], global_timeout: float | None = None) -> tuple[int, int]:
|
|
"""Run a sequence of tests. Returns (passed, total)."""
|
|
passed = 0
|
|
tested = 0
|
|
start_time = time.monotonic()
|
|
|
|
for idx, test in enumerate(tests, start=1):
|
|
if global_timeout is not None and (time.monotonic() - start_time) >= global_timeout:
|
|
logger.warning(
|
|
"Global timeout (%.1fs) reached - skipping remaining %d tests.",
|
|
global_timeout, len(tests) - tested
|
|
)
|
|
break
|
|
|
|
logger.info("(%d/%d) %s", idx, len(tests), test.description)
|
|
if self.run_one(test):
|
|
passed += 1
|
|
tested += 1
|
|
|
|
total_skipped = len(tests) - tested
|
|
if total_skipped > 0:
|
|
logger.warning("%d tests skipped due to global timeout.", total_skipped)
|
|
|
|
return passed, tested
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration loading
|
|
# ---------------------------------------------------------------------------
|
|
def load_config(config_path: Path) -> list[TestCase]:
|
|
"""Parse `expect.toml` and build list of TestCase objects."""
|
|
if not config_path.is_file():
|
|
logger.error("Config file not found: %s", config_path)
|
|
sys.exit(1)
|
|
|
|
with config_path.open("rb") as fh:
|
|
config = tomllib.load(fh)
|
|
|
|
cases: list[TestCase] = []
|
|
for test_file, expected in config.get("return_val_cases", {}).items():
|
|
cases.append(
|
|
TestCase(
|
|
source=WORKSPACE / test_file,
|
|
expected=expected,
|
|
test_type="return",
|
|
origin_key=test_file,
|
|
)
|
|
)
|
|
for test_file, expected in config.get("stdout_val_cases", {}).items():
|
|
cases.append(
|
|
TestCase(
|
|
source=WORKSPACE / test_file,
|
|
expected=expected,
|
|
test_type="stdout",
|
|
origin_key=test_file,
|
|
)
|
|
)
|
|
return cases
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI helpers
|
|
# ---------------------------------------------------------------------------
|
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Integration test runner for scc",
|
|
)
|
|
parser.add_argument(
|
|
"tests",
|
|
nargs="*",
|
|
metavar="SOURCE",
|
|
help="Specific test source files to run (as listed in expect.toml). "
|
|
"If none given, all tests are executed.",
|
|
)
|
|
parser.add_argument(
|
|
"--list",
|
|
action="store_true",
|
|
help="List all available test cases from expect.toml and exit.",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v",
|
|
action="store_true",
|
|
help="Enable debug-level logging.",
|
|
)
|
|
parser.add_argument(
|
|
"--keep-temps",
|
|
action="store_true",
|
|
help="Keep temporary executables (useful for debugging).",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=DEFAULT_TIMEOUT,
|
|
help=f"Timeout in seconds for compilation/execution (default: {DEFAULT_TIMEOUT}).",
|
|
)
|
|
parser.add_argument(
|
|
"--cc",
|
|
type=Path,
|
|
default=CC_PATH,
|
|
help=f"Path to scc compiler (default: {CC_PATH}).",
|
|
)
|
|
return parser
|
|
|
|
|
|
def select_tests(all_tests: list[TestCase], requested: list[str]) -> list[TestCase]:
|
|
"""Filter *all_tests* by user-provided source paths."""
|
|
if not requested:
|
|
return all_tests
|
|
|
|
selected: list[TestCase] = []
|
|
for name in requested:
|
|
matched = [
|
|
t for t in all_tests
|
|
if t.origin_key == name or str(t.source) == name
|
|
or t.source.name == name
|
|
]
|
|
if not matched:
|
|
logger.warning("No test case matches '%s'", name)
|
|
continue
|
|
selected.extend(matched)
|
|
return selected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main entry point
|
|
# ---------------------------------------------------------------------------
|
|
def main() -> None:
|
|
parser = build_arg_parser()
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(message)s",
|
|
)
|
|
|
|
# Load configuration
|
|
all_tests = load_config(CONFIG_PATH)
|
|
|
|
if args.list:
|
|
print("Available test cases:")
|
|
for i, test in enumerate(all_tests, start=1):
|
|
print(f" {i:3d}: {test.description}")
|
|
return
|
|
|
|
# Filter tests
|
|
selected = select_tests(all_tests, args.tests)
|
|
if not selected:
|
|
logger.warning("No tests to run.")
|
|
return
|
|
|
|
runner = Runner(
|
|
cc=args.cc,
|
|
workspace=CC_ROOT,
|
|
timeout=args.timeout,
|
|
keep_temps=args.keep_temps,
|
|
)
|
|
|
|
global_timeout = args.timeout * 3
|
|
passed, total = runner.run_all(selected, global_timeout)
|
|
logger.info("=" * 40)
|
|
if global_timeout and total < len(selected):
|
|
logger.info("Summary: %d/%d passed (%d skipped due to timeout)",
|
|
passed, total, len(selected) - total)
|
|
else:
|
|
logger.info("Summary: %d/%d passed", passed, total)
|
|
|
|
runner.cleanup()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |