test_status_reporter.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import json
  4. import tempfile
  5. from data_modules.config import DataModulesConfig
  6. from data_modules.index_manager import (
  7. IndexManager,
  8. ChapterReadingPowerMeta,
  9. EntityMeta,
  10. RelationshipMeta,
  11. RelationshipEventMeta,
  12. )
  13. from status_reporter import StatusReporter
  14. def _write_state(project_root, state: dict):
  15. webnovel_dir = project_root / ".webnovel"
  16. webnovel_dir.mkdir(parents=True, exist_ok=True)
  17. (webnovel_dir / "state.json").write_text(
  18. json.dumps(state, ensure_ascii=False, indent=2),
  19. encoding="utf-8",
  20. )
  21. def test_foreshadowing_analysis_uses_real_chapters_and_handles_missing_data():
  22. with tempfile.TemporaryDirectory() as tmpdir:
  23. project_root = DataModulesConfig.from_project_root(tmpdir).project_root
  24. state = {
  25. "progress": {"current_chapter": 120, "total_words": 360000},
  26. "plot_threads": {
  27. "foreshadowing": [
  28. {
  29. "content": "林家宝库铭文的秘密",
  30. "status": "未回收",
  31. "tier": "核心",
  32. "planted_chapter": 20,
  33. "target_chapter": 100,
  34. },
  35. {
  36. "content": "神秘玉佩来历",
  37. "status": "待回收",
  38. "tier": "支线",
  39. "added_chapter": 50,
  40. "target": 150,
  41. },
  42. {
  43. "content": "旧日誓言",
  44. "status": "未回收",
  45. "tier": "装饰",
  46. },
  47. {
  48. "content": "已完成伏笔",
  49. "status": "已回收",
  50. "planted_chapter": 10,
  51. "target_chapter": 20,
  52. },
  53. ]
  54. },
  55. }
  56. _write_state(project_root, state)
  57. reporter = StatusReporter(str(project_root))
  58. assert reporter.load_state() is True
  59. foreshadowing = reporter.analyze_foreshadowing()
  60. assert len(foreshadowing) == 3
  61. records = {item["content"]: item for item in foreshadowing}
  62. assert records["林家宝库铭文的秘密"]["planted_chapter"] == 20
  63. assert records["林家宝库铭文的秘密"]["elapsed"] == 100
  64. assert records["林家宝库铭文的秘密"]["status"] == "🔴 已超期"
  65. assert records["神秘玉佩来历"]["planted_chapter"] == 50
  66. assert records["神秘玉佩来历"]["target_chapter"] == 150
  67. assert records["神秘玉佩来历"]["status"] in {"🟡 轻度超时", "🟢 正常"}
  68. assert records["旧日誓言"]["planted_chapter"] is None
  69. assert records["旧日誓言"]["status"] == "⚪ 数据不足"
  70. urgency = reporter.analyze_foreshadowing_urgency()
  71. urgency_by_content = {item["content"]: item for item in urgency}
  72. assert urgency_by_content["林家宝库铭文的秘密"]["urgency"] is not None
  73. assert urgency_by_content["林家宝库铭文的秘密"]["status"] == "🔴 已超期"
  74. assert urgency_by_content["旧日誓言"]["urgency"] is None
  75. assert urgency_by_content["旧日誓言"]["status"] == "⚪ 数据不足"
  76. def test_pacing_analysis_prefers_real_coolpoint_metadata_over_estimation():
  77. with tempfile.TemporaryDirectory() as tmpdir:
  78. config = DataModulesConfig.from_project_root(tmpdir)
  79. config.ensure_dirs()
  80. project_root = config.project_root
  81. state = {
  82. "progress": {"current_chapter": 3, "total_words": 12000},
  83. "chapter_meta": {
  84. "0003": {
  85. "hook": "下章有变",
  86. "coolpoint_patterns": ["身份掉马", "反派翻车"],
  87. }
  88. },
  89. }
  90. _write_state(project_root, state)
  91. idx = IndexManager(config)
  92. idx.save_chapter_reading_power(
  93. ChapterReadingPowerMeta(
  94. chapter=1,
  95. hook_type="渴望钩",
  96. hook_strength="strong",
  97. coolpoint_patterns=["打脸权威", "身份掉马"],
  98. )
  99. )
  100. idx.save_chapter_reading_power(
  101. ChapterReadingPowerMeta(
  102. chapter=2,
  103. hook_type="悬念钩",
  104. hook_strength="medium",
  105. coolpoint_patterns=["身份掉马"],
  106. )
  107. )
  108. reporter = StatusReporter(str(project_root))
  109. assert reporter.load_state() is True
  110. reporter.chapters_data = [
  111. {"chapter": 1, "word_count": 4000, "cool_point": "", "dominant": "", "characters": []},
  112. {"chapter": 2, "word_count": 3000, "cool_point": "", "dominant": "", "characters": []},
  113. {"chapter": 3, "word_count": 5000, "cool_point": "", "dominant": "", "characters": []},
  114. ]
  115. segments = reporter.analyze_pacing()
  116. assert len(segments) == 1
  117. seg = segments[0]
  118. assert seg["cool_points"] == 5
  119. assert round(seg["words_per_point"], 2) == 2400.00
  120. assert seg["missing_chapters"] == 0
  121. assert seg["dominant_source"] == "chapter_reading_power"
  122. def test_pacing_analysis_marks_missing_data_instead_of_assuming_one_point_per_chapter():
  123. with tempfile.TemporaryDirectory() as tmpdir:
  124. config = DataModulesConfig.from_project_root(tmpdir)
  125. config.ensure_dirs()
  126. project_root = config.project_root
  127. state = {
  128. "progress": {"current_chapter": 1, "total_words": 2000},
  129. "chapter_meta": {},
  130. }
  131. _write_state(project_root, state)
  132. reporter = StatusReporter(str(project_root))
  133. assert reporter.load_state() is True
  134. reporter.chapters_data = [
  135. {"chapter": 1, "word_count": 2000, "cool_point": "", "dominant": "", "characters": []}
  136. ]
  137. seg = reporter.analyze_pacing()[0]
  138. assert seg["cool_points"] == 0
  139. assert seg["words_per_point"] is None
  140. assert seg["rating"] == "数据不足"
  141. assert seg["missing_chapters"] == 1
  142. def test_relationship_graph_prefers_index_db_data():
  143. with tempfile.TemporaryDirectory() as tmpdir:
  144. config = DataModulesConfig.from_project_root(tmpdir)
  145. config.ensure_dirs()
  146. project_root = config.project_root
  147. state = {
  148. "progress": {"current_chapter": 12, "total_words": 24000},
  149. "protagonist_state": {"name": "萧炎"},
  150. "relationships": {"allies": [{"name": "旧盟友", "relation": "友好"}], "enemies": []},
  151. }
  152. _write_state(project_root, state)
  153. idx = IndexManager(config)
  154. idx.upsert_entity(
  155. EntityMeta(
  156. id="xiaoyan",
  157. type="角色",
  158. canonical_name="萧炎",
  159. tier="核心",
  160. current={},
  161. first_appearance=1,
  162. last_appearance=12,
  163. is_protagonist=True,
  164. )
  165. )
  166. idx.upsert_entity(
  167. EntityMeta(
  168. id="yaolao",
  169. type="角色",
  170. canonical_name="药老",
  171. tier="重要",
  172. current={},
  173. first_appearance=1,
  174. last_appearance=12,
  175. )
  176. )
  177. idx.upsert_relationship(
  178. RelationshipMeta(
  179. from_entity="xiaoyan",
  180. to_entity="yaolao",
  181. type="师徒",
  182. description="师徒关系",
  183. chapter=10,
  184. )
  185. )
  186. idx.record_relationship_event(
  187. RelationshipEventMeta(
  188. from_entity="xiaoyan",
  189. to_entity="yaolao",
  190. type="师徒",
  191. chapter=10,
  192. action="create",
  193. polarity=1,
  194. strength=0.9,
  195. description="拜师",
  196. evidence="萧炎拜药老为师",
  197. )
  198. )
  199. reporter = StatusReporter(str(project_root))
  200. assert reporter.load_state() is True
  201. graph = reporter.generate_relationship_graph()
  202. assert "mermaid" in graph
  203. assert "药老" in graph
  204. assert "师徒" in graph