assembly.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  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 {
  6. assembleMaterials,
  7. computeMaterials,
  8. confirmOutline,
  9. deriveChapterFacts,
  10. readManifest,
  11. scanChapter,
  12. seedMinDesign,
  13. serializeDocument,
  14. writeCandidate,
  15. writeRecentWindow,
  16. 材料包十段,
  17. } from '../src/index'
  18. import { sectionFileName } from '../src/assembly/sections'
  19. import { paths } from '../src/repo/paths'
  20. import { MACHINE_SCHEMA_VERSION, parseMachineJson } from '../src/repo/schema'
  21. import { removeSync } from '../src/repo/remove'
  22. const roots: string[] = []
  23. function mkBook(): string {
  24. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-assembly-'))
  25. roots.push(dir)
  26. seedMinDesign(dir)
  27. return dir
  28. }
  29. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  30. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  31. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'] as const
  32. function confirmReady(root: string): void {
  33. const body = [
  34. '# 章细纲', '', '## 定位段', '',
  35. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  36. '### 章节功能', '建立开场冲突', '- 〔软〕节奏平稳', '',
  37. '### 视角与焦点', '主角视角', '',
  38. '### 时空锚定', '城门口,清晨', '',
  39. '### 起止边界', '从抵达城门到发现异状', '',
  40. '### 故事线与承诺分配', '推进主线承诺', '',
  41. '### 信息边界', '只披露主角所见', '',
  42. '### 情绪与节奏目标', '紧张后留钩子', '',
  43. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  44. '## 细纲段', '', '### 单元 1', '',
  45. '- 目标: 找到异常', '- 人物: 主角', '- 时空: 城门口',
  46. '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  47. ].join('\n')
  48. writeCandidate(root, { 卷: 1, 章: 1, 章名: '开篇任务', 来源引用: refs, body })
  49. const r = confirmOutline(root, key)
  50. if (!r.ok) throw new Error(`confirm failed:${r.gaps.join(';')}`)
  51. }
  52. describe('写作备料(格式规格 §9.2)', () => {
  53. it('no confirmed outline → assemble fails / 段有缺', () => {
  54. const root = mkBook()
  55. const r = assembleMaterials(root, key)
  56. expect(r.ok).toBe(false)
  57. expect(r.状态).toBe('段有缺')
  58. expect(r.gaps.some((g) => g.includes('确认细纲'))).toBe(true)
  59. expect(fs.existsSync(path.join(root, paths.材料包目录(1, '开篇任务'), '材料清单.json'))).toBe(false)
  60. })
  61. it('after confirmOutline + assemble → 清单 段齐备, derive 建议写稿, compute/write 同源', () => {
  62. const root = mkBook()
  63. confirmReady(root)
  64. const r = assembleMaterials(root, key)
  65. expect(r.ok).toBe(true)
  66. expect(r.状态).toBe('段齐备')
  67. const 清单Abs = path.join(root, paths.材料包目录(1, '开篇任务'), '材料清单.json')
  68. const parsed = parseMachineJson(fs.readFileSync(清单Abs, 'utf-8'), MACHINE_SCHEMA_VERSION)
  69. expect(parsed.ok).toBe(true)
  70. if (parsed.ok) expect(parsed.data['状态']).toBe('段齐备')
  71. // compute/write 同源(R21 切割防漂移):写入器内部重算与纯算结果一致
  72. expect(computeMaterials(root, key).状态).toBe('段齐备')
  73. expect(readManifest(root, key)?.状态).toBe('段齐备')
  74. const facts = scanChapter(root, key)
  75. expect(deriveChapterFacts(facts).建议).toEqual({ row: 4, 环节: '写稿' })
  76. })
  77. it('missing referenced 仓内 file → 段有缺, not 段齐备', () => {
  78. const root = mkBook()
  79. confirmReady(root)
  80. removeSync(path.join(root, '世界书/人物档案/主角.md'))
  81. const r = assembleMaterials(root, key)
  82. expect(r.ok).toBe(false)
  83. expect(r.状态).toBe('段有缺')
  84. expect(r.gaps.some((g) => g.includes('世界书/人物档案/主角.md'))).toBe(true)
  85. const 清单Abs = path.join(root, paths.材料包目录(1, '开篇任务'), '材料清单.json')
  86. const parsed = parseMachineJson(fs.readFileSync(清单Abs, 'utf-8'), MACHINE_SCHEMA_VERSION)
  87. expect(parsed.ok).toBe(true)
  88. if (parsed.ok) expect(parsed.data['状态']).toBe('段有缺')
  89. expect(readManifest(root, key)?.状态).toBe('段有缺')
  90. expect(deriveChapterFacts(scanChapter(root, key)).建议).toEqual({ row: 3, 环节: '写作备料' })
  91. })
  92. it('10 section files exist', () => {
  93. const root = mkBook()
  94. confirmReady(root)
  95. assembleMaterials(root, key)
  96. const dir = path.join(root, paths.材料包目录(1, '开篇任务'))
  97. for (const [i, name] of 材料包十段.entries()) {
  98. expect(fs.existsSync(path.join(dir, sectionFileName(i, name)))).toBe(true)
  99. }
  100. expect(fs.existsSync(path.join(dir, '材料清单.json'))).toBe(true)
  101. })
  102. })
  103. describe('跨卷材料(任务21, 件二):卷N首章带卷N−1卷摘要与上一章末尾', () => {
  104. /** 建卷一3章定稿(章摘要齐)+卷二第4章确认细纲的跨卷书。 */
  105. function mkCrossVolumeBook(withSummary: boolean): string {
  106. const root = mkBook()
  107. const 章名 = ['第一章 夜巡', '第二章 灯影', '第三章 渡口']
  108. for (const [i, name] of 章名.entries()) {
  109. const 章 = i + 1
  110. const fin = path.join(root, `定稿/卷01/${String(章).padStart(4, '0')}-${name}.md`)
  111. fs.mkdirSync(path.dirname(fin), { recursive: true })
  112. fs.writeFileSync(fin, serializeDocument({ 状态: '已定稿' }, `第${章}章正文末尾:渡口的灯灭了。`), 'utf-8')
  113. const sum = path.join(root, `大纲/卷规划/卷01/章摘要/${String(章).padStart(4, '0')}-${name}.md`)
  114. fs.mkdirSync(path.dirname(sum), { recursive: true })
  115. fs.writeFileSync(sum, `# 章摘要\n\n${name}摘要:灯灭之因。`, 'utf-8')
  116. }
  117. if (withSummary) {
  118. fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/卷摘要.md'), '# 卷摘要\n\n卷一摘要:三盏灯与一只空渡口。', 'utf-8')
  119. }
  120. // 卷二第4章:窗口就绪+确认细纲
  121. fs.mkdirSync(path.join(root, '大纲/卷规划/卷02'), { recursive: true })
  122. writeRecentWindow(root, 2, [{ 名称: '第四章 新城', 状态: '已确认' }])
  123. writeCandidate(root, {
  124. 卷: 2,
  125. 章: 4,
  126. 章名: '第四章 新城',
  127. 来源引用: ['作品契约/契约.md@1'],
  128. body: [
  129. '# 章细纲', '', '## 定位段', '',
  130. '### 来源窗口项及拆并关系', '第四章 新城', '',
  131. '### 章节功能', '开卷二', '- 〔软〕稳', '',
  132. '### 视角与焦点', '主角', '',
  133. '### 时空锚定', '新城', '',
  134. '### 起止边界', '全程', '',
  135. '### 故事线与承诺分配', '主线', '',
  136. '### 信息边界', '限本章', '',
  137. '### 情绪与节奏目标', '起', '',
  138. '### 前置条件核对结果', '已核对', '',
  139. '## 细纲段', '', '### 单元 1', '',
  140. '- 目标: x', '- 人物: x', '- 时空: x', '- 行动/冲突: x', '- 信息披露: x', '- 状态变化: x', '',
  141. ].join('\n'),
  142. })
  143. const r = confirmOutline(root, { 卷: 2, 章: 4, 章名: '第四章 新城' })
  144. if (!r.ok) throw new Error('confirm 卷二第4章失败')
  145. return root
  146. }
  147. it('卷二第一章衔接段含卷一卷摘要(整文件)与第3章末尾,来源标卷号', () => {
  148. const root = mkCrossVolumeBook(true)
  149. const c = computeMaterials(root, { 卷: 2, 章: 4, 章名: '第四章 新城' })
  150. expect(c.ok).toBe(true)
  151. const sec = c.文件?.find((f) => f.relPath.includes('近期正文衔接'))
  152. expect(sec).toBeDefined()
  153. expect(sec!.content).toContain('卷01 卷摘要')
  154. expect(sec!.content).toContain('三盏灯与一只空渡口')
  155. expect(sec!.content).toContain('渡口的灯灭了')
  156. expect(sec!.content).toContain('第三章 渡口摘要')
  157. const rec = c.段?.find((s) => s.段 === '近期正文衔接')
  158. expect(rec?.来源).toContain('大纲/卷规划/卷01/卷摘要.md')
  159. expect(rec?.来源).toContain('定稿/卷01/0003-第三章 渡口.md')
  160. })
  161. it('前一卷无卷摘要如实标缺,不静默省略', () => {
  162. const root = mkCrossVolumeBook(false)
  163. const c = computeMaterials(root, { 卷: 2, 章: 4, 章名: '第四章 新城' })
  164. const sec = c.文件?.find((f) => f.relPath.includes('近期正文衔接'))
  165. expect(sec!.content).toContain('(卷01 无卷摘要)')
  166. expect(sec!.content).toContain('渡口的灯灭了')
  167. })
  168. })