test_webnovel_unified_cli.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  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 test_init_does_not_resolve_existing_project_root(monkeypatch):
  18. module = _load_webnovel_module()
  19. called = {}
  20. def _fake_run_script(script_name, argv):
  21. called["script_name"] = script_name
  22. called["argv"] = list(argv)
  23. return 0
  24. def _fail_resolve(_explicit_project_root=None):
  25. raise AssertionError("init 子命令不应触发 project_root 解析")
  26. monkeypatch.setenv("WEBNOVEL_PROJECT_ROOT", r"D:\invalid\root")
  27. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  28. monkeypatch.setattr(module, "_resolve_root", _fail_resolve)
  29. monkeypatch.setattr(sys, "argv", ["webnovel", "init", "proj-dir", "测试书", "修仙"])
  30. with pytest.raises(SystemExit) as exc:
  31. module.main()
  32. assert int(exc.value.code or 0) == 0
  33. assert called["script_name"] == "init_project.py"
  34. assert called["argv"] == ["proj-dir", "测试书", "修仙"]
  35. def test_extract_context_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  36. module = _load_webnovel_module()
  37. book_root = (tmp_path / "book").resolve()
  38. called = {}
  39. def _fake_resolve(explicit_project_root=None):
  40. return book_root
  41. def _fake_run_script(script_name, argv):
  42. called["script_name"] = script_name
  43. called["argv"] = list(argv)
  44. return 0
  45. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  46. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  47. monkeypatch.setattr(
  48. sys,
  49. "argv",
  50. [
  51. "webnovel",
  52. "--project-root",
  53. str(tmp_path),
  54. "extract-context",
  55. "--chapter",
  56. "12",
  57. "--format",
  58. "json",
  59. ],
  60. )
  61. with pytest.raises(SystemExit) as exc:
  62. module.main()
  63. assert int(exc.value.code or 0) == 0
  64. assert called["script_name"] == "extract_chapter_context.py"
  65. assert called["argv"] == [
  66. "--project-root",
  67. str(book_root),
  68. "--chapter",
  69. "12",
  70. "--format",
  71. "json",
  72. ]
  73. def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  74. module = _load_webnovel_module()
  75. book_root = (tmp_path / "book").resolve()
  76. called = {}
  77. def _fake_resolve(explicit_project_root=None):
  78. return book_root
  79. def _fake_run_script(script_name, argv):
  80. called["script_name"] = script_name
  81. called["argv"] = list(argv)
  82. return 0
  83. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  84. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  85. monkeypatch.setattr(
  86. sys,
  87. "argv",
  88. [
  89. "webnovel",
  90. "--project-root",
  91. str(tmp_path),
  92. "story-system",
  93. "玄幻退婚流",
  94. ],
  95. )
  96. with pytest.raises(SystemExit) as exc:
  97. module.main()
  98. assert int(exc.value.code or 0) == 0
  99. assert called["script_name"] == "story_system.py"
  100. assert called["argv"][:2] == ["--project-root", str(book_root)]
  101. def test_webnovel_story_system_runtime_forwards(monkeypatch, tmp_path):
  102. module = _load_webnovel_module()
  103. project_root = (tmp_path / "book").resolve()
  104. called = {}
  105. def _fake_resolve(explicit_project_root=None):
  106. return project_root
  107. def _fake_run_script(script_name, argv):
  108. called["script_name"] = script_name
  109. called["argv"] = list(argv)
  110. return 0
  111. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  112. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  113. monkeypatch.setattr(
  114. sys,
  115. "argv",
  116. [
  117. "webnovel",
  118. "--project-root",
  119. str(project_root),
  120. "story-system",
  121. "玄幻退婚流",
  122. "--emit-runtime-contracts",
  123. ],
  124. )
  125. with pytest.raises(SystemExit) as exc:
  126. module.main()
  127. assert int(exc.value.code or 0) == 0
  128. assert called["script_name"] == "story_system.py"
  129. assert "--emit-runtime-contracts" in called["argv"]
  130. def test_webnovel_commit_forwards(monkeypatch, tmp_path):
  131. module = _load_webnovel_module()
  132. project_root = tmp_path / "book"
  133. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  134. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  135. called = {}
  136. def _fake_run_script(script_name, argv):
  137. called["script_name"] = script_name
  138. called["argv"] = list(argv)
  139. return 0
  140. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  141. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "chapter-commit", "--chapter", "3"])
  142. with pytest.raises(SystemExit) as exc:
  143. module.main()
  144. assert int(exc.value.code or 0) == 0
  145. assert called["script_name"] == "chapter_commit.py"
  146. def test_webnovel_story_events_forwards(monkeypatch, tmp_path):
  147. module = _load_webnovel_module()
  148. project_root = tmp_path / "book"
  149. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  150. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  151. called = {}
  152. def _fake_run_script(script_name, argv):
  153. called["script_name"] = script_name
  154. called["argv"] = list(argv)
  155. return 0
  156. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  157. monkeypatch.setattr(
  158. sys,
  159. "argv",
  160. ["webnovel", "--project-root", str(project_root), "story-events", "--chapter", "3"],
  161. )
  162. with pytest.raises(SystemExit) as exc:
  163. module.main()
  164. assert int(exc.value.code or 0) == 0
  165. assert called["script_name"] == "story_events.py"
  166. def test_preflight_succeeds_for_valid_project_root(monkeypatch, tmp_path, capsys):
  167. module = _load_webnovel_module()
  168. project_root = tmp_path / "book"
  169. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  170. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  171. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight"])
  172. with pytest.raises(SystemExit) as exc:
  173. module.main()
  174. captured = capsys.readouterr()
  175. assert int(exc.value.code or 0) == 0
  176. assert "OK project_root" in captured.out
  177. assert str(project_root.resolve()) in captured.out
  178. def test_preflight_fails_when_required_scripts_are_missing(monkeypatch, tmp_path, capsys):
  179. module = _load_webnovel_module()
  180. project_root = tmp_path / "book"
  181. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  182. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  183. fake_scripts_dir = tmp_path / "fake-scripts"
  184. fake_scripts_dir.mkdir(parents=True, exist_ok=True)
  185. monkeypatch.setattr(module, "_scripts_dir", lambda: fake_scripts_dir)
  186. monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"])
  187. with pytest.raises(SystemExit) as exc:
  188. module.main()
  189. captured = capsys.readouterr()
  190. assert int(exc.value.code or 0) == 1
  191. assert '"ok": false' in captured.out
  192. assert '"name": "entry_script"' in captured.out
  193. def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
  194. _ensure_scripts_on_path()
  195. import quality_trend_report as quality_trend_report_module
  196. workspace_root = (tmp_path / "workspace").resolve()
  197. book_root = (workspace_root / "凡人资本论").resolve()
  198. (workspace_root / ".claude").mkdir(parents=True, exist_ok=True)
  199. (workspace_root / ".claude" / ".webnovel-current-project").write_text(str(book_root), encoding="utf-8")
  200. (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  201. (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  202. output_path = workspace_root / "report.md"
  203. monkeypatch.setattr(
  204. sys,
  205. "argv",
  206. [
  207. "quality_trend_report",
  208. "--project-root",
  209. str(workspace_root),
  210. "--limit",
  211. "1",
  212. "--output",
  213. str(output_path),
  214. ],
  215. )
  216. quality_trend_report_module.main()
  217. assert output_path.is_file()
  218. assert (book_root / ".webnovel" / "index.db").is_file()
  219. assert not (workspace_root / ".webnovel" / "index.db").exists()
  220. def test_review_pipeline_builds_artifacts(tmp_path):
  221. _ensure_scripts_on_path()
  222. import review_pipeline as review_pipeline_module
  223. project_root = (tmp_path / "book").resolve()
  224. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  225. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  226. review_results_path = tmp_path / "review_results.json"
  227. review_results_path.write_text(
  228. json.dumps(
  229. {
  230. "issues": [
  231. {
  232. "severity": "critical",
  233. "category": "timeline",
  234. "location": "第2段",
  235. "description": "时间线回跳",
  236. "evidence": "上章深夜,本章突然中午",
  237. "fix_hint": "补时间过渡",
  238. "blocking": True,
  239. },
  240. {
  241. "severity": "medium",
  242. "category": "ai_flavor",
  243. "location": "第5段",
  244. "description": "'稳住心神'出现2次",
  245. "fix_hint": "替换为具体动作",
  246. },
  247. ],
  248. "summary": "1个阻断,1个中等",
  249. },
  250. ensure_ascii=False,
  251. ),
  252. encoding="utf-8",
  253. )
  254. payload = review_pipeline_module.build_review_artifacts(
  255. project_root=project_root,
  256. chapter=20,
  257. review_results_path=review_results_path,
  258. report_file="审查报告/第20章.md",
  259. )
  260. assert payload["review_result"]["blocking_count"] == 1
  261. assert payload["review_result"]["has_blocking"] is True
  262. assert payload["review_result"]["issues_count"] == 2
  263. assert payload["metrics"]["start_chapter"] == 20
  264. assert payload["metrics"]["end_chapter"] == 20
  265. assert payload["metrics"]["issues_count"] == 2
  266. assert payload["metrics"]["blocking_count"] == 1
  267. assert payload["metrics"]["severity_counts"]["critical"] == 1
  268. assert payload["metrics"]["severity_counts"]["medium"] == 1
  269. assert payload["metrics"]["critical_issues"] == ["时间线回跳"]
  270. assert payload["metrics"]["overall_score"] < 100
  271. assert payload["metrics"]["report_file"] == "审查报告/第20章.md"
  272. def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_path):
  273. module = _load_webnovel_module()
  274. book_root = (tmp_path / "book").resolve()
  275. review_results = (tmp_path / "review_results.json").resolve()
  276. called = {}
  277. def _fake_resolve(explicit_project_root=None):
  278. return book_root
  279. def _fake_run_script(script_name, argv):
  280. called["script_name"] = script_name
  281. called["argv"] = list(argv)
  282. return 0
  283. monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
  284. monkeypatch.setattr(module, "_run_script", _fake_run_script)
  285. monkeypatch.setattr(
  286. sys,
  287. "argv",
  288. [
  289. "webnovel",
  290. "--project-root",
  291. str(tmp_path),
  292. "review-pipeline",
  293. "--chapter",
  294. "18",
  295. "--review-results",
  296. str(review_results),
  297. "--metrics-out",
  298. str(tmp_path / "metrics.json"),
  299. "--report-file",
  300. "审查报告/第18章.md",
  301. ],
  302. )
  303. with pytest.raises(SystemExit) as exc:
  304. module.main()
  305. assert int(exc.value.code or 0) == 0
  306. assert called["script_name"] == "review_pipeline.py"
  307. assert called["argv"] == [
  308. "--project-root",
  309. str(book_root),
  310. "--chapter",
  311. "18",
  312. "--review-results",
  313. str(review_results),
  314. "--metrics-out",
  315. str(tmp_path / "metrics.json"),
  316. "--report-file",
  317. "审查报告/第18章.md",
  318. ]
  319. def test_review_pipeline_main_creates_output_directories(tmp_path):
  320. _ensure_scripts_on_path()
  321. import review_pipeline as review_pipeline_module
  322. project_root = (tmp_path / "book").resolve()
  323. (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
  324. (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  325. review_results_path = tmp_path / "review_results.json"
  326. review_results_path.write_text(
  327. json.dumps(
  328. {
  329. "issues": [
  330. {
  331. "severity": "low",
  332. "category": "other",
  333. "location": "p1",
  334. "description": "小问题",
  335. }
  336. ],
  337. "summary": "轻微",
  338. },
  339. ensure_ascii=False,
  340. ),
  341. encoding="utf-8",
  342. )
  343. metrics_out = project_root / ".webnovel" / "tmp" / "review" / "metrics.json"
  344. old_argv = sys.argv
  345. sys.argv = [
  346. "review_pipeline",
  347. "--project-root",
  348. str(project_root),
  349. "--chapter",
  350. "9",
  351. "--review-results",
  352. str(review_results_path),
  353. "--metrics-out",
  354. str(metrics_out),
  355. ]
  356. try:
  357. review_pipeline_module.main()
  358. finally:
  359. sys.argv = old_argv
  360. assert metrics_out.is_file()
  361. def test_webnovel_skill_flow_runs_story_contract_context_and_review_pipeline_with_stubbed_vector_model(
  362. monkeypatch, tmp_path, capsys
  363. ):
  364. _ensure_scripts_on_path()
  365. module = _load_webnovel_module()
  366. import data_modules.rag_adapter as rag_module
  367. from data_modules.config import DataModulesConfig
  368. project_root = (tmp_path / "book").resolve()
  369. cfg = DataModulesConfig.from_project_root(project_root)
  370. cfg.ensure_dirs()
  371. cfg.state_file.write_text(
  372. json.dumps(
  373. {
  374. "project": {"genre": "xuanhuan"},
  375. "progress": {
  376. "current_chapter": 3,
  377. "total_words": 9000,
  378. "volumes_planned": [{"volume": 1, "chapters_range": "1-20"}],
  379. },
  380. "protagonist_state": {
  381. "name": "萧炎",
  382. "location": {"current": "天云宗外院"},
  383. "power": {"realm": "斗者", "layer": 9},
  384. },
  385. "chapter_meta": {},
  386. "disambiguation_warnings": [],
  387. "disambiguation_pending": [],
  388. },
  389. ensure_ascii=False,
  390. ),
  391. encoding="utf-8",
  392. )
  393. outline_dir = project_root / "大纲"
  394. outline_dir.mkdir(parents=True, exist_ok=True)
  395. (outline_dir / "第1卷-详细大纲.md").write_text(
  396. "\n".join(
  397. [
  398. "### 第3章:试炼冲突",
  399. "本章将聚焦萧炎与药老关系冲突,并回收旧线索真相。",
  400. "CBN:萧炎进入试炼场",
  401. "CPNs:",
  402. "- 药老提醒规则异常",
  403. "- 萧炎发现师徒分歧",
  404. "CEN:萧炎决定暂缓冲突",
  405. "必须覆盖节点:发现规则异常",
  406. "本章禁区:不可提前摊牌",
  407. ]
  408. ),
  409. encoding="utf-8",
  410. )
  411. refs_dir = project_root / ".claude" / "references"
  412. refs_dir.mkdir(parents=True, exist_ok=True)
  413. (refs_dir / "genre-profiles.md").write_text("## xuanhuan\n- 升级线清晰", encoding="utf-8")
  414. (refs_dir / "reading-power-taxonomy.md").write_text("## xuanhuan\n- 冲突钩优先", encoding="utf-8")
  415. calls = {"embed": 0, "embed_batch": 0, "rerank": 0}
  416. class _StubVectorClient:
  417. async def embed(self, texts):
  418. calls["embed"] += 1
  419. return [[1.0, 0.0] for _ in texts]
  420. async def embed_batch(self, texts, skip_failures=True):
  421. calls["embed_batch"] += 1
  422. return [[1.0, 0.0] for _ in texts]
  423. async def rerank(self, query, documents, top_n=None):
  424. calls["rerank"] += 1
  425. limit = top_n or len(documents)
  426. return [
  427. {"index": i, "relevance_score": 1.0 / (i + 1)}
  428. for i in range(min(limit, len(documents)))
  429. ]
  430. monkeypatch.setenv("EMBED_API_KEY", "fake-embed-key")
  431. monkeypatch.setattr(rag_module, "get_client", lambda config: _StubVectorClient())
  432. adapter = rag_module.RAGAdapter(cfg)
  433. asyncio.run(
  434. adapter.store_chunks(
  435. [
  436. {
  437. "chapter": 2,
  438. "scene_index": 1,
  439. "content": "萧炎与药老关系紧张,线索逐步浮现,冲突升级。",
  440. }
  441. ]
  442. )
  443. )
  444. script_to_module = {
  445. "story_system.py": "story_system",
  446. "extract_chapter_context.py": "extract_chapter_context",
  447. "review_pipeline.py": "review_pipeline",
  448. }
  449. def _run_script_inproc(script_name, argv):
  450. module_name = script_to_module.get(script_name)
  451. if not module_name:
  452. raise AssertionError(f"unexpected script call: {script_name}")
  453. script_module = importlib.import_module(module_name)
  454. old_argv = sys.argv
  455. try:
  456. sys.argv = [module_name, *argv]
  457. script_module.main()
  458. return 0
  459. except SystemExit as exc:
  460. return int(exc.code or 0)
  461. finally:
  462. sys.argv = old_argv
  463. monkeypatch.setattr(module, "_run_script", _run_script_inproc)
  464. def _run_webnovel(argv):
  465. monkeypatch.setattr(sys, "argv", ["webnovel", *argv])
  466. with pytest.raises(SystemExit) as exc:
  467. module.main()
  468. return int(exc.value.code or 0)
  469. assert (
  470. _run_webnovel(
  471. [
  472. "--project-root",
  473. str(project_root),
  474. "story-system",
  475. "玄幻退婚流",
  476. "--chapter",
  477. "3",
  478. "--persist",
  479. "--emit-runtime-contracts",
  480. "--format",
  481. "json",
  482. ]
  483. )
  484. == 0
  485. )
  486. capsys.readouterr()
  487. story_root = project_root / ".story-system"
  488. assert (story_root / "MASTER_SETTING.json").is_file()
  489. assert (story_root / "volumes" / "volume_001.json").is_file()
  490. assert (story_root / "reviews" / "chapter_003.review.json").is_file()
  491. assert (
  492. _run_webnovel(
  493. [
  494. "--project-root",
  495. str(project_root),
  496. "extract-context",
  497. "--chapter",
  498. "3",
  499. "--format",
  500. "json",
  501. ]
  502. )
  503. == 0
  504. )
  505. context_payload = json.loads(capsys.readouterr().out)
  506. assert (
  507. context_payload["story_contract"]["review_contract"]["meta"]["contract_type"]
  508. == "REVIEW_CONTRACT"
  509. )
  510. assert context_payload["prewrite_validation"]["blocking"] is False
  511. assert context_payload["rag_assist"]["invoked"] is True
  512. assert context_payload["rag_assist"]["hits"]
  513. assert calls["embed_batch"] >= 1
  514. assert calls["embed"] >= 1
  515. assert calls["rerank"] >= 1
  516. review_results_path = project_root / ".webnovel" / "tmp" / "review_results.json"
  517. review_results_path.parent.mkdir(parents=True, exist_ok=True)
  518. review_results_path.write_text(
  519. json.dumps(
  520. {
  521. "issues": [
  522. {
  523. "severity": "medium",
  524. "category": "continuity",
  525. "location": "第3段",
  526. "description": "衔接略弱",
  527. "evidence": "上章钩子未明确承接",
  528. "fix_hint": "补衔接句",
  529. }
  530. ],
  531. "summary": "1个中优问题",
  532. },
  533. ensure_ascii=False,
  534. ),
  535. encoding="utf-8",
  536. )
  537. metrics_out = project_root / ".webnovel" / "tmp" / "review_metrics.json"
  538. assert (
  539. _run_webnovel(
  540. [
  541. "--project-root",
  542. str(project_root),
  543. "review-pipeline",
  544. "--chapter",
  545. "3",
  546. "--review-results",
  547. str(review_results_path),
  548. "--metrics-out",
  549. str(metrics_out),
  550. "--report-file",
  551. "审查报告/第3章.md",
  552. ]
  553. )
  554. == 0
  555. )
  556. assert metrics_out.is_file()
  557. metrics_payload = json.loads(metrics_out.read_text(encoding="utf-8"))
  558. assert metrics_payload["issues_count"] == 1