195 lines
8.8 KiB
Python
195 lines
8.8 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 跨层契约漂移门禁(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
|