aps-agent/tests/golden/test_branch_merge_diff.py

201 lines
9.1 KiB
Python
Raw Normal View History

# ============================================================
# 分支合并世界差异黄金测试(矩阵 51 行:merge diff 摘要)
# 覆盖:merge 响应附 diff 且字段齐;无 checkpoint 时优雅降级;
# diff 可 JSON / 可进审计(branch.merge rationale.diff)。
# ============================================================
from __future__ import annotations
import copy
import json
import pytest
from fastapi.testclient import TestClient
from server.state.branches import compute_world_diff
from server.state.seed import seed_world
from tests.auth_provider import install_test_auth
@pytest.fixture()
def secure_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
monkeypatch.setenv("APS_BRANCH_DIR", str(tmp_path / "branches"))
monkeypatch.setenv("APS_CHECKPOINT_PATH", str(tmp_path / "ckpt.json"))
import server.state.checkpoints as ck_mod
from server.db.database import reset_engine
from server.state import store as world_store
from server.state.branches import reset_branch_stores
from tests.pi_primary_adapter import install_fake_pi_tool_adapter
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
install_fake_pi_tool_adapter(monkeypatch)
world_store._stores.clear()
ck_mod._checkpoints.clear()
reset_branch_stores()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
def _login(client: TestClient, username: str = "planner") -> None:
r = client.post("/api/auth/login", json={
"method": "password", "username": username, "password": "test",
})
assert r.status_code == 200
def _tree(client, sid):
return client.get(f"/api/sessions/{sid}/branches").json()["tree"]
def _schedule(client, sid, text: str) -> None:
r = client.post("/api/chat", json={"sessionId": sid, "text": text})
assert r.status_code == 200, r.text[:300]
def _checkpoint(client, sid, label: str) -> str:
"""排产后显式建档:把 checkpointId 锚定到当前活动分支。"""
r = client.post("/api/chat", json={"sessionId": sid, "text": "建一个检查点"})
assert r.status_code == 200, r.text[:300]
tree = _tree(client, sid)
active = next(b for b in tree["branches"] if b["id"] == tree["activeNode"])
cpid = active.get("checkpointId")
assert cpid, f"建档后活动分支应锚定检查点({label})"
return cpid
def test_compute_world_diff_stable_json_and_graceful(tmp_path):
"""compute_world_diff:结构稳定、可 JSON、空世界优雅降级。"""
w = seed_world()
w2 = copy.deepcopy(w)
wos = w2.get("workOrders") or []
if wos:
wos[0]["plannedStartTime"] = "2026-08-03 08:00"
wos[0]["plannedEndTime"] = "2026-08-03 16:00"
diff = compute_world_diff(w, w2, source_label="A", target_label="B")
parsed = json.loads(json.dumps(diff, ensure_ascii=False)) # 可 JSON
assert parsed["computedAt"]
for key in ("version", "kpi", "operationTimeDiffs", "flexVersion", "capacityPools", "changed"):
assert key in parsed
assert set(parsed["version"]) >= {"sourceVersionNo", "targetVersionNo", "sourceStatus", "targetStatus"}
assert set(parsed["kpi"]) >= {"workOrderCount", "conflictCount", "totalTardiness", "avgUtilization"}
assert {"count", "orders", "truncated"} <= set(parsed["operationTimeDiffs"])
assert {"sourceCount", "targetCount", "changed", "pools"} <= set(parsed["capacityPools"])
assert isinstance(parsed["changed"], bool)
# 无 checkpoint / 极简世界:降级为空投影,不抛错
empty = compute_world_diff(None, {}, source_label="x", target_label="y")
assert empty["changed"] is False
assert empty["version"]["sourceVersionNo"] is None
assert empty["kpi"]["workOrderCount"]["source"] is None
assert empty["operationTimeDiffs"]["count"] == 0
assert empty["capacityPools"]["sourceCount"] == 0
def test_merge_returns_world_diff_with_all_fields(secure_app):
"""两分支均有锚定 checkpoint → merge 响应附 diff,字段齐且 checkpointId 正确。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-df", "name": "合并差异项目"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
# 第一次排产 + 建档 → 主干锚定 checkpoint C1
_schedule(client, sid, "试排一版交付优先")
tree = _tree(client, sid)
root = next(b for b in tree["branches"] if b["id"] == tree["treeRoot"])["id"]
ck_root = _checkpoint(client, sid, "主干C1")
# fork 分支(继承 C1),分支上再次排产 + 建档 → 分支锚定新 checkpoint C2
forked = client.post(f"/api/sessions/{sid}/branches",
json={"name": "交付优先A", "checkpointId": ck_root}).json()["branch"]
_schedule(client, sid, "试排一版产能均衡")
ck_fork = _checkpoint(client, sid, "分支C2")
assert ck_fork != ck_root
# 切回主干后合并分支 → diff 基于 C2 vs C1
sw = client.post(f"/api/sessions/{sid}/branches/{root}/switch")
assert sw.status_code == 200
merged = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/merge",
json={"targetId": root})
assert merged.status_code == 200, merged.text[:400]
body = merged.json()
assert body["source"]["status"] == "merged" # 合并语义不变
assert body["target"]["id"] == tree["treeRoot"]
diff = body["diff"]
assert diff["source"]["checkpointId"] == ck_fork
assert diff["target"]["checkpointId"] == ck_root
assert diff["source"]["label"] == "交付优先A"
assert diff["version"]["sourceVersionNo"], "源分支应有版本号"
assert diff["version"]["targetVersionNo"], "目标分支应有版本号"
assert diff["version"]["sourceVersionNo"] != diff["version"]["targetVersionNo"]
assert diff["changed"] is True
assert diff["kpi"]["workOrderCount"]["source"] is not None
assert diff["kpi"]["conflictCount"]["target"] is not None
assert diff["operationTimeDiffs"]["count"] >= 0
# 可 JSON(响应本就 JSON 序列化;此处再显式校验一次)
json.loads(json.dumps(diff, ensure_ascii=False))
def test_merge_without_checkpoints_degrades_gracefully(secure_app):
"""无 checkpoint 分支合并:diff 优雅降级(空投影/全 null),merge 仍成功。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-nd", "name": "无锚点合并"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
tree = _tree(client, sid)
root = tree["treeRoot"]
forked = client.post(f"/api/sessions/{sid}/branches", json={"name": "无锚点分支"}).json()["branch"]
assert forked["checkpointId"] is None
merged = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/merge",
json={"targetId": root})
assert merged.status_code == 200, merged.text[:400]
body = merged.json()
assert body["source"]["status"] == "merged"
diff = body["diff"]
assert diff["changed"] is False
assert diff["version"]["sourceVersionNo"] is None
assert diff["kpi"]["workOrderCount"]["source"] is None
assert diff["operationTimeDiffs"]["count"] == 0
assert diff["capacityPools"]["sourceCount"] == diff["capacityPools"]["targetCount"]
assert diff["capacityPools"]["changed"] is False
def test_merge_diff_is_auditable(secure_app):
"""merge diff 写入审计(branch.merge rationale.diff),可复现。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-ad", "name": "审计差异"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
_schedule(client, sid, "试排一版交付优先")
tree = _tree(client, sid)
root = tree["treeRoot"]
ck_root = _checkpoint(client, sid, "审计C1")
forked = client.post(f"/api/sessions/{sid}/branches",
json={"name": "审计分支", "checkpointId": ck_root}).json()["branch"]
_schedule(client, sid, "试排一版产能均衡")
_checkpoint(client, sid, "审计C2")
client.post(f"/api/sessions/{sid}/branches/{root}/switch")
merged = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/merge",
json={"targetId": root})
assert merged.status_code == 200
diff = merged.json()["diff"]
audit = client.get("/api/gov/audit?limit=50").json()["events"]
events = [e for e in audit
if e.get("action") == "branch.merge" and (e.get("rationale") or {}).get("targetId") == root]
assert events, "应存在 branch.merge 审计事件"
ev = events[-1]
assert ev["rationale"]["diff"]["version"]["sourceVersionNo"] == diff["version"]["sourceVersionNo"]
assert ev["rationale"]["diff"]["kpi"]["workOrderCount"] == diff["kpi"]["workOrderCount"]
assert ev["rationale"]["diff"]["source"]["checkpointId"] == diff["source"]["checkpointId"]