149 lines
6.7 KiB
Python
149 lines
6.7 KiB
Python
# ============================================================
|
||
# 分支 ↔ 成对快照联动黄金测试(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
|