aps-agent/tests/golden/test_contract_sync.py

195 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 跨层契约漂移门禁(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