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