finalize-batch.test.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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 {
  8. stageChapter,
  9. finalizeBatch,
  10. rejectFrom,
  11. restageReview,
  12. discardBatch,
  13. readBatch,
  14. 章状态,
  15. } from '../../src/staging/index.js'
  16. import { finalizeChapter } from '../../src/finalize/index.js'
  17. import { determineNextState } from '../../src/state-machine/index.js'
  18. import { createGit } from '../../src/finalize/git.js'
  19. import { gitBookCtx } from '../commands/_helper.js'
  20. const execFileAsync = promisify(execFile)
  21. function chapterPayload(num, { body, 钩子 = '危机钩-强' } = {}) {
  22. return {
  23. chapterNum: num,
  24. frontMatter: {
  25. 章号: num,
  26. 标题: `连写${num}`,
  27. 卷: 1,
  28. 视角: '林晚',
  29. 书内时间: `夏月初${num}`,
  30. 字数: 100,
  31. 章定位: '推进',
  32. 钩子,
  33. 情绪定位: '铺垫',
  34. 伏笔: ['推进 伏笔-001'],
  35. },
  36. body: body ?? `第${num}章正文:林晚继续追查,步步逼近真相。`,
  37. summary: `第${num}章摘要。`,
  38. threadUpdates: [{ id: '伏笔-001', updates: { 最后推进章: num }, history: `第${num}章:推进` }],
  39. timelineRows: [
  40. { volumeNum: 1, row: { 章: num, 书内时间: `夏月初${num}`, 一句话事件: `第${num}章事件`, 在场: '林晚' } },
  41. ],
  42. commitLines: { 条目: '~伏笔-001' },
  43. workspaceFiles: [],
  44. }
  45. }
  46. async function stage(ctx, num, opts) {
  47. await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
  48. await fs.writeFile(
  49. path.join(ctx.repoPath, '工作区', '审稿.md'),
  50. `# 第 ${num} 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n`,
  51. 'utf8'
  52. )
  53. const r = await stageChapter(ctx, { chapterNum: num, payload: chapterPayload(num, opts) })
  54. assert.equal(r.ok, true, r.error)
  55. return r
  56. }
  57. // 定稿/大纲 全树快照(相对路径 → 内容),批次转正 vs 手动定稿逐字段对比
  58. async function snapshotTree(repoPath) {
  59. const map = {}
  60. async function walk(dir) {
  61. let entries = []
  62. try {
  63. entries = await fs.readdir(dir, { withFileTypes: true })
  64. } catch {
  65. return
  66. }
  67. for (const e of entries) {
  68. const full = path.join(dir, e.name)
  69. if (e.isDirectory()) await walk(full)
  70. else {
  71. map[path.relative(repoPath, full).replace(/\\/g, '/')] = (
  72. await fs.readFile(full, 'utf8')
  73. ).replace(/\r\n/g, '\n')
  74. }
  75. }
  76. }
  77. await walk(path.join(repoPath, '定稿'))
  78. await walk(path.join(repoPath, '大纲'))
  79. return map
  80. }
  81. test('AC1 批次端到端:stage×3 → finalize-batch 逐章 commit,入档与手动定稿逐字段一致', async () => {
  82. const batchRepo = await gitBookCtx()
  83. const manualRepo = await gitBookCtx()
  84. try {
  85. for (const n of [3, 4, 5]) await stage(batchRepo.ctx, n)
  86. // AC7 后半:批内期间 fingerprints/meta 零 staged 污染
  87. assert.equal(
  88. (await batchRepo.ctx.cache.query('SELECT COUNT(*) AS c FROM fingerprints'))[0].c,
  89. 0
  90. )
  91. assert.equal(
  92. (await batchRepo.ctx.cache.query("SELECT COUNT(*) AS c FROM meta WHERE key = 'imagery_top'"))[0].c,
  93. 0
  94. )
  95. const git = createGit(batchRepo.ctx.repoPath)
  96. const before = await git.revCount()
  97. const r = await finalizeBatch(batchRepo.ctx)
  98. assert.equal(r.ok, true, r.error)
  99. assert.deepEqual(r.已入档.map((x) => x.章号), [3, 4, 5])
  100. assert.equal(await git.revCount(), before + 3, '每章一个独立 commit')
  101. const log = await git.log()
  102. const [i5, i4, i3] = [log.indexOf('ch(5)'), log.indexOf('ch(4)'), log.indexOf('ch(3)')]
  103. assert.ok(i5 !== -1 && i4 !== -1 && i3 !== -1 && i5 < i4 && i4 < i3, '按章号升序逐章 commit')
  104. assert.equal((await readBatch(batchRepo.ctx.repoPath)).exists, false, '批次目录清空')
  105. assert.match(r.体检, /体检/)
  106. const nx = await determineNextState(batchRepo.ctx)
  107. assert.equal(nx.序, 6, JSON.stringify(nx))
  108. assert.match(nx.message, /第 6 章/)
  109. // 手动模式同 payload 逐章定稿 → 定稿/大纲 全树逐字段一致
  110. for (const n of [3, 4, 5]) {
  111. const m = await finalizeChapter(manualRepo.ctx, chapterPayload(n))
  112. assert.equal(m.ok, true, m.error)
  113. }
  114. assert.deepEqual(
  115. await snapshotTree(batchRepo.ctx.repoPath),
  116. await snapshotTree(manualRepo.ctx.repoPath)
  117. )
  118. } finally {
  119. await batchRepo.cleanup()
  120. await manualRepo.cleanup()
  121. }
  122. })
  123. test('AC3 注入错误恢复演练:打回传染 → 拒绝定稿 → 重写重审 → 成功且旧内容零入档', async () => {
  124. const { ctx, cleanup } = await gitBookCtx()
  125. try {
  126. const badPhrase = '这段写崩了的旧版本'
  127. await stage(ctx, 3)
  128. await stage(ctx, 4, { body: `第4章正文:${badPhrase}。` })
  129. await stage(ctx, 5)
  130. const rej = await rejectFrom(ctx.repoPath, 4)
  131. assert.equal(rej.ok, true, rej.error)
  132. assert.deepEqual(rej.受影响, [5])
  133. const git = createGit(ctx.repoPath)
  134. const before = await git.revCount()
  135. const blocked = await finalizeBatch(ctx)
  136. assert.equal(blocked.ok, false)
  137. assert.match(blocked.error, /打回/)
  138. assert.match(blocked.error, /受影响/)
  139. assert.equal(await git.revCount(), before, '拒绝时零 commit')
  140. // 重写打回章 → stage 覆盖;受影响章重审 → batch-restage 通道
  141. await stage(ctx, 4, { body: '第4章正文:重写后的干净版本。' })
  142. await fs.writeFile(
  143. path.join(ctx.repoPath, '工作区', '审稿.md'),
  144. '# 第 5 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n',
  145. 'utf8'
  146. )
  147. const rs = await restageReview(ctx.repoPath, 5)
  148. assert.equal(rs.ok, true, rs.error)
  149. const done = await finalizeBatch(ctx)
  150. assert.equal(done.ok, true, done.error)
  151. assert.deepEqual(done.已入档.map((x) => x.章号), [3, 4, 5])
  152. const ch4 = await fs.readFile(path.join(ctx.repoPath, '定稿', '正文', '0004-连写4.md'), 'utf8')
  153. assert.match(ch4, /重写后的干净版本/)
  154. // 打回章的旧预登记内容不出现在任何 commit(批内污染不出批次)
  155. const { stdout } = await execFileAsync('git', ['log', '-p', '--all'], {
  156. cwd: ctx.repoPath,
  157. encoding: 'utf8',
  158. maxBuffer: 32 * 1024 * 1024,
  159. })
  160. assert.ok(!stdout.includes(badPhrase), '旧版本内容泄漏进了 git 历史')
  161. } finally {
  162. await cleanup()
  163. }
  164. })
  165. test('中途失败按章保留:坏定稿包停在该章,已入档保留、剩余原样可续跑', async () => {
  166. const { ctx, cleanup } = await gitBookCtx()
  167. try {
  168. await stage(ctx, 3)
  169. await stage(ctx, 4)
  170. const batch = await readBatch(ctx.repoPath)
  171. const dir4 = batch.章列表.find((x) => x.章号 === 4).目录
  172. await fs.writeFile(path.join(ctx.repoPath, '工作区', '待定稿', dir4, '定稿包.json'), '{{{', 'utf8')
  173. const r = await finalizeBatch(ctx)
  174. assert.equal(r.ok, false)
  175. assert.deepEqual(r.已入档.map((x) => x.章号), [3])
  176. assert.match(r.error, /第 4 章/)
  177. assert.match(r.error, /已入档保留/)
  178. const rows = await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
  179. assert.equal(rows[0].m, 3, '第 3 章已入档并刷新缓存')
  180. const after = await readBatch(ctx.repoPath)
  181. assert.deepEqual(after.章列表.map((x) => x.章号), [4], '失败章留在批次里')
  182. } finally {
  183. await cleanup()
  184. }
  185. })
  186. test('AC6 整批丢弃:定稿零变化、批次消失、next 回起草细纲', async () => {
  187. const { ctx, cleanup } = await gitBookCtx()
  188. try {
  189. await stage(ctx, 3)
  190. const git = createGit(ctx.repoPath)
  191. const before = await git.revCount()
  192. const r = await discardBatch(ctx.repoPath)
  193. assert.equal(r.ok, true, r.error)
  194. assert.equal(r.章数, 1)
  195. assert.equal(await git.revCount(), before)
  196. const { stdout } = await execFileAsync('git', ['status', '--porcelain', '--', '定稿', '大纲'], {
  197. cwd: ctx.repoPath,
  198. encoding: 'utf8',
  199. })
  200. assert.equal(stdout.trim(), '', '定稿/大纲 工作树零变化')
  201. assert.equal((await readBatch(ctx.repoPath)).exists, false)
  202. const nx = await determineNextState(ctx)
  203. assert.equal(nx.序, 6, JSON.stringify(nx))
  204. assert.match(nx.message, /第 3 章/)
  205. } finally {
  206. await cleanup()
  207. }
  208. })
  209. test('finalize-batch --until:只转正前段,剩余待审收、next 报批次续跑', async () => {
  210. const { ctx, cleanup } = await gitBookCtx()
  211. try {
  212. for (const n of [3, 4, 5]) await stage(ctx, n)
  213. const r = await finalizeBatch(ctx, { until: 4 })
  214. assert.equal(r.ok, true, r.error)
  215. assert.deepEqual(r.已入档.map((x) => x.章号), [3, 4])
  216. assert.equal(r.剩余, 1)
  217. const batch = await readBatch(ctx.repoPath)
  218. assert.deepEqual(
  219. batch.章列表.map((x) => [x.章号, x.状态]),
  220. [[5, 章状态.待审收]]
  221. )
  222. const nx = await determineNextState(ctx)
  223. assert.equal(nx.序, 3, JSON.stringify(nx))
  224. } finally {
  225. await cleanup()
  226. }
  227. })