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
|