131 lines
6.0 KiB
Python
131 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
||
"""落库字段覆盖门禁回归:把「字段全覆盖」从文档纪律变成机械门禁。
|
||
|
||
门禁读 meta/schemas/<schema_type>.yaml:只对该型声明的「必填字段」失败关闭,
|
||
推荐字段缺失只报告不拦;确无依据的字段可在 payload['字段存疑'] 显式标注原因放行。
|
||
未标「字段覆盖门禁: 强制」的型(如中文键的老 schema)不溯及强拦。
|
||
"""
|
||
|
||
import pathlib
|
||
import sys
|
||
import unittest
|
||
|
||
|
||
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||
SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "flow" / "skills" / "book" / "plan-story" / "scripts"
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from persist_planning import ( # noqa: E402
|
||
check_field_coverage,
|
||
write_section,
|
||
_load_schema,
|
||
assert_section_owner,
|
||
FINE_OUTLINE_OWNER,
|
||
)
|
||
|
||
REQUIRED = ["targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents",
|
||
"adjustableBeats", "declaredNewFacts", "mustAppearEntities", "chapterEndHook"]
|
||
|
||
|
||
def full_payload():
|
||
return {
|
||
"targetChapter": 3,
|
||
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"},
|
||
"chapterGoal": "本章戏剧任务",
|
||
"hardConstraints": ["衔接上一章结尾"],
|
||
"keyEvents": [{"id": "e1", "order": 1, "event": "事件", "participants": ["林深"],
|
||
"trigger": "触发", "resultDirection": "结果方向"}],
|
||
"adjustableBeats": ["次序可调整"],
|
||
"declaredNewFacts": [{"factId": "f1", "text": "新事实",
|
||
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"}}],
|
||
"mustAppearEntities": ["林深"],
|
||
"chapterEndHook": "章末钩子",
|
||
# 推荐字段
|
||
"foreshadowing": [{"action": "推进", "subject": "伏笔", "evidence": "unknown"}],
|
||
"entities": [{"name": "林深", "type": "character", "role": "主角"}],
|
||
"stateChanges": ["状态变化"],
|
||
"unknowns": [],
|
||
"assumptions": [],
|
||
}
|
||
|
||
|
||
class FieldCoverageTest(unittest.TestCase):
|
||
def test_schema_loads_with_required_and_enforce(self):
|
||
fields, required, enforce = _load_schema("fine_outline")
|
||
self.assertTrue(enforce)
|
||
self.assertEqual(required, REQUIRED)
|
||
self.assertIn("keyEvents", fields) # 块式字段被解析
|
||
self.assertIn("foreshadowing", fields) # 推荐字段被解析
|
||
|
||
def test_full_payload_covers_all(self):
|
||
r = check_field_coverage("fine_outline", full_payload())
|
||
self.assertTrue(r["enforced"])
|
||
self.assertEqual(r["missing_required"], [])
|
||
self.assertEqual(r["missing_recommended"], ["sourceRefs", "targetChars"])
|
||
|
||
def test_only_required_is_passable_recommended_reported_not_blocking(self):
|
||
payload = {k: v for k, v in full_payload().items() if k in REQUIRED}
|
||
r = check_field_coverage("fine_outline", payload)
|
||
self.assertEqual(r["missing_required"], []) # 不会被拦
|
||
self.assertEqual(len(r["missing_recommended"]), 7) # 只报告
|
||
|
||
def test_missing_required_is_caught(self):
|
||
for missing in REQUIRED:
|
||
payload = {k: v for k, v in full_payload().items() if k != missing}
|
||
r = check_field_coverage("fine_outline", payload)
|
||
self.assertIn(missing, r["missing_required"], f"缺 {missing} 应进 missing_required")
|
||
|
||
def test_excuse_hatch_releases_field(self):
|
||
payload = {k: v for k, v in full_payload().items() if k in REQUIRED}
|
||
payload["字段存疑"] = {"foreshadowing": "本章无伏笔动作", "entities": "出场已在 mustAppearEntities"}
|
||
r = check_field_coverage("fine_outline", payload)
|
||
self.assertNotIn("foreshadowing", r["missing_recommended"])
|
||
self.assertNotIn("entities", r["missing_recommended"])
|
||
# 必填也能用存疑放行(确无依据时显式声明,而非静默缺省)
|
||
payload2 = {k: v for k, v in payload.items() if k != "chapterEndHook"}
|
||
payload2["字段存疑"] = {"chapterEndHook": "本章无钩子,审查认可"}
|
||
self.assertEqual(check_field_coverage("fine_outline", payload2)["missing_required"], [])
|
||
|
||
def test_non_enforced_schema_not_blocked(self):
|
||
# outline 是中文键老 schema,未标强制:enforce=False,不溯及强拦。
|
||
_, _, enforce = _load_schema("outline")
|
||
self.assertFalse(enforce)
|
||
r = check_field_coverage("outline", {"title": "x"})
|
||
self.assertFalse(r["enforced"])
|
||
|
||
def test_unknown_schema_skipped(self):
|
||
r = check_field_coverage("no_such_type", {"anything": 1})
|
||
self.assertFalse(r["enforced"])
|
||
self.assertEqual(r["missing_required"], [])
|
||
|
||
def test_write_section_rejects_missing_required_before_db(self):
|
||
# 门禁在连库之前失败关闭:缺必填的 fine_outline 落库直接 ValueError。
|
||
payload = {k: v for k, v in full_payload().items() if k != "hardConstraints"}
|
||
with self.assertRaises(ValueError) as ctx:
|
||
write_section(12, section_type="fine_outline", schema_type="fine_outline",
|
||
target_chapter=99, payload=payload, owner_skill=FINE_OUTLINE_OWNER)
|
||
self.assertIn("字段覆盖门禁", str(ctx.exception))
|
||
|
||
def test_fine_outline_requires_plan_chapter_owner(self):
|
||
assert_section_owner("outline", "plan-story")
|
||
with self.assertRaises(ValueError) as ctx:
|
||
assert_section_owner("fine_outline", "plan-story")
|
||
self.assertIn("FINE_OUTLINE_OWNER", str(ctx.exception))
|
||
with self.assertRaises(ValueError) as ctx2:
|
||
write_section(
|
||
12,
|
||
section_type="fine_outline",
|
||
schema_type="fine_outline",
|
||
target_chapter=99,
|
||
payload=full_payload(),
|
||
owner_skill="plan-story",
|
||
)
|
||
self.assertIn("FINE_OUTLINE_OWNER", str(ctx2.exception))
|
||
with self.assertRaises(ValueError) as ctx3:
|
||
assert_section_owner("outline", FINE_OUTLINE_OWNER)
|
||
self.assertIn("BOOK_LEVEL_OWNER", str(ctx3.exception))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|