This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

158
engine/tests/test_runner.py Normal file
View File

@@ -0,0 +1,158 @@
import json
import os
import subprocess
import sys
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _run(script, *extra):
# 默认关掉 hide_internal这些测试是结构 / 状态 / 形状的契约测试,
# 要看到 fixture 里所有 frame 才能断言。hide_internal 是 UI 默认开的产品行为,
# 不是引擎协议的一部分 —— 测协议就别测产品默认。
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script,
"--no-hide-internal", *extra],
capture_output=True, text=True, cwd=ROOT,
)
assert p.stdout.strip(), f"no stdout; stderr={p.stderr}"
return json.loads(p.stdout.strip().splitlines()[-1])
def test_ok_run():
data = _run(os.path.join(FX, "nested_calls.py"))
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] > 0
assert data["wallTime"]["unit"] == "s"
assert len(data["functions"]) >= 3
def test_syntax_error(tmp_path):
bad = tmp_path / "syntax_error.py"
bad.write_text("def f(:\n pass\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "syntax_error"
assert data["error"]["type"]
def test_runtime_error(tmp_path):
bad = tmp_path / "runtime_error.py"
bad.write_text("raise ValueError('boom')\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "boom" in data["error"]["message"]
def test_sys_exit_becomes_runtime_error(tmp_path):
"""sys.exit() 抛 SystemExitBaseException 子类),之前 except Exception 抓不到,
子进程裸退出无 JSON → UI 显示 "引擎无输出"。现在应被显式捕获并归为 runtime_error。"""
bad = tmp_path / "exit.py"
bad.write_text("import sys\nsys.exit(7)\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "SystemExit" in data["error"]["type"]
assert "7" in data["error"]["message"]
def test_io_error_returns_runtime_error(tmp_path):
"""脚本文件不存在 / 不可读时,不要让子进程裸崩 — 应该回 runtime_error 给出有用信息。"""
missing = tmp_path / "does_not_exist.py"
data = _run(str(missing))
assert data["status"] == "runtime_error"
assert "FileNotFoundError" in data["error"]["type"] or "无法读取" in data["error"]["message"]
def test_bom_script_runs_ok(tmp_path):
"""带 BOM 的 UTF-8 脚本Windows 记事本默认保存格式)必须能正常跑。
回归测试runner 曾用 encoding="utf-8" 读源码BOM 的  前缀残留下来,
语法预检的 compile() 直接抛 SyntaxError: invalid character用户看到的是
"语法错误"而不是正常结果 —— 而 harness.py 早就用 utf-8-sig 处理对了,
两处不一致导致这个 bug 只在预检阶段出现。
"""
script = tmp_path / "bom.py"
script.write_text("x = sum(range(1000))\n", encoding="utf-8-sig")
# 确认 fixture 真的带 BOM否则这个测试会静默失效
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
data = _run(str(script))
assert data["status"] == "ok", f"BOM 脚本被误判: {data.get('error')}"
def test_binary_script_returns_runtime_error(tmp_path):
"""非 UTF-8 / 二进制文件应回结构化 runtime_error而不是让引擎裸崩。
回归测试UnicodeDecodeError 不是 OSError 的子类,之前只 except OSError
的那层保护抓不到它,子进程崩溃且无 JSON 输出 → UI 显示"引擎无输出"
"""
script = tmp_path / "binary.py"
script.write_bytes(b"\x00\x01\x02\xff\xfe\xfd binary garbage \x80\x81")
data = _run(str(script))
assert data["status"] in ("runtime_error", "syntax_error")
assert data["error"]["type"]
def test_scope_default_is_user():
"""默认 --scope=user —— 不给 scope 参数时只看到用户脚本里的函数。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script)
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# user 模式:不应出现 json 模块的内部帧(虽然 fixture 里 import 了 json
json_internal = {f for f in files if "json" in f and "decoder" in f or "json" in f and "encoder" in f}
assert not json_internal, f"默认 scope 应为 user但出现了 json 内部帧: {json_internal}"
def test_scope_all_includes_library_frames():
"""--scope=all让耗时可以下钻到 import 的包里 —— json 模块的函数也应进入结果。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script, "--scope", "all")
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# scope=all 时至少有一个 json 相关的文件路径进来 —— 说明 import 的包被归因了
has_json = any("json" in f for f in files)
assert has_json, f"scope=all 应包含 json 模块帧,实际 files: {files}"
def test_user_side_effects_run_once(tmp_path):
"""v4 单跑架构核心回归:用户脚本里的副作用只执行一次。
v3 时期 runner 跑两次(裸跑测 wall_time + cProfile 跑),
用户脚本里 plot()、print()、写文件等副作用也跟着跑两次 —— 经典"plot 弹两个窗口"
bug 的根因。v4 改成单次 exec(code) under cProfile + 校准系数折算 wall-time 后,
副作用应只发生 1 次。
检测手段:让用户脚本 append 一行到文件exec 一次就一行,跑两次就两行。
读 marker 文件的行数即可判断 exec 次数。
"""
marker = tmp_path / "marker.txt"
script = tmp_path / "s.py"
# 用 raw 字符串避 Windows 反斜杠麻烦append 模式保证两次跑不会被覆盖
script.write_text(
"with open(r'" + str(marker) + "', 'a', encoding='utf-8') as f:\n"
" f.write('X\\n')\n",
encoding="utf-8",
)
# 直接 spawn runner 子进程(不走 _run helper —— 那个 helper 是从 stdout 解析 JSON
# 的,我们这次主要观察 marker 文件)
out_path = tmp_path / "result.json"
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", str(script), "--out", str(out_path)],
capture_output=True, text=True, cwd=ROOT,
)
assert p.returncode == 0, f"runner 失败:{p.stderr}"
assert marker.exists(), "用户脚本副作用根本没执行"
lines = marker.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1, (
f"副作用被多次执行(找到 {len(lines)}v4 单跑架构回归:{lines}"
)
# 同时验证 result.json 里 calibration 字段在 + wallTime 被校准
result = json.loads(out_path.read_text(encoding="utf-8"))
assert result["status"] == "ok"
assert "calibration" in result and result["calibration"] is not None
assert result["calibration"]["ratio"] >= 1.0
assert result["wallTime"]["seconds"] > 0