prepare.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { spawnSync } from 'node:child_process'
  6. import {
  7. applyRevision,
  8. archiveChapter,
  9. assembleMaterials,
  10. confirmOutline,
  11. deriveChapterFacts,
  12. listChapterDrafts,
  13. preparePack,
  14. parseDocument,
  15. scanChapter,
  16. seedMinDesign,
  17. serializeDocument,
  18. paths,
  19. writeFileAtomic,
  20. writeCandidate,
  21. } from '../src/index'
  22. import { ingestFindings, planReview, registerDefaultChecks, resetChecks, runReview } from '@webnovel/review'
  23. import { removeSync } from '../src/repo/remove'
  24. const roots: string[] = []
  25. function mkDir(prefix: string): string {
  26. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  27. roots.push(dir)
  28. return dir
  29. }
  30. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  31. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  32. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'] as const
  33. const HARD = '开场必须点名主角现身'
  34. function git(cwd: string, args: readonly string[]): ReturnType<typeof spawnSync> {
  35. return spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true })
  36. }
  37. function initRepo(): string {
  38. const root = mkDir('webnovel-prepare-')
  39. expect(git(root, ['init']).status).toBe(0)
  40. git(root, ['config', 'user.email', 'test@example.com'])
  41. git(root, ['config', 'user.name', 'test'])
  42. git(root, ['config', 'commit.gpgsign', 'false'])
  43. return root
  44. }
  45. function confirmWithHard(root: string): void {
  46. seedMinDesign(root)
  47. const body = [
  48. '# 章细纲',
  49. '',
  50. '## 定位段',
  51. '',
  52. '### 来源窗口项及拆并关系',
  53. '开篇任务,单章承接',
  54. '',
  55. '### 章节功能',
  56. '',
  57. `- 〔硬〕${HARD}`,
  58. '',
  59. '### 视角与焦点',
  60. '主角视角',
  61. '',
  62. '### 时空锚定',
  63. '城门口,清晨',
  64. '',
  65. '### 起止边界',
  66. '从抵达城门到发现异状',
  67. '',
  68. '### 故事线与承诺分配',
  69. '推进主线承诺',
  70. '',
  71. '### 信息边界',
  72. '只披露主角所见',
  73. '',
  74. '### 情绪与节奏目标',
  75. '紧张后留钩子',
  76. '',
  77. '### 前置条件核对结果',
  78. '已核对来源与窗口,前置设定均非留白',
  79. '',
  80. '## 细纲段',
  81. '',
  82. '### 单元 1',
  83. '',
  84. '- 目标: 开场',
  85. '- 人物: 主角',
  86. '- 时空: 城门口',
  87. '- 行动/冲突: 盘问与发现',
  88. '- 信息披露: 异常线索',
  89. '- 状态变化: 从平静到警觉',
  90. '',
  91. ].join('\n')
  92. writeCandidate(root, { 卷: 1, 章: 1, 章名: '开篇任务', 来源引用: refs, body })
  93. const r = confirmOutline(root, key)
  94. if (!r.ok) throw new Error(`confirm failed:${r.gaps.join(';')}`)
  95. }
  96. function putPending(root: string, body: string, file = '稿1.md'): void {
  97. const rel = `草稿区/草稿/卷01-开篇任务/${file}`
  98. fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true })
  99. // 真实草稿一定带版本协议(写稿/润色都会写),夹具照真实形状来
  100. fs.writeFileSync(
  101. path.join(root, rel),
  102. serializeDocument({ 角色: '待审稿', 选定: true, 版本: 1, 父版本: null, 生成模块: '写稿' }, body),
  103. 'utf-8',
  104. )
  105. }
  106. describe('改稿最小 + 定稿准备', () => {
  107. it('applyRevision 产出新一版待审稿,原稿降级留档 → derive 定稿准备', () => {
  108. const root = mkDir('webnovel-revise-')
  109. confirmWithHard(root)
  110. assembleMaterials(root, key)
  111. putPending(root, `综上所述,${HARD}。`)
  112. resetChecks()
  113. registerDefaultChecks()
  114. const reviewed = runReview(root, key)
  115. const finding = reviewed.record?.问题.find((p) => p.问题说明.includes('综上所述'))
  116. expect(finding).toBeTruthy()
  117. const applied = applyRevision(root, key, {
  118. 发现项编号: finding!.发现项编号,
  119. 处置: '已解决',
  120. 补丁: { old: '综上所述,', new: '' },
  121. })
  122. expect(applied.ok).toBe(true)
  123. expect(applied.relPath).toBe('草稿区/草稿/卷01-开篇任务/稿2.md')
  124. // 新稿带补丁,旧稿原样留档可追溯
  125. const 新稿 = fs.readFileSync(path.join(root, '草稿区/草稿/卷01-开篇任务/稿2.md'), 'utf-8')
  126. expect(新稿).not.toContain('综上所述')
  127. const 旧稿 = fs.readFileSync(path.join(root, '草稿区/草稿/卷01-开篇任务/稿1.md'), 'utf-8')
  128. expect(旧稿).toContain('综上所述')
  129. // 版本链与唯一性
  130. const drafts = listChapterDrafts(root, key)
  131. expect(drafts.filter((d) => d.角色 === '待审稿').map((d) => d.file)).toEqual(['稿2.md'])
  132. expect(drafts.filter((d) => d.选定).map((d) => d.file)).toEqual(['稿2.md'])
  133. const 新 = drafts.find((d) => d.file === '稿2.md')!
  134. expect(新.版本).toBe(2)
  135. const doc = parseDocument(新稿)
  136. expect(doc.ok && doc.data.fields['父版本']).toBe(1)
  137. // 回写其余隔离子 Agent 模块(空审 fail-closed:不回写不算完成)
  138. const after = runReview(root, key).record
  139. for (const [name, st] of Object.entries(after?.模块 ?? {})) {
  140. if (st.待回写 === true) expect(ingestFindings(root, key, name, []).ok).toBe(true)
  141. }
  142. // F1:语义类硬约束产「需审读」发现项——按真实流程给处置(作者保留)后再推进
  143. const current = JSON.parse(fs.readFileSync(path.join(root, paths.审核记录(1, key.章名)), 'utf-8'))
  144. for (const q of current.问题) {
  145. if (q.处置状态 === '待处理') {
  146. const d = applyRevision(root, key, { 发现项编号: q.发现项编号, 处置: '作者保留' })
  147. expect(d.ok).toBe(true)
  148. }
  149. }
  150. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('定稿准备与沉淀')
  151. })
  152. it('applyRevision 不带补丁只记处置,不产生新稿', () => {
  153. const root = mkDir('webnovel-revise-nopatch-')
  154. confirmWithHard(root)
  155. assembleMaterials(root, key)
  156. putPending(root, `综上所述,${HARD}。`)
  157. resetChecks()
  158. registerDefaultChecks()
  159. const finding = runReview(root, key).record!.问题.find((p) => p.问题说明.includes('综上所述'))!
  160. const applied = applyRevision(root, key, { 发现项编号: finding.发现项编号, 处置: '作者保留' })
  161. expect(applied.ok).toBe(true)
  162. expect(applied.relPath).toBeUndefined()
  163. expect(listChapterDrafts(root, key).map((d) => d.file)).toEqual(['稿1.md'])
  164. })
  165. it('true-source 处置 → 须呈报上游', () => {
  166. const root = mkDir('webnovel-revise-up-')
  167. confirmWithHard(root)
  168. putPending(root, `${HARD}。`)
  169. resetChecks()
  170. registerDefaultChecks()
  171. runReview(root, key)
  172. const r = applyRevision(root, key, {
  173. 发现项编号: '不存在',
  174. 处置: '已解决',
  175. 范围: '世界书/人物档案/主角.md',
  176. })
  177. expect(r).toEqual({ ok: false, reason: '须呈报上游' })
  178. })
  179. it('preparePack writes 8 files, 卷对账 contains 偏离 or 无偏离', () => {
  180. const root = mkDir('webnovel-pack-')
  181. confirmWithHard(root)
  182. putPending(root, `巷口风大。${HARD}。`)
  183. const r = preparePack(root, key)
  184. expect(r.ok).toBe(true)
  185. const dir = path.join(root, '草稿区/定稿准备/卷01-开篇任务')
  186. for (const f of ['清单.json', '正文.md', '事实变更.md', '时间线变更.md', '账本变更.md', '章摘要.md', '卷对账.md', '本书层记忆候选.md']) {
  187. expect(fs.existsSync(path.join(dir, f))).toBe(true)
  188. }
  189. const 对账 = fs.readFileSync(path.join(dir, '卷对账.md'), 'utf-8')
  190. expect(对账).toMatch(/无偏离|偏离/)
  191. const 清单 = fs.readFileSync(path.join(dir, '清单.json'), 'utf-8')
  192. expect(清单).not.toMatch(/"目标":"草稿区\//)
  193. expect(清单).toContain('定稿/卷01/0001-开篇任务.md')
  194. expect(清单).not.toContain('"源":"卷对账.md"')
  195. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('作者定稿裁决')
  196. })
  197. it('preparePack presents actual ledger reconciliation instead of hardcoded 无偏离', () => {
  198. const root = mkDir('webnovel-pack-reconcile-')
  199. confirmWithHard(root)
  200. for (const name of ['故事线', '人物弧线', '承诺', '线索']) {
  201. writeFileAtomic(root, paths.账本(name as '故事线' | '人物弧线' | '承诺' | '线索'), `# ${name}\n`)
  202. }
  203. writeFileAtomic(root, paths.账本('时间线'), '# 时间线\n\n## 临时发现\n事件:意外线索\n章号:第0001章\n')
  204. writeFileAtomic(root, paths.计划时间线(1), '# 计划时间线\n\n## 窗口覆盖\n- 计划事件〔已确认〕\n')
  205. putPending(root, `巷口风大。${HARD}。`)
  206. expect(preparePack(root, key).ok).toBe(true)
  207. const report = fs.readFileSync(path.join(root, '草稿区/定稿准备/卷01-开篇任务/卷对账.md'), 'utf-8')
  208. expect(report).toContain('临时发现')
  209. expect(report).toContain('事实未计划:临时发现')
  210. expect(report).not.toMatch(/^无偏离$/m)
  211. })
  212. it('正文 includes finalized frontmatter contract fields', () => {
  213. const root = mkDir('webnovel-frontmatter-')
  214. confirmWithHard(root)
  215. putPending(root, `巷口风大。${HARD}。`)
  216. expect(preparePack(root, key).ok).toBe(true)
  217. const text = fs.readFileSync(path.join(root, '草稿区/定稿准备/卷01-开篇任务/正文.md'), 'utf-8')
  218. const doc = parseDocument(text)
  219. expect(doc.ok).toBe(true)
  220. if (doc.ok) {
  221. expect(doc.data.fields['状态']).toBe('已定稿')
  222. expect(doc.data.fields['章号']).toBe(1)
  223. expect(doc.data.fields['字数统计']).toMatchObject({ 汉字数: expect.any(Number) })
  224. expect(doc.data.fields['来源细纲版本']).toMatch(/章细纲/)
  225. expect(doc.data.fields['事实条目引用清单']).toEqual([])
  226. }
  227. })
  228. it('汉字数 counts CJK, not ASCII letters and digits', () => {
  229. const root = mkDir('webnovel-hancount-')
  230. confirmWithHard(root)
  231. putPending(root, `主角走进城门,发现异响。${HARD}。ABC123`)
  232. expect(preparePack(root, key).ok).toBe(true)
  233. const doc = parseDocument(
  234. fs.readFileSync(path.join(root, '草稿区/定稿准备/卷01-开篇任务/正文.md'), 'utf-8'))
  235. expect(doc.ok).toBe(true)
  236. if (!doc.ok) return
  237. const 统计 = doc.data.fields['字数统计'] as { 汉字数: number; 字符数: number }
  238. const body = `主角走进城门,发现异响。${HARD}。ABC123`
  239. const 期望 = (body.match(/[㐀-鿿]/g) ?? []).length
  240. expect(期望).toBeGreaterThan(0)
  241. expect(统计.汉字数).toBe(期望)
  242. })
  243. it('对账无法进行时清单不谎报偏离已呈报', () => {
  244. const root = mkDir('webnovel-manifest-honesty-')
  245. confirmWithHard(root)
  246. removeSync(path.join(root, paths.计划时间线(1)))
  247. putPending(root, `巷口风大。${HARD}。`)
  248. expect(preparePack(root, key).ok).toBe(true)
  249. const dir = path.join(root, '草稿区/定稿准备/卷01-开篇任务')
  250. expect(fs.readFileSync(path.join(dir, '卷对账.md'), 'utf-8')).toContain('无法对账')
  251. const 清单 = JSON.parse(fs.readFileSync(path.join(dir, '清单.json'), 'utf-8')) as Record<string, unknown>
  252. expect(清单['偏离已呈报']).toBe(false)
  253. })
  254. it('preparePack 本卷对账不把其他卷事实列为未计划,且不改写卷纲', () => {
  255. const root = mkDir('webnovel-pack-volume-filter-')
  256. confirmWithHard(root)
  257. for (const name of ['故事线', '人物弧线', '承诺', '线索'] as const) {
  258. writeFileAtomic(root, paths.账本(name), `# ${name}\n`)
  259. }
  260. writeFileAtomic(root, paths.计划时间线(1), '# 计划时间线\n\n## 窗口覆盖\n- 城门异响〔已确认〕\n')
  261. writeFileAtomic(root, paths.计划时间线(2), '# 计划时间线\n\n## 窗口覆盖\n- 卷二后事〔已确认〕\n')
  262. writeFileAtomic(root, paths.账本('时间线'), [
  263. '# 时间线',
  264. '',
  265. '## 城门异响',
  266. '事件:发现异常',
  267. '实际落点:定稿/卷01/0001-开篇任务.md',
  268. '',
  269. '## 卷二后事',
  270. '事件:后事',
  271. '来源:定稿/卷02/0002-后事.md',
  272. '',
  273. ].join('\n'))
  274. const outline = fs.readFileSync(path.join(root, paths.卷纲(1)), 'utf-8')
  275. putPending(root, `巷口风大。${HARD}。`)
  276. expect(preparePack(root, key).ok).toBe(true)
  277. const report = fs.readFileSync(path.join(root, '草稿区/定稿准备/卷01-开篇任务/卷对账.md'), 'utf-8')
  278. expect(report).toContain('城门异响')
  279. expect(report).not.toContain('事实未计划:卷二后事')
  280. expect(fs.readFileSync(path.join(root, paths.卷纲(1)), 'utf-8')).toBe(outline)
  281. })
  282. it('multiple 待审稿 → preparePack refuses', () => { const root = mkDir('webnovel-pack-multi-')
  283. confirmWithHard(root)
  284. putPending(root, `巷口风大。${HARD}。`)
  285. putPending(root, `另一版。${HARD}。`, '稿2.md')
  286. const result = preparePack(root, key)
  287. expect(result.ok).toBe(false)
  288. expect(result.reason).toMatch(/不唯一/)
  289. })
  290. it('archiveChapter from 清单 → 定稿 exists, derive 完成', () => {
  291. const root = initRepo()
  292. confirmWithHard(root)
  293. putPending(root, `巷口风大。${HARD}。`)
  294. expect(preparePack(root, key).ok).toBe(true)
  295. const archived = archiveChapter({
  296. bookRoot: root,
  297. packageDir: '草稿区/定稿准备/卷01-开篇任务',
  298. summary: '开篇任务入档',
  299. })
  300. expect(archived.ok).toBe(true)
  301. expect(fs.existsSync(path.join(root, '定稿/卷01/0001-开篇任务.md'))).toBe(true)
  302. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('完成')
  303. })
  304. })