test_state_manager_extra.py 20 KB

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