#!/usr/bin/env python3 """落库字段覆盖门禁回归:把「字段全覆盖」从文档纪律变成机械门禁。 门禁读 meta/schemas/.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()