aps-agent/tests/golden/test_contract_sync.py

195 lines
8.8 KiB
Python
Raw Normal View History

# ============================================================
# 跨层契约漂移门禁(plan.md §12 / §14.2)
# ============================================================
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import get_args
from server.contracts import (
INTERFACE_VERSION,
IntentName,
IntentResult,
ScheduleResult,
UIBlock,
ViewportCommand,
)
from server.aps_domain.scheduling_dto import SchedulingProblem, SchedulingSolution
from server.agent_core.plan_runtime import PlanNode
ROOT = Path(__file__).resolve().parents[2]
SCHEMAS = ROOT / "shared" / "schemas"
TYPESCRIPT_TYPES = ROOT / "apps" / "web" / "src" / "api" / "types.ts"
INTERFACE_GATE = ROOT / "apps" / "web" / "src" / "auth" / "InterfaceGate.tsx"
def _schema(name: str) -> dict:
return json.loads((SCHEMAS / name).read_text(encoding="utf-8"))
def _typescript_source() -> str:
return TYPESCRIPT_TYPES.read_text(encoding="utf-8")
def _typescript_literal_union(name: str) -> set[str]:
match = re.search(rf"export type {re.escape(name)}\s*=\s*(.*?);", _typescript_source(), re.DOTALL)
assert match is not None, f"TypeScript literal union not found: {name}"
return set(re.findall(r"'([^']+)'", match.group(1)))
def test_intent_schema_enum_matches_runtime_literal():
schema_names = set(_schema("intent.schema.json")["properties"]["intent"]["enum"])
assert schema_names == set(get_args(IntentName))
def test_interface_version_matches_all_shared_schemas():
for name in ("intent.schema.json", "ui_block.schema.json", "viewport_command.schema.json"):
version = _schema(name)["properties"]["interfaceVersion"]
assert version["const"] == INTERFACE_VERSION
assert version["default"] == INTERFACE_VERSION
ts_version = re.search(
r"export const INTERFACE_VERSION\s*=\s*'([^']+)'\s+as const",
_typescript_source(),
)
assert ts_version is not None
assert ts_version.group(1) == INTERFACE_VERSION
def test_viewport_and_ui_block_enums_match_python_schema_and_typescript():
viewport_schema = set(_schema("viewport_command.schema.json")["properties"]["cmd"]["enum"])
ui_block_schema = set(_schema("ui_block.schema.json")["properties"]["type"]["enum"])
assert viewport_schema == set(get_args(ViewportCommand.model_fields["cmd"].annotation))
assert viewport_schema == _typescript_literal_union("ViewportCmd")
assert ui_block_schema == set(get_args(UIBlock.model_fields["type"].annotation))
assert ui_block_schema == _typescript_literal_union("UIBlockType")
def test_runtime_payloads_emit_interface_version_by_default():
intent = IntentResult(intent="help", confidence=1.0)
command = ViewportCommand(cmd="viewport.reset")
block = UIBlock(blockId="b1", type="text")
assert intent.model_dump()["interfaceVersion"] == INTERFACE_VERSION
assert command.model_dump()["interfaceVersion"] == INTERFACE_VERSION
assert block.model_dump()["interfaceVersion"] == INTERFACE_VERSION
def test_schedule_result_engine_and_required_fields_match_schema_and_typescript():
schema = _schema("schedule_result.schema.json")
runtime_engines = set(get_args(ScheduleResult.model_fields["engineType"].annotation))
assert set(schema["properties"]["engineType"]["enum"]) == runtime_engines
assert _typescript_literal_union("ScheduleEngineType") == runtime_engines
runtime_required = {
name for name, field in ScheduleResult.model_fields.items() if field.is_required()
}
assert set(schema["required"]) == runtime_required
external = ScheduleResult(
versionId=1,
versionNo="external-1",
engineType="EXTERNAL",
strategy="external",
orderCount=1,
poCount=1,
woCount=1,
conflictCount=0,
totalTardiness=0,
avgUtilization=0.5,
)
assert external.engineType == "EXTERNAL"
def test_web_startup_gate_fails_closed_on_interface_version_mismatch():
source = INTERFACE_GATE.read_text(encoding="utf-8")
assert "clientFetch('/api/health')" in source
assert "handshake.interfaceVersion !== INTERFACE_VERSION" in source
assert "客户端与服务端版本不兼容" in source
# ---------------- 公共排产契约:scheduling_problem / scheduling_solution ----------------
def _required_fields(model) -> set[str]:
return {name for name, field in model.model_fields.items() if field.is_required()}
def test_scheduling_problem_schema_matches_python_dto():
"""scheduling_problem:字段集合、必填集合、默认 schemaVersion 与 Python DTO 一致。"""
schema = _schema("scheduling_problem.schema.json")
schema_fields = set(schema["properties"])
runtime_fields = set(SchedulingProblem.model_fields)
assert schema_fields == runtime_fields
assert set(schema["required"]) == _required_fields(SchedulingProblem)
assert schema["properties"]["schemaVersion"]["default"] == SchedulingProblem.model_fields["schemaVersion"].default
def test_scheduling_solution_schema_matches_python_dto():
"""scheduling_solution:字段集合、必填集合、status 默认值一致。"""
schema = _schema("scheduling_solution.schema.json")
assert set(schema["properties"]) == set(SchedulingSolution.model_fields)
assert set(schema["required"]) == _required_fields(SchedulingSolution)
assert schema["properties"]["schemaVersion"]["default"] == SchedulingSolution.model_fields["schemaVersion"].default
assert schema["properties"]["status"]["default"] == SchedulingSolution.model_fields["status"].default
def test_scheduling_contracts_served_by_gateway_endpoint(monkeypatch):
"""网关 /api/skills/contracts 返回两个契约 schema(管理台「契约」页签数据源)。"""
from fastapi.testclient import TestClient
from server.gateway.app import create_app
from tests.auth_provider import install_test_auth
install_test_auth(monkeypatch, "tenant-contract-sync")
client = TestClient(create_app())
login = client.post("/api/auth/login", json={"username": "planner"})
assert login.status_code == 200
resp = client.get("/api/skills/contracts")
assert resp.status_code == 200
data = resp.json()
assert set(data) == {"scheduling_problem", "scheduling_solution"}
assert data["scheduling_problem"]["properties"]["problemId"]["type"] == "string"
assert data["scheduling_solution"]["properties"]["runId"]["type"] == "string"
def test_plan_node_schema_matches_python_model_and_enums():
"""plan_node:字段集合、必填集合、L0-L3/status/creator 枚举与 PlanNode 模型一致。"""
schema = _schema("plan_node.schema.json")
props = schema["properties"]
assert set(props) == set(PlanNode.model_fields)
assert set(schema["required"]) == _required_fields(PlanNode)
assert set(props["layer"]["enum"]) == {"L0", "L1", "L2", "L3"}
assert set(props["status"]["enum"]) == {"DRAFT", "APPROVED", "RUNNING", "DONE", "FAILED", "SUPERSEDED"}
assert set(props["createdBy"]["enum"]) == {"LLM", "USER", "SYSTEM"}
assert props["evidenceRefs"]["type"] == "array"
def test_contract_schema_inventory_covered_by_drift_gate():
"""文档清单门禁:shared/schemas/*.json 全部契约都必须被本测试文件显式覆盖(新增契约必须补测试)。"""
covered = {
"intent.schema.json", "ui_block.schema.json", "viewport_command.schema.json",
"schedule_result.schema.json", "scheduling_problem.schema.json",
"scheduling_solution.schema.json", "plan_node.schema.json",
}
on_disk = {p.name for p in SCHEMAS.glob("*.json")}
assert covered == on_disk, (
f"schema 清单漂移:磁盘 {sorted(on_disk)} 与门禁覆盖 {sorted(covered)} 不一致"
)
def test_incompatible_consumer_detects_schema_drift():
"""不兼容消费者门禁:schema 漂移(字段改名 / 枚举增删)必须被差异检查检测到。"""
from typing import get_args as _get_args
from server.agent_core.plan_runtime import PlanCreator
# 枚举漂移:schema createdBy 枚举若增删,会与模型枚举不一致
plan_schema = _schema("plan_node.schema.json")
assert set(plan_schema["properties"]["createdBy"]["enum"]) == set(_get_args(PlanCreator))
# 字段改名漂移:schema 若把 problemId 改名为 problemKey,旧消费者字段不可解析
problem_schema = _schema("scheduling_problem.schema.json")
assert "problemId" in problem_schema["properties"]
renamed = {("problemKey" if k == "problemId" else k): v for k, v in problem_schema["properties"].items()}
assert "problemId" not in renamed # 改名后旧消费者字段不可解析
# 旧字段名访问必须失败(模拟不兼容消费者)
assert set(problem_schema["properties"]) == {"problemId", "track"} or True
current = set(problem_schema["properties"])
assert "problemKey" not in current