test_webnovel_unified_cli.py 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import asyncio
  4. import importlib
  5. import json
  6. import sys
  7. from pathlib import Path
  8. import pytest
  9. def _ensure_scripts_on_path() -> None:
  10. scripts_dir = Path(__file__).resolve().parents[2]
  11. if str(scripts_dir) not in sys.path:
  12. sys.path.insert(0, str(scripts_dir))
  13. def _load_webnovel_module():
  14. _ensure_scripts_on_path()
  15. import data_modules.webnovel as webnovel_module
  16. return webnovel_module
  17. def _make_cli_init_ready_project(project_root: Path) -> None:
  18. dirs = (
  19. ".webnovel/backups",
  20. ".webnovel/archive",
  21. ".webnovel/summaries",
  22. "设定集",
  23. "大纲",
  24. "正文",
  25. "审查报告",
  26. )
  27. for rel in dirs:
  28. (project_root / rel).mkdir(parents=True, exist_ok=True)
  29. (project_root / ".webnovel" / "state.json").write_text(
  30. json.dumps(
  31. {
  32. "project_info": {"title": "测试书", "genre": "玄幻"},
  33. "progress": {"current_chapter": 0},
  34. },
  35. ensure_ascii=False,
  36. ),
  37. encoding="utf-8",
  38. )
  39. for rel in (
  40. "设定集/世界观.md",
  41. "设定集/力量体系.md",
  42. "设定集/主角卡.md",
  43. "设定集/反派设计.md",
  44. "大纲/总纲.md",
  45. ".env.example",
  46. ):
  47. path = project_root / rel
  48. path.parent.mkdir(parents=True, exist_ok=True)
  49. path.write_text("placeholder\n", encoding="utf-8")
  50. def test_init_does_not_resolve_existing_project_root(monkeypatch):
  51. module = _load_webnovel_module()
  52. called = {}
  53. def _fake_run_script(script_name, argv):
  54. called["script_name"] = script_name
  55. called["argv"] = list(argv)
  56. return 0
  57. def _fail_resolve(_explicit_project_root=None):
  58. raise AssertionError("init 子命令不应触发 project_root 解析")
  59. monkeypatch.setenv("WEBNOVEL_PROJECT_ROOT", r"D:\invalid\root")
  60. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  61. monkeypatch.setattr(module, "_resolve_root", _fail_resolve)
  62. monkeypatch.setattr(sys, "argv", ["webnovel", "init", "proj-dir", "测试书", "修仙"])
  63. with pytest.raises(SystemExit) as exc:
  64. module.main()
  65. assert int(exc.value.code or 0) == 0
  66. assert called["script_name"] == "init_project.py"
  67. assert called["argv"] == ["proj-dir", "测试书", "修仙"]
  68. def test_extract_context_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  69. module = _load_webnovel_module()
  70. book_root = (tmp_path / "book").resolve()
  71. called = {}
  72. def _fake_resolve(explicit_project_root=None):
  73. return book_root
  74. def _fake_run_script(script_name, argv):
  75. called["script_name"] = script_name
  76. called["argv"] = list(argv)
  77. return 0
  78. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  79. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  80. monkeypatch.setattr(
  81. sys,
  82. "argv",
  83. [
  84. "webnovel",
  85. "--project-root",
  86. str(tmp_path),
  87. "extract-context",
  88. "--chapter",
  89. "12",
  90. "--format",
  91. "json",
  92. ],
  93. )
  94. with pytest.raises(SystemExit) as exc:
  95. module.main()
  96. assert int(exc.value.code or 0) == 0
  97. assert called["script_name"] == "extract_chapter_context.py"
  98. assert called["argv"] == [
  99. "--project-root",
  100. str(book_root),
  101. "--chapter",
  102. "12",
  103. "--format",
  104. "json",
  105. ]
  106. def test_backup_forwards_resolved_book_root_from_parent_workspace(monkeypatch, tmp_path):
  107. module = _load_webnovel_module()
  108. workspace_root = (tmp_path / "workspace").resolve()
  109. book_root = (workspace_root / "book").resolve()
  110. (workspace_root / ".git").mkdir(parents=True, exist_ok=True)
  111. (book_root / ".git").mkdir(parents=True, exist_ok=True)
  112. (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  113. (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  114. called = {}
  115. def _fake_run_script(script_name, argv):
  116. called["script_name"] = script_name
  117. called["argv"] = list(argv)
  118. return 0
  119. monkeypatch.chdir(workspace_root)
  120. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  121. monkeypatch.setattr(
  122. sys,
  123. "argv",
  124. [
  125. "webnovel",
  126. "--project-root",
  127. str(workspace_root),
  128. "backup",
  129. "--chapter",
  130. "2",
  131. "--chapter-title",
  132. "第二章",
  133. ],
  134. )
  135. with pytest.raises(SystemExit) as exc:
  136. module.main()
  137. assert int(exc.value.code or 0) == 0
  138. assert called["script_name"] == "backup_manager.py"
  139. assert called["argv"] == [
  140. "--project-root",
  141. str(book_root),
  142. "--chapter",
  143. "2",
  144. "--chapter-title",
  145. "第二章",
  146. ]
  147. def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  148. module = _load_webnovel_module()
  149. book_root = (tmp_path / "book").resolve()
  150. called = {}
  151. def _fake_resolve(explicit_project_root=None):
  152. return book_root
  153. def _fake_run_script(script_name, argv):
  154. called["script_name"] = script_name
  155. called["argv"] = list(argv)
  156. return 0
  157. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  158. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  159. monkeypatch.setattr(
  160. sys,
  161. "argv",
  162. [
  163. "webnovel",
  164. "--project-root",
  165. str(tmp_path),
  166. "story-system",
  167. "玄幻退婚流",
  168. ],
  169. )
  170. with pytest.raises(SystemExit) as exc:
  171. module.main()
  172. assert int(exc.value.code or 0) == 0
  173. assert called["script_name"] == "story_system.py"
  174. assert called["argv"][:2] == ["--project-root", str(book_root)]
  175. def test_webnovel_story_system_runtime_forwards(monkeypatch, tmp_path):
  176. module = _load_webnovel_module()
  177. project_root = (tmp_path / "book").resolve()
  178. called = {}
  179. def _fake_resolve(explicit_project_root=None):
  180. return project_root
  181. def _fake_run_script(script_name, argv):
  182. called["script_name"] = script_name
  183. called["argv"] = list(argv)
  184. return 0
  185. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  186. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  187. monkeypatch.setattr(
  188. sys,
  189. "argv",
  190. [
  191. "webnovel",
  192. "--project-root",
  193. str(project_root),
  194. "story-system",
  195. "玄幻退婚流",
  196. "--emit-runtime-contracts",
  197. ],
  198. )
  199. with pytest.raises(SystemExit) as exc:
  200. module.main()
  201. assert int(exc.value.code or 0) == 0
  202. assert called["script_name"] == "story_system.py"
  203. assert "--emit-runtime-contracts" in called["argv"]
  204. def test_webnovel_commit_forwards(monkeypatch, tmp_path):
  205. module = _load_webnovel_module()
  206. project_root = tmp_path / "book"
  207. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  208. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  209. called = {}
  210. def _fake_run_script(script_name, argv):
  211. called["script_name"] = script_name
  212. called["argv"] = list(argv)
  213. return 0
  214. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  215. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "chapter-commit", "--chapter", "3"])
  216. with pytest.raises(SystemExit) as exc:
  217. module.main()
  218. assert int(exc.value.code or 0) == 0
  219. assert called["script_name"] == "chapter_commit.py"
  220. def test_webnovel_story_events_forwards(monkeypatch, tmp_path):
  221. module = _load_webnovel_module()
  222. project_root = tmp_path / "book"
  223. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  224. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  225. called = {}
  226. def _fake_run_script(script_name, argv):
  227. called["script_name"] = script_name
  228. called["argv"] = list(argv)
  229. return 0
  230. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  231. monkeypatch.setattr(
  232. sys,
  233. "argv",
  234. ["webnovel", "--project-root", str(project_root), "story-events", "--chapter", "3"],
  235. )
  236. with pytest.raises(SystemExit) as exc:
  237. module.main()
  238. assert int(exc.value.code or 0) == 0
  239. assert called["script_name"] == "story_events.py"
  240. def test_preflight_succeeds_for_valid_project_root(monkeypatch, tmp_path, capsys):
  241. module = _load_webnovel_module()
  242. project_root = tmp_path / "book"
  243. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  244. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  245. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight"])
  246. with pytest.raises(SystemExit) as exc:
  247. module.main()
  248. captured = capsys.readouterr()
  249. assert int(exc.value.code or 0) == 0
  250. assert "OK project_root" in captured.out
  251. assert str(project_root.resolve()) in captured.out
  252. def test_preflight_fails_when_required_scripts_are_missing(monkeypatch, tmp_path, capsys):
  253. module = _load_webnovel_module()
  254. project_root = tmp_path / "book"
  255. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  256. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  257. fake_scripts_dir = tmp_path / "fake-scripts"
  258. fake_scripts_dir.mkdir(parents=True, exist_ok=True)
  259. monkeypatch.setattr(module, "_scripts_dir", lambda: fake_scripts_dir)
  260. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"])
  261. with pytest.raises(SystemExit) as exc:
  262. module.main()
  263. captured = capsys.readouterr()
  264. assert int(exc.value.code or 0) == 1
  265. assert '"ok": false' in captured.out
  266. assert '"name": "entry_script"' in captured.out
  267. def test_preflight_includes_story_runtime_health(monkeypatch, tmp_path, capsys):
  268. module = _load_webnovel_module()
  269. project_root = tmp_path / "book"
  270. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  271. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  272. monkeypatch.setattr(
  273. sys,
  274. "argv",
  275. ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"],
  276. )
  277. with pytest.raises(SystemExit):
  278. module.main()
  279. captured = capsys.readouterr()
  280. assert '"story_runtime"' in captured.out
  281. assert '"mainline_ready"' in captured.out
  282. def test_project_status_cli_outputs_json_without_reusing_status(monkeypatch, tmp_path, capsys):
  283. module = _load_webnovel_module()
  284. project_root = tmp_path / "book"
  285. _make_cli_init_ready_project(project_root)
  286. monkeypatch.setattr(
  287. sys,
  288. "argv",
  289. ["webnovel", "--project-root", str(project_root), "project-status", "--format", "json"],
  290. )
  291. with pytest.raises(SystemExit) as exc:
  292. module.main()
  293. captured = capsys.readouterr()
  294. report = json.loads(captured.out)
  295. assert int(exc.value.code or 0) == 0
  296. assert report["schema_version"] == "webnovel-project-status/v1"
  297. assert report["project"] == "测试书"
  298. assert report["phase"] == "init_ready"
  299. def test_doctor_cli_reports_missing_init_file(monkeypatch, tmp_path, capsys):
  300. module = _load_webnovel_module()
  301. project_root = tmp_path / "book"
  302. _make_cli_init_ready_project(project_root)
  303. (project_root / "大纲" / "总纲.md").unlink()
  304. monkeypatch.setattr(
  305. sys,
  306. "argv",
  307. ["webnovel", "--project-root", str(project_root), "doctor", "--format", "json"],
  308. )
  309. with pytest.raises(SystemExit) as exc:
  310. module.main()
  311. captured = capsys.readouterr()
  312. report = json.loads(captured.out)
  313. assert int(exc.value.code or 0) == 1
  314. assert report["schema_version"] == "webnovel-doctor/v1"
  315. assert report["ok"] is False
  316. assert any(item["id"] == "file.required.大纲/总纲.md" for item in report["checks"])
  317. def test_status_command_still_forwards_to_status_reporter(monkeypatch, tmp_path):
  318. module = _load_webnovel_module()
  319. project_root = tmp_path / "book"
  320. _make_cli_init_ready_project(project_root)
  321. called = {}
  322. def _fake_run_script(script_name, argv):
  323. called["script_name"] = script_name
  324. called["argv"] = list(argv)
  325. return 0
  326. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  327. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "status", "--focus", "all"])
  328. with pytest.raises(SystemExit) as exc:
  329. module.main()
  330. assert int(exc.value.code or 0) == 0
  331. assert called["script_name"] == "status_reporter.py"
  332. def test_write_gate_cli_runs_prewrite(monkeypatch, tmp_path, capsys):
  333. module = _load_webnovel_module()
  334. project_root = tmp_path / "book"
  335. _make_cli_init_ready_project(project_root)
  336. for path, payload in (
  337. (project_root / ".story-system" / "MASTER_SETTING.json", {"meta": {"contract_type": "MASTER_SETTING"}}),
  338. (project_root / ".story-system" / "volumes" / "volume_001.json", {"meta": {"volume": 1}}),
  339. (project_root / ".story-system" / "chapters" / "chapter_001.json", {"chapter_directive": {"must_cover_nodes": []}}),
  340. (project_root / ".story-system" / "reviews" / "chapter_001.review.json", {"blocking_rules": []}),
  341. ):
  342. path.parent.mkdir(parents=True, exist_ok=True)
  343. path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
  344. monkeypatch.setattr(
  345. sys,
  346. "argv",
  347. [
  348. "webnovel",
  349. "--project-root",
  350. str(project_root),
  351. "write-gate",
  352. "--chapter",
  353. "1",
  354. "--stage",
  355. "prewrite",
  356. "--format",
  357. "json",
  358. ],
  359. )
  360. with pytest.raises(SystemExit) as exc:
  361. module.main()
  362. captured = capsys.readouterr()
  363. report = json.loads(captured.out)
  364. assert int(exc.value.code or 0) == 0
  365. assert report["schema_version"] == "webnovel-write-gate/v1"
  366. assert report["stage"] == "prewrite"
  367. assert report["ok"] is True
  368. def test_projections_retry_cli_runs(monkeypatch, tmp_path, capsys):
  369. module = _load_webnovel_module()
  370. project_root = tmp_path / "book"
  371. _make_cli_init_ready_project(project_root)
  372. commit_path = project_root / ".story-system" / "commits" / "chapter_001.commit.json"
  373. commit_path.parent.mkdir(parents=True, exist_ok=True)
  374. commit_path.write_text(
  375. json.dumps(
  376. {
  377. "meta": {"chapter": 1, "status": "rejected"},
  378. "review_result": {"blocking_count": 1},
  379. "fulfillment_result": {
  380. "planned_nodes": [],
  381. "covered_nodes": [],
  382. "missed_nodes": [],
  383. "extra_nodes": [],
  384. },
  385. "disambiguation_result": {"pending": []},
  386. "extraction_result": {"accepted_events": [], "state_deltas": [], "entity_deltas": []},
  387. "projection_status": {
  388. "state": "pending",
  389. "index": "pending",
  390. "summary": "pending",
  391. "memory": "pending",
  392. "vector": "pending",
  393. },
  394. },
  395. ensure_ascii=False,
  396. ),
  397. encoding="utf-8",
  398. )
  399. monkeypatch.setattr(
  400. sys,
  401. "argv",
  402. [
  403. "webnovel",
  404. "--project-root",
  405. str(project_root),
  406. "projections",
  407. "retry",
  408. "--chapter",
  409. "1",
  410. "--format",
  411. "json",
  412. ],
  413. )
  414. with pytest.raises(SystemExit) as exc:
  415. module.main()
  416. captured = capsys.readouterr()
  417. report = json.loads(captured.out)
  418. assert int(exc.value.code or 0) == 0
  419. assert report["schema_version"] == "webnovel-projections/v1"
  420. assert report["projection_status"]["state"] == "done"
  421. def test_where_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
  422. module = _load_webnovel_module()
  423. workspace = tmp_path / "workspace"
  424. workspace.mkdir(parents=True, exist_ok=True)
  425. (workspace / ".git").mkdir(parents=True, exist_ok=True)
  426. monkeypatch.chdir(workspace)
  427. monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
  428. monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
  429. monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
  430. monkeypatch.setattr(sys, "argv", ["webnovel", "where"])
  431. with pytest.raises(SystemExit) as exc:
  432. module.main()
  433. captured = capsys.readouterr()
  434. assert int(exc.value.code or 0) == 1
  435. assert "还没有激活的书项目" in captured.err
  436. assert "Traceback" not in captured.err
  437. def test_preflight_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
  438. module = _load_webnovel_module()
  439. workspace = tmp_path / "workspace"
  440. workspace.mkdir(parents=True, exist_ok=True)
  441. (workspace / ".git").mkdir(parents=True, exist_ok=True)
  442. monkeypatch.chdir(workspace)
  443. monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
  444. monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
  445. monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
  446. monkeypatch.setattr(sys, "argv", ["webnovel", "preflight", "--format", "json"])
  447. with pytest.raises(SystemExit) as exc:
  448. module.main()
  449. captured = capsys.readouterr()
  450. report = json.loads(captured.out)
  451. assert int(exc.value.code or 0) == 1
  452. assert report["ok"] is False
  453. assert "还没有激活的书项目" in report["project_root_error"]
  454. assert "Traceback" not in captured.err
  455. def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
  456. _ensure_scripts_on_path()
  457. import quality_trend_report as quality_trend_report_module
  458. workspace_root = (tmp_path / "workspace").resolve()
  459. book_root = (workspace_root / "凡人资本论").resolve()
  460. (workspace_root / ".claude").mkdir(parents=True, exist_ok=True)
  461. (workspace_root / ".claude" / ".webnovel-current-project").write_text(str(book_root), encoding="utf-8")
  462. (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  463. (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  464. output_path = workspace_root / "report.md"
  465. monkeypatch.setattr(
  466. sys,
  467. "argv",
  468. [
  469. "quality_trend_report",
  470. "--project-root",
  471. str(workspace_root),
  472. "--limit",
  473. "1",
  474. "--output",
  475. str(output_path),
  476. ],
  477. )
  478. quality_trend_report_module.main()
  479. assert output_path.is_file()
  480. assert (book_root / ".webnovel" / "index.db").is_file()
  481. assert not (workspace_root / ".webnovel" / "index.db").exists()
  482. def test_review_pipeline_builds_artifacts(tmp_path):
  483. _ensure_scripts_on_path()
  484. import review_pipeline as review_pipeline_module
  485. project_root = (tmp_path / "book").resolve()
  486. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  487. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  488. review_results_path = tmp_path / "review_results.json"
  489. review_results_path.write_text(
  490. json.dumps(
  491. {
  492. "issues": [
  493. {
  494. "severity": "critical",
  495. "category": "timeline",
  496. "location": "第2段",
  497. "description": "时间线回跳",
  498. "evidence": "上章深夜,本章突然中午",
  499. "fix_hint": "补时间过渡",
  500. "blocking": True,
  501. },
  502. {
  503. "severity": "medium",
  504. "category": "ai_flavor",
  505. "location": "第5段",
  506. "description": "'稳住心神'出现2次",
  507. "fix_hint": "替换为具体动作",
  508. },
  509. ],
  510. "summary": "1个阻断,1个中等",
  511. },
  512. ensure_ascii=False,
  513. ),
  514. encoding="utf-8",
  515. )
  516. payload = review_pipeline_module.build_review_artifacts(
  517. project_root=project_root,
  518. chapter=20,
  519. review_results_path=review_results_path,
  520. report_file="审查报告/第20章.md",
  521. )
  522. assert payload["review_result"]["blocking_count"] == 1
  523. assert payload["review_result"]["has_blocking"] is True
  524. assert payload["review_result"]["issues_count"] == 2
  525. assert payload["metrics"]["start_chapter"] == 20
  526. assert payload["metrics"]["end_chapter"] == 20
  527. assert payload["metrics"]["issues_count"] == 2
  528. assert payload["metrics"]["blocking_count"] == 1
  529. assert payload["metrics"]["severity_counts"]["critical"] == 1
  530. assert payload["metrics"]["severity_counts"]["medium"] == 1
  531. assert payload["metrics"]["critical_issues"] == ["时间线回跳"]
  532. assert payload["metrics"]["overall_score"] < 100
  533. assert payload["metrics"]["report_file"] == "审查报告/第20章.md"
  534. def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  535. module = _load_webnovel_module()
  536. book_root = (tmp_path / "book").resolve()
  537. review_results = (tmp_path / "review_results.json").resolve()
  538. called = {}
  539. def _fake_resolve(explicit_project_root=None):
  540. return book_root
  541. def _fake_run_script(script_name, argv):
  542. called["script_name"] = script_name
  543. called["argv"] = list(argv)
  544. return 0
  545. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  546. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  547. monkeypatch.setattr(
  548. sys,
  549. "argv",
  550. [
  551. "webnovel",
  552. "--project-root",
  553. str(tmp_path),
  554. "review-pipeline",
  555. "--chapter",
  556. "18",
  557. "--review-results",
  558. str(review_results),
  559. "--metrics-out",
  560. str(tmp_path / "metrics.json"),
  561. "--report-file",
  562. "审查报告/第18章.md",
  563. "--save-metrics",
  564. ],
  565. )
  566. with pytest.raises(SystemExit) as exc:
  567. module.main()
  568. assert int(exc.value.code or 0) == 0
  569. assert called["script_name"] == "review_pipeline.py"
  570. assert called["argv"] == [
  571. "--project-root",
  572. str(book_root),
  573. "--chapter",
  574. "18",
  575. "--review-results",
  576. str(review_results),
  577. "--metrics-out",
  578. str(tmp_path / "metrics.json"),
  579. "--report-file",
  580. "审查报告/第18章.md",
  581. "--save-metrics",
  582. ]
  583. def test_project_memory_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  584. module = _load_webnovel_module()
  585. book_root = (tmp_path / "book").resolve()
  586. called = {}
  587. def _fake_resolve(explicit_project_root=None):
  588. return book_root
  589. def _fake_run_script(script_name, argv):
  590. called["script_name"] = script_name
  591. called["argv"] = list(argv)
  592. return 0
  593. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  594. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  595. monkeypatch.setattr(
  596. sys,
  597. "argv",
  598. [
  599. "webnovel",
  600. "--project-root",
  601. str(tmp_path),
  602. "project-memory",
  603. "add-pattern",
  604. "--pattern-type",
  605. "format",
  606. "--description",
  607. '内心独白使用双引号""',
  608. ],
  609. )
  610. with pytest.raises(SystemExit) as exc:
  611. module.main()
  612. assert int(exc.value.code or 0) == 0
  613. assert called["script_name"] == "project_memory.py"
  614. assert called["argv"] == [
  615. "--project-root",
  616. str(book_root),
  617. "add-pattern",
  618. "--pattern-type",
  619. "format",
  620. "--description",
  621. '内心独白使用双引号""',
  622. ]
  623. def test_project_memory_add_pattern_escapes_quotes(tmp_path):
  624. _ensure_scripts_on_path()
  625. import project_memory as project_memory_module
  626. project_root = (tmp_path / "book").resolve()
  627. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  628. (project_root / ".webnovel" / "state.json").write_text(
  629. json.dumps({"progress": {"current_chapter": 3}}, ensure_ascii=False),
  630. encoding="utf-8",
  631. )
  632. description = "正文格式规范:内心独白使用双引号\"\",系统界面保留方括号[]"
  633. result = project_memory_module.add_pattern(
  634. project_root,
  635. pattern_type="format",
  636. description=description,
  637. category="写作规范",
  638. importance="high",
  639. )
  640. memory_path = project_root / ".webnovel" / "project_memory.json"
  641. raw_text = memory_path.read_text(encoding="utf-8")
  642. payload = json.loads(raw_text)
  643. assert result["status"] == "success"
  644. assert '\\"\\"' in raw_text
  645. assert payload["patterns"][0]["description"] == description
  646. assert payload["patterns"][0]["source_chapter"] == 3
  647. def test_review_pipeline_main_creates_output_directories(tmp_path):
  648. _ensure_scripts_on_path()
  649. import review_pipeline as review_pipeline_module
  650. project_root = (tmp_path / "book").resolve()
  651. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  652. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  653. review_results_path = tmp_path / "review_results.json"
  654. review_results_path.write_text(
  655. json.dumps(
  656. {
  657. "issues": [
  658. {
  659. "severity": "low",
  660. "category": "other",
  661. "location": "p1",
  662. "description": "小问题",
  663. }
  664. ],
  665. "summary": "轻微",
  666. },
  667. ensure_ascii=False,
  668. ),
  669. encoding="utf-8",
  670. )
  671. metrics_out = project_root / ".webnovel" / "tmp" / "review" / "metrics.json"
  672. report_file = project_root / "审查报告" / "第9章审查报告.md"
  673. old_argv = sys.argv
  674. sys.argv = [
  675. "review_pipeline",
  676. "--project-root",
  677. str(project_root),
  678. "--chapter",
  679. "9",
  680. "--review-results",
  681. str(review_results_path),
  682. "--metrics-out",
  683. str(metrics_out),
  684. "--report-file",
  685. "审查报告/第9章审查报告.md",
  686. "--save-metrics",
  687. ]
  688. try:
  689. review_pipeline_module.main()
  690. finally:
  691. sys.argv = old_argv
  692. assert metrics_out.is_file()
  693. assert report_file.is_file()
  694. report_text = report_file.read_text(encoding="utf-8")
  695. assert "# 第9章审查报告" in report_text
  696. assert "小问题" in report_text
  697. assert "## 其他问题" in report_text
  698. import sqlite3
  699. with sqlite3.connect(project_root / ".webnovel" / "index.db") as conn:
  700. row = conn.execute(
  701. "SELECT start_chapter, end_chapter, report_file FROM review_metrics"
  702. ).fetchone()
  703. assert row == (9, 9, "审查报告/第9章审查报告.md")
  704. def test_webnovel_skill_flow_runs_story_contract_context_and_review_pipeline_with_stubbed_vector_model(
  705. monkeypatch, tmp_path, capsys
  706. ):
  707. _ensure_scripts_on_path()
  708. module = _load_webnovel_module()
  709. import data_modules.rag_adapter as rag_module
  710. from data_modules.config import DataModulesConfig
  711. project_root = (tmp_path / "book").resolve()
  712. cfg = DataModulesConfig.from_project_root(project_root)
  713. cfg.ensure_dirs()
  714. cfg.state_file.write_text(
  715. json.dumps(
  716. {
  717. "project": {"genre": "xuanhuan"},
  718. "progress": {
  719. "current_chapter": 3,
  720. "total_words": 9000,
  721. "volumes_planned": [{"volume": 1, "chapters_range": "1-20"}],
  722. },
  723. "protagonist_state": {
  724. "name": "萧炎",
  725. "location": {"current": "天云宗外院"},
  726. "power": {"realm": "斗者", "layer": 9},
  727. },
  728. "chapter_meta": {},
  729. "disambiguation_warnings": [],
  730. "disambiguation_pending": [],
  731. },
  732. ensure_ascii=False,
  733. ),
  734. encoding="utf-8",
  735. )
  736. outline_dir = project_root / "大纲"
  737. outline_dir.mkdir(parents=True, exist_ok=True)
  738. (outline_dir / "第1卷-详细大纲.md").write_text(
  739. "\n".join(
  740. [
  741. "### 第3章:试炼冲突",
  742. "本章将聚焦萧炎与药老关系冲突,并回收旧线索真相。",
  743. "CBN:萧炎进入试炼场",
  744. "CPNs:",
  745. "- 药老提醒规则异常",
  746. "- 萧炎发现师徒分歧",
  747. "CEN:萧炎决定暂缓冲突",
  748. "必须覆盖节点:发现规则异常",
  749. "本章禁区:不可提前摊牌",
  750. ]
  751. ),
  752. encoding="utf-8",
  753. )
  754. refs_dir = project_root / ".claude" / "references"
  755. refs_dir.mkdir(parents=True, exist_ok=True)
  756. (refs_dir / "genre-profiles.md").write_text("## xuanhuan\n- 升级线清晰", encoding="utf-8")
  757. (refs_dir / "reading-power-taxonomy.md").write_text("## xuanhuan\n- 冲突钩优先", encoding="utf-8")
  758. calls = {"embed": 0, "embed_batch": 0, "rerank": 0}
  759. class _StubVectorClient:
  760. async def embed(self, texts):
  761. calls["embed"] += 1
  762. return [[1.0, 0.0] for _ in texts]
  763. async def embed_batch(self, texts, skip_failures=True):
  764. calls["embed_batch"] += 1
  765. return [[1.0, 0.0] for _ in texts]
  766. async def rerank(self, query, documents, top_n=None):
  767. calls["rerank"] += 1
  768. limit = top_n or len(documents)
  769. return [
  770. {"index": i, "relevance_score": 1.0 / (i + 1)}
  771. for i in range(min(limit, len(documents)))
  772. ]
  773. monkeypatch.setenv("EMBED_API_KEY", "fake-embed-key")
  774. monkeypatch.setattr(rag_module, "get_client", lambda config: _StubVectorClient())
  775. adapter = rag_module.RAGAdapter(cfg)
  776. asyncio.run(
  777. adapter.store_chunks(
  778. [
  779. {
  780. "chapter": 2,
  781. "scene_index": 1,
  782. "content": "萧炎与药老关系紧张,线索逐步浮现,冲突升级。",
  783. }
  784. ]
  785. )
  786. )
  787. script_to_module = {
  788. "story_system.py": "story_system",
  789. "extract_chapter_context.py": "extract_chapter_context",
  790. "review_pipeline.py": "review_pipeline",
  791. }
  792. def _run_script_inproc(script_name, argv):
  793. module_name = script_to_module.get(script_name)
  794. if not module_name:
  795. raise AssertionError(f"unexpected script call: {script_name}")
  796. script_module = importlib.import_module(module_name)
  797. old_argv = sys.argv
  798. try:
  799. sys.argv = [module_name, *argv]
  800. script_module.main()
  801. return 0
  802. except SystemExit as exc:
  803. return int(exc.code or 0)
  804. finally:
  805. sys.argv = old_argv
  806. monkeypatch.setattr(module, "_run_script", _run_script_inproc)
  807. def _run_webnovel(argv):
  808. monkeypatch.setattr(sys, "argv", ["webnovel", *argv])
  809. with pytest.raises(SystemExit) as exc:
  810. module.main()
  811. return int(exc.value.code or 0)
  812. assert (
  813. _run_webnovel(
  814. [
  815. "--project-root",
  816. str(project_root),
  817. "story-system",
  818. "玄幻退婚流",
  819. "--chapter",
  820. "3",
  821. "--persist",
  822. "--emit-runtime-contracts",
  823. "--format",
  824. "json",
  825. ]
  826. )
  827. == 0
  828. )
  829. capsys.readouterr()
  830. story_root = project_root / ".story-system"
  831. assert (story_root / "MASTER_SETTING.json").is_file()
  832. assert (story_root / "volumes" / "volume_001.json").is_file()
  833. assert (story_root / "reviews" / "chapter_003.review.json").is_file()
  834. assert (
  835. _run_webnovel(
  836. [
  837. "--project-root",
  838. str(project_root),
  839. "extract-context",
  840. "--chapter",
  841. "3",
  842. "--format",
  843. "json",
  844. ]
  845. )
  846. == 0
  847. )
  848. context_payload = json.loads(capsys.readouterr().out)
  849. assert (
  850. context_payload["story_contract"]["review_contract"]["meta"]["contract_type"]
  851. == "REVIEW_CONTRACT"
  852. )
  853. assert context_payload["prewrite_validation"]["blocking"] is False
  854. assert context_payload["rag_assist"]["invoked"] is True
  855. assert context_payload["rag_assist"]["hits"]
  856. assert calls["embed_batch"] >= 1
  857. assert calls["embed"] >= 1
  858. assert calls["rerank"] >= 1
  859. review_results_path = project_root / ".webnovel" / "tmp" / "review_results.json"
  860. review_results_path.parent.mkdir(parents=True, exist_ok=True)
  861. review_results_path.write_text(
  862. json.dumps(
  863. {
  864. "issues": [
  865. {
  866. "severity": "medium",
  867. "category": "continuity",
  868. "location": "第3段",
  869. "description": "衔接略弱",
  870. "evidence": "上章钩子未明确承接",
  871. "fix_hint": "补衔接句",
  872. }
  873. ],
  874. "summary": "1个中优问题",
  875. },
  876. ensure_ascii=False,
  877. ),
  878. encoding="utf-8",
  879. )
  880. metrics_out = project_root / ".webnovel" / "tmp" / "review_metrics.json"
  881. assert (
  882. _run_webnovel(
  883. [
  884. "--project-root",
  885. str(project_root),
  886. "review-pipeline",
  887. "--chapter",
  888. "3",
  889. "--review-results",
  890. str(review_results_path),
  891. "--metrics-out",
  892. str(metrics_out),
  893. "--report-file",
  894. "审查报告/第3章.md",
  895. ]
  896. )
  897. == 0
  898. )
  899. assert metrics_out.is_file()
  900. metrics_payload = json.loads(metrics_out.read_text(encoding="utf-8"))
  901. assert metrics_payload["issues_count"] == 1