| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- import asyncio
- import importlib
- import json
- import sys
- from pathlib import Path
- import pytest
- def _ensure_scripts_on_path() -> None:
- scripts_dir = Path(__file__).resolve().parents[2]
- if str(scripts_dir) not in sys.path:
- sys.path.insert(0, str(scripts_dir))
- def _load_webnovel_module():
- _ensure_scripts_on_path()
- import data_modules.webnovel as webnovel_module
- return webnovel_module
- def _make_cli_init_ready_project(project_root: Path) -> None:
- dirs = (
- ".webnovel/backups",
- ".webnovel/archive",
- ".webnovel/summaries",
- "设定集",
- "大纲",
- "正文",
- "审查报告",
- )
- for rel in dirs:
- (project_root / rel).mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text(
- json.dumps(
- {
- "project_info": {"title": "测试书", "genre": "玄幻"},
- "progress": {"current_chapter": 0},
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- for rel in (
- "设定集/世界观.md",
- "设定集/力量体系.md",
- "设定集/主角卡.md",
- "设定集/反派设计.md",
- "大纲/总纲.md",
- ".env.example",
- ):
- path = project_root / rel
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("placeholder\n", encoding="utf-8")
- def test_init_does_not_resolve_existing_project_root(monkeypatch):
- module = _load_webnovel_module()
- called = {}
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- def _fail_resolve(_explicit_project_root=None):
- raise AssertionError("init 子命令不应触发 project_root 解析")
- monkeypatch.setenv("WEBNOVEL_PROJECT_ROOT", r"D:\invalid\root")
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(module, "_resolve_root", _fail_resolve)
- monkeypatch.setattr(sys, "argv", ["webnovel", "init", "proj-dir", "测试书", "修仙"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "init_project.py"
- assert called["argv"] == ["proj-dir", "测试书", "修仙"]
- def test_extract_context_forwards_with_resolved_project_root(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- book_root = (tmp_path / "book").resolve()
- called = {}
- def _fake_resolve(explicit_project_root=None):
- return book_root
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(tmp_path),
- "extract-context",
- "--chapter",
- "12",
- "--format",
- "json",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "extract_chapter_context.py"
- assert called["argv"] == [
- "--project-root",
- str(book_root),
- "--chapter",
- "12",
- "--format",
- "json",
- ]
- def test_backup_forwards_resolved_book_root_from_parent_workspace(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- workspace_root = (tmp_path / "workspace").resolve()
- book_root = (workspace_root / "book").resolve()
- (workspace_root / ".git").mkdir(parents=True, exist_ok=True)
- (book_root / ".git").mkdir(parents=True, exist_ok=True)
- (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- called = {}
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.chdir(workspace_root)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(workspace_root),
- "backup",
- "--chapter",
- "2",
- "--chapter-title",
- "第二章",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "backup_manager.py"
- assert called["argv"] == [
- "--project-root",
- str(book_root),
- "--chapter",
- "2",
- "--chapter-title",
- "第二章",
- ]
- def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- book_root = (tmp_path / "book").resolve()
- called = {}
- def _fake_resolve(explicit_project_root=None):
- return book_root
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(tmp_path),
- "story-system",
- "玄幻退婚流",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "story_system.py"
- assert called["argv"][:2] == ["--project-root", str(book_root)]
- def test_webnovel_story_system_runtime_forwards(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- project_root = (tmp_path / "book").resolve()
- called = {}
- def _fake_resolve(explicit_project_root=None):
- return project_root
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(project_root),
- "story-system",
- "玄幻退婚流",
- "--emit-runtime-contracts",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "story_system.py"
- assert "--emit-runtime-contracts" in called["argv"]
- def test_webnovel_commit_forwards(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- called = {}
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "chapter-commit", "--chapter", "3"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "chapter_commit.py"
- def test_webnovel_story_events_forwards(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- called = {}
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- ["webnovel", "--project-root", str(project_root), "story-events", "--chapter", "3"],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "story_events.py"
- def test_preflight_succeeds_for_valid_project_root(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- assert int(exc.value.code or 0) == 0
- assert "OK project_root" in captured.out
- assert str(project_root.resolve()) in captured.out
- def test_preflight_fails_when_required_scripts_are_missing(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- fake_scripts_dir = tmp_path / "fake-scripts"
- fake_scripts_dir.mkdir(parents=True, exist_ok=True)
- monkeypatch.setattr(module, "_scripts_dir", lambda: fake_scripts_dir)
- monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- assert int(exc.value.code or 0) == 1
- assert '"ok": false' in captured.out
- assert '"name": "entry_script"' in captured.out
- def test_preflight_includes_story_runtime_health(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- monkeypatch.setattr(
- sys,
- "argv",
- ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"],
- )
- with pytest.raises(SystemExit):
- module.main()
- captured = capsys.readouterr()
- assert '"story_runtime"' in captured.out
- assert '"mainline_ready"' in captured.out
- def test_project_status_cli_outputs_json_without_reusing_status(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- _make_cli_init_ready_project(project_root)
- monkeypatch.setattr(
- sys,
- "argv",
- ["webnovel", "--project-root", str(project_root), "project-status", "--format", "json"],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- report = json.loads(captured.out)
- assert int(exc.value.code or 0) == 0
- assert report["schema_version"] == "webnovel-project-status/v1"
- assert report["project"] == "测试书"
- assert report["phase"] == "init_ready"
- def test_doctor_cli_reports_missing_init_file(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- _make_cli_init_ready_project(project_root)
- (project_root / "大纲" / "总纲.md").unlink()
- monkeypatch.setattr(
- sys,
- "argv",
- ["webnovel", "--project-root", str(project_root), "doctor", "--format", "json"],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- report = json.loads(captured.out)
- assert int(exc.value.code or 0) == 1
- assert report["schema_version"] == "webnovel-doctor/v1"
- assert report["ok"] is False
- assert any(item["id"] == "file.required.大纲/总纲.md" for item in report["checks"])
- def test_status_command_still_forwards_to_status_reporter(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- _make_cli_init_ready_project(project_root)
- called = {}
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "status", "--focus", "all"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "status_reporter.py"
- def test_write_gate_cli_runs_prewrite(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- _make_cli_init_ready_project(project_root)
- for path, payload in (
- (project_root / ".story-system" / "MASTER_SETTING.json", {"meta": {"contract_type": "MASTER_SETTING"}}),
- (project_root / ".story-system" / "volumes" / "volume_001.json", {"meta": {"volume": 1}}),
- (project_root / ".story-system" / "chapters" / "chapter_001.json", {"chapter_directive": {"must_cover_nodes": []}}),
- (project_root / ".story-system" / "reviews" / "chapter_001.review.json", {"blocking_rules": []}),
- ):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(project_root),
- "write-gate",
- "--chapter",
- "1",
- "--stage",
- "prewrite",
- "--format",
- "json",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- report = json.loads(captured.out)
- assert int(exc.value.code or 0) == 0
- assert report["schema_version"] == "webnovel-write-gate/v1"
- assert report["stage"] == "prewrite"
- assert report["ok"] is True
- def test_projections_retry_cli_runs(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- project_root = tmp_path / "book"
- _make_cli_init_ready_project(project_root)
- commit_path = project_root / ".story-system" / "commits" / "chapter_001.commit.json"
- commit_path.parent.mkdir(parents=True, exist_ok=True)
- commit_path.write_text(
- json.dumps(
- {
- "meta": {"chapter": 1, "status": "rejected"},
- "review_result": {"blocking_count": 1},
- "fulfillment_result": {
- "planned_nodes": [],
- "covered_nodes": [],
- "missed_nodes": [],
- "extra_nodes": [],
- },
- "disambiguation_result": {"pending": []},
- "extraction_result": {"accepted_events": [], "state_deltas": [], "entity_deltas": []},
- "projection_status": {
- "state": "pending",
- "index": "pending",
- "summary": "pending",
- "memory": "pending",
- "vector": "pending",
- },
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(project_root),
- "projections",
- "retry",
- "--chapter",
- "1",
- "--format",
- "json",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- report = json.loads(captured.out)
- assert int(exc.value.code or 0) == 0
- assert report["schema_version"] == "webnovel-projections/v1"
- assert report["projection_status"]["state"] == "done"
- def test_where_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- workspace = tmp_path / "workspace"
- workspace.mkdir(parents=True, exist_ok=True)
- (workspace / ".git").mkdir(parents=True, exist_ok=True)
- monkeypatch.chdir(workspace)
- monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
- monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
- monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
- monkeypatch.setattr(sys, "argv", ["webnovel", "where"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- assert int(exc.value.code or 0) == 1
- assert "还没有激活的书项目" in captured.err
- assert "Traceback" not in captured.err
- def test_preflight_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
- module = _load_webnovel_module()
- workspace = tmp_path / "workspace"
- workspace.mkdir(parents=True, exist_ok=True)
- (workspace / ".git").mkdir(parents=True, exist_ok=True)
- monkeypatch.chdir(workspace)
- monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
- monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
- monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
- monkeypatch.setattr(sys, "argv", ["webnovel", "preflight", "--format", "json"])
- with pytest.raises(SystemExit) as exc:
- module.main()
- captured = capsys.readouterr()
- report = json.loads(captured.out)
- assert int(exc.value.code or 0) == 1
- assert report["ok"] is False
- assert "还没有激活的书项目" in report["project_root_error"]
- assert "Traceback" not in captured.err
- def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
- _ensure_scripts_on_path()
- import quality_trend_report as quality_trend_report_module
- workspace_root = (tmp_path / "workspace").resolve()
- book_root = (workspace_root / "凡人资本论").resolve()
- (workspace_root / ".claude").mkdir(parents=True, exist_ok=True)
- (workspace_root / ".claude" / ".webnovel-current-project").write_text(str(book_root), encoding="utf-8")
- (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- output_path = workspace_root / "report.md"
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "quality_trend_report",
- "--project-root",
- str(workspace_root),
- "--limit",
- "1",
- "--output",
- str(output_path),
- ],
- )
- quality_trend_report_module.main()
- assert output_path.is_file()
- assert (book_root / ".webnovel" / "index.db").is_file()
- assert not (workspace_root / ".webnovel" / "index.db").exists()
- def test_review_pipeline_builds_artifacts(tmp_path):
- _ensure_scripts_on_path()
- import review_pipeline as review_pipeline_module
- project_root = (tmp_path / "book").resolve()
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- review_results_path = tmp_path / "review_results.json"
- review_results_path.write_text(
- json.dumps(
- {
- "issues": [
- {
- "severity": "critical",
- "category": "timeline",
- "location": "第2段",
- "description": "时间线回跳",
- "evidence": "上章深夜,本章突然中午",
- "fix_hint": "补时间过渡",
- "blocking": True,
- },
- {
- "severity": "medium",
- "category": "ai_flavor",
- "location": "第5段",
- "description": "'稳住心神'出现2次",
- "fix_hint": "替换为具体动作",
- },
- ],
- "summary": "1个阻断,1个中等",
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- payload = review_pipeline_module.build_review_artifacts(
- project_root=project_root,
- chapter=20,
- review_results_path=review_results_path,
- report_file="审查报告/第20章.md",
- )
- assert payload["review_result"]["blocking_count"] == 1
- assert payload["review_result"]["has_blocking"] is True
- assert payload["review_result"]["issues_count"] == 2
- assert payload["metrics"]["start_chapter"] == 20
- assert payload["metrics"]["end_chapter"] == 20
- assert payload["metrics"]["issues_count"] == 2
- assert payload["metrics"]["blocking_count"] == 1
- assert payload["metrics"]["severity_counts"]["critical"] == 1
- assert payload["metrics"]["severity_counts"]["medium"] == 1
- assert payload["metrics"]["critical_issues"] == ["时间线回跳"]
- assert payload["metrics"]["overall_score"] < 100
- assert payload["metrics"]["report_file"] == "审查报告/第20章.md"
- def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- book_root = (tmp_path / "book").resolve()
- review_results = (tmp_path / "review_results.json").resolve()
- called = {}
- def _fake_resolve(explicit_project_root=None):
- return book_root
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(tmp_path),
- "review-pipeline",
- "--chapter",
- "18",
- "--review-results",
- str(review_results),
- "--metrics-out",
- str(tmp_path / "metrics.json"),
- "--report-file",
- "审查报告/第18章.md",
- "--save-metrics",
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "review_pipeline.py"
- assert called["argv"] == [
- "--project-root",
- str(book_root),
- "--chapter",
- "18",
- "--review-results",
- str(review_results),
- "--metrics-out",
- str(tmp_path / "metrics.json"),
- "--report-file",
- "审查报告/第18章.md",
- "--save-metrics",
- ]
- def test_project_memory_forwards_with_resolved_project_root(monkeypatch, tmp_path):
- module = _load_webnovel_module()
- book_root = (tmp_path / "book").resolve()
- called = {}
- def _fake_resolve(explicit_project_root=None):
- return book_root
- def _fake_run_script(script_name, argv):
- called["script_name"] = script_name
- called["argv"] = list(argv)
- return 0
- monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
- monkeypatch.setattr(module, "_run_script", _fake_run_script)
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "webnovel",
- "--project-root",
- str(tmp_path),
- "project-memory",
- "add-pattern",
- "--pattern-type",
- "format",
- "--description",
- '内心独白使用双引号""',
- ],
- )
- with pytest.raises(SystemExit) as exc:
- module.main()
- assert int(exc.value.code or 0) == 0
- assert called["script_name"] == "project_memory.py"
- assert called["argv"] == [
- "--project-root",
- str(book_root),
- "add-pattern",
- "--pattern-type",
- "format",
- "--description",
- '内心独白使用双引号""',
- ]
- def test_project_memory_add_pattern_escapes_quotes(tmp_path):
- _ensure_scripts_on_path()
- import project_memory as project_memory_module
- project_root = (tmp_path / "book").resolve()
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text(
- json.dumps({"progress": {"current_chapter": 3}}, ensure_ascii=False),
- encoding="utf-8",
- )
- description = "正文格式规范:内心独白使用双引号\"\",系统界面保留方括号[]"
- result = project_memory_module.add_pattern(
- project_root,
- pattern_type="format",
- description=description,
- category="写作规范",
- importance="high",
- )
- memory_path = project_root / ".webnovel" / "project_memory.json"
- raw_text = memory_path.read_text(encoding="utf-8")
- payload = json.loads(raw_text)
- assert result["status"] == "success"
- assert '\\"\\"' in raw_text
- assert payload["patterns"][0]["description"] == description
- assert payload["patterns"][0]["source_chapter"] == 3
- def test_review_pipeline_main_creates_output_directories(tmp_path):
- _ensure_scripts_on_path()
- import review_pipeline as review_pipeline_module
- project_root = (tmp_path / "book").resolve()
- (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
- (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
- review_results_path = tmp_path / "review_results.json"
- review_results_path.write_text(
- json.dumps(
- {
- "issues": [
- {
- "severity": "low",
- "category": "other",
- "location": "p1",
- "description": "小问题",
- }
- ],
- "summary": "轻微",
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- metrics_out = project_root / ".webnovel" / "tmp" / "review" / "metrics.json"
- report_file = project_root / "审查报告" / "第9章审查报告.md"
- old_argv = sys.argv
- sys.argv = [
- "review_pipeline",
- "--project-root",
- str(project_root),
- "--chapter",
- "9",
- "--review-results",
- str(review_results_path),
- "--metrics-out",
- str(metrics_out),
- "--report-file",
- "审查报告/第9章审查报告.md",
- "--save-metrics",
- ]
- try:
- review_pipeline_module.main()
- finally:
- sys.argv = old_argv
- assert metrics_out.is_file()
- assert report_file.is_file()
- report_text = report_file.read_text(encoding="utf-8")
- assert "# 第9章审查报告" in report_text
- assert "小问题" in report_text
- assert "## 其他问题" in report_text
- import sqlite3
- with sqlite3.connect(project_root / ".webnovel" / "index.db") as conn:
- row = conn.execute(
- "SELECT start_chapter, end_chapter, report_file FROM review_metrics"
- ).fetchone()
- assert row == (9, 9, "审查报告/第9章审查报告.md")
- def test_webnovel_skill_flow_runs_story_contract_context_and_review_pipeline_with_stubbed_vector_model(
- monkeypatch, tmp_path, capsys
- ):
- _ensure_scripts_on_path()
- module = _load_webnovel_module()
- import data_modules.rag_adapter as rag_module
- from data_modules.config import DataModulesConfig
- project_root = (tmp_path / "book").resolve()
- cfg = DataModulesConfig.from_project_root(project_root)
- cfg.ensure_dirs()
- cfg.state_file.write_text(
- json.dumps(
- {
- "project": {"genre": "xuanhuan"},
- "progress": {
- "current_chapter": 3,
- "total_words": 9000,
- "volumes_planned": [{"volume": 1, "chapters_range": "1-20"}],
- },
- "protagonist_state": {
- "name": "萧炎",
- "location": {"current": "天云宗外院"},
- "power": {"realm": "斗者", "layer": 9},
- },
- "chapter_meta": {},
- "disambiguation_warnings": [],
- "disambiguation_pending": [],
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- outline_dir = project_root / "大纲"
- outline_dir.mkdir(parents=True, exist_ok=True)
- (outline_dir / "第1卷-详细大纲.md").write_text(
- "\n".join(
- [
- "### 第3章:试炼冲突",
- "本章将聚焦萧炎与药老关系冲突,并回收旧线索真相。",
- "CBN:萧炎进入试炼场",
- "CPNs:",
- "- 药老提醒规则异常",
- "- 萧炎发现师徒分歧",
- "CEN:萧炎决定暂缓冲突",
- "必须覆盖节点:发现规则异常",
- "本章禁区:不可提前摊牌",
- ]
- ),
- encoding="utf-8",
- )
- refs_dir = project_root / ".claude" / "references"
- refs_dir.mkdir(parents=True, exist_ok=True)
- (refs_dir / "genre-profiles.md").write_text("## xuanhuan\n- 升级线清晰", encoding="utf-8")
- (refs_dir / "reading-power-taxonomy.md").write_text("## xuanhuan\n- 冲突钩优先", encoding="utf-8")
- calls = {"embed": 0, "embed_batch": 0, "rerank": 0}
- class _StubVectorClient:
- async def embed(self, texts):
- calls["embed"] += 1
- return [[1.0, 0.0] for _ in texts]
- async def embed_batch(self, texts, skip_failures=True):
- calls["embed_batch"] += 1
- return [[1.0, 0.0] for _ in texts]
- async def rerank(self, query, documents, top_n=None):
- calls["rerank"] += 1
- limit = top_n or len(documents)
- return [
- {"index": i, "relevance_score": 1.0 / (i + 1)}
- for i in range(min(limit, len(documents)))
- ]
- monkeypatch.setenv("EMBED_API_KEY", "fake-embed-key")
- monkeypatch.setattr(rag_module, "get_client", lambda config: _StubVectorClient())
- adapter = rag_module.RAGAdapter(cfg)
- asyncio.run(
- adapter.store_chunks(
- [
- {
- "chapter": 2,
- "scene_index": 1,
- "content": "萧炎与药老关系紧张,线索逐步浮现,冲突升级。",
- }
- ]
- )
- )
- script_to_module = {
- "story_system.py": "story_system",
- "extract_chapter_context.py": "extract_chapter_context",
- "review_pipeline.py": "review_pipeline",
- }
- def _run_script_inproc(script_name, argv):
- module_name = script_to_module.get(script_name)
- if not module_name:
- raise AssertionError(f"unexpected script call: {script_name}")
- script_module = importlib.import_module(module_name)
- old_argv = sys.argv
- try:
- sys.argv = [module_name, *argv]
- script_module.main()
- return 0
- except SystemExit as exc:
- return int(exc.code or 0)
- finally:
- sys.argv = old_argv
- monkeypatch.setattr(module, "_run_script", _run_script_inproc)
- def _run_webnovel(argv):
- monkeypatch.setattr(sys, "argv", ["webnovel", *argv])
- with pytest.raises(SystemExit) as exc:
- module.main()
- return int(exc.value.code or 0)
- assert (
- _run_webnovel(
- [
- "--project-root",
- str(project_root),
- "story-system",
- "玄幻退婚流",
- "--chapter",
- "3",
- "--persist",
- "--emit-runtime-contracts",
- "--format",
- "json",
- ]
- )
- == 0
- )
- capsys.readouterr()
- story_root = project_root / ".story-system"
- assert (story_root / "MASTER_SETTING.json").is_file()
- assert (story_root / "volumes" / "volume_001.json").is_file()
- assert (story_root / "reviews" / "chapter_003.review.json").is_file()
- assert (
- _run_webnovel(
- [
- "--project-root",
- str(project_root),
- "extract-context",
- "--chapter",
- "3",
- "--format",
- "json",
- ]
- )
- == 0
- )
- context_payload = json.loads(capsys.readouterr().out)
- assert (
- context_payload["story_contract"]["review_contract"]["meta"]["contract_type"]
- == "REVIEW_CONTRACT"
- )
- assert context_payload["prewrite_validation"]["blocking"] is False
- assert context_payload["rag_assist"]["invoked"] is True
- assert context_payload["rag_assist"]["hits"]
- assert calls["embed_batch"] >= 1
- assert calls["embed"] >= 1
- assert calls["rerank"] >= 1
- review_results_path = project_root / ".webnovel" / "tmp" / "review_results.json"
- review_results_path.parent.mkdir(parents=True, exist_ok=True)
- review_results_path.write_text(
- json.dumps(
- {
- "issues": [
- {
- "severity": "medium",
- "category": "continuity",
- "location": "第3段",
- "description": "衔接略弱",
- "evidence": "上章钩子未明确承接",
- "fix_hint": "补衔接句",
- }
- ],
- "summary": "1个中优问题",
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- metrics_out = project_root / ".webnovel" / "tmp" / "review_metrics.json"
- assert (
- _run_webnovel(
- [
- "--project-root",
- str(project_root),
- "review-pipeline",
- "--chapter",
- "3",
- "--review-results",
- str(review_results_path),
- "--metrics-out",
- str(metrics_out),
- "--report-file",
- "审查报告/第3章.md",
- ]
- )
- == 0
- )
- assert metrics_out.is_file()
- metrics_payload = json.loads(metrics_out.read_text(encoding="utf-8"))
- assert metrics_payload["issues_count"] == 1
|