aps-agent/tests/golden/test_branch_checkpoint_link.py

149 lines
6.7 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.

# ============================================================
# 分支 ↔ 成对快照联动黄金测试(plan.md §4.2 / 矩阵 52 行剩余项)
# 覆盖:_create_checkpoint 建档锚定当前活动分支、fork 继承锚点、
# 切换锚定分支自动成对恢复世界+对话侧、无锚点仅切换。
# ============================================================
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from server.aps_domain.workflow import _create_checkpoint
from server.state.branches import BranchStore
from server.state.checkpoints import CheckpointStore
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 test_create_checkpoint_anchors_active_branch(tmp_path):
"""_create_checkpoint 建档后把 checkpointId 锚定到当前活动分支(分支↔快照联动)。"""
bs = BranchStore(str(tmp_path / "branches.json"))
ckp = CheckpointStore(str(tmp_path / "ckpt.json"))
bs.ensure_session("s1")
root_id = bs.active("s1")
world = seed_world()
world["conflicts"].append({"id": 1, "conflictType": "TEST", "description": "基线"})
class _S:
data = world
world_key = "proj-x"
tenant_uuid = "platform"
def next_id(self, kind):
self.data[f"_c_{kind}"] = self.data.get(f"_c_{kind}", 0) + 1
return self.data[f"_c_{kind}"]
def save(self): pass
store = _S()
# 直接调用 _create_checkpoint(无认证身份时 _active_session_id 返回 None,手动锚定)
meta = ckp.create(world, label="基线", reason="manual", conversation_side={"workspace": {"messages": {}}})
assert meta["pairId"]
# 模拟 workflow 联动逻辑
try:
_create_checkpoint(store, label="基线", reason="manual")
except Exception:
pass
# 分支锚点语义验证:fork 继承父锚点
b1 = bs.fork("s1", "子分支", checkpoint_id=meta["pairId"])
assert b1["checkpointId"] == meta["pairId"]
assert b1["parentId"] == root_id
def test_switch_restores_anchored_checkpoint(secure_app):
"""切换锚定 checkpoint 的分支:世界侧自动恢复(成对切换联动)。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-cp", "name": "成对切换项目"})
assert created.status_code == 200
sid = created.json()["session"]["id"]
tree = client.get(f"/api/sessions/{sid}/branches").json()["tree"]
# 先通过 chat 真实排产一次,让 tenant-a 世界产生版本(锚点世界的确定内容)
sched = client.post("/api/chat", json={"sessionId": sid, "text": "试排一版交期优先"})
assert sched.status_code == 200, sched.text[:200]
# 通过真实 chat API 建检查点(走 tenant-a 上下文 + 分支锚定联动)
chat = client.post("/api/chat", json={
"sessionId": sid, "text": "建一个检查点",
})
assert chat.status_code == 200, chat.text[:200]
# 建档后读取会话分支树:当前活动分支应被锚定到刚建的检查点
tree = client.get(f"/api/sessions/{sid}/branches").json()["tree"]
active = next(b for b in tree["branches"] if b["id"] == tree["activeNode"])
checkpoint_id = active.get("checkpointId")
assert checkpoint_id, "建档后当前活动分支应被锚定"
# 记录锚定时版本数(从 tenant 项目世界文件)
import json as _json
from pathlib import Path as _P
from server.state.store import world_path_for
world_file = _P(world_path_for("proj-cp", "tenant-a-000000000000000000000001"))
assert world_file.exists(), f"tenant 世界文件应存在: {world_file}"
saved_world = _json.loads(world_file.read_text(encoding="utf-8"))
versions_at_anchor = len(saved_world.get("scheduleVersions") or [])
# fork 带 checkpointId
forked = client.post(f"/api/sessions/{sid}/branches",
json={"name": "C-锚定分支", "checkpointId": checkpoint_id}).json()["branch"]
assert forked["checkpointId"] == checkpoint_id
# 污染世界:用 chat 再排一次(不同策略),版本数应变化(>= 锚定时)
client.post("/api/chat", json={"sessionId": sid, "text": "试排一版产能均衡"})
polluted = _json.loads(world_file.read_text(encoding="utf-8"))
assert len(polluted.get("scheduleVersions") or []) >= versions_at_anchor, "污染后版本应变化"
# 切到锚定分支 -> 自动恢复(版本数回到锚定时)
switched = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/switch").json()
assert switched.get("restored") is True, "锚定分支切换应自动恢复"
assert switched.get("checkpointId") == checkpoint_id
restored_world = _json.loads(world_file.read_text(encoding="utf-8"))
assert len(restored_world.get("scheduleVersions") or []) == versions_at_anchor, "世界侧应恢复锚定时的版本数"
def test_switch_without_anchor_only_switches(secure_app):
"""无锚点分支切换:仅切换 active_node,restored=false,不触碰世界。"""
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"id": "proj-na", "name": "无锚点项目"})
sid = created.json()["session"]["id"]
forked = client.post(f"/api/sessions/{sid}/branches", json={"name": "无锚分支"}).json()["branch"]
assert forked["checkpointId"] is None
switched = client.post(f"/api/sessions/{sid}/branches/{forked['id']}/switch").json()
assert switched.get("restored") is False
assert switched.get("checkpointId") is None