aps-agent/tests/golden/test_contract_generation.py

103 lines
4.1 KiB
Python
Raw Normal View History

# 契约生成自动化黄金测试(plan.md §12 / 矩阵 117 行)
# ============================================================
# 生成器以 schema 为权威源;手写 types.ts 关键面(字段/必填/枚举)
# 必须与 schema 一致(兼容豁免 interfaceVersion 等可选字段)。
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCHEMAS = ROOT / "shared" / "schemas"
TYPESCRIPT_TYPES = ROOT / "apps" / "web" / "src" / "api" / "types.ts"
GENERATOR = ROOT / "scripts" / "generate_contract_types.py"
def _schema(name: str) -> dict:
return json.loads((SCHEMAS / f"{name}.schema.json").read_text(encoding="utf-8"))
def _run_generator(name: str | None = None) -> str:
cmd = [sys.executable, str(GENERATOR)]
if name:
cmd += ["--name", name]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60)
assert result.returncode == 0, result.stderr
return result.stdout
def _typescript_source() -> str:
return TYPESCRIPT_TYPES.read_text(encoding="utf-8")
def test_generator_runs_for_all_schemas():
"""生成器可对全部 7 个 schema 无异常执行,且输出含 interface。"""
out = _run_generator()
assert "export interface" in out
for path in SCHEMAS.glob("*.schema.json"):
schema = json.loads(path.read_text(encoding="utf-8"))
title = str(schema.get("title") or path.stem)
assert f"export interface {title}" in out, f"{title} 缺失"
def test_generator_emits_required_fields():
"""生成 interface 的必填字段与 schema required 一致。"""
schema = _schema("viewport_command")
out = _run_generator("viewport_command")
required = set(schema["required"])
for key in required:
assert re.search(rf"^ {key}: ", out, re.M), f"必填字段 {key} 未生成"
# 非必填字段带 ?
for key in set(schema["properties"]) - required:
assert re.search(rf"^ {key}\?: ", out, re.M), f"可选字段 {key} 未生成"
def test_generator_emits_enum_unions_matching_schema():
"""枚举 union 与 schema 枚举一致。"""
schema = _schema("ui_block")
out = _run_generator("ui_block")
enum = set(schema["properties"]["type"]["enum"])
# 生成 union 形式:'a' | 'b' ...
for value in enum:
assert f"'{value}'" in out, f"枚举 {value} 缺失"
# 手写 types.ts 的 UIBlockType 必须含 schema 全部枚举
ts = _typescript_source()
match = re.search(r"export type UIBlockType\s*=\s*(.*?);", ts, re.DOTALL)
assert match is not None
ts_enum = set(re.findall(r"'([^']+)'", match.group(1)))
assert enum <= ts_enum, "手写 UIBlockType 与 schema 枚举漂移"
def test_handwritten_types_cover_schema_required_fields():
"""手写 types.ts 关键面 ⊆ schema:每个 schema 必填字段出现在对应 TS 接口。"""
ts = _typescript_source()
for path in SCHEMAS.glob("*.schema.json"):
schema = json.loads(path.read_text(encoding="utf-8"))
title = str(schema.get("title") or path.stem)
# 手写接口名与 schema title 相同的契约
interface_match = re.search(
rf"export interface {re.escape(title)}\s*\{{(.*?)\n\}}", ts, re.DOTALL)
if not interface_match:
continue # 该契约 TS 端以其他形式承载(如 PlanNode 在 plan 端),跳过
body = interface_match.group(1)
for key in schema.get("required") or []:
assert re.search(rf"^\s*{re.escape(key)}\??:", body, re.M), \
f"{title} 手写缺必填字段 {key}"
def test_generator_maps_null_type_correctly():
"""nullable 类型:"null" 显式映射为 TS null(如 plan_node.parentId)。"""
out = _run_generator("plan_node")
assert "parentId: string | null;" in out
assert "| unknown;" not in out # null 不再坍缩为 unknown
def test_generator_deterministic():
"""生成器确定性:同输入两次输出一致。"""
a = _run_generator()
b = _run_generator()
assert a == b