# ============================================================ # 参数优化网关黄金测试(矩阵 87 行 · 方向 W) # 覆盖:端点契约 / 优化触发(split→回放→候选) / # P2 确认卡 promote(批准升级、拒绝留 GRAY) / # 手动回滚 P1 / 线上观测劣化自动回滚+审计 # ============================================================ from __future__ import annotations import pytest from fastapi.testclient import TestClient from server.agent_core import harness from server.agent_core.param_opt import ParameterOptimizer from server.auth.context import bind_identity, reset_identity from server.state.seed import seed_world from server.timeutil import add_minutes, fmt_date, today0 def _tight_world(): """6 单紧张交期世界(与 test_param_opt 同构)。""" w = seed_world() base = today0() order_date = fmt_date(base) product = next(m for m in w["materials"] if m["type"] == "FINISHED_PRODUCT") sos = [] for i in range(6): oid = i + 1 level = "VIP" if i % 2 == 0 else "C" due = fmt_date(add_minutes(base, (2, 2, 3, 3, 4, 5)[i] * 24 * 60)) sos.append({ "id": oid, "orderNo": f"SO-PT-{oid}", "customerId": f"C{oid}", "customerName": f"客户{level}", "customerLevel": level, "orderDate": order_date, "deliveryDate": due, "priority": 5, "manualPriority": None, "status": "CONFIRMED", "source": "MANUAL", "specialRequirements": "", "totalAmount": 1000, "isRush": False, "rushStrategy": None, "changes": [], "createdBy": "test", "createdAt": order_date + " 09:00", "updatedAt": order_date + " 09:00", "items": [{ "id": oid * 10, "orderId": oid, "lineNo": 1, "productId": product["id"], "productName": product["name"], "productCode": product["code"], "quantity": 300, "unit": "件", "bomVersion": "V1.0", "routingVersion": "V1.0", "status": "PENDING", "note": "", }], }) w["salesOrders"] = sos w["scheduleVersions"] = [] w["productionOrders"] = [] w["workOrders"] = [] w["conflicts"] = [] return w class _WorldStore: """测试用世界存储:与确认卡记录的租户/世界键匹配。""" def __init__(self, world: dict, *, tenant_uuid: str = "tenant-param-opt", world_key: str = "personal-1001"): self.tenant_uuid = tenant_uuid self.world_key = world_key self.data = world def next_id(self, _kind: str) -> int: return len(self.data.get("auditEvents") or []) + 1 def save(self) -> None: pass class _CheckpointStore: def __init__(self) -> None: self.pairs: list[dict] = [] def create(self, *_args, **_kwargs) -> dict: pair = {"pairId": f"pair-{len(self.pairs) + 1}"} self.pairs.append(pair) return pair class _ProjectStore: def active_world_key(self) -> str: return "default" def require_active_write(self) -> None: pass @pytest.fixture(autouse=True) def _clear_pending(): harness._approval_store.clear() yield harness._approval_store.clear() @pytest.fixture def client_factory(monkeypatch): """返回 (client, store, provider);用测试认证代替 JMS。""" import server.gateway.app as gateway_module import server.state.projects as projects_module from tests.auth_provider import install_test_auth provider = install_test_auth(monkeypatch, "tenant-param-opt") def factory(world: dict | None = None): store = _WorldStore(world if world is not None else _tight_world()) monkeypatch.setattr(gateway_module, "get_store", lambda: store) monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: _CheckpointStore()) monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore()) client = TestClient(gateway_module.create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text return client, store, provider return factory def _as(identity, callback): token = bind_identity(identity) try: return callback() finally: reset_identity(token) def _audit_actions(world: dict) -> list[str]: return [e.get("action") for e in world.get("auditEvents") or []] # ---------------- 端点契约 / 优化触发 ---------------- def test_params_experiments_contract_empty(client_factory): """GET /api/params/experiments 契约:无实验时返回空列表。""" client, _, _ = client_factory() resp = client.get("/api/params/experiments") assert resp.status_code == 200, resp.text assert resp.json() == {"experiments": []} def test_params_optimize_creates_gray_experiments_and_audits(client_factory): """POST /api/params/optimize:split→回放→候选,产出 GRAY 实验且审计 param.optimize。""" client, store, _ = client_factory() resp = client.post("/api/params/optimize", json={ "sessionId": "opt-session", "strategy": "COMPREHENSIVE", "scope": {"lineIds": [1]}, "candidates": [{"freezeWindowHours": 0.0}, {"freezeWindowHours": 48.0}], }) assert resp.status_code == 200, resp.text data = resp.json() assert data["summary"]["total"] == 2 assert data["summary"]["gray"] == 1 # freeze 48h 训练回放退化被拒绝 assert data["summary"]["rejected"] == 1 gray = data["results"][0] assert gray["stage"] == "gray" and gray["experimentId"] exp = next(e for e in store.data["paramExperiments"] if e["id"] == gray["experimentId"]) assert exp["status"] == "GRAY" assert exp["scope"] == {"lineIds": [1]} assert "split" in exp and "trainReplay" in exp # 集隔离与回放证据均在 assert "param.optimize" in _audit_actions(store.data) # 实验列表可查 resp2 = client.get("/api/params/experiments") assert len(resp2.json()["experiments"]) == 1 assert resp2.json()["experiments"][0]["id"] == exp["id"] # 主干参数未动(灰度不全量写入) assert store.data["scheduleParams"]["freezeWindowHours"] == 24.0 def test_params_optimize_default_candidates_from_sensitivity(client_factory): """默认候选:敏感性分析方向→候选→回放→GRAY(闭环驱动)。""" client, store, _ = client_factory() resp = client.post("/api/params/optimize", json={"sessionId": "opt2"}) assert resp.status_code == 200, resp.text data = resp.json() assert data["summary"]["total"] >= 3 assert data["summary"]["gray"] >= 1 assert data["summary"]["rejected"] == 0 assert len(store.data.get("paramExperiments") or []) == data["summary"]["total"] # ---------------- P2 确认卡 promote ---------------- def test_promote_stages_confirm_card_and_approve_activates(client_factory): """promote 经 P2 门禁:出卡后仍为 GRAY;批准后验证回放通过 → FULL+全量生效。""" world = _tight_world() opt = ParameterOptimizer() g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) exp_id = g["experiment"]["id"] client, _, provider = client_factory(world) resp = client.post(f"/api/params/experiments/{exp_id}/promote", json={"sessionId": "promote-session"}) assert resp.status_code == 200, resp.text data = resp.json() block = data["block"] assert block["type"] == "confirm-card" confirm_id = block["props"]["confirmId"] assert data["message"].find("P2") >= 0 # 卡面文案含实验摘要/候选参数/回放 KPI card_lines = "\n".join(block["props"].get("summary") or []) assert "freezeWindowHours=0.0" in card_lines assert "GRAY" in card_lines and "KPI" in card_lines assert "GRAY→ACTIVE" in data["message"] or "升级" in data["message"] assert world["paramExperiments"][0]["status"] == "GRAY" # 确认前不动 assert "param.promote.stage" in _audit_actions(world) # 批准执行(单次确认,P2 不需二次确认) resp2 = client.post("/api/actions/confirm", json={"sessionId": "promote-session", "confirmId": confirm_id, "approve": True, "note": "同意升级"}) assert resp2.status_code == 200, resp2.text assert resp2.json()["secondConfirmRequired"] is False rec = world["paramExperiments"][0] assert rec["status"] == "FULL" assert world["scheduleParams"]["freezeWindowHours"] == 0.0 assert rec["validationReplay"]["degraded"] is False assert rec["productionBaseline"] is None # 激活时无观测,等首个观测自校准 audits = _audit_actions(world) assert "param.promote" in audits assert "param.promote.stage" in audits promote_evt = next(e for e in world["auditEvents"] if e["action"] == "param.promote") assert promote_evt["power"] == "P2" assert promote_evt["rationale"]["confirmId"] == confirm_id # 批准历史记录 note identity = provider._identity("planner") history = _as(identity, harness.list_approval_history) assert history[-1]["note"] == "同意升级" assert history[-1]["status"] == "APPROVED" def test_promote_reject_keeps_gray(client_factory): """拒绝则留在 GRAY:无任何世界写入,审计 param.experiment.promote.reject。""" world = _tight_world() opt = ParameterOptimizer() g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) exp_id = g["experiment"]["id"] client, _, _ = client_factory(world) resp = client.post(f"/api/params/experiments/{exp_id}/promote", json={"sessionId": "s"}) confirm_id = resp.json()["block"]["props"]["confirmId"] resp2 = client.post("/api/actions/confirm", json={"sessionId": "s", "confirmId": confirm_id, "approve": False}) assert resp2.status_code == 200, resp2.text assert "拒绝" in resp2.json()["message"] or "取消" in resp2.json()["message"] or "DENIED" in resp2.json()["message"].upper() or "驳回" in resp2.json()["message"] rec = world["paramExperiments"][0] assert rec["status"] == "GRAY" # 留在 GRAY assert world["scheduleParams"]["freezeWindowHours"] == 24.0 assert "param.experiment.promote.reject" in _audit_actions(world) def test_promote_unknown_or_non_gray_rejected(client_factory): """契约保护:不存在实验 / 非 GRAY 实验不出卡。""" client, store, _ = client_factory() resp = client.post("/api/params/experiments/px-none/promote", json={"sessionId": "s"}) assert resp.status_code == 200 and "error" in resp.json() # 已升级实验不可再提交 world = store.data opt = ParameterOptimizer() g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) rec = opt.finalize(world, g["experiment"]["id"]) assert rec["status"] == "FULL" resp2 = client.post(f"/api/params/experiments/{g['experiment']['id']}/promote", json={"sessionId": "s"}) assert "error" in resp2.json() # ---------------- 手动回滚(P1) ---------------- def test_manual_rollback_p1_direct(client_factory): """手动回滚 P1:直接执行,恢复上一参数版本,审计 param.rollback。""" world = _tight_world() opt = ParameterOptimizer() g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) exp_id = g["experiment"]["id"] opt.finalize(world, exp_id) # → FULL,freeze=0.0 assert world["scheduleParams"]["freezeWindowHours"] == 0.0 client, _, _ = client_factory(world) resp = client.post(f"/api/params/experiments/{exp_id}/rollback", json={"sessionId": "s", "reason": "人工干预"}) assert resp.status_code == 200, resp.text rec = world["paramExperiments"][0] assert rec["status"] == "ROLLED_BACK" assert rec["rollbackReason"] == "人工干预" assert world["scheduleParams"]["freezeWindowHours"] == 24.0 # 恢复上一版本 evt = next(e for e in world["auditEvents"] if e["action"] == "param.rollback") assert evt["power"] == "P1" assert evt["rationale"]["reason"] == "人工干预" # ---------------- 线上观测驱动回滚监控 ---------------- def test_observation_degradation_auto_rollback_via_endpoint(client_factory): """观测回调:连续劣化达阈值自动回滚+审计,观测数据落盘 paramObservations。""" world = _tight_world() opt = ParameterOptimizer(max_consecutive_degraded=2) g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) exp_id = g["experiment"]["id"] opt.finalize(world, exp_id) # → FULL client, _, _ = client_factory(world) # 基线自校准(首个观测) r1 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 10.0}}) assert r1.status_code == 200, r1.text assert r1.json()["autoRolledBack"] == [] # 劣化 1 r2 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 60.0}}) assert r2.status_code == 200, r2.text assert r2.json()["autoRolledBack"] == [] assert world["paramExperiments"][0]["degradedStreak"] == 1 # 劣化 2 → 自动回滚 r3 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 90.0}}) assert r3.status_code == 200, r3.text assert r3.json()["autoRolledBack"] == [exp_id] rec = world["paramExperiments"][0] assert rec["status"] == "ROLLED_BACK" assert rec["rollbackReason"] == "production-degraded" assert world["scheduleParams"]["freezeWindowHours"] == 24.0 # 观测数据落盘 assert len(world.get("paramObservations") or []) == 3 assert world["paramObservations"][-1]["kpi"] == {"tardiness": 90.0} # 审计 evts = [e for e in world["auditEvents"] if e["action"] == "param.experiment.auto_rolled_back"] assert len(evts) == 1 assert evts[0]["target"] == {"type": "PARAM_EXPERIMENT", "id": exp_id} assert evts[0]["power"] == "P1" assert evts[0]["rationale"]["degradedStreak"] == 2 assert evts[0]["rationale"]["observationId"] == world["paramObservations"][-1]["id"] def test_observation_endpoint_configurable_threshold(client_factory): """可配置连续劣化阈值:maxConsecutiveDegraded=1 单次劣化即回滚。""" world = _tight_world() opt = ParameterOptimizer() g = opt.promote_gray(world, {"freezeWindowHours": 0.0}) exp_id = g["experiment"]["id"] opt.finalize(world, exp_id) client, _, _ = client_factory(world) r1 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 10.0}}) assert r1.status_code == 200 r2 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 70.0}, "maxConsecutiveDegraded": 1}) assert r2.status_code == 200, r2.text assert r2.json()["autoRolledBack"] == [exp_id] assert world["paramExperiments"][0]["status"] == "ROLLED_BACK"