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