"""跨语言契约测试:engine/schema.py 的输出必须与 src/shared/analysis.ts 的类型对齐。 之前这个文件把 key 集合硬编码在 Python 里,从不读 analysis.ts —— 也就是说 TS 侧加一个字段、或者两边字段名写歧了,这个"契约测试"一律绿灯, 而 README 声称的"用契约测试保证一致"并不成立。 现在两边都是解析出来的:Python 侧从 dataclass 的注解拿,TS 侧从 interface 声明拿。 """ import dataclasses import io import json import os import re import subprocess import sys from engine import schema ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) FX = os.path.join(os.path.dirname(__file__), "fixtures") ANALYSIS_TS = os.path.join(ROOT, "src", "shared", "analysis.ts") def _ts_interface_fields(name): """从 analysis.ts 里抽出某个 interface 的字段名集合。 只处理"一行一个字段"的写法(本仓库的风格),够用且不需要引 TS parser。 可选字段的 `?` 去掉后再比较。 """ src = io.open(ANALYSIS_TS, encoding="utf-8").read() m = re.search(r"export interface %s\s*\{(.*?)\n\}" % re.escape(name), src, re.S) assert m, "analysis.ts 里找不到 interface %s" % name fields = set() for line in m.group(1).splitlines(): line = line.strip() if not line or line.startswith("//") or line.startswith("*"): continue fm = re.match(r"([A-Za-z_][A-Za-z0-9_]*)\??\s*:", line) if fm: fields.add(fm.group(1)) assert fields, "interface %s 解析出 0 个字段,解析器该修了" % name return fields def _py_dataclass_fields(cls): return {f.name for f in dataclasses.fields(cls)} def _run_engine(extra=(), env=None): # 默认关掉 hide_internal:契约测试要断言 keys / shapes, # filtered 之后的 functions 列表可能为空,IndexError 挂掉。 p = subprocess.run( [sys.executable, "-m", "engine.runner", "--script", os.path.join(FX, "nested_calls.py"), "--no-hide-internal", *extra], capture_output=True, cwd=ROOT, env=env, ) return p def _engine_result(): p = _run_engine() return json.loads(p.stdout.decode("utf-8").strip().splitlines()[-1]) def test_top_level_keys_match_typescript(): data = _engine_result() assert set(data.keys()) == _ts_interface_fields("AnalysisResult") def test_top_level_keys_match_python_dataclass(): data = _engine_result() assert set(data.keys()) == _py_dataclass_fields(schema.AnalysisResult) def test_wall_time_keys_match_both_sides(): data = _engine_result() keys = set(data["wallTime"].keys()) assert keys == _ts_interface_fields("WallTime") assert keys == _py_dataclass_fields(schema.WallTime) def test_function_keys_match_both_sides(): data = _engine_result() keys = set(data["functions"][0].keys()) assert keys == _ts_interface_fields("FunctionNode") assert keys == _py_dataclass_fields(schema.FunctionNode) def test_output_is_pure_ascii(): """输出必须是纯 ASCII。 反模式说明现在是历史的;保留 ensure_ascii=True 后输出应当逐字节 ASCII, 跨 locale 一致。这个回归保护防止有人"为可读性"改回 ensure_ascii=False。 """ env = {k: v for k, v in os.environ.items() if k not in ("PYTHONUTF8", "PYTHONIOENCODING")} p = _run_engine(env=env) raw = p.stdout bad = [b for b in raw if b > 127] assert not bad, "引擎输出含 %d 个非 ASCII 字节" % len(bad) raw.decode("ascii") # 不该抛 def test_out_flag_writes_result_to_file(tmp_path): """--out:结果走独立文件,stdout 留给用户代码。 锁的是那个回归 —— 结果曾和用户输出共用 stdout,一句不带换行的 sys.stdout.write 就能让合法 Python 报"结果解析失败"。 """ out = os.path.join(str(tmp_path), "result.json") script = os.path.join(str(tmp_path), "chatty.py") io.open(script, "w", encoding="utf-8", newline="\n").write( 'import sys\ndef f():\n sys.stdout.write("done")\n return 1\nf()\n' ) p = subprocess.run( [sys.executable, "-m", "engine.runner", "--script", script, "--out", out], capture_output=True, cwd=ROOT, ) assert os.path.exists(out), "引擎没写出结果文件;stderr=%s" % p.stderr[-400:] data = json.loads(io.open(out, encoding="utf-8").read()) assert data["status"] == "ok" # 用户的输出确实进了 stdout,而且没污染结果 assert b"done" in p.stdout assert b"schemaVersion" not in p.stdout