- 归档并收敛 R2 历史留痕至 docs/实现回顾/R2改造历史留痕.md,删除已退出生命周期的改造计划、旧文件处置及验证过程文档 - 现行测试用例身份收敛至 tests/用例清单.json(1358条扁平登记),适配 conftest、索引维护与测试执行验证 - 清理源码、配置、SQL迁移头、测试夹具及规则文档中的 Wxx 实施分期标签与过时过程描述 - 同步重新打包构建资源清单,通过全量离线测试、模块边界、类型检查与索引双向强核验
419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""元数据的独立字段合同;仅通过公开接口解释结构与候选值。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from muse.元数据.接口 import (
|
||
作品扩展,
|
||
值规则,
|
||
元数据错误,
|
||
合成结构,
|
||
字段定义,
|
||
校验值,
|
||
结构定义,
|
||
)
|
||
|
||
|
||
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"
|
||
|
||
|
||
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": [],
|
||
}
|
||
|
||
|
||
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"
|
||
|
||
|
||
def test_完整细纲保留缺推荐报告__9191dd(细纲, 细纲值) -> None:
|
||
"""given 全部必填与五项推荐;when 校验;then 可保存并报告其余两项推荐。"""
|
||
结果 = 校验值(细纲, 细纲值)
|
||
assert 结果.值 == 细纲值
|
||
assert 结果.缺少推荐字段 == ("sourceRefs", "targetChars")
|
||
assert any(项.值 == {"sourceId": "outline-a", "sourceVersion": 2} for 项 in 结果.引用需求)
|
||
|
||
|
||
def test_仅必填不被推荐字段阻断__d03c31(细纲, 细纲值) -> None:
|
||
"""given 九项必填;when 校验;then 缺七项推荐只报告不阻断。"""
|
||
必填 = {项.key for 项 in 细纲.字段 if 项.必填}
|
||
结果 = 校验值(细纲, {键: 值 for 键, 值 in 细纲值.items() if 键 in 必填})
|
||
assert len(结果.缺少推荐字段) == 7
|
||
|
||
|
||
@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.上下文["字段"]
|
||
|
||
|
||
def test_未登记免责键不能放行必填__3b368c(细纲, 细纲值) -> None:
|
||
"""given 旧字段存疑免责形式;when 缺必填又追加免责键;then 按新版拒绝未知字段。"""
|
||
del 细纲值["chapterEndHook"]
|
||
细纲值["字段存疑"] = {"chapterEndHook": "本章没有钩子"}
|
||
with pytest.raises(元数据错误) as 错误:
|
||
校验值(细纲, 细纲值)
|
||
assert 错误.value.错误码 == "FIELD_NOT_ALLOWED"
|
||
|
||
|
||
def test_旧非强制结构也拒绝未知字段__f5268d() -> None:
|
||
"""given 已发布大纲结构;when 使用未登记旧 title 键;then 明确拒绝而非静默跳过。"""
|
||
from muse.元数据.接口 import 读取内置结构
|
||
|
||
结构 = 合成结构(读取内置结构("outline"))
|
||
assert 校验值(结构, {"主线一句话": "送信人寻找失踪证人"}).值["主线一句话"]
|
||
with pytest.raises(元数据错误) as 错误:
|
||
校验值(结构, {"title": "未登记字段"})
|
||
assert 错误.value.错误码 == "FIELD_NOT_ALLOWED"
|
||
|
||
|
||
def test_未知结构版本拒绝跳过__7d96a5() -> None:
|
||
"""given 未登记结构身份;when 请求加载;then 拒绝未知版本。"""
|
||
from muse.元数据.接口 import 读取内置结构
|
||
|
||
with pytest.raises(元数据错误) as 错误:
|
||
读取内置结构("no_such_type")
|
||
assert 错误.value.错误码 == "SCHEMA_UNKNOWN"
|
||
|
||
|
||
@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(元数据错误):
|
||
合成结构(基础, 作品扩展("work-a", "character", 1, 1, (追加,)))
|
||
|
||
|
||
def test_别名规范化与双键冲突__a40003(细纲, 细纲值) -> None:
|
||
"""given 已审中文别名;when 单键或双键提交;then 单键规范化,双键冲突拒绝。"""
|
||
细纲值["本章目标"] = 细纲值.pop("chapterGoal")
|
||
assert 校验值(细纲, 细纲值).值["chapterGoal"] == "寻找雨中的证人"
|
||
细纲值["chapterGoal"] = "另一目标"
|
||
with pytest.raises(元数据错误) as 错误:
|
||
校验值(细纲, 细纲值)
|
||
assert 错误.value.错误码 == "FIELD_NOT_ALLOWED"
|
||
|
||
|
||
@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"
|
||
|
||
|
||
def test_条件字段和列表数量来自内置合同__a40005() -> None:
|
||
"""given 技法形态合同;when 缺条件必填、禁填或收益超量;then 同源校验直接拒绝。"""
|
||
from muse.元数据.接口 import 读取内置结构
|
||
|
||
结构 = 合成结构(读取内置结构("craft"))
|
||
场景 = {
|
||
"装置形态": "场景手法",
|
||
"复用节奏": "同场景只用一次",
|
||
"异质化要求": "更换承载动作",
|
||
"单章上限": 1,
|
||
"读者收益": ["期待", "意外"],
|
||
}
|
||
assert 校验值(结构, 场景).值 == 场景
|
||
for 非法 in (
|
||
{"装置形态": "结构装置"},
|
||
{**场景, "间隔纪律": "并不存在跨章间隔"},
|
||
{**场景, "读者收益": ["期待", "意外", "紧张"]},
|
||
):
|
||
with pytest.raises(元数据错误):
|
||
校验值(结构, 非法)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 细纲读取与落库门禁(原 FineOutlineReader/FieldCoverage)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class 假连接:
|
||
"""按SQL特征返回合成行;只覆盖读取路径需要的最小查询。"""
|
||
|
||
def __init__(self, 行集: dict[str, object]):
|
||
self.行集 = 行集
|
||
|
||
def execute(self, sql: str, 参数=None):
|
||
键 = next((k for k in self.行集 if k in sql), None)
|
||
return 假游标(self.行集.get(键))
|
||
|
||
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.行
|
||
|
||
|
||
def test_文本投影JSON字符串解析为对象__4232c6() -> None:
|
||
"""历史文本投影的JSON载荷归一化为对象;解析失败关闭。"""
|
||
from muse.作品规划.存储 import 解析规划载荷
|
||
|
||
assert 解析规划载荷('{"targetChapter": 5, "chapterGoal": "y"}') == {
|
||
"targetChapter": 5,
|
||
"chapterGoal": "y",
|
||
}
|
||
assert 解析规划载荷({"targetChapter": 3}) == {"targetChapter": 3}
|
||
|
||
|
||
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"
|
||
|
||
|
||
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,
|
||
}
|
||
)
|
||
with pytest.raises(Exception) as 错:
|
||
读取已确认细纲(连, "author", "w", "c3")
|
||
assert getattr(错.value, "错误码", None) == "PRECONDITION_PENDING"
|
||
assert "已确认细纲" in str(错.value)
|
||
# 目标章不属于本书时同样失败关闭,不退回默认章。
|
||
with pytest.raises(Exception) as 越界:
|
||
读取已确认细纲(连, "author", "w", "其他章")
|
||
assert getattr(越界.value, "错误码", None) == "SCOPE_DENIED"
|
||
|
||
|
||
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"]["字段"]
|
||
|
||
|
||
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"
|