aps-agent/tests/golden/test_m2_state.py

148 lines
8.6 KiB
Python
Raw Permalink 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.

# ============================================================
# M2 状态治理黄金测试(moduleId: golden-m2-state, 发版门禁 §14.3)
# 固化三个核心不变式:
# S1 Explore 沙盒隔离:方案对比绝不污染主干世界(§5.1 铁律)
# S2 Checkpoint 往返:建档 → 破坏 → 回滚 → 完整恢复(§4.3)
# S3 审计哈希链:正常链校验通过;篡改任意字段即断链(§3.6)
# ============================================================
from __future__ import annotations # 前向类型引用
import copy # 世界对照拷贝
import json # 深比较辅助
from server.agent_core.audit import write_audit # 审计写入
from server.agent_core.registry import verify_audit_chain # 链校验
from server.aps_domain.scenario import compare_scenarios # 方案对比(沙盒)
from server.state.checkpoints import CheckpointStore # 快照仓(用临时路径实例化)
from server.state.seed import seed_world # 种子世界
def _next_id_factory():
"""独立发号器(用例内存态)。"""
counters: dict[str, int] = {} # 计数器
def next_id(kind: str) -> int: # 闭包发号
counters[kind] = counters.get(kind, 0) + 1 # 自增
return counters[kind] # 返回
return next_id # 闭包
# ---------------- S1:沙盒隔离 ----------------
def test_scenario_compare_does_not_mutate_world():
"""方案对比在深拷贝沙盒运行:主干世界的所有表必须逐字节不变。"""
world = seed_world() # 主干世界
before = json.dumps(world, ensure_ascii=False, sort_keys=True) # 运行前指纹
text, block = compare_scenarios(world) # 执行三策略沙盒对比
after = json.dumps(world, ensure_ascii=False, sort_keys=True) # 运行后指纹
assert before == after, "方案对比污染了主干世界(违反 §5.1 Explore 铁律)" # 逐字节一致
assert len(block.props["cards"]) == 7 # 七张方案卡(含齐套/技能/换型/战役)
for card in block.props["cards"]: # 逐卡校验结构
assert card["kpi"]["woCount"] > 0 # 沙盒确实完成了排产
assert card["strategy"] in (
"DELIVERY_FIRST", "KITTING_FIRST", "SKILL_FIRST", "CAPACITY_BALANCE",
"CHANGEOVER_MIN", "CAMPAIGN", "COMPREHENSIVE",
) # 策略合法
assert "totalChangeoverMin" in card["kpi"]
assert "沙盒" in text # 文案明示未影响当前方案
# ---------------- S2:Checkpoint 往返 ----------------
def test_checkpoint_roundtrip(tmp_path):
"""建档 → 破坏世界 → 从档恢复:恢复结果与建档时刻逐字节一致。"""
store = CheckpointStore(path=str(tmp_path / "ckpt.json")) # 临时路径快照仓(隔离生产文件)
world = seed_world() # 初始世界
baseline = copy.deepcopy(world) # 建档时刻的对照副本
meta = store.create(world, label="测试基线", reason="manual") # 建档
assert meta["pairId"] # 拿到快照 ID
world["salesOrders"].clear() # 破坏:清空订单
world["scheduleVersions"].append({"id": 999}) # 破坏:塞入脏版本
pair = store.get(meta["pairId"]) # 取回快照
assert pair is not None # 快照存在
restored = pair["world"] # 快照中的世界
assert json.dumps(restored, ensure_ascii=False, sort_keys=True) == \
json.dumps(baseline, ensure_ascii=False, sort_keys=True), "快照未完整保留建档时刻状态" # 逐字节恢复
# 快照与运行态隔离:破坏运行态不应影响已存快照(深拷贝验证)
assert len(restored["salesOrders"]) == 7 # 快照内订单完好
# ---------------- S2b:容量淘汰 ----------------
def test_checkpoint_capacity_eviction(tmp_path):
"""快照数超过上限时淘汰最旧(防文件膨胀)。"""
store = CheckpointStore(path=str(tmp_path / "ckpt.json")) # 临时快照仓
world = seed_world() # 世界
ids = [store.create(world, label=f"c{i}", reason="manual")["pairId"] for i in range(25)] # 建 25 档(上限 20)
assert len(store.pairs) == 20 # 只保留 20 个
assert store.get(ids[0]) is None # 最旧的已被淘汰
assert store.get(ids[-1]) is not None # 最新的仍在
# ---------------- S3:审计哈希链 ----------------
def test_audit_chain_verify_and_tamper_detection():
"""正常链校验通过;篡改任一历史事件的任一字段必须被检出。"""
world = seed_world() # 世界(审计表随种子创建)
nid = _next_id_factory() # 发号器
for i in range(5): # 连续写 5 条审计
write_audit(world, nid, actor="tester", category="TOOL", action=f"op{i}",
target={"type": "T", "id": i}, power="P0", rationale={})
events = world["auditEvents"] # 审计链
assert verify_audit_chain(events)["ok"] is True # 完整链校验通过
tampered = copy.deepcopy(events) # 拷贝后篡改
tampered[2]["actor"] = "hacker" # 篡改第 3 条的行为人
result = verify_audit_chain(tampered) # 重新校验
assert result["ok"] is False # 必须检出断链
assert result["checked"] == 2 # 断点定位在被篡改处
# ---------------- S2c:成对快照(对话侧 + 世界侧)与成对回滚 ----------------
def test_checkpoint_pair_roundtrip_world_and_conversation(tmp_path):
"""矩阵 52 行:快照包含完整对话(workspace+messages)和世界;
破坏两侧后回滚,两侧均逐字段一致。"""
store = CheckpointStore(path=str(tmp_path / "ckpt.json"))
world = seed_world()
world_baseline = json.dumps(world, ensure_ascii=False, sort_keys=True)
conversation_side = {
"workspace": {
"projects": [{"id": "p1", "name": "项目A"}],
"sessions": [{"id": "s1", "projectId": "p1", "title": "话题1", "status": "running"}],
"files": [],
"messages": {"s1": [{"role": "user", "content": "试排一版"}]},
"activeProjectId": "p1",
"activeSessionId": "s1",
"worldKey": "default",
},
"plans": {"plan_x": [{"version": 1, "layer": "L0", "status": "DRAFT"}]},
}
side_baseline = json.dumps(conversation_side, ensure_ascii=False, sort_keys=True)
meta = store.create(world, label="成对基线", reason="manual",
conversation_note="对话摘要",
conversation_side=conversation_side)
assert meta["pairId"]
# 破坏两侧
world["salesOrders"].clear()
conversation_side["workspace"]["messages"]["s1"].append({"role": "assistant", "content": "被污染"})
conversation_side["plans"]["plan_x"].append({"version": 2, "layer": "L0", "status": "DRAFT"})
pair = store.get(meta["pairId"])
assert pair is not None
# 世界侧完整恢复
assert json.dumps(pair["world"], ensure_ascii=False, sort_keys=True) == world_baseline
# 对话侧完整恢复
assert json.dumps(pair["conversationSide"], ensure_ascii=False, sort_keys=True) == side_baseline
assert pair["conversationSide"]["workspace"]["messages"]["s1"] == [{"role": "user", "content": "试排一版"}]
# 快照与运行态隔离(后续破坏不影响已存快照)
world["salesOrders"].append({"id": "broken"})
assert len(pair["world"]["salesOrders"]) == 7
def test_checkpoint_pair_without_conversation_side_is_backward_compatible(tmp_path):
"""不传 conversation_side 时保持旧行为(仅 world + note),不报错。"""
store = CheckpointStore(path=str(tmp_path / "ckpt.json"))
world = seed_world()
meta = store.create(world, label="旧式基线", reason="manual", conversation_note="只有摘要")
pair = store.get(meta["pairId"])
assert pair is not None
assert pair["conversationNote"] == "只有摘要"
assert pair.get("conversationSide") is None
assert json.dumps(pair["world"], ensure_ascii=False, sort_keys=True) == json.dumps(world, ensure_ascii=False, sort_keys=True)