material-supplements.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import {
  6. assembleMaterials, computeMaterials, loadMaterialPackage, rebuildResume, scanChapter,
  7. serializeDocument, writeReviewRecord, type SupplementEdit,
  8. } from '../src/index'
  9. import { removeSync } from '../src/repo/remove'
  10. import { makeFixtureBook } from './helpers/fixture-book'
  11. import { loadDraftContext } from '../../drafting/src/context'
  12. import { computeReview, registerCheck, registerDefaultChecks, resetChecks } from '../../review/src/index'
  13. import * as rename from '../src/repo/rename'
  14. const roots: string[] = []
  15. const key = { 卷: 1, 章: 3, 章名: '章3' }
  16. afterAll(() => roots.forEach(root => removeSync(root)))
  17. beforeEach(() => { resetChecks(); registerDefaultChecks() })
  18. afterEach(() => vi.restoreAllMocks())
  19. function fixture() {
  20. const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-supplements-'))
  21. roots.push(workspace)
  22. const root = path.join(workspace, '测试书')
  23. makeFixtureBook(root, 3)
  24. const put = (base: string, rel: string, body: string) => {
  25. fs.mkdirSync(path.dirname(path.join(base, rel)), { recursive: true })
  26. fs.writeFileSync(path.join(base, rel), body)
  27. }
  28. put(root, '大纲/卷规划/卷01/卷纲.md', '# 卷纲\n\n' + ['叙事结构', '弧线', '线索推进', '卷末兑现'].map(name => `## ${name}\n\n章3承接城门事件,核对铜符。\n`).join('\n'))
  29. put(root, '草稿区/草稿/卷01-章3/稿1.md', serializeDocument({ 角色: '待审稿', 版本: 1 }, '主角继续前行,铜符仍在袖中。'))
  30. put(root, '世界书/人物档案/关系补记.md', '# 关系原文\n第二章后两人约定共同守门。【关系变化样本】\n')
  31. put(workspace, '书房/作者记忆/早期决定.md', '# 作者决定\n主角始终拒绝加入内卫。【早期决策样本】\n')
  32. const earlier = path.join(root, '定稿/卷01/0001-章1.md')
  33. fs.appendFileSync(earlier, '\n守门人遗落一枚旧铜符。【伏笔回收样本】\n')
  34. const operations: SupplementEdit[] = [
  35. { 操作: '设置', 编号: 'earlier', 标题: '早期伏笔', 理由: '本章回收第一章线索', 性质: '原文', 来源: { 域: '本书', 路径: '定稿/卷01/0001-章1.md' } },
  36. { 操作: '设置', 编号: 'relationship', 标题: '关系变化', 理由: '核对两人当前约定', 性质: '原文', 来源: { 域: '本书', 路径: '世界书/人物档案/关系补记.md' } },
  37. { 操作: '设置', 编号: 'decision', 标题: '作者早期决定', 理由: '保持早期裁决', 性质: '建议', 来源: { 域: '书房', 路径: '作者记忆/早期决定.md' } },
  38. ]
  39. const preview = (edits = operations) => computeMaterials(root, { ...key, 仅预览: true, 补充操作: edits })
  40. const save = (edits = operations) => {
  41. const prepared = preview(edits)
  42. expect(prepared.ok, prepared.gaps.join(';')).toBe(true)
  43. return assembleMaterials(root, { ...key, 材料清单哈希: prepared.已有清单哈希, 补充操作: prepared.补充预览!.map(item => item.操作) })
  44. }
  45. return { workspace, root, put, operations, preview, save, dir: path.join(root, '草稿区/材料包/卷01-章3') }
  46. }
  47. describe('持久补料与实际消费(15B)', () => {
  48. it('公开预览入口不进入写锁或事务恢复', () => {
  49. const f = fixture()
  50. const lock = path.join(f.root, '.webnovel/book.lock')
  51. fs.writeFileSync(lock, JSON.stringify({ pid: process.pid }))
  52. const before = fs.readFileSync(lock, 'utf8')
  53. expect(assembleMaterials(f.root, { ...key, 仅预览: true, 补充操作: f.operations }).ok).toBe(true)
  54. expect(fs.readFileSync(lock, 'utf8')).toBe(before)
  55. expect(fs.existsSync(f.dir)).toBe(false)
  56. fs.unlinkSync(lock)
  57. })
  58. it('预览零写盘;三种来源进入写稿、恢复和实际审读模块/输出', () => {
  59. const f = fixture()
  60. const beforeSource = fs.readFileSync(path.join(f.root, '定稿/卷01/0001-章1.md'), 'utf8')
  61. const preview = f.preview()
  62. expect(preview.ok, preview.gaps.join(';')).toBe(true)
  63. expect(fs.existsSync(f.dir)).toBe(false)
  64. expect(preview.补充预览).toHaveLength(3)
  65. expect(f.save().ok).toBe(true)
  66. let seen: Readonly<Record<string, string>> | undefined
  67. registerCheck({ 名称: '补料消费验收', 审什么: '实际材料', 依赖材料: [], 执行形态: '确定性代码', 适用范围: '章', run(input) { seen = input.材料段; return [] } })
  68. const draft = loadDraftContext(f.root, key)
  69. const restored = rebuildResume(f.root, key)
  70. const review = computeReview(f.root, key)
  71. expect(review.ok).toBe(true)
  72. for (const materials of [draft.材料段, restored.injection.材料段, review.材料段, seen]) {
  73. const text = JSON.stringify(materials)
  74. expect(text).toContain('伏笔回收样本')
  75. expect(text).toContain('关系变化样本')
  76. expect(text).toContain('早期决策样本')
  77. expect(text).toContain('不是自动确认的事实')
  78. }
  79. expect(fs.readFileSync(path.join(f.root, '定稿/卷01/0001-章1.md'), 'utf8')).toBe(beforeSource)
  80. })
  81. it('普通重建保留快照,重复提交不重复添加,移除后不读取遗留附件', () => {
  82. const f = fixture()
  83. const p = f.preview()
  84. const input = { ...key, 材料清单哈希: p.已有清单哈希, 补充操作: p.补充预览!.map(item => item.操作) }
  85. expect(assembleMaterials(f.root, input).ok).toBe(true)
  86. const manifestPath = path.join(f.dir, '材料清单.json')
  87. const first = fs.readFileSync(manifestPath, 'utf8')
  88. expect(assembleMaterials(f.root, input).ok).toBe(true)
  89. expect(assembleMaterials(f.root, key).ok).toBe(true)
  90. expect(fs.readFileSync(manifestPath, 'utf8')).toBe(first)
  91. expect(f.save([{ 操作: '移除', 编号: 'decision' }]).ok).toBe(true)
  92. expect(fs.existsSync(path.join(f.dir, '补充/decision.md'))).toBe(true)
  93. expect(JSON.stringify(loadMaterialPackage(f.root, key).段)).not.toContain('早期决策样本')
  94. expect(loadMaterialPackage(f.root, key).补充).toHaveLength(2)
  95. })
  96. it('来源漂移保留旧摘录并报过期,三个消费方一致,旧审核失效', () => {
  97. const f = fixture()
  98. expect(f.save().ok).toBe(true)
  99. const reviewed = computeReview(f.root, key)
  100. expect(reviewed.ok).toBe(true)
  101. writeReviewRecord(f.root, key, reviewed.record!)
  102. expect(scanChapter(f.root, key).审核证据过期).toBe(false)
  103. const snapshot = fs.readFileSync(path.join(f.dir, '补充/relationship.md'), 'utf8')
  104. f.put(f.root, '世界书/人物档案/关系补记.md', '# 关系原文\n两人已经分道扬镳。【新关系不能静默替换】\n')
  105. const rebuilt = assembleMaterials(f.root, key)
  106. expect(rebuilt).toMatchObject({ ok: false, 状态: '已过期' })
  107. expect(fs.readFileSync(path.join(f.dir, '补充/relationship.md'), 'utf8')).toBe(snapshot)
  108. expect(JSON.stringify(rebuildResume(f.root, key).injection)).toContain('已过期')
  109. expect(JSON.stringify(loadDraftContext(f.root, key))).not.toContain('新关系不能静默替换')
  110. expect(scanChapter(f.root, key)).toMatchObject({ 审核证据过期: true, 材料包状态: '已过期' })
  111. expect(computeReview(f.root, key)).toMatchObject({ ok: false, reason: expect.stringContaining('材料需要核对') })
  112. expect(f.save([f.operations[1]!]).ok).toBe(true)
  113. expect(JSON.stringify(loadDraftContext(f.root, key))).toContain('新关系不能静默替换')
  114. })
  115. it('仅改附件也使旧审核失效,损坏内容不当作已核对原文消费', () => {
  116. const f = fixture()
  117. f.save()
  118. const review = computeReview(f.root, key)
  119. writeReviewRecord(f.root, key, review.record!)
  120. fs.appendFileSync(path.join(f.dir, '补充/earlier.md'), '篡改附件')
  121. expect(scanChapter(f.root, key).审核证据过期).toBe(true)
  122. const loaded = loadMaterialPackage(f.root, key)
  123. expect(loaded.问题.join(';')).toContain('附件哈希不匹配')
  124. expect(JSON.stringify(loaded.段)).not.toContain('篡改附件')
  125. expect(computeReview(f.root, key).ok).toBe(false)
  126. })
  127. it('预览后来源/材料包被修改时拒绝旧版本,清单不被覆盖', () => {
  128. const f = fixture()
  129. const p = f.preview()
  130. fs.appendFileSync(path.join(f.workspace, '书房/作者记忆/早期决定.md'), '修改')
  131. const stale = assembleMaterials(f.root, { ...key, 材料清单哈希: p.已有清单哈希, 补充操作: p.补充预览!.map(item => item.操作) })
  132. expect(stale.ok).toBe(false)
  133. expect(fs.existsSync(f.dir)).toBe(false)
  134. f.save()
  135. const old = f.preview([{ 操作: '移除', 编号: 'decision' }])
  136. f.save([{ 操作: '移除', 编号: 'relationship' }])
  137. const before = fs.readFileSync(path.join(f.dir, '材料清单.json'), 'utf8')
  138. expect(assembleMaterials(f.root, { ...key, 材料清单哈希: old.已有清单哈希, 补充操作: old.补充预览!.map(item => item.操作) }).ok).toBe(false)
  139. expect(fs.readFileSync(path.join(f.dir, '材料清单.json'), 'utf8')).toBe(before)
  140. })
  141. it.each(['escape', 'index', 'future', 'range', 'duplicate', 'link'])('%s来源/操作被拒绝且零落盘', kind => {
  142. const f = fixture()
  143. let op = structuredClone(f.operations[0]!)
  144. if (op.操作 !== '设置') throw new Error('fixture')
  145. if (kind === 'escape') op = { ...op, 来源: { 域: '本书', 路径: '../另一书/正文.md' } }
  146. if (kind === 'index') op = { ...op, 来源: { 域: '本书', 路径: '本书记忆/索引.md' } }
  147. if (kind === 'future') { f.put(f.root, '定稿/卷01/0004-未来.md', '未来正文'); op = { ...op, 来源: { 域: '本书', 路径: '定稿/卷01/0004-未来.md' } } }
  148. if (kind === 'range') op = { ...op, 来源: { ...op.来源, 起行: 99999 } }
  149. if (kind === 'link') {
  150. fs.symlinkSync(path.join(f.workspace, '书房'), path.join(f.root, '链接'), process.platform === 'win32' ? 'junction' : 'dir')
  151. op = { ...op, 来源: { 域: '本书', 路径: '链接/作者记忆/早期决定.md' } }
  152. }
  153. const p = f.preview(kind === 'duplicate' ? [op, op] : [op])
  154. expect(p.ok).toBe(false)
  155. expect(p.文件).toBeUndefined()
  156. expect(fs.existsSync(f.dir)).toBe(false)
  157. })
  158. it('旧十段包保留原审读身份,新增补充的字数计入同一清单', () => {
  159. const f = fixture()
  160. expect(assembleMaterials(f.root, key).ok).toBe(true)
  161. const old = loadMaterialPackage(f.root, key)
  162. expect(old.审读材料标识).toBe(old.清单)
  163. const result = f.save()
  164. const manifest = JSON.parse(fs.readFileSync(path.join(f.dir, '材料清单.json'), 'utf8'))
  165. expect(manifest.段).toHaveLength(13)
  166. expect(result.合计字数).toBe(manifest.段.reduce((sum: number, item: { 字数: number }) => sum + item.字数, 0))
  167. })
  168. it('清单最后落盘失败时,已更新附件和基础材料一起回滚', () => {
  169. const f = fixture()
  170. f.save()
  171. const manifestPath = path.join(f.dir, '材料清单.json')
  172. const oldManifest = fs.readFileSync(manifestPath, 'utf8')
  173. const oldAttachment = fs.readFileSync(path.join(f.dir, '补充/relationship.md'), 'utf8')
  174. f.put(f.root, '世界书/人物档案/关系补记.md', '关系更新待写入。')
  175. const prepared = f.preview([f.operations[1]!])
  176. const original = rename.renameSync
  177. let failed = false
  178. vi.spyOn(rename, 'renameSync').mockImplementation((source, target) => {
  179. if (!failed && target === manifestPath) { failed = true; throw new Error('material manifest write failed') }
  180. return original(source, target)
  181. })
  182. expect(() => assembleMaterials(f.root, { ...key, 材料清单哈希: prepared.已有清单哈希, 补充操作: prepared.补充预览!.map(p => p.操作) })).toThrow('material manifest write failed')
  183. expect(failed).toBe(true)
  184. expect(fs.readFileSync(manifestPath, 'utf8')).toBe(oldManifest)
  185. expect(fs.readFileSync(path.join(f.dir, '补充/relationship.md'), 'utf8')).toBe(oldAttachment)
  186. })
  187. it('坏清单、缺失附件与未来事实范围明确报出,不取未登记文件', () => {
  188. const f = fixture()
  189. f.save()
  190. fs.unlinkSync(path.join(f.dir, '补充/earlier.md'))
  191. expect(loadMaterialPackage(f.root, key).问题.join(';')).toContain('补充附件不可读')
  192. const manifestPath = path.join(f.dir, '材料清单.json')
  193. const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
  194. manifest.段.find((item: { 编号?: string }) => item.编号 === 'decision').文件 = '../../作者资料.md'
  195. fs.writeFileSync(manifestPath, JSON.stringify(manifest))
  196. expect(computeReview(f.root, key).ok).toBe(false)
  197. expect(loadMaterialPackage(f.root, key).问题.join(';')).toContain('补充材料字段')
  198. const future = fixture()
  199. future.put(future.root, '世界书/人物档案/关系补记.md', '# 关系\n当前约定。\n### 事实@第 4 章\n未来关系。\n')
  200. expect(future.preview([future.operations[1]!]).ok).toBe(false)
  201. future.put(future.root, '世界书/人物档案/关系补记.md', '---\n来源: 定稿/卷01/0004-未来.md\n---\n未来来源不能补入第三章。')
  202. expect(future.preview([future.operations[1]!]).ok).toBe(false)
  203. })
  204. })