aps-agent/tests/golden/test_branch_merge_diff.py

201 lines
9.1 KiB
Python
Raw 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.

# ============================================================
# 分支合并世界差异黄金测试(矩阵 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"]