"""元数据的独立字段合同;仅通过公开接口解释结构与候选值。""" from __future__ import annotations import json from pathlib import Path import pytest from muse.元数据.接口 import ( 作品扩展, 值规则, 元数据错误, 合成结构, 字段定义, 校验值, 结构定义, ) @pytest.mark.case_id( "NC-meta-extension-compose", environment="离线,固定结构及合成候选值", given="人物基础与题材扩展", when="合成并保存输入", then=["同源结构解释新增枚举。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_作品新增字段可重复合成并校验__a40001() -> None: """given 人物基础与题材扩展;when 合成结构并校验输入;then 同源结构解释新增枚举。""" 基础 = 结构定义( "character", "character", 1, (字段定义("character.name", "姓名", "姓名", 值规则("text"), 必填=True),), ) 扩展 = 作品扩展( "work-a", "character", 1, 1, (字段定义("work-a.weather", "天气亲和", "天气亲和", 值规则("enum", 枚举=("雨", "雪"))),), ) 结构 = 合成结构(基础, 扩展) assert 结构.effective_schema_hash == 合成结构(基础, 扩展).effective_schema_hash 样例 = json.loads((Path(__file__).resolve().parents[1] / "夹具/动态结构.json").read_text()) 结果 = 校验值(结构, 样例["valid"]) assert 结果.值 == {"姓名": "青棠", "天气亲和": "雨"} with pytest.raises(元数据错误) as 错误: 校验值(结构, 样例["invalid_enum"]) assert 错误.value.错误码 == "FIELD_INVALID" @pytest.mark.case_id( "NC-meta-nested-binding", environment="离线纯规则", given="嵌套作品扩展声明 work.title 原生绑定", when="合成有效结构", then=["任何嵌套深度的扩展原生绑定均拒绝"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_嵌套扩展不能声明原生存储绑定__a40021() -> None: 基础 = 结构定义("t", "s", 1, ()) 伪字段 = 字段定义( "forged", "书名", "书名", 值规则("text"), 存储="native_column", 绑定身份="work.title" ) 扩展 = 作品扩展( "work-a", "s", 1, 1, (字段定义("outer", "附加", "附加", 值规则("list", 元素=值规则("object", 字段=(伪字段,)))),), ) with pytest.raises(元数据错误) as 错误: 合成结构(基础, 扩展) assert 错误.value.错误码 == "FIELD_NOT_ALLOWED" @pytest.fixture def 细纲(): from muse.元数据.接口 import 读取内置结构 return 合成结构(读取内置结构("fine_outline")) @pytest.fixture def 细纲值(): return { "targetChapter": 3, "sourceRef": {"sourceId": "outline-a", "sourceVersion": 2}, "chapterGoal": "寻找雨中的证人", "hardConstraints": ["上章已经封城"], "keyEvents": [ { "id": "event-a", "order": 1, "event": "收到口信", "participants": ["青棠"], "trigger": "信使抵达", "resultDirection": "得知桥下有人等候", } ], "adjustableBeats": ["可调整停留时间"], "declaredNewFacts": [], "mustAppearEntities": ["character-a"], "chapterEndHook": "证人失约", "foreshadowing": [], "entities": [], "stateChanges": [], "unknowns": [], "assumptions": [], } @pytest.mark.case_id( "TC-01cbb1d6b293", environment="离线,固定结构及合成候选值", given="原细纲合同", when="读取固定种子", then=["九项必填与对象节拍均有明确约束。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_细纲固定结构保留必填与推荐__01cbb1(细纲) -> None: """given 原细纲合同;when 读取固定种子;then 九项必填与对象节拍均有明确约束。""" assert {项.key for 项 in 细纲.字段 if 项.必填} == { "targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents", "adjustableBeats", "declaredNewFacts", "mustAppearEntities", "chapterEndHook", } 按键 = {项.key: 项 for 项 in 细纲.字段} assert 按键["keyEvents"].值.类型 == "list" assert 按键["foreshadowing"].值.元素.类型 == "object" @pytest.mark.case_id( "TC-9191dd03a6cf", environment="离线,固定结构及合成候选值", given="全部必填与五项推荐", when="校验", then=["可保存并报告其余两项推荐。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_完整细纲保留缺推荐报告__9191dd(细纲, 细纲值) -> None: """given 全部必填与五项推荐;when 校验;then 值校验通过并报告其余两项推荐。""" 结果 = 校验值(细纲, 细纲值) assert 结果.值 == 细纲值 assert 结果.缺少推荐字段 == ("sourceRefs", "targetChars") assert any(项.值 == {"sourceId": "outline-a", "sourceVersion": 2} for 项 in 结果.引用需求) @pytest.mark.case_id( "TC-d03c31c928c7", environment="离线,固定结构及合成候选值", given="九项必填", when="校验", then=["缺七项推荐只报告不阻断。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_仅必填不被推荐字段阻断__d03c31(细纲, 细纲值) -> None: """given 九项必填;when 校验;then 缺七项推荐只报告不阻断。""" 必填 = {项.key for 项 in 细纲.字段 if 项.必填} 结果 = 校验值(细纲, {键: 值 for 键, 值 in 细纲值.items() if 键 in 必填}) assert len(结果.缺少推荐字段) == 7 @pytest.mark.case_id( "TC-64236c24059f", environment="离线,固定结构及合成候选值", given="每种缺少必填的细纲", when="校验", then=["明确指出缺失字段。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) @pytest.mark.parametrize( "缺少", [ "targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents", "adjustableBeats", "declaredNewFacts", "mustAppearEntities", "chapterEndHook", ], ids=[ "targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents", "adjustableBeats", "declaredNewFacts", "mustAppearEntities", "chapterEndHook", ], ) def test_任一细纲必填缺失均拒绝__64236c(细纲, 细纲值, 缺少: str) -> None: """given 每种缺少必填的细纲;when 校验;then 明确指出缺失字段。""" del 细纲值[缺少] with pytest.raises(元数据错误) as 错误: 校验值(细纲, 细纲值) assert 错误.value.错误码 == "FIELD_INVALID" assert 缺少 in 错误.value.上下文["字段"] @pytest.mark.case_id( "TC-3b368c564466", environment="离线,固定结构及合成候选值", given="旧字段存疑免责形式", when="缺必填又追加免责键", then=["按新版拒绝未知字段。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_未登记免责键不能放行必填__3b368c(细纲, 细纲值) -> None: """given 旧字段存疑免责形式;when 缺必填又追加免责键;then 按新版拒绝未知字段。""" del 细纲值["chapterEndHook"] 细纲值["字段存疑"] = {"chapterEndHook": "本章没有钩子"} with pytest.raises(元数据错误) as 错误: 校验值(细纲, 细纲值) assert 错误.value.错误码 == "FIELD_NOT_ALLOWED" @pytest.mark.case_id( "TC-f5268d6a6e5f", environment="离线,固定结构及合成候选值", given="已发布大纲结构", when="使用未登记旧 title 键", then=["明确拒绝而非静默跳过。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_旧非强制结构也拒绝未知字段__f5268d() -> None: """given 已发布大纲结构;when 使用未登记旧 title 键;then 明确拒绝而非静默跳过。""" from muse.元数据.接口 import 读取内置结构 结构 = 合成结构(读取内置结构("outline")) assert 校验值(结构, {"主线一句话": "送信人寻找失踪证人"}).值["主线一句话"] with pytest.raises(元数据错误) as 错误: 校验值(结构, {"title": "未登记字段"}) assert 错误.value.错误码 == "FIELD_NOT_ALLOWED" @pytest.mark.case_id( "TC-7d96a5a7fec3", environment="离线,固定结构及合成候选值", given="未登记结构身份", when="请求加载", then=["拒绝未知版本。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_未知结构版本拒绝跳过__7d96a5() -> None: """given 未登记结构身份;when 请求加载;then 拒绝未知版本。""" from muse.元数据.接口 import 读取内置结构 with pytest.raises(元数据错误) as 错误: 读取内置结构("no_such_type") assert 错误.value.错误码 == "SCHEMA_UNKNOWN" @pytest.mark.case_id( "NC-meta-extension-protection", environment="离线,固定结构及合成候选值", given="基础字段", when="扩展覆盖、注入原生列或复用别名", then=["拒绝合成。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) @pytest.mark.parametrize( "修改", ["base", "identity", "native", "alias"], ids=["redefine-base", "system-identity", "native-column", "alias-conflict"], ) def test_扩展不能覆盖基础和系统骨架__a40002(修改: str) -> None: """given 基础字段;when 扩展覆盖、注入原生列或复用别名;then 拒绝合成。""" 基础 = 结构定义( "character", "character", 1, (字段定义("name", "姓名", "姓名", 值规则("text"), 别名=("名字",)),), ) 追加 = { "base": 字段定义("name", "另名", "另名", 值规则("text")), "identity": 字段定义("mine", "revision", "版本", 值规则("integer")), "native": 字段定义( "mine", "年龄", "年龄", 值规则("integer"), 存储="native_column", 绑定身份="user.table" ), "alias": 字段定义("mine", "另名", "另名", 值规则("text"), 别名=("名字",)), }[修改] with pytest.raises(元数据错误) as 错误: 合成结构(基础, 作品扩展("work-a", "character", 1, 1, (追加,))) assert 错误.value.错误码 == "FIELD_NOT_ALLOWED" @pytest.mark.case_id( "NC-meta-field-aliases", environment="离线,固定结构及合成候选值", given="已审中文别名", when="单键或双键提交", then=["单键规范化,双键冲突拒绝。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_别名规范化与双键冲突__a40003(细纲, 细纲值) -> None: """given 已审中文别名;when 单键或双键提交;then 单键规范化,双键冲突拒绝。""" 细纲值["本章目标"] = 细纲值.pop("chapterGoal") assert 校验值(细纲, 细纲值).值["chapterGoal"] == "寻找雨中的证人" 细纲值["chapterGoal"] = "另一目标" with pytest.raises(元数据错误) as 错误: 校验值(细纲, 细纲值) assert 错误.value.错误码 == "FIELD_NOT_ALLOWED" @pytest.mark.case_id( "NC-meta-value-types", environment="离线,固定结构及合成候选值", given="明确值类型", when="传入不相容值", then=["类型错误不能经自由 JSON 放行。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) @pytest.mark.parametrize( "规则,值", [ (值规则("integer"), True), (值规则("boolean"), 1), (值规则("date"), "赤历三年"), (值规则("number"), float("nan")), (值规则("list", 元素=值规则("text")), [1]), ], ids=["integer-is-not-bool", "boolean-is-not-int", "real-date", "finite-number", "list-element"], ) def test_动态值类型实际校验__a40004(规则: 值规则, 值) -> None: """given 明确值类型;when 传入不相容值;then 类型错误不能经自由 JSON 放行。""" 结构 = 合成结构( 结构定义("new_type", "new_schema", 1, (字段定义("new.field", "字段", "字段", 规则),)) ) with pytest.raises(元数据错误) as 错误: 校验值(结构, {"字段": 值}) assert 错误.value.错误码 == "FIELD_INVALID" @pytest.mark.case_id( "NC-meta-conditional-fields", environment="离线,固定结构及合成候选值", given="技法形态合同", when="缺条件必填、禁填或收益超量", then=["同源校验直接拒绝。"], contract="docs/系统架构/新版设计/接口契约/元数据投影与版本.md", ) def test_条件字段和列表数量来自内置合同__a40005() -> None: """given 技法形态合同;when 缺条件必填、禁填或收益超量;then 同源校验直接拒绝。""" from muse.元数据.接口 import 读取内置结构 结构 = 合成结构(读取内置结构("craft")) 场景 = { "装置形态": "场景手法", "复用节奏": "同场景只用一次", "异质化要求": "更换承载动作", "单章上限": 1, "读者收益": ["期待", "意外"], } assert 校验值(结构, 场景).值 == 场景 用例 = ( ( {"装置形态": "结构装置"}, {"缺少字段": ["回收点", "埋设手法", "记忆维持", "间隔纪律"], "禁止字段": []}, ), ({**场景, "间隔纪律": "并不存在跨章间隔"}, {"缺少字段": [], "禁止字段": ["间隔纪律"]}), ({**场景, "读者收益": ["期待", "意外", "紧张"]}, {"路径": "$.读者收益"}), ) for 非法, 预期上下文 in 用例: with pytest.raises(元数据错误) as 错误: 校验值(结构, 非法) assert 错误.value.错误码 == "FIELD_INVALID" assert 错误.value.上下文 == 预期上下文 # --------------------------------------------------------------------------- # 细纲读取与落库门禁(原 FineOutlineReader/FieldCoverage) # --------------------------------------------------------------------------- class 假连接: """按SQL特征返回合成行;只覆盖读取路径需要的最小查询。""" def __init__(self, 行集: dict[str, object]): self.行集 = 行集 self.请求记录 = [] def execute(self, sql: str, 参数=None): self.请求记录.append((sql, 参数)) 命中 = [k for k in self.行集 if k in sql] if len(命中) != 1: raise AssertionError(f"未预期或歧义SQL:{sql}") return 假游标(self.行集[命中[0]]) def cursor(self, row_factory=None): return self class 假游标: def __init__(self, 行): self.行 = 行 def execute(self, sql: str, 参数=None): return self def fetchone(self): return self.行 @pytest.mark.case_id( "TC-4232c6fa3cfa", environment="离线,固定结构与扩展样例", when="合成结构并对具体实例字段及别名运行校验。", contract="docs/系统架构/新版设计/数据模型/元数据与结构演进.md", ) def test_文本投影JSON字符串解析为对象__4232c6() -> None: """历史文本投影的JSON载荷归一化为对象;解析失败关闭。""" from muse.作品规划.存储 import 解析规划载荷 assert 解析规划载荷('{"targetChapter": 5, "chapterGoal": "y"}') == { "targetChapter": 5, "chapterGoal": "y", } assert 解析规划载荷({"targetChapter": 3}) == {"targetChapter": 3} @pytest.mark.case_id( "TC-58a5e287c8be", environment="离线,固定结构与扩展样例", when="合成结构并对具体实例字段及别名运行校验。", contract="docs/系统架构/新版设计/数据模型/元数据与结构演进.md", ) def test_非法规划载荷失败关闭__58a5e2() -> None: from muse.作品规划.存储 import 解析规划载荷 from muse.作品规划.模型 import 作品错误 for 非法 in (None, "[]", '"str"', "not-json", 42): with pytest.raises(作品错误) as 错: 解析规划载荷(非法) assert 错.value.错误码 == "INVALID_OUTPUT" @pytest.mark.case_id( "TC-00a2432d3397", environment="离线,固定结构与扩展样例", when="合成结构并对具体实例字段及别名运行校验。", contract="docs/系统架构/新版设计/数据模型/元数据与结构演进.md", ) def test_缺已确认细纲失败关闭__00a243() -> None: from muse.作品规划.近期细化 import 读取已确认细纲 连 = 假连接( { "muse_work_version": {"author_id": "author", "work_id": "w"}, "muse_directory_version": (1, [{"chapter_id": "c3", "title": "三", "position": 3}]), "FROM muse_plan WHERE": None, } ) from muse.作品规划.接口 import 作品错误 with pytest.raises(作品错误) as 错: 读取已确认细纲(连, "author", "w", "c3") assert getattr(错.value, "错误码", None) == "PRECONDITION_PENDING" assert "已确认细纲" in str(错.value) # 目标章不属于本书时同样失败关闭,不退回默认章。 with pytest.raises(作品错误) as 越界: 读取已确认细纲(连, "author", "w", "其他章") assert getattr(越界.value, "错误码", None) == "SCOPE_DENIED" @pytest.mark.case_id( "TC-3c112c210a65", environment="离线,固定结构与扩展样例", when="合成结构并对具体实例字段及别名运行校验。", contract="docs/系统架构/新版设计/数据模型/元数据与结构演进.md", ) def test_细纲只能由章层规划承担__3c112c() -> None: """fine_outline实例粒度为chapter:作品级、卷或场景节点都拒绝。""" from muse.作品规划.分层规划 import 规划目标 from muse.作品规划.模型 import 作品错误, 档案结构, 规划保存 from muse.元数据.接口 import 读取内置种子 类型 = next(s.类型 for s in 读取内置种子() if s.类型.type_id == "fine_outline") assert 类型.实例粒度 == "chapter" 目录 = {"chapters": [{"chapter_id": "c1", "title": "一", "position": 1}]} 作品级 = 规划保存("w", 0, 档案结构("fine_outline", 1, "", ""), {}) with pytest.raises(作品错误) as 错: 规划目标(None, 作品级, 类型, 目录) assert 错.value.错误码 == "PLAN_SCOPE_INVALID" 章级 = 规划保存("w", 0, 档案结构("fine_outline", 1, "", ""), {}, chapter_id="c1") assert 规划目标(None, 章级, 类型, 目录)["chapter_id"] == "c1" 节点 = 假连接( { "muse_directory_version": ( 1, [ {"node_id": "v1", "kind": "volume", "title": "卷", "parent_id": None}, {"node_id": "s1", "kind": "scene", "title": "景", "parent_id": "c1"}, ], [{"chapter_id": "c1", "title": "一", "position": 1}], ) } ) for 节点ID in ("v1", "s1"): 请求 = 规划保存("w", 0, 档案结构("fine_outline", 1, "", ""), {}, node_id=节点ID) with pytest.raises(作品错误) as 错: 规划目标(节点, 请求, 类型, 目录) assert 错.value.错误码 == "PLAN_SCOPE_INVALID" @pytest.mark.case_id( "TC-4b15f1809a64", environment="离线,固定结构与扩展样例", when="合成结构并对具体实例字段及别名运行校验。", contract="docs/系统架构/新版设计/数据模型/元数据与结构演进.md", ) def test_缺必填细纲在纯校验阶段拒绝__4b15f1() -> None: """仅验证字段覆盖纯函数:缺必填字段拒绝;持久化由集成用例验证。""" from muse.元数据.接口 import 读取内置结构 结构 = 合成结构(读取内置结构("fine_outline")) 完整 = { "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": "钩子", } assert 校验值(结构, 完整).值["targetChapter"] == 3 缺失 = {k: v for k, v in 完整.items() if k != "hardConstraints"} with pytest.raises(元数据错误) as 错: 校验值(结构, 缺失) 呈现 = 错.value.呈现() assert 呈现["code"] == "FIELD_INVALID" assert "hardConstraints" in 呈现["details"]["字段"]