reliability-fix1.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * 可靠性修复批 1(F2/F3/F7)验收。
  3. *
  4. * F2 统一编号池:提案与补偿事件共用单调序列,连续/交错/预置同名均不覆盖。
  5. * F7 提交恢复:失败 hook → written:true 落盘;移除 hook 重试 → committed;重复重试 → alreadyCommitted;
  6. * 作者期间新改的目标仍拒绝。F3 见 retcon 同提交断言(末例)。
  7. */
  8. import { afterAll, beforeEach, describe, expect, it } from 'vitest'
  9. import { spawnSync } from 'node:child_process'
  10. import * as fs from 'node:fs'
  11. import * as nodePath from 'node:path'
  12. import * as os from 'node:os'
  13. import {
  14. archiveChapter,
  15. archiveRetcon,
  16. paths,
  17. registerProposal,
  18. recordRetconEvent,
  19. resolveProposal,
  20. retconEventOps,
  21. nextProposalNumber,
  22. seedMinDesign,
  23. serializeDocument,
  24. writeCandidate,
  25. confirmOutline,
  26. preparePack,
  27. } from '../src/index'
  28. import { runReview } from '@webnovel/review'
  29. const roots: string[] = []
  30. function mkBook(withGit = true): string {
  31. const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'webnovel-rel1-'))
  32. roots.push(dir)
  33. seedMinDesign(dir)
  34. if (withGit) {
  35. const git = (args: readonly string[]): void => { spawnSync('git', args, { cwd: dir, windowsHide: true }) }
  36. git(['init'])
  37. git(['config', 'user.email', 'test@example.com'])
  38. git(['config', 'user.name', 'test'])
  39. git(['config', 'commit.gpgsign', 'false'])
  40. }
  41. return dir
  42. }
  43. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放 */ } } })
  44. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  45. function git(cwd: string, args: readonly string[]): string {
  46. const r = spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true })
  47. return r.stdout ?? ''
  48. }
  49. function settledBook(root: string): void {
  50. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1']
  51. const body = [
  52. '# 章细纲', '',
  53. '## 定位段', '',
  54. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  55. '### 章节功能', '',
  56. '- 〔硬〕开场必须点名主角现身', '',
  57. '### 视角与焦点', '主角视角', '',
  58. '### 时空锚定', '城门口,清晨', '',
  59. '### 起止边界', '从抵达城门到发现异状', '',
  60. '### 故事线与承诺分配', '推进主线承诺', '',
  61. '### 信息边界', '只披露主角所见', '',
  62. '### 情绪与节奏目标', '紧张后留钩子', '',
  63. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  64. '## 细纲段', '', '### 单元 1', '',
  65. '- 目标: 找到异常', '- 人物: 主角', '- 时空: 城门口',
  66. '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  67. ].join('\n')
  68. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: refs, body })
  69. const c = confirmOutline(root, key)
  70. if (!c.ok) throw new Error(`confirm failed:${c.gaps.join(';')}`)
  71. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  72. fs.mkdirSync(draftDir, { recursive: true })
  73. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '沈青梧走进城门,开场必须点名主角现身的规矩她记得清楚。'), 'utf-8')
  74. const rr = runReview(root, key)
  75. if (!rr.ok) throw new Error(`runReview failed:${rr.reason ?? ''}`)
  76. const p = preparePack(root, key)
  77. if (!p.ok) throw new Error(`preparePack failed:${p.reason ?? ''}`)
  78. const a = archiveChapter({ bookRoot: root, packageDir: nodePath.join(root, p.dir), summary: '第一章 开篇任务' })
  79. if (!a.ok) throw new Error(`archive failed:${a.reason ?? ''}`)
  80. }
  81. describe('F2:统一编号池(提案与补偿事件共用单调序列)', () => {
  82. it('连续两次补偿事件 → 编号递增,旧记录不被覆盖', () => {
  83. const root = mkBook(false)
  84. const e1 = recordRetconEvent(root, { 受影响工件: ['定稿/卷01/0001-开篇任务.md'], 账本留痕: '留痕一', 摘要: '补偿一' })
  85. const e2 = recordRetconEvent(root, { 受影响工件: ['定稿/卷01/0001-开篇任务.md'], 账本留痕: '留痕二', 摘要: '补偿二' })
  86. expect(e1.relPath).not.toBe(e2.relPath)
  87. expect(e2.relPath).toContain('补偿-0002')
  88. expect(fs.readFileSync(nodePath.join(root, e1.relPath), 'utf-8')).toContain('补偿一')
  89. expect(fs.readFileSync(nodePath.join(root, e2.relPath), 'utf-8')).toContain('补偿二')
  90. })
  91. it('提案与补偿交错 → 统一池单调无碰撞', () => {
  92. const root = mkBook(false)
  93. const p1 = registerProposal(root, { 域: '内容修订', 类型: '修改计划', 内容: '一', 来源: '测试' })
  94. const e1 = recordRetconEvent(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '偿一' })
  95. const p2 = registerProposal(root, { 域: '内容修订', 类型: '事实更正', 内容: '二', 来源: '测试' })
  96. const e2 = recordRetconEvent(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '偿二' })
  97. expect([p1.编号, e1.relPath, p2.编号, e2.relPath]).toEqual(['0001', '草稿区/提案/补偿-0002.md', '0003', '草稿区/提案/补偿-0004.md'])
  98. })
  99. it('retconEventOps(新位置):定稿/卷NN/补偿/ 纯构建,落盘后计入统一池', () => {
  100. const root = mkBook(false)
  101. const op = retconEventOps(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '偿', 卷: 1 })
  102. expect(op.relPath).toBe('定稿/卷01/补偿/补偿-0001.md')
  103. // 未落盘前重建仍取同号(纯构建);落盘后池递增
  104. const again = retconEventOps(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '偿', 卷: 1 })
  105. expect(again.relPath).toBe('定稿/卷01/补偿/补偿-0001.md')
  106. fs.mkdirSync(nodePath.join(root, '定稿/卷01/补偿'), { recursive: true })
  107. fs.writeFileSync(nodePath.join(root, op.relPath), op.content, 'utf-8')
  108. const next = retconEventOps(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '偿二', 卷: 1 })
  109. expect(next.relPath).toBe('定稿/卷01/补偿/补偿-0002.md')
  110. expect(nextProposalNumber(root)).toBe(2)
  111. })
  112. it('预置同名补偿文件 → 递增不覆盖(不允许静默覆盖)', () => {
  113. const root = mkBook(false)
  114. const p1 = registerProposal(root, { 域: '内容修订', 类型: '修改计划', 内容: '占号', 来源: '测试' })
  115. const e = recordRetconEvent(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '补偿内容A' })
  116. void p1
  117. // e.relPath = 补偿-0002.md;手工预置一个占位同名文件于后续编号
  118. fs.mkdirSync(nodePath.join(root, '草稿区/提案'), { recursive: true })
  119. fs.writeFileSync(nodePath.join(root, '草稿区/提案/补偿-0003.md'), '已有内容不得覆盖', 'utf-8')
  120. const e2 = recordRetconEvent(root, { 受影响工件: ['x'], 账本留痕: '痕', 摘要: '补偿内容B' })
  121. expect(e2.relPath).toBe('草稿区/提案/补偿-0004.md')
  122. expect(fs.readFileSync(nodePath.join(root, '草稿区/提案/补偿-0003.md'), 'utf-8')).toBe('已有内容不得覆盖')
  123. expect(fs.readFileSync(nodePath.join(root, e.relPath), 'utf-8')).toContain('补偿内容A')
  124. })
  125. })
  126. describe('F7:提交失败后的可重试恢复', () => {
  127. function writeFailingHook(root: string): void {
  128. const hooksDir = nodePath.join(root, '.git', 'hooks')
  129. fs.mkdirSync(hooksDir, { recursive: true })
  130. // sh 脚本:直接 exit 1(Windows Git for Windows 自带 sh 执行 hook)
  131. fs.writeFileSync(nodePath.join(hooksDir, 'pre-commit'), '#!/bin/sh\nexit 1\n', 'utf-8')
  132. fs.chmodSync(nodePath.join(hooksDir, 'pre-commit'), 0o755)
  133. }
  134. function removeHook(root: string): void {
  135. fs.rmSync(nodePath.join(root, '.git', 'hooks', 'pre-commit'), { force: true })
  136. }
  137. it('失败 hook → ok:false 且 written:true,文件已落盘;移除 hook 重试 → committed;再重试 → alreadyCommitted 且提交数不变', () => {
  138. const root = mkBook(true)
  139. settledBook(root)
  140. const commitsBefore = git(root, ['rev-list', '--count', 'HEAD']).trim()
  141. const 定稿相对 = paths.定稿章(1, 1, key.章名)
  142. const 更正后 = serializeDocument({ 身份: { 卷: 1, 章: 1, 章名: key.章名 }, 角色: '已定稿' }, '更正后的定稿正文。')
  143. writeFailingHook(root)
  144. const fail = archiveRetcon({ bookRoot: root, files: [{ 目标: 定稿相对, 内容: 更正后 }], summary: '吃书补偿:恢复验证' })
  145. expect(fail.ok).toBe(false)
  146. if (fail.ok) return
  147. expect(fail.written).toBe(true)
  148. expect(fs.readFileSync(nodePath.join(root, 定稿相对), 'utf-8')).toContain('更正后的定稿正文')
  149. expect(git(root, ['rev-list', '--count', 'HEAD']).trim()).toBe(commitsBefore)
  150. removeHook(root)
  151. const retry = archiveRetcon({ bookRoot: root, files: [{ 目标: 定稿相对, 内容: 更正后 }], summary: '吃书补偿:恢复验证' })
  152. expect(retry.ok).toBe(true)
  153. expect(git(root, ['rev-list', '--count', 'HEAD']).trim()).toBe(String(Number(commitsBefore) + 1))
  154. expect(git(root, ['log', '-1', '--format=%s']).trim()).toContain('吃书补偿:恢复验证')
  155. const again = archiveRetcon({ bookRoot: root, files: [{ 目标: 定稿相对, 内容: 更正后 }], summary: '吃书补偿:恢复验证' })
  156. expect(again.ok).toBe(true)
  157. if (again.ok) expect(again.alreadyCommitted).toBe(true)
  158. expect(git(root, ['rev-list', '--count', 'HEAD']).trim()).toBe(String(Number(commitsBefore) + 1))
  159. })
  160. it('ch 模式:作者期间新改的目标(内容不同) → 拒绝且指明「内容不同」;内容一致 → 放行至章号冲突检查', () => {
  161. const root = mkBook(true)
  162. settledBook(root)
  163. const 定稿相对 = paths.定稿章(1, 1, key.章名)
  164. // 作者期间新改(内容与归档版不同)→ 拒绝且 reason 明确「内容不同」
  165. const r1 = archiveChapter({ bookRoot: root, files: [{ 目标: 定稿相对, 内容: serializeDocument({ 身份: { 卷: 1, 章: 1, 章名: key.章名 }, 角色: '已定稿' }, '另一版更正。') }], summary: 'ch: 冲突验证' })
  166. expect(r1.ok).toBe(false)
  167. if (!r1.ok) expect(r1.reason).toContain('内容不同')
  168. // 内容与归档版逐字一致 → 内容一致放行(不误拒);因无实质变更,git 层「nothing to commit」→ written:true 如实回报
  169. const 现有定稿全文 = fs.readFileSync(nodePath.join(root, 定稿相对), 'utf-8')
  170. const r2 = archiveChapter({ bookRoot: root, files: [{ 目标: 定稿相对, 内容: 现有定稿全文 }], summary: 'ch: 幂等验证' })
  171. expect(r2.ok).toBe(false)
  172. if (!r2.ok) expect(r2.written).toBe(true)
  173. })
  174. })