Files
scc/tests/simple/test.py
zzy aa8a1ff8ce feat(compiler): 启用 ir2mcode 和 sccf2target 库并实现 x86_64 代码生成
- 在 cbuild.toml 中启用 ir2mcode 和 sccf2target 依赖库
- 修改 justfile 中的构建命令,使用 release 模式并更新 tokei 统计排除 mcode 目录
- 重构 LIR 中的地址操作数类型,将 SCC_LIR_INSTR_KIND_ADDR 重命名为 SCC_LIR_INSTR_KIND_MEM
- 实现完整的 MIR 到 x86_64 机器码转换,包括:
  - 添加 move、compare、binary operation 等指令发射函数
  - 实现条件分支和跳转指令生成
  - 支持算术、逻辑、移位等基本操作
  - 添加调用和返回指令处理
  - 实现栈分配和寄存器分配功能
- 完善 ir2mcode 模块,将 MIR 指令转换为机器码
- 更新 ir2sccf 模块,集成机器码生成功能
- 添加 mcode 库的架构支持和内存管理功能
- 修复 PE 文件生成中的空指针检查问题
2026-05-05 15:59:31 +08:00

327 lines
9.9 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 logging
import os
import subprocess
import sys
import tempfile
import tomllib
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
WORKSPACE = Path(__file__).resolve().parent
CC_PATH = WORKSPACE / "../../build/dev/scc"
CONFIG_PATH = WORKSPACE / "expect.toml"
DEFAULT_TIMEOUT = 10 # 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:
return False, "Compilation timed out"
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 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:
logger.error(
" FAILED: expected %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]) -> tuple[int, int]:
"""Run a sequence of tests. Returns (passed, total)."""
passed = 0
for idx, test in enumerate(tests, start=1):
logger.info("(%d/%d) %s", idx, len(tests), test.description)
if self.run_one(test):
passed += 1
return passed, len(tests)
# ---------------------------------------------------------------------------
# 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
tests = select_tests(all_tests, args.tests)
if not tests:
logger.warning("No tests to run.")
return
runner = Runner(
cc=args.cc,
workspace=WORKSPACE,
timeout=args.timeout,
keep_temps=args.keep_temps,
)
passed, total = runner.run_all(tests)
logger.info("=" * 40)
logger.info("Summary: %d/%d passed", passed, total)
if passed != total:
sys.exit(1)
runner.cleanup()
if __name__ == "__main__":
main()