update
This commit is contained in:
158
engine/tests/test_runner.py
Normal file
158
engine/tests/test_runner.py
Normal 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() 抛 SystemExit(BaseException 子类),之前 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
|
||||
Reference in New Issue
Block a user