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