test_state_manager_extra.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. StateManager extra tests
  5. """
  6. import json
  7. import sys
  8. import pytest
  9. from data_modules.state_manager import StateManager, EntityState
  10. from data_modules.index_manager import IndexManager, EntityMeta
  11. @pytest.fixture
  12. def temp_project(tmp_path):
  13. from data_modules.config import DataModulesConfig
  14. cfg = DataModulesConfig.from_project_root(tmp_path)
  15. cfg.ensure_dirs()
  16. return cfg
  17. def test_ensure_state_schema_and_progress(temp_project):
  18. # relationships as list should be migrated to structured_relationships
  19. state = {
  20. "relationships": [
  21. {"from_entity": "a", "to_entity": "b", "type": "师徒", "chapter": 1}
  22. ],
  23. "progress": {"current_chapter": "2", "total_words": "10"},
  24. }
  25. temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  26. manager = StateManager(temp_project, enable_sqlite_sync=False)
  27. assert isinstance(manager._state.get("relationships"), dict)
  28. assert isinstance(manager._state.get("structured_relationships"), list)
  29. assert int(manager.get_current_chapter()) == 2
  30. manager.update_progress(3)
  31. assert manager.get_current_chapter() == 3
  32. def test_add_update_entities_and_alias(temp_project):
  33. manager = StateManager(temp_project, enable_sqlite_sync=False)
  34. entity = EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心", aliases=["炎帝"])
  35. assert manager.add_entity(entity) is True
  36. assert manager.add_entity(entity) is False
  37. manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}})
  38. updated = manager.get_entity("xiaoyan")
  39. assert updated["current"]["realm"] == "斗师"
  40. assert manager.get_entity_type("xiaoyan") == "角色"
  41. assert manager.get_entity_type("missing") is None
  42. assert "xiaoyan" in manager.get_all_entities()
  43. assert "xiaoyan" in manager.get_entities_by_type("角色")
  44. assert "xiaoyan" in manager.get_entities_by_tier("核心")
  45. # unknown type update
  46. assert manager.update_entity("missing", {"current": {"realm": "斗者"}}, "角色") is False
  47. def test_update_entity_appearance_and_relationships(temp_project):
  48. manager = StateManager(temp_project, enable_sqlite_sync=False)
  49. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
  50. manager.update_entity_appearance("xiaoyan", 5, "角色")
  51. entity = manager.get_entity("xiaoyan")
  52. assert entity.get("first_appearance") == 5
  53. assert entity.get("last_appearance") == 5
  54. # unknown entity should no-op
  55. manager.update_entity_appearance("missing", 3, "角色")
  56. manager.add_relationship("xiaoyan", "yaolao", "师徒", "收徒", 1)
  57. rels = manager.get_relationships("xiaoyan")
  58. assert len(rels) == 1
  59. def test_disambiguation_and_save_state(temp_project):
  60. manager = StateManager(temp_project, enable_sqlite_sync=False)
  61. warnings = manager._record_disambiguation(
  62. 1,
  63. [
  64. {
  65. "mention": "宗主",
  66. "candidates": ["zongzhu", "lintian"],
  67. "suggested": "zongzhu",
  68. "confidence": 0.4,
  69. },
  70. {
  71. "mention": "萧炎",
  72. "candidates": [{"type": "角色", "id": "xiaoyan"}],
  73. "suggested": "xiaoyan",
  74. "confidence": 0.6,
  75. },
  76. ],
  77. )
  78. assert any("需人工确认" in w for w in warnings)
  79. assert any("消歧警告" in w for w in warnings)
  80. manager.save_state()
  81. assert temp_project.state_file.exists()
  82. def test_save_state_no_pending(temp_project):
  83. manager = StateManager(temp_project, enable_sqlite_sync=False)
  84. manager.save_state()
  85. assert not temp_project.state_file.exists()
  86. def test_save_state_with_sqlite_sync_and_protagonist(temp_project):
  87. manager = StateManager(temp_project)
  88. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
  89. manager.update_entity("xiaoyan", {"current": {"realm": "斗师", "location": "天云宗"}})
  90. manager.update_progress(10, words=500)
  91. manager.save_state()
  92. state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
  93. assert state.get("_migrated_to_sqlite") is True
  94. assert state.get("progress", {}).get("current_chapter") == 10
  95. # 标记为主角并同步
  96. idx = IndexManager(temp_project)
  97. idx.upsert_entity(
  98. EntityMeta(
  99. id="xiaoyan",
  100. type="角色",
  101. canonical_name="萧炎",
  102. tier="核心",
  103. current={"realm": "斗王", "location": "天云宗"},
  104. first_appearance=1,
  105. last_appearance=10,
  106. is_protagonist=True,
  107. ),
  108. update_metadata=True,
  109. )
  110. manager.sync_protagonist_from_entity()
  111. assert manager._state.get("protagonist_state", {}).get("power", {}).get("realm") == "斗王"
  112. manager._state["protagonist_state"] = {
  113. "power": {"realm": "斗皇", "layer": 2},
  114. "location": {"current": "中州"},
  115. }
  116. manager._state.setdefault("entities_v3", {"角色": {}})
  117. manager._state["entities_v3"]["角色"]["xiaoyan"] = {
  118. "canonical_name": "萧炎",
  119. "tier": "核心",
  120. "desc": "",
  121. "current": {"realm": "斗王", "location": "天云宗"},
  122. "first_appearance": 1,
  123. "last_appearance": 10,
  124. "history": [],
  125. }
  126. manager.sync_protagonist_to_entity("xiaoyan")
  127. manager.save_state()
  128. updated = idx.get_entity("xiaoyan")
  129. assert updated["current_json"]["realm"] == "斗皇"
  130. # export context
  131. exported = manager.export_for_context()
  132. assert exported.get("alias_index") == {}
  133. def test_process_chapter_result_and_sqlite_sync(temp_project):
  134. manager = StateManager(temp_project)
  135. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
  136. result = {
  137. "entities_appeared": [
  138. {"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"], "confidence": 0.9}
  139. ],
  140. "entities_new": [
  141. {
  142. "suggested_id": "yaolao",
  143. "name": "药老",
  144. "type": "角色",
  145. "tier": "重要",
  146. "mentions": ["药老"],
  147. "aliases": ["药老先生"],
  148. }
  149. ],
  150. "state_changes": [
  151. {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"}
  152. ],
  153. "relationships_new": [
  154. {"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}
  155. ],
  156. "uncertain": [
  157. {"mention": "宗主", "candidates": ["zongzhu", "lintian"], "suggested": "zongzhu", "confidence": 0.2},
  158. {
  159. "mention": "萧炎",
  160. "candidates": [{"type": "角色", "id": "xiaoyan"}],
  161. "suggested": "xiaoyan",
  162. "confidence": 0.8,
  163. "adopted": True,
  164. },
  165. ],
  166. "chapter_meta": {"hook": "test", "end": "ok"},
  167. }
  168. warnings = manager.process_chapter_result(12, result)
  169. assert any("需人工确认" in w for w in warnings)
  170. assert any("消歧警告" in w for w in warnings)
  171. manager.save_state()
  172. idx = IndexManager(temp_project)
  173. assert idx.get_entity("yaolao") is not None
  174. assert idx.get_relationship_between("xiaoyan", "yaolao")
  175. assert idx.get_entity_state_changes("xiaoyan")
  176. by_type = manager.get_entities_by_type("角色")
  177. by_tier = manager.get_entities_by_tier("核心")
  178. assert "xiaoyan" in by_type
  179. assert "xiaoyan" in by_tier
  180. def test_export_context_and_protagonist_alias(temp_project):
  181. manager = StateManager(temp_project, enable_sqlite_sync=False)
  182. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
  183. manager._state["disambiguation_warnings"] = [{"chapter": 1, "mention": "萧炎"}]
  184. manager._state["disambiguation_pending"] = [{"chapter": 2, "mention": "宗主"}]
  185. exported = manager.export_for_context()
  186. assert "xiaoyan" in exported.get("entities", {})
  187. assert exported["disambiguation"]["warnings"]
  188. assert exported["disambiguation"]["pending"]
  189. manager_sql = StateManager(temp_project)
  190. idx = IndexManager(temp_project)
  191. idx.upsert_entity(
  192. EntityMeta(
  193. id="xiaoyan",
  194. type="角色",
  195. canonical_name="萧炎",
  196. tier="核心",
  197. current={},
  198. first_appearance=1,
  199. last_appearance=1,
  200. is_protagonist=False,
  201. ),
  202. update_metadata=True,
  203. )
  204. idx.register_alias("小炎子", "xiaoyan", "角色")
  205. manager_sql._state["protagonist_state"] = {"name": "小炎子"}
  206. assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
  207. idx.upsert_entity(
  208. EntityMeta(
  209. id="xiaoyan",
  210. type="角色",
  211. canonical_name="萧炎",
  212. tier="核心",
  213. current={},
  214. first_appearance=1,
  215. last_appearance=1,
  216. is_protagonist=True,
  217. ),
  218. update_metadata=True,
  219. )
  220. assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
  221. def test_sqlite_metadata_update_and_alias_sync(temp_project):
  222. manager = StateManager(temp_project)
  223. idx = IndexManager(temp_project)
  224. idx.upsert_entity(
  225. EntityMeta(
  226. id="xiaoyan",
  227. type="角色",
  228. canonical_name="萧炎",
  229. tier="核心",
  230. current={"realm": "斗者"},
  231. first_appearance=1,
  232. last_appearance=1,
  233. is_protagonist=False,
  234. )
  235. )
  236. manager._state.setdefault("entities_v3", {"角色": {}})
  237. manager._state["entities_v3"]["角色"]["xiaoyan"] = {
  238. "canonical_name": "萧炎",
  239. "tier": "核心",
  240. "desc": "",
  241. "current": {"realm": "斗者"},
  242. "first_appearance": 1,
  243. "last_appearance": 1,
  244. "history": [],
  245. }
  246. manager.update_entity(
  247. "xiaoyan",
  248. {"canonical_name": "萧炎·新", "tier": "重要", "current": {"realm": "斗王"}},
  249. "角色",
  250. )
  251. manager.update_entity("xiaoyan", {"location": "中州"}, "角色")
  252. manager.update_entity_appearance("xiaoyan", 2, "角色")
  253. manager._pending_alias_entries["小炎子"] = [{"type": "角色", "id": "xiaoyan"}]
  254. manager.save_state()
  255. updated = idx.get_entity("xiaoyan")
  256. assert updated["canonical_name"] == "萧炎·新"
  257. assert updated["current_json"]["realm"] == "斗王"
  258. assert updated["current_json"]["location"] == "中州"
  259. assert updated["last_appearance"] == 2
  260. aliases = idx.get_entity_aliases("xiaoyan")
  261. assert "萧炎·新" in aliases
  262. assert "小炎子" in aliases
  263. def test_ensure_state_schema_invalid_inputs(temp_project):
  264. manager = StateManager(temp_project, enable_sqlite_sync=False)
  265. schema = manager._ensure_state_schema("bad")
  266. assert isinstance(schema, dict)
  267. schema2 = manager._ensure_state_schema({
  268. "progress": "bad",
  269. "relationships": "bad",
  270. "disambiguation_warnings": "bad",
  271. "disambiguation_pending": "bad",
  272. })
  273. assert isinstance(schema2["progress"], dict)
  274. assert isinstance(schema2["relationships"], dict)
  275. assert isinstance(schema2["disambiguation_warnings"], list)
  276. assert isinstance(schema2["disambiguation_pending"], list)
  277. def test_save_state_progress_and_disambiguation_merge(temp_project):
  278. state = {
  279. "progress": {"current_chapter": "bad", "total_words": "bad"},
  280. "disambiguation_warnings": "bad",
  281. "disambiguation_pending": "bad",
  282. }
  283. temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  284. manager = StateManager(temp_project, enable_sqlite_sync=False)
  285. manager.config.max_disambiguation_warnings = 1
  286. manager.config.max_disambiguation_pending = 1
  287. manager._pending_progress_chapter = 5
  288. manager._pending_progress_words_delta = 10
  289. manager._pending_disambiguation_warnings = [
  290. {"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
  291. {"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
  292. "bad",
  293. ]
  294. manager._pending_disambiguation_pending = [
  295. {"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
  296. {"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
  297. "bad",
  298. ]
  299. manager.save_state()
  300. saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
  301. assert saved["progress"]["current_chapter"] == 5
  302. assert saved["progress"]["total_words"] == 10
  303. assert len(saved["disambiguation_warnings"]) == 1
  304. assert len(saved["disambiguation_pending"]) == 1
  305. def test_sync_to_sqlite_exceptions_and_no_sql_manager(temp_project, monkeypatch):
  306. manager = StateManager(temp_project)
  307. manager._pending_progress_chapter = 1
  308. manager._pending_sqlite_data["chapter"] = 1
  309. manager._pending_alias_entries["alias"] = [{"type": "角色", "id": "xiaoyan"}]
  310. def boom(*args, **kwargs):
  311. raise RuntimeError("boom")
  312. monkeypatch.setattr(manager._sql_state_manager, "process_chapter_entities", boom)
  313. monkeypatch.setattr(manager._sql_state_manager, "register_alias", boom)
  314. manager.save_state()
  315. manager_no_sql = StateManager(temp_project, enable_sqlite_sync=False)
  316. manager_no_sql._sync_pending_patches_to_sqlite()
  317. def test_entity_fallbacks_and_updates(temp_project):
  318. manager = StateManager(temp_project, enable_sqlite_sync=False)
  319. manager.add_entity(EntityState(id="hero", name="主角", type="未知", tier="核心"))
  320. manager.add_entity(EntityState(id="place", name="乌坦城", type="地点", tier="重要"))
  321. assert manager.get_entity("hero", "角色")["canonical_name"] == "主角"
  322. assert manager.get_entity("place")["canonical_name"] == "乌坦城"
  323. assert manager.get_entity_type("place") == "地点"
  324. assert "hero" in manager.get_entities_by_type("角色")
  325. assert "hero" in manager.get_entities_by_tier("核心")
  326. assert "hero" in manager.get_all_entities()
  327. assert manager.update_entity("missing", {"current": {"a": 1}}) is False
  328. manager.update_entity("hero", {"attributes": {"hp": 1}}, "角色")
  329. manager._state["entities_v3"]["角色"]["hero"].pop("current", None)
  330. manager.update_entity("hero", {"current": {"mp": 2}}, "角色")
  331. manager.update_entity("hero", {"tier": "重要"}, "角色")
  332. manager._state["entities_v3"] = "bad"
  333. manager.update_entity_appearance("hero", 1, "角色")
  334. manager._state["entities_v3"]["角色"]["hero"] = {"first_appearance": 0, "last_appearance": 0}
  335. manager.update_entity_appearance("hero", 1, "角色")
  336. manager.update_entity_appearance("hero", 2, "角色")
  337. def test_register_alias_internal_and_get_all_entities_sqlite(temp_project):
  338. manager = StateManager(temp_project)
  339. manager._register_alias_internal("xiaoyan", "角色", "")
  340. manager._register_alias_internal("xiaoyan", "角色", "萧炎")
  341. idx = IndexManager(temp_project)
  342. idx.upsert_entity(
  343. EntityMeta(
  344. id="xiaoyan",
  345. type="角色",
  346. canonical_name="萧炎",
  347. tier="核心",
  348. current={},
  349. first_appearance=1,
  350. last_appearance=1,
  351. is_protagonist=False,
  352. )
  353. )
  354. all_entities = manager.get_all_entities()
  355. assert "xiaoyan" in all_entities
  356. def test_record_disambiguation_and_process_chapter_existing(temp_project):
  357. manager = StateManager(temp_project, enable_sqlite_sync=False)
  358. warnings = manager._record_disambiguation(
  359. 1,
  360. [
  361. "bad",
  362. {"mention": "", "confidence": 0.1},
  363. {"mention": "宗主", "confidence": "bad", "adopted": "zongzhu"},
  364. ],
  365. )
  366. assert warnings
  367. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
  368. warnings = manager.process_chapter_result(2, {"entities_new": [{"id": "xiaoyan", "name": "萧炎"}]})
  369. assert any("实体已存在" in w for w in warnings)
  370. def test_sync_protagonist_from_string_and_empty_updates(temp_project):
  371. manager = StateManager(temp_project, enable_sqlite_sync=False)
  372. manager._state.setdefault("entities_v3", {"角色": {}})
  373. manager._state["entities_v3"]["角色"]["bad"] = {
  374. "current": None,
  375. "current_json": "not-json",
  376. }
  377. manager._state["entities_v3"]["角色"]["hero"] = {
  378. "current": None,
  379. "current_json": json.dumps({"realm": "斗师", "layer": 2, "location": "乌坦城", "last_chapter": 3}),
  380. }
  381. manager.sync_protagonist_from_entity("bad")
  382. manager.sync_protagonist_from_entity("hero")
  383. assert manager._state["protagonist_state"]["power"]["realm"] == "斗师"
  384. manager._state["protagonist_state"] = {}
  385. manager.sync_protagonist_to_entity()
  386. def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
  387. idx = IndexManager(temp_project)
  388. idx.upsert_entity(
  389. EntityMeta(
  390. id="xiaoyan",
  391. type="角色",
  392. canonical_name="萧炎",
  393. tier="核心",
  394. current={},
  395. first_appearance=1,
  396. last_appearance=1,
  397. is_protagonist=False,
  398. )
  399. )
  400. def run_cli(args):
  401. monkeypatch.setattr(sys, "argv", args)
  402. from data_modules import state_manager as sm
  403. sm.main()
  404. return capsys.readouterr().out
  405. out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-progress"])
  406. assert "current_chapter" in out
  407. out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "missing"])
  408. assert "未找到实体" in out
  409. out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "xiaoyan"])
  410. assert "xiaoyan" in out
  411. out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--type", "角色"])
  412. assert "xiaoyan" in out
  413. out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--tier", "核心"])
  414. assert "xiaoyan" in out
  415. payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
  416. out = run_cli([
  417. "state_manager",
  418. "--project-root",
  419. str(temp_project.project_root),
  420. "process-chapter",
  421. "--chapter",
  422. "1",
  423. "--data",
  424. payload,
  425. ])
  426. assert "已处理第 1 章" in out
  427. def test_save_state_timeout(monkeypatch, temp_project):
  428. import filelock
  429. from data_modules import state_manager as sm
  430. manager = StateManager(temp_project, enable_sqlite_sync=False)
  431. manager.update_progress(1)
  432. class FakeLock:
  433. def __init__(self, *args, **kwargs):
  434. pass
  435. def __enter__(self):
  436. raise filelock.Timeout("timeout")
  437. def __exit__(self, exc_type, exc, tb):
  438. return False
  439. monkeypatch.setattr(sm.filelock, "FileLock", FakeLock)
  440. with pytest.raises(RuntimeError):
  441. manager.save_state()