实现侧: - 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。 - 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。 - 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。 - 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。 - 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。 - 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。 - 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。 - 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。 用例侧: - 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存; - 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""合成游标与窄投影验收;记录真实存储层发出的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 连.语句 == []
|