finalize.test.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import path from 'node:path'
  4. import { promises as fs } from 'node:fs'
  5. import { execFile } from 'node:child_process'
  6. import { promisify } from 'node:util'
  7. import { finalizeChapter } from '../../src/finalize/index.js'
  8. import { createGit } from '../../src/finalize/git.js'
  9. import { gitBookCtx } from '../commands/_helper.js'
  10. import { mechanicalCheck } from '../../src/mechanical-check/index.js'
  11. const execFileAsync = promisify(execFile)
  12. function payload() {
  13. return {
  14. chapterNum: 3,
  15. frontMatter: {
  16. 章号: 3,
  17. 标题: '初露',
  18. 卷: 1,
  19. 视角: '林晚',
  20. 字数: 100,
  21. 章定位: '推进',
  22. 钩子: '危机钩-强',
  23. 情绪定位: '铺垫',
  24. },
  25. body: '林晚查到玉佩的第一条线索,心头巨震。\n',
  26. summary: '林晚查到玉佩的第一条线索。',
  27. threadUpdates: [
  28. { id: '伏笔-001', updates: { 最后推进章: 3 }, history: '第3章:推进——林晚查到线索' },
  29. ],
  30. characterUpdates: [{ name: '林晚', updates: { 最后变更章: 3 } }],
  31. timelineRows: [
  32. { volumeNum: 1, row: { 章: 3, 书内时间: '春月初三', 一句话事件: '查到玉佩线索', 在场: '林晚' } },
  33. ],
  34. commitLines: { 条目: '~伏笔-001', 设定: '林晚.最后变更章=3' },
  35. workspaceFiles: ['细纲.md'],
  36. }
  37. }
  38. test('finalizeChapter 正常定稿:落档 + git commit + 清工作区', async () => {
  39. const { ctx, cleanup } = await gitBookCtx()
  40. try {
  41. const git = createGit(ctx.repoPath)
  42. const before = await git.revCount()
  43. const r = await finalizeChapter(ctx, payload())
  44. assert.equal(r.ok, true, r.error)
  45. const ch = await fs.readFile(path.join(ctx.repoPath, '定稿/正文/0003-初露.md'), 'utf8')
  46. assert.match(ch, /标题: 初露/)
  47. await fs.access(path.join(ctx.repoPath, '定稿/摘要/章摘要/0003.md'))
  48. const log = await git.log()
  49. assert.match(log, /ch\(3\):/)
  50. assert.equal(await git.revCount(), before + 1)
  51. await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区/细纲.md')))
  52. } finally {
  53. await cleanup()
  54. }
  55. })
  56. test('finalizeChapter 断电注入:无新 commit + 工作区原样 + 定稿净恢复(出口)', async () => {
  57. const { ctx, cleanup } = await gitBookCtx()
  58. try {
  59. const git = createGit(ctx.repoPath)
  60. const before = await git.revCount()
  61. const r = await finalizeChapter(ctx, payload(), { faultAfterWrite: true })
  62. assert.equal(r.ok, false)
  63. // 无新 commit
  64. assert.equal(await git.revCount(), before)
  65. // 工作区草稿原样(细纲还在)
  66. await fs.access(path.join(ctx.repoPath, '工作区/细纲.md'))
  67. // 定稿 未残留半成品章
  68. await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0003-初露.md')))
  69. // 定稿/大纲 工作树干净(回滚成功)
  70. const { stdout } = await execFileAsync(
  71. 'git',
  72. ['status', '--porcelain', '--', '定稿', '大纲'],
  73. { cwd: ctx.repoPath, encoding: 'utf8' }
  74. )
  75. assert.equal(stdout.trim(), '')
  76. } finally {
  77. await cleanup()
  78. }
  79. })
  80. test('finalizeChapter 定稿后删 .cache 全量重建一致(不变量 2)', async () => {
  81. const { ctx, cleanup } = await gitBookCtx()
  82. try {
  83. const r = await finalizeChapter(ctx, payload())
  84. assert.equal(r.ok, true, r.error)
  85. await ctx.cache.rebuildFromSource(ctx.repoPath)
  86. const rows = await ctx.cache.query('SELECT * FROM chapters WHERE chapter_num = 3')
  87. assert.equal(rows.length, 1)
  88. assert.equal(rows[0].title, '初露')
  89. } finally {
  90. await cleanup()
  91. }
  92. })
  93. test('finalizeChapter 断电回滚(P1-7):不误伤同子树其他章的未提交手改', async () => {
  94. const { ctx, cleanup } = await gitBookCtx()
  95. try {
  96. // 在已跟踪的第1章上手改(不提交)——不在本次 written 集合里
  97. const ch1 = path.join(ctx.repoPath, '定稿/正文/0001-开局.md')
  98. await fs.writeFile(ch1, (await fs.readFile(ch1, 'utf8')) + '\n第1章手改。', 'utf8')
  99. const r = await finalizeChapter(ctx, payload(), { faultAfterWrite: true })
  100. assert.equal(r.ok, false)
  101. // 本次新写的第3章被清(未跟踪 → clean)
  102. await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0003-初露.md')))
  103. // 第1章手改保留(回滚范围收窄到 written,不再整棵 定稿/ 子树)
  104. assert.ok((await fs.readFile(ch1, 'utf8')).includes('第1章手改。'), '第1章手改不应被回滚抹掉')
  105. } finally {
  106. await cleanup()
  107. }
  108. })
  109. test('finalizeChapter 改同章标题后断电:旧章恢复,新章不残留', async () => {
  110. const { ctx, cleanup } = await gitBookCtx()
  111. try {
  112. const oldPath = path.join(ctx.repoPath, '定稿/正文/0001-开局.md')
  113. const oldContent = await fs.readFile(oldPath, 'utf8')
  114. const r = await finalizeChapter(ctx, {
  115. chapterNum: 1,
  116. frontMatter: {
  117. 章号: 1,
  118. 标题: '改名',
  119. 卷: 1,
  120. 字数: 100,
  121. 章定位: '推进',
  122. },
  123. body: '新正文',
  124. }, { faultAfterWrite: true })
  125. assert.equal(r.ok, false)
  126. assert.equal(
  127. (await fs.readFile(oldPath, 'utf8')).replace(/\r\n/g, '\n'),
  128. oldContent.replace(/\r\n/g, '\n'),
  129. '旧标题章文件必须恢复同一内容'
  130. )
  131. await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0001-改名.md')))
  132. const { stdout } = await execFileAsync(
  133. 'git',
  134. ['status', '--porcelain', '--', '定稿/正文'],
  135. { cwd: ctx.repoPath, encoding: 'utf8' }
  136. )
  137. assert.equal(stdout.trim(), '')
  138. } finally {
  139. await cleanup()
  140. }
  141. })
  142. // —— 新开条目入档(M6 P1.0:threadCreates,补 M2 起「埋下」无入档通道的缺口)——
  143. const 新条目 = () => ({
  144. id: '伏笔-002',
  145. 短题: '黑影来历',
  146. frontMatter: { 强度: '中', 状态: '进行', 开启章: 3, 最后推进章: 3 },
  147. body: '## 描述\n黑影的来历。\n\n## 收尾计划\n第二卷揭晓。\n\n## 履历\n- 第3章:埋下——黑影首次现身\n',
  148. })
  149. test('finalizeChapter threadCreates:埋下建档→缓存可见→下一章推进机检零误报(接力)', async () => {
  150. const { ctx, cleanup } = await gitBookCtx()
  151. try {
  152. const p = payload()
  153. p.frontMatter.伏笔 = ['推进 伏笔-001', '埋下 伏笔-002']
  154. p.threadCreates = [新条目()]
  155. const r = await finalizeChapter(ctx, p)
  156. assert.equal(r.ok, true, r.error)
  157. const f = await fs.readFile(path.join(ctx.repoPath, '大纲/伏笔/伏笔-002-黑影来历.md'), 'utf8')
  158. assert.match(f, /状态: 进行/)
  159. assert.match(f, /## 履历/)
  160. const rows = await ctx.cache.query("SELECT id, status FROM threads WHERE id = '伏笔-002'")
  161. assert.equal(rows.length, 1, '定稿刷缓存后新条目必须可见')
  162. // 接力:下一章草稿声明「推进 伏笔-002」,机检不得误判「不存在」
  163. const draft = [
  164. '---',
  165. '章号: 4',
  166. '标题: 追影',
  167. '卷: 1',
  168. '字数: 40',
  169. '章定位: 推进',
  170. '钩子: 危机钩-强',
  171. '情绪定位: 铺垫',
  172. '伏笔:',
  173. ' - 推进 伏笔-002',
  174. '---',
  175. '林晚循着黑影的踪迹一路追到后山,夜风里那道身影再次出现,她悄悄握紧了袖中的短刀。',
  176. ].join('\n')
  177. const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
  178. await fs.mkdir(path.dirname(draftPath), { recursive: true })
  179. await fs.writeFile(draftPath, draft, 'utf8')
  180. const mc = await mechanicalCheck(ctx, { chapterNum: 4, draftPath })
  181. assert.equal(mc.ok, true, mc.error)
  182. assert.deepEqual(
  183. mc.issues.filter((i) => i.check === '条目变动'),
  184. [],
  185. JSON.stringify(mc.issues)
  186. )
  187. } finally {
  188. await cleanup()
  189. }
  190. })
  191. test('finalizeChapter threadCreates 撞已有编号 → 整体失败并干净回滚', async () => {
  192. const { ctx, cleanup } = await gitBookCtx()
  193. try {
  194. const git = createGit(ctx.repoPath)
  195. const before = await git.revCount()
  196. const p = payload()
  197. p.threadCreates = [{ ...新条目(), id: '伏笔-001' }] // sample-book 已有
  198. const r = await finalizeChapter(ctx, p)
  199. assert.equal(r.ok, false)
  200. assert.match(r.error, /已存在/)
  201. assert.equal(await git.revCount(), before)
  202. const { stdout } = await execFileAsync(
  203. 'git',
  204. ['status', '--porcelain', '--', '定稿', '大纲'],
  205. { cwd: ctx.repoPath, encoding: 'utf8' }
  206. )
  207. assert.equal(stdout.trim(), '')
  208. } finally {
  209. await cleanup()
  210. }
  211. })
  212. test('finalizeChapter threadCreates 断电回滚:新条目文件不残留', async () => {
  213. const { ctx, cleanup } = await gitBookCtx()
  214. try {
  215. const p = payload()
  216. p.threadCreates = [新条目()]
  217. const r = await finalizeChapter(ctx, p, { faultAfterWrite: true })
  218. assert.equal(r.ok, false)
  219. await assert.rejects(() => fs.access(path.join(ctx.repoPath, '大纲/伏笔/伏笔-002-黑影来历.md')))
  220. } finally {
  221. await cleanup()
  222. }
  223. })