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