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

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

806 lines
32 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.

"""私人探索与正式交接分开;局部草稿不受最终目录约束。"""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from muse.作品规划.接口 import 作品错误, 探索服务, 校验交付格式
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
def 作者(id_="author"):
return 调用身份(id_, None, 用途.生产, 内容用途.规划)
@pytest.mark.case_id(
"NC-exploration-private-cas",
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
given="同作者私人探索原文",
when="两次并发保存并以相同内容重试",
then=["只一次推进版本,原话保留,冲突拒绝,作者之间分区"],
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
)
def test_私人稿保存原话且并发冲突不覆盖__ad1001(tmp_path):
服务 = 探索服务(tmp_path / "私人探索")
首次 = 服务.保存(作者(), "umbrella", "雨伞", "哥哥没回来。\n妹妹望着门口的伞。", None)
assert 首次["revision"] == 1
assert 服务.读取(作者(), "umbrella")["content"] == "哥哥没回来。\n妹妹望着门口的伞。"
assert 服务.列出(作者("second")) == []
with pytest.raises(作品错误):
服务.读取(作者("second"), "umbrella")
def 修改(正文):
try:
return 服务.保存(作者(), "umbrella", "雨伞", 正文, 首次["draft_hash"])
except 作品错误 as exc:
return exc.错误码
with ThreadPoolExecutor(2) as pool:
结果 = list(pool.map(修改, ["她把伞撑开。", "她把伞收起。"]))
assert sum(isinstance(x, dict) for x in 结果) == 1
assert "REVISION_CONFLICT" in 结果
当前 = 服务.读取(作者(), "umbrella")
assert 当前["revision"] == 2
# 同一内容的未知返回可以重试,不额外产生版本。
assert 服务.保存(作者(), "umbrella", 当前["title"], 当前["content"], 首次["draft_hash"]) == 当前
@pytest.mark.case_id(
"NC-exploration-partial-draft",
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
given="只有一个动作的探索片段",
when="保存后请求最终格式检查",
then=["片段保存成功,格式问题单独返回,不改变内容"],
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
)
def test_探索不先查最终格式且检查不产生正式内容__ad1002(tmp_path):
服务 = 探索服务(tmp_path / "私人探索")
稿 = 服务.保存(作者(), "first-fragment", "只有一个动作", "有人敲门。", None)
检查 = 服务.检查交付(作者(), "first-fragment", 稿["draft_hash"])
assert not 检查["valid"] and 检查["problems"]
assert 服务.读取(作者(), "first-fragment") == 稿
assert [v["draft_id"] for v in 服务.列出(作者())] == ["first-fragment"]
@pytest.mark.case_id(
"TC-61c0a6864824",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
@pytest.mark.parametrize("形式,编号数", [("短稿", 0), ("局部展开", 0), ("带编号", 1)])
def test_局部展开的最终稿不设编号和篇幅门__61c0a6(形式, 编号数):
文本 = 原探索稿
if 形式 == "短稿":
文本 = "\n".join(
行 if 行.startswith("#") or not 行 else "待定。" for 行 in 文本.splitlines()
)
elif 形式 == "局部展开":
文本 = 文本.replace("待定:先从人物行动中寻找中心。", "### 1.1 作品形态与定位\n暂定。")
else:
文本 = 文本.replace("哥哥每天", "- S900 哥哥每天")
结果 = 校验交付格式(文本)
assert 结果["valid"] is True
assert 结果["problems"] == []
assert 结果["setting_count"] == 编号数
assert 结果["contract_version"] == "完善故事基础设定/output-v3"
@pytest.mark.case_id(
"TC-8eed6ecb704b",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_不同探索稿可展开不同子集__8eed6e():
展开 = 原探索稿.replace(
"待定:先从人物行动中寻找中心。",
"### 1.1 作品形态与定位\n\n### 1.1.1 题材、载体与目标读者\n\n暂定现实题材。",
)
assert all(校验交付格式(s)["valid"] for s in (原探索稿, 展开))
@pytest.mark.case_id(
"TC-c6ad4215a8fa",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
@pytest.mark.parametrize(
"场景", ["unknown", "missing", "reordered"], ids=["unknown", "missing", "reordered"]
)
def test_最终导航标题未知缺失或倒序拒绝__c6ad42(场景):
输入 = {
"unknown": 原探索稿.replace("## 三、角色与关系", "## 三、人物"),
"missing": 原探索稿.replace("## 三、角色与关系\n", ""),
"reordered": 原探索稿.replace(
"待定:先从人物行动中寻找中心。",
"### 1.2 故事承诺与一句话梗概\n\n设想。\n\n### 1.1 作品形态与定位\n\n设想。",
),
}[场景]
assert "TREE_MISMATCH" in {v["code"] for v in 校验交付格式(输入)["problems"]}
@pytest.mark.case_id(
"TC-64d9e4509499",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_空节和裸占位拒绝而明确未知允许__64d9e4():
for 原, 替换, 错误码 in [
("未探索:尚未讨论社会制度。", "<!-- 留空 -->", "SECTION_EMPTY"),
("待定:哥哥为何留着伞。", "TODO", "CANDIDATE_PLACEHOLDER"),
]:
assert 错误码 in {v["code"] for v in 校验交付格式(原探索稿.replace(原, 替换))["problems"]}
assert 校验交付格式(原探索稿)["valid"]
@pytest.mark.case_id(
"TC-ec3fc32c7779",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_可选编号无冻结号段但必须唯一__ec3fc3():
编号稿 = 原探索稿.replace("哥哥每天", "- **S090|哥哥:** 哥哥每天").replace(
"先从动作里表现关系。", "- **S003|表达:** 先从动作里表现关系。"
)
assert 校验交付格式(编号稿)["valid"]
assert "SETTING_ID_DUPLICATE" in {
v["code"] for v in 校验交付格式(编号稿.replace("S003", "S090"))["problems"]
}
@pytest.mark.case_id(
"TC-1d35b43692d1",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
@pytest.mark.parametrize("场景", ["no-title", "deep-heading"], ids=["no-title", "deep-heading"])
def test_最终稿缺标题或标题过深拒绝__1d35b4(场景):
内容 = (
原探索稿.replace("# 雨伞|作品探索\n", "")
if 场景 == "no-title"
else 原探索稿 + "\n#### 额外层级\n说明。"
)
assert "CANDIDATE_STRUCTURE" in {v["code"] for v in 校验交付格式(内容)["problems"]}
@pytest.mark.case_id(
"TC-3a6d20c30f35",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_缺失私人稿不会得到交付成功__3a6d20(tmp_path):
with pytest.raises(作品错误) as 错:
探索服务(tmp_path / "私人探索").读取(作者(), "missing")
assert 错.value.错误码 == "EXPLORATION_NOT_FOUND"
def _探索配置(tmp_path, *, 口令="synthetic", 来源="http://localhost"):
secret = tmp_path / "口令.txt"
secret.write_text(口令)
secret.chmod(0o600)
cfg = tmp_path / "作者.toml"
cfg.write_text(
'["数据库"]\n"取值方式"="受控存储"\n"位置"="missing-database.txt"\n'
'["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n'
f'"公开地址"="{来源}"\n"允许来源"=["{来源}"]\n'
'["文件"]\n"探索草稿"="私人探索"\n'
)
return cfg
@pytest.mark.case_id(
"NC-exploration-http-cli-offline",
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
given="明确私人目录与不存在的数据库连接文件",
when="真实HTTP登录保存,CLI读回及检查",
then=["身份来自会话,私有原话可读,格式失败非零,默认OS禁网仍通过"],
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
)
def test_HTTP与CLI保存探索不访问数据库__ad1003(tmp_path, 源码CLI):
import json
import subprocess
from fastapi.testclient import TestClient
from muse.接入.http.应用 import 创建应用
from muse.配置 import 读取配置
cfg = _探索配置(tmp_path, 口令="synthetic-exploration-password", 来源="http://testserver")
app = 创建应用(读取配置(cfg))
with TestClient(app, headers={"origin": "http://testserver"}) as client:
assert client.get("/api/v1/explorations").status_code == 401
assert (
client.post(
"/api/v1/session", json={"password": "synthetic-exploration-password"}
).status_code
== 200
)
req = {
"draft_id": "http-draft",
"title": "原话",
"content": "只是一个念头。",
"expected_hash": None,
}
assert (
client.post(
"/api/v1/explorations/http-draft", json={**req, "author_id": "forged"}
).status_code
== 422
)
saved = client.post("/api/v1/explorations/http-draft", json=req)
assert saved.status_code == 200, saved.text
assert client.get("/api/v1/explorations").json()[0]["title"] == "原话"
checked = client.post(
"/api/v1/explorations/http-draft/check",
json={"expected_hash": saved.json()["draft_hash"]},
)
assert checked.status_code == 200 and not checked.json()["valid"]
result = subprocess.run(
[*源码CLI, "探索", str(cfg), "查看", "http-draft"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == saved.json()
invalid = subprocess.run(
[
*源码CLI,
"探索",
str(cfg),
"检查",
"http-draft",
"--预期哈希",
saved.json()["draft_hash"],
],
capture_output=True,
text=True,
)
assert invalid.returncode == 1 and not json.loads(invalid.stdout)["valid"]
# 保留LC原始探索输入,不借旧测试的导入和运行顺序。
原探索稿 = """# 雨伞|作品探索
## 根设定
作者希望保留雨天的兄妹关系。
## 一、作品定位与核心命题
待定:先从人物行动中寻找中心。
## 二、故事世界与社会系统
未探索:尚未讨论社会制度。
## 三、角色与关系
哥哥每天擦一把从不用的伞,妹妹故意把它拿走。
## 四、故事机制与关键实体
未探索:尚未决定是否有超自然力量。
## 五、阶段与状态变化
未探索:只有当前这一幕。
## 六、情节架构与全书大纲
未探索:兄妹争执后的事情还没想好。
## 七、主题与读者体验弧线
暂想保留亲近中带着疑问的感觉。
## 八、信息、悬念与伏笔管理
待定:哥哥为何留着伞。
## 九、叙事执行与一致性
先从动作里表现关系。
## 待决与取舍
保留争执这一幕,暂缓决定雨伞的来历。
"""
@pytest.mark.parametrize(
"场景,输入",
[
pytest.param(
"missing",
None,
id="missing",
marks=pytest.mark.case_id(
"TC-e7566b7729ef",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"unconfirmed",
False,
id="unconfirmed",
marks=pytest.mark.case_id(
"TC-5ed750849bb8",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"string-false",
"false",
id="string-false",
marks=pytest.mark.case_id(
"TC-802d1addb6c9",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"string-true",
"true",
id="string-true",
marks=pytest.mark.case_id(
"TC-802d1addb6c9",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"user-string",
"user",
id="user-string",
marks=pytest.mark.case_id(
"TC-802d1addb6c9",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"integer-true",
1,
id="integer-true",
marks=pytest.mark.case_id(
"TC-802d1addb6c9",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"list-true",
["true"],
id="list-true",
marks=pytest.mark.case_id(
"TC-802d1addb6c9",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"empty-id",
"",
id="empty-id",
marks=pytest.mark.case_id(
"TC-ed5e97c5e169",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"list-id",
["x"],
id="list-id",
marks=pytest.mark.case_id(
"TC-dd77a90a8a79",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
pytest.param(
"blank-time",
" ",
id="blank-time",
marks=pytest.mark.case_id(
"TC-1f742f4ca972",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
),
),
],
ids=[
"missing",
"unconfirmed",
"string-false",
"string-true",
"user-string",
"integer-true",
"list-true",
"empty-id",
"list-id",
"blank-time",
],
)
def test_选定输入不能自报确认时间或缺失稿件__ad1004(场景, 输入):
from pydantic import ValidationError
from muse.接入.创作操作 import 探索选定请求
selection = {
"work_id": "work",
"draft_id": "x",
"expected_hash": "source-hash",
"expected_revision": 0,
}
if 场景 == "missing":
selection = {}
elif 场景 in {"empty-id", "list-id"}:
selection["draft_id"] = 输入
elif 场景 == "blank-time":
selection["confirmedAt"] = 输入
else:
selection["confirmedByUser"] = 输入
# 基准先通过,再只变异目标字段,避免错误前置冒充拒绝。
基准 = {
"work_id": "work",
"draft_id": "x",
"expected_hash": "source-hash",
"expected_revision": 0,
}
assert (
探索选定请求.model_validate(
{"command_id": "select-direction", "selection": 基准}
).selection.draft_id
== "x"
)
if 场景 == "empty-id":
with pytest.raises(作品错误, match="探索交接需要作品、明确稿件、内容哈希和选定版本") as 错:
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
assert 错.value.错误码 == "INVALID_REQUEST"
return
with pytest.raises(ValidationError) as 错:
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
实际 = {(tuple(e["loc"]), e["type"]) for e in 错.value.errors()}
if 场景 == "missing":
assert 实际 == {(("selection", f), "missing") for f in 基准}
elif 场景 == "list-id":
assert 实际 == {(("selection", "draft_id"), "string_type")}
else:
字段 = "confirmedAt" if 场景 == "blank-time" else "confirmedByUser"
assert 实际 == {(("selection", 字段), "unexpected_keyword_argument")}
# ---------------------------------------------------------------------------
# 作者旅程、细纲合同与失败边界(原 AuthorInstruction/FineOutlineContract)
# ---------------------------------------------------------------------------
作者根 = Path(__file__).resolve().parents[2] / ".agent" / "作者"
仓库根 = Path(__file__).resolve().parents[2]
@pytest.mark.case_id(
"TC-aab5b6ff836c",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_自然语言旅程到达作者决定__aab5b6():
"""路由表命中六大场景;最小旅程按序推进,最终决定交给新版候选决定技能。"""
import re
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
路由 = re.search(
r"^## 自然语言路由\s*$\n(?P<body>.*?)(?=^## )",
入口,
flags=re.MULTILINE | re.DOTALL,
).group("body")
for 说法, 场景 in (
("我只有一个点子", "定故事"),
("帮我排一下整本书", "排故事"),
("这个人物还站不住", "塑人物"),
("接着写下一章", "写下一章"),
("把这段改顺", "改正文"),
("看看这部作品哪里有问题", "查作品"),
):
assert re.search(rf"\|\s*{re.escape(说法)}\s*\|\s*\[{re.escape(场景)}\]", 路由)
旅程 = re.search(
r"^## 最小作者旅程\s*$\n(?P<body>.*?)(?=^## |\Z)",
入口,
flags=re.MULTILINE | re.DOTALL,
).group("body")
顺序 = re.findall(r"\[([^\]]+)\]\(场景/[^)]+\)", 旅程)
assert 顺序 == ["定故事", "排故事", "写下一章", "查作品", "改正文"]
决定 = re.search(r"\[决定正文候选去留\]\(([^)]+)\)", 旅程)
assert 决定 and "skills/操作/决定正文候选去留/SKILL.md" in 决定.group(1)
for 动作 in ("采用", "丢弃", "修改"):
assert f"`{动作}`" in 旅程
def _细纲结构():
from muse.元数据.接口 import 合成结构, 读取内置结构
return 合成结构(读取内置结构("fine_outline"))
def _完整细纲值():
return {
"targetChapter": 3,
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
"chapterGoal": "本章戏剧任务",
"hardConstraints": ["衔接上一章结尾"],
"keyEvents": [
{
"id": "e1",
"order": 1,
"event": "事件",
"participants": ["林深"],
"trigger": "触发",
"resultDirection": "结果方向",
}
],
"adjustableBeats": ["次序可调整"],
"declaredNewFacts": [
{
"factId": "f1",
"text": "新事实",
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
}
],
"mustAppearEntities": ["林深"],
"chapterEndHook": "章末钩子",
}
@pytest.mark.case_id(
"TC-aaae60775fc0",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_细纲技能引用结构为字段权威__aaae60():
"""字段权威是S04结构:合同由结构生成并带字段身份;操作技能不再自持字段清单。"""
from muse.元数据.接口 import 生成结构合同
结构 = _细纲结构()
合同 = 生成结构合同(结构)
assert 合同["required"], "细纲合同必须声明必填字段"
for 字段 in ("targetChapter", "keyEvents", "chapterEndHook"):
属性 = 合同["properties"][字段]
assert 属性["x-field-id"].startswith("fine_outline."), 字段
assert 属性["title"], f"{字段} 缺中文名"
技能 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
encoding="utf-8"
)
for 字段名 in ("targetChapter", "chapterGoal", "hardConstraints"):
assert 字段名 not in 技能, "操作技能不得自持细纲字段清单,字段权威在结构"
@pytest.mark.case_id(
"TC-7592152c80cc",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
@pytest.mark.parametrize(
"缺失字段",
[
"targetChapter",
"sourceRef",
"chapterGoal",
"hardConstraints",
"keyEvents",
"adjustableBeats",
"declaredNewFacts",
"mustAppearEntities",
"chapterEndHook",
],
)
def test_细纲必填字段齐全且缺一拒绝__759215(缺失字段):
from muse.元数据.接口 import 校验值, 生成结构合同
from muse.元数据.模型 import 元数据错误
结构 = _细纲结构()
合同 = 生成结构合同(结构)
assert set(合同["required"]) == {
"targetChapter",
"sourceRef",
"chapterGoal",
"hardConstraints",
"keyEvents",
"adjustableBeats",
"declaredNewFacts",
"mustAppearEntities",
"chapterEndHook",
}
值 = {k: v for k, v in _完整细纲值().items() if k != 缺失字段}
with pytest.raises(元数据错误) as 错:
校验值(结构, 值)
assert 错.value.错误码 == "FIELD_INVALID"
assert 缺失字段 in 错.value.呈现()["details"]["字段"]
@pytest.mark.case_id(
"TC-0766daae232f",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_细纲推荐字段缺失不拦类型错误拒__0766da():
from muse.元数据.接口 import 校验值
from muse.元数据.模型 import 元数据错误
结构 = _细纲结构()
结果 = 校验值(结构, _完整细纲值())
assert 结果.值["targetChapter"] == 3 # 推荐字段全部缺失仍通过
with pytest.raises(元数据错误) as 错:
校验值(结构, {**_完整细纲值(), "unknowns": "不是列表"})
assert 错.value.错误码 == "FIELD_INVALID"
@pytest.mark.case_id(
"TC-a5ee390b62d2",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_阴影边界与红线明确__a5ee39():
"""只核对作者路由文档的边界说明;正式写入保护由 S01 集成测试验收。"""
决定正文 = (仓库根 / ".agent" / "skills" / "操作" / "决定正文候选去留" / "SKILL.md").read_text(
encoding="utf-8"
)
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
encoding="utf-8"
)
assert "不能自行填补或改用最新版本" in 决定正文
assert "必须重新展示再决定" in 决定正文
assert "不代替正文或故事事实的独立确认" in 确认规划
assert "不自动刷新成最新版本后重试采纳" in 确认规划
@pytest.mark.case_id(
"TC-9bb8291586df",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_冻结与来源两行规则明确__9bb829():
"""审阅绑定确切版本与哈希;检索呈现内容与来源版本两份信息。"""
from muse.元数据.接口 import 投影字段, 授权摘要, 策略快照
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
encoding="utf-8"
)
for 锚 in ("candidate_hash", "review_hash", "expected_revision"):
assert 锚 in 确认规划, f"确认规划缺少冻结锚点 {锚}"
结构 = _细纲结构()
投影 = 投影字段(
结构,
策略快照("fine_outline", 1),
授权摘要("a1", "s1"),
字段用途名="aiContext",
内容用途="generation",
运行用途="production",
)
# 检索规则:细纲进入模型上下文的字段带来源引用,正文不在细纲内自造。
键 = {f.key for f in 投影.允许字段}
assert {"targetChapter", "chapterGoal", "keyEvents", "sourceRef"} <= 键
assert not any("正文" in k or k == "content" for k in 键), "细纲检索不得携带正文字段"
@pytest.mark.case_id(
"TC-1f5c8cd807f7",
environment="离线,合成探索稿与明确作者选择",
given="版本冲突或探索交接非法输入",
when="通过对应 CLI 业务入口触发失败",
then=["返回稳定错误码和非零退出状态,保留原内容;不以继承某个旧 Click 异常类作为行为证明"],
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_CLI失败可识别且不转为成功__1f5c8c(tmp_path):
"""版本冲突与非法输入经CLI返回非零与稳定错误码,不静默成功。"""
import json
import subprocess
import sys
cfg = _探索配置(tmp_path)
请求 = tmp_path / "保存.json"
首次 = {
"draft_id": "cli-draft",
"title": "伞",
"content": "第一稿。",
"expected_hash": None,
}
请求.write_text(json.dumps(首次, ensure_ascii=False))
保存 = subprocess.run(
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
capture_output=True,
text=True,
)
assert 保存.returncode == 0, 保存.stderr
冲突 = {**首次, "content": "第二稿。", "expected_hash": "stale-hash"}
请求.write_text(json.dumps(冲突, ensure_ascii=False))
失败 = subprocess.run(
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
capture_output=True,
text=True,
)
assert 失败.returncode == 1, (失败.stdout, 失败.stderr)
呈现 = json.loads(失败.stderr)
assert 呈现["code"] == "REVISION_CONFLICT"
缺稿 = subprocess.run(
[sys.executable, "-m", "muse", "探索", str(cfg), "查看", "missing-draft"],
capture_output=True,
text=True,
)
assert 缺稿.returncode == 1
assert json.loads(缺稿.stderr)["code"] == "EXPLORATION_NOT_FOUND"
@pytest.mark.case_id(
"TC-d55c2da4ad82",
environment="离线,合成探索稿与明确作者选择",
when="验证最终交接与规划字段。",
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
)
def test_细纲合同绑定既有规划槽位__d55c2d():
"""细纲归属B01规划族并由规划员角色承担;不再靠旧技能文档字符串绑定。"""
import json as _json
from muse.元数据.接口 import 读取内置种子, 选择实例维护者
类型 = next(s.类型 for s in 读取内置种子() if s.类型.type_id == "fine_outline")
assert "planning" in 类型.实例族
assert 选择实例维护者(类型, "planning") == "B01"
登记 = _json.loads((仓库根 / "工具" / "资源登记.json").read_text(encoding="utf-8"))
角色文件 = 仓库根 / ".agent" / "角色" / "规划员.md"
assert ".agent/角色/规划员.md" in 登记["能力文件"]
头部 = 角色文件.read_text(encoding="utf-8").split("---")[1]
assert "id: planner" in 头部
正文 = 角色文件.read_text(encoding="utf-8")
assert "规划候选" in 正文 and "不替正文" in 正文
@pytest.mark.case_id(
"NC-b01-delivery-format-section-hints", when="交付格式按实际章节数提示并拒绝缺失乱序"
)
def test_交付格式按实际章节数提示并拒绝缺失乱序__e13003(monkeypatch):
import json
import muse.作品规划.探索交接 as 交接
结构 = {
"contractVersion": "test-two",
"chapters": [
{"heading": "一、人物", "sections": []},
{"heading": "二、后果", "sections": []},
],
}
monkeypatch.setattr(交接, "打开资源", lambda _: json.dumps(结构))
正确 = (
"# 作品\n## 根设定\n未知。\n## 一、人物\n她。\n## 二、后果\n待定。\n## 待决与取舍\n保留。"
)
assert 校验交付格式(正确) == {
"valid": True,
"problems": [],
"setting_count": 0,
"contract_version": "test-two",
}
for 错稿 in (
正确.replace("## 二、后果\n", ""),
正确.replace("## 一、人物", "## 二、后果").replace(
"## 二、后果\n待定", "## 一、人物\n待定"
),
):
assert 校验交付格式(错稿)["problems"] == [
{"code": "TREE_MISMATCH", "message": "整理稿须按导航顺序包含根设定、2章和待决与取舍"}
]