proposal.spec.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /**
  2. * S4 验收:提案三件套(§9.5 字段)+ retcon 流(更正落位/retcon: 提交/补偿留痕/裁决门)。
  3. */
  4. import { afterAll, beforeEach, describe, expect, it } from 'vitest'
  5. import { spawnSync } from 'node:child_process'
  6. import * as fs from 'node:fs'
  7. import * as nodePath from 'node:path'
  8. import * as os from 'node:os'
  9. import {
  10. archiveRetcon,
  11. archiveChapter,
  12. confirmOutline,
  13. loadProposal,
  14. preparePack,
  15. recordRetconEvent,
  16. registerProposal,
  17. resolveProposal,
  18. seedMinDesign,
  19. serializeDocument,
  20. writeCandidate,
  21. paths,
  22. MACHINE_SCHEMA_VERSION,
  23. } from '../src/index'
  24. import { runReview } from '@webnovel/review'
  25. const roots: string[] = []
  26. function mkBook(): string {
  27. const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'webnovel-s4-'))
  28. roots.push(dir)
  29. seedMinDesign(dir)
  30. // 建书在真实流程中 git init+首提交;测试夹具补齐(B6 健康检查需要)
  31. const git = (args: readonly string[]): void => { spawnSync('git', args, { cwd: dir, windowsHide: true }) }
  32. git(['init'])
  33. git(['config', 'user.email', 'test@example.com'])
  34. git(['config', 'user.name', 'test'])
  35. git(['config', 'commit.gpgsign', 'false'])
  36. return dir
  37. }
  38. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放 */ } } })
  39. beforeEach(() => { /* 每例独立书仓 */ })
  40. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  41. function settledBook(root: string): void {
  42. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1']
  43. const body = [
  44. '# 章细纲', '',
  45. '## 定位段', '',
  46. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  47. '### 章节功能', '',
  48. '- 〔硬〕开场必须点名主角现身', '',
  49. '### 视角与焦点', '主角视角', '',
  50. '### 时空锚定', '城门口,清晨', '',
  51. '### 起止边界', '从抵达城门到发现异状', '',
  52. '### 故事线与承诺分配', '推进主线承诺', '',
  53. '### 信息边界', '只披露主角所见', '',
  54. '### 情绪与节奏目标', '紧张后留钩子', '',
  55. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  56. '## 细纲段', '', '### 单元 1', '',
  57. '- 目标: 找到异常', '- 人物: 主角', '- 时空: 城门口',
  58. '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  59. ].join('\n')
  60. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: refs, body })
  61. const r = confirmOutline(root, key)
  62. if (!r.ok) throw new Error(`confirm failed:${r.gaps.join(';')}`)
  63. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  64. fs.mkdirSync(draftDir, { recursive: true })
  65. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '沈青梧走进城门,开场必须点名主角现身的规矩她记得清楚。'), 'utf-8')
  66. // 正式链:审核→组包(占位候选,无需批准)→ch: 入档(取 retcon 的覆盖对象)
  67. const rr = runReview(root, key)
  68. if (!rr.ok) throw new Error(`runReview failed:${rr.reason ?? ''}`)
  69. const p = preparePack(root, key)
  70. if (!p.ok) throw new Error(`preparePack failed:${p.reason ?? ''}`)
  71. const res = archiveChapter({ bookRoot: root, packageDir: nodePath.join(root, p.dir), summary: '第一章 开篇任务' })
  72. if (!res.ok) throw new Error(`archive failed:${res.reason ?? ''}`)
  73. }
  74. describe('S4:提案三件套(§9.5)', () => {
  75. it('登记→文件落盘,frontmatter 字段齐;编号递增', () => {
  76. const root = mkBook()
  77. const a = registerProposal(root, { 域: '内容修订', 类型: '修改计划', 内容: '把第二卷反派从 A 改为 B', 来源: '作者对话指示' })
  78. expect(a.ok).toBe(true)
  79. expect(a.编号).toBe('0001')
  80. const b = registerProposal(root, { 域: '吃书补偿', 类型: '吃书', 内容: '第1章旗幟描写与设定冲突', 来源: '第1章审核发现项 1 条' })
  81. expect(b.编号).toBe('0002')
  82. const loaded = loadProposal(root, '0001')
  83. expect(loaded.ok).toBe(true)
  84. if (!loaded.ok) return
  85. expect(loaded.text).toContain('状态: 待裁决')
  86. expect(loaded.text).toContain('把第二卷反派从 A 改为 B')
  87. expect(loaded.relPath).toBe('草稿区/提案/0001-修改计划.md')
  88. })
  89. it('裁决回写:只改状态与裁决记录节;重复裁决拒绝', () => {
  90. const root = mkBook()
  91. const a = registerProposal(root, { 域: '内容修订', 类型: '修改计划', 内容: '改反派', 来源: '作者对话指示' })
  92. const r = resolveProposal(root, a.编号, '通过', '方向成立,同批整改未定稿下游')
  93. expect(r.ok).toBe(true)
  94. const loaded = loadProposal(root, a.编号)
  95. if (!loaded.ok) throw new Error('load failed')
  96. expect(loaded.text).toContain('状态: 已通过')
  97. expect(loaded.text).toContain('【通过】方向成立')
  98. expect(loaded.text).toContain('改反派')
  99. const dup = resolveProposal(root, a.编号, '驳回', '再驳回一次')
  100. expect(dup.ok).toBe(false)
  101. })
  102. it('补偿事件记录落盘', () => {
  103. const root = mkBook()
  104. const e = recordRetconEvent(root, { 提案编号: '0001', 受影响工件: ['定稿/卷01/0001-开篇任务.md'], 账本留痕: '吃书补偿:第1章旗幡设定更正', 摘要: '更正旗幟描写' })
  105. expect(e.ok).toBe(true)
  106. expect(fs.readFileSync(nodePath.join(root, e.relPath), 'utf-8')).toContain('关联提案: "0001"')
  107. })
  108. })
  109. describe('S4:retcon 提交通道(不变量 3 的吃书例外)', () => {
  110. it('retcon 模式允许覆写既有定稿章,ch: 与 retcon: 各自成提交', () => {
  111. const root = mkBook()
  112. settledBook(root)
  113. // 定稿文件在,普通 ch: 通道会拒绝覆写;retcon 允许
  114. const 更正后 = serializeDocument({ 身份: { 卷: 1, 章: 1, 章名: key.章名 }, 角色: '已定稿' }, '沈青梧走进城门——旗幟的规矩她记得清楚。')
  115. const r = archiveRetcon({
  116. bookRoot: root,
  117. files: [{ 目标: paths.定稿章(1, 1, key.章名), 内容: 更正后 }],
  118. summary: '吃书补偿:旗幟描写更正',
  119. })
  120. expect(r.ok).toBe(true)
  121. if (!r.ok) return
  122. expect(fs.readFileSync(nodePath.join(root, paths.定稿章(1, 1, key.章名)), 'utf-8')).toContain('旗幟的规矩')
  123. })
  124. })