"""私人探索与正式交接分开;局部草稿不受最终目录约束。""" 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.*?)(?=^## )", 入口, 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.*?)(?=^## |\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章和待决与取舍"} ]