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