103 lines
4.1 KiB
Python
103 lines
4.1 KiB
Python
|
|
# 契约生成自动化黄金测试(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
|