muse-agent-example/tests/单元/test_候选摘要分页.py
zizi d909d1bd1b 后端实现与用例身份:19 包集成落地并修复收尾缺陷
实现侧:
- 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。
- 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。
- 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。
- 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。
- 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。
- 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。
- 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。
- 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。

用例侧:
- 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存;
- 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
2026-09-18 01:15:00 +08:00

258 lines
10 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.

"""合成游标与窄投影验收;记录真实存储层发出的SQL,不连接数据库。"""
import base64
import json
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import UUID
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from muse.作品规划.接口 import 作品错误
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
from muse.接入.http.作者会话 import 要求作者
from muse.接入.http.路由.作者审阅 import 路由
from muse.接入.http.错误响应 import 安装错误响应
from muse.正式变更.接口 import 变更错误
from muse.正文写作.候选摘要 import 编码游标, 解码游标, 读取摘要页
from muse.正文写作.接口 import 正文服务
身份 = 调用身份("author-local", None, 用途.生产, 内容用途.规划)
时间 = datetime(2026, 9, 17, tzinfo=UTC)
class 合成连接:
def __init__(self, 数量):
self.行集 = [
{
"candidate_id": str(UUID(int=i + 1)),
"revision": 1,
"candidate_hash": f"hash-{i}",
"created_at": 时间,
"origin": "author",
"base_revision": 2,
"source_stale": False,
}
for i in range(数量)
]
self.语句 = []
self.结果 = []
self.作者 = "author-local"
self.当前版本 = 2
self.决定 = {}
self.借用 = 0
@contextmanager
def 连接(self, *, 只读):
assert 只读
self.借用 += 1
yield self
def cursor(self, **_):
return self
def execute(self, sql, args):
self.语句.append((sql, args))
if "FROM muse_chapter c" in sql:
self.结果 = [{"chapter_id": "chapter", "work_id": "work", "author_id": self.作者}]
elif "FROM muse_writing_candidate c" in sql:
assert "first.revision=1" in sql
assert "v.revision=c.current_revision" in sql
assert "first.created_at DESC,c.candidate_id DESC LIMIT %s" in sql
assert "OFFSET" not in sql
# 真实SQL须只选择窄字段;不允许把全文丢到Python后再裁剪。
projection = sql.split("FROM muse_writing_candidate c")[0]
assert not any(v in projection for v in ("v.*", "v.document,", "checks", "merge_basis"))
assert "LEFT JOIN muse_branch_merge_source" in sql
assert "IS DISTINCT FROM" in sql
上界 = (args[3], args[4]) if args[2] else None
最后 = (args[6], args[7]) if args[5] else None
self.结果 = sorted(
[
r.copy()
for r in self.行集
if (上界 is None or (r["created_at"], r["candidate_id"]) <= 上界)
and (最后 is None or (r["created_at"], r["candidate_id"]) < 最后)
],
key=lambda r: (r["created_at"], r["candidate_id"]),
reverse=True,
)[: args[-1]]
elif "current_revision AS revision" in sql:
assert "document_hash" not in sql and "document_version" not in sql
self.结果 = [{"revision": self.当前版本}] if self.当前版本 else []
elif "FROM muse_candidate_decision" in sql:
assert args[0] == "author-local" and "author_id=%s" in sql
self.结果 = [
{"candidate_id": key[0], "candidate_revision": key[1], "decision": value}
for key, value in self.决定.items()
if key in zip(args[1], args[2], strict=True)
]
else:
raise AssertionError(f"摘要发出了未知或全文SQL: {sql}")
return self
def fetchone(self):
return self.结果[0] if self.结果 else None
def fetchall(self):
return self.结果
@pytest.mark.case_id("TC-O08-SUMMARY-001")
@pytest.mark.parametrize("数量", [0, 1, 20, 200, 2000])
@pytest.mark.parametrize("limit", [20, 50])
def test_摘要有界且查询数不随候选数增长__o08001(数量, limit):
连 = 合成连接(数量)
页 = 正文服务(连, None).列出候选摘要(身份, "chapter", limit=limit)
assert len(页["items"]) == min(数量, limit)
assert len(连.语句) == (4 if 数量 else 3)
assert 连.借用 == 1
assert bool(页["next_cursor"]) == (数量 > limit)
for 项 in 页["items"]:
assert set(项) == {
"candidate_id",
"revision",
"candidate_hash",
"document_id",
"chapter_id",
"branch_id",
"origin",
"base_revision",
"current_document_revision",
"decision",
"stale",
}
@pytest.mark.case_id("TC-O08-SUMMARY-002")
def test_同时间跨页不重漏且新增刷新可见__o08002():
连 = 合成连接(45)
第一页 = 读取摘要页(连, 身份, "chapter", "main", 20, None)
ids = [r["candidate_id"] for r in 第一页["items"]]
新行 = {
**连.行集[0],
"candidate_id": str(UUID(int=999)),
"created_at": 时间 + timedelta(seconds=1),
}
连.行集.append(新行)
# 首版本创建时间不变;编辑当前候选也不改变排序身份。
连.行集[0]["revision"] = 2
cursor = 第一页["next_cursor"]
while cursor:
页 = 读取摘要页(连, 身份, "chapter", "main", 20, cursor)
ids.extend(r["candidate_id"] for r in 页["items"])
cursor = 页["next_cursor"]
assert len(ids) == len(set(ids)) == 45
assert 新行["candidate_id"] not in ids
assert (
读取摘要页(连, 身份, "chapter", "main", 20, None)["items"][0]["candidate_id"]
== 新行["candidate_id"]
)
@pytest.mark.case_id("NC-O08-SUMMARY-003")
@pytest.mark.parametrize(
"字段,值",
[
("version", True),
("version", 2),
("chapter", "other"),
("branch", "other"),
("order", "asc"),
("upper", []),
("upper", ["2026-09-17", str(UUID(int=10))]),
("last", [时间.isoformat(), "not-a-uuid"]),
("last", [9, str(UUID(int=1))]),
("last", [时间.isoformat(), str(UUID(int=11))]),
("extra", 1),
],
)
def test_游标严格校验作用域类型顺序__o08003(字段, 值):
cursor = 编码游标("chapter", "main", (时间, str(UUID(int=10))), (时间, str(UUID(int=5))))
数据 = json.loads(base64.urlsafe_b64decode(cursor))
数据[字段] = 值
无效 = base64.urlsafe_b64encode(json.dumps(数据).encode()).decode()
with pytest.raises(变更错误, match="候选摘要游标无效"):
解码游标(无效, "chapter", "main")
@pytest.mark.case_id("NC-O08-SUMMARY-004")
@pytest.mark.parametrize("cursor", ["", "!invalid", "a" * 2049, "W10=", "bnVsbA==", "e30="])
def test_损坏游标拒绝__o08004(cursor):
with pytest.raises(变更错误):
解码游标(cursor, "chapter", "main")
@pytest.mark.case_id("NC-O08-SUMMARY-005")
@pytest.mark.parametrize("limit", [0, 51, -1, True, "20"])
def test_服务拒绝无效页尺寸__o08005(limit):
连 = 合成连接(20)
with pytest.raises(变更错误, match="数量必须"):
读取摘要页(连, 身份, "chapter", "main", limit, None)
assert 连.语句 == []
@pytest.mark.case_id("TC-O08-SUMMARY-006")
def test_只读当前版本一次且合并来源与决定独立__o08006():
连 = 合成连接(3)
连.行集[0]["source_stale"] = True
连.行集[1]["base_revision"] = 1
连.决定[(连.行集[2]["candidate_id"], 1)] = "reject"
页 = 读取摘要页(连, 身份, "chapter", "main", 20, None)
assert [r["stale"] for r in 页["items"]] == [False, True, True]
assert [r["decision"] for r in 页["items"]] == ["reject", "undecided", "undecided"]
assert sum("current_revision AS revision" in sql for sql, _ in 连.语句) == 1
连.作者 = "other-author"
with pytest.raises(作品错误, match="不属于当前作者"):
读取摘要页(连, 身份, "chapter", "main", 20, None)
assert "FROM muse_chapter c" in 连.语句[-1][0]
@pytest.mark.case_id("TC-O08-SUMMARY-007")
def test_HTTP摘要页尺寸与准确响应合同__o08007():
连 = 合成连接(55)
服务 = 正文服务(连, None)
app = FastAPI()
app.include_router(路由)
安装错误响应(app)
app.dependency_overrides[要求作者] = lambda: "author-local"
app.state.装配 = SimpleNamespace(
要求正文=lambda: 服务, 配置=SimpleNamespace(运行用途=用途.生产)
)
with TestClient(app) as 客户端:
路径 = "/api/v1/chapters/chapter/writing-candidate-summaries"
响应 = 客户端.get(路径)
assert 响应.status_code == 200
assert len(响应.json()["items"]) == 20
assert 客户端.get(路径, params={"limit": 50}).status_code == 200
assert 客户端.get(路径, params={"limit": 51}).status_code == 422
assert 客户端.get(路径, params={"limit": 0}).status_code == 422
assert 客户端.get(路径, params={"cursor": "malformed"}).json()["code"] == "INVALID_CURSOR"
跨支 = 客户端.get(路径, params={"cursor": 响应.json()["next_cursor"], "branch_id": "other"})
assert 跨支.status_code == 422 and 跨支.json()["code"] == "INVALID_CURSOR"
下一页 = 客户端.get(路径, params={"cursor": 响应.json()["next_cursor"]})
assert 下一页.status_code == 200
assert not set(r["candidate_id"] for r in 响应.json()["items"]) & set(
r["candidate_id"] for r in 下一页.json()["items"]
)
schema = app.openapi()
摘要 = next(
s["properties"]
for s in schema["components"]["schemas"].values()
if s.get("title") == "正文候选摘要"
)
assert not {"document", "visible_text", "changes"} & set(摘要)
assert "/api/v1/chapters/{chapter_id}/writing-candidates" in schema["paths"]
@pytest.mark.case_id("NC-O08-SUMMARY-008")
def test_未认证调用不得读取候选__o08008():
连 = 合成连接(20)
未认证 = 调用身份(None, None, 用途.生产, 内容用途.规划)
with pytest.raises(变更错误, match="需要已认证作者"):
读取摘要页(连, 未认证, "chapter", "main", 20, None)
assert 连.语句 == []