settlement.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { spawnSync } from 'node:child_process'
  6. import { archiveChapter, archiveRetcon } from '../src/commit/archive'
  7. import { serializeDocument } from '../src/repo/frontmatter'
  8. import { queryLedger, queryMemory } from '../src/ledger'
  9. const roots: string[] = []
  10. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  11. function mkRoot(): string {
  12. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-settlement-'))
  13. roots.push(root)
  14. fs.mkdirSync(path.join(root, '账本'), { recursive: true })
  15. fs.mkdirSync(path.join(root, '本书记忆'), { recursive: true })
  16. for (const name of ['故事线', '人物弧线', '承诺', '线索', '时间线']) {
  17. fs.writeFileSync(path.join(root, '账本', `${name}.md`), `# ${name}\n既有条目\n`, 'utf-8')
  18. }
  19. for (const name of ['文风', '决策', '对话', '灵感']) {
  20. fs.writeFileSync(path.join(root, '本书记忆', `${name}.md`), `# ${name}\n既有记忆\n`, 'utf-8')
  21. }
  22. return root
  23. }
  24. function initGit(root: string): void {
  25. expect(spawnSync('git', ['init'], { cwd: root, windowsHide: true }).status).toBe(0)
  26. spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root, windowsHide: true })
  27. spawnSync('git', ['config', 'user.name', 'test'], { cwd: root, windowsHide: true })
  28. spawnSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root, windowsHide: true })
  29. }
  30. function putPackage(root: string, candidates: Record<string, string>): string {
  31. const rel = '草稿区/定稿准备/卷01-开篇任务'
  32. const dir = path.join(root, rel)
  33. fs.mkdirSync(dir, { recursive: true })
  34. fs.writeFileSync(path.join(dir, '正文.md'), '正文\n', 'utf-8')
  35. fs.writeFileSync(path.join(dir, '章摘要.md'), '摘要\n', 'utf-8')
  36. for (const [name, content] of Object.entries(candidates)) fs.writeFileSync(path.join(dir, name), content, 'utf-8')
  37. fs.writeFileSync(path.join(dir, '事实变更.md'), candidates['事实变更.md'] ?? '# 事实变更\n\n(无事实变更)\n', 'utf-8')
  38. fs.writeFileSync(path.join(dir, '时间线变更.md'), candidates['时间线变更.md'] ?? '# 时间线变更\n\n(无时间线变更)\n', 'utf-8')
  39. fs.writeFileSync(path.join(dir, '账本变更.md'), candidates['账本变更.md'] ?? '# 账本变更\n\n(无账本变更)\n', 'utf-8')
  40. fs.writeFileSync(path.join(dir, '本书层记忆候选.md'), candidates['本书层记忆候选.md'] ?? '# 本书层记忆候选\n\n(无记忆候选)\n', 'utf-8')
  41. fs.writeFileSync(path.join(dir, '清单.json'), JSON.stringify({
  42. schemaVersion: 1,
  43. 文件: [
  44. { 源: '正文.md', 目标: '定稿/卷01/0001-开篇任务.md' },
  45. { 源: '章摘要.md', 目标: '大纲/卷规划/卷01/章摘要/0001-开篇任务.md' },
  46. ],
  47. }), 'utf-8')
  48. return rel
  49. }
  50. afterAll(() => { for (const root of roots) { try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  51. describe('定稿沉淀', () => {
  52. it('未批准时 fail-closed 且不写任何真源', () => {
  53. const root = mkRoot()
  54. const pkg = putPackage(root, { '时间线变更.md': '# 时间线变更\n\n## 城门异响\n事件:发现异常\n' })
  55. const result = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '开篇任务入档' })
  56. expect(result).toEqual({ ok: false, reason: '存在沉淀候选但缺少作者批准' })
  57. expect(fs.existsSync(path.join(root, '定稿/卷01/0001-开篇任务.md'))).toBe(false)
  58. expect(fs.readFileSync(path.join(root, '账本/时间线.md'), 'utf-8')).not.toContain('城门异响')
  59. })
  60. it('批准后将时间线、账本、记忆与正文放入同一 ch 提交', () => {
  61. const root = mkRoot()
  62. initGit(root)
  63. const pkg = putPackage(root, {
  64. '时间线变更.md': '# 时间线变更\n\n## 城门异响\n事件:发现异常\n',
  65. '账本变更.md': [
  66. '# 账本变更',
  67. '## 故事线', '### 主线启动', '名称:主线启动', '状态:进行中', '计划来源:卷纲#1', '备注:这一行属于正文',
  68. '## 人物弧线', '### 主角警觉', '状态:进行中', '计划来源:卷纲#2',
  69. '## 承诺', '### 查明异响', '状态:进行中', '计划来源:卷纲#3',
  70. '## 线索', '### 纸鹤', '状态:已埋', '计划来源:卷纲#4', '埋设点:第1章', '预期兑现区间:第8-12章',
  71. '',
  72. ].join('\n'),
  73. '本书层记忆候选.md': [
  74. '# 本书层记忆候选',
  75. '## 文风', '### 克制叙述', '状态:候选', '短句,少用解释性旁白。', '例:他没有回头。',
  76. '## 决策', '### 开篇视角', '状态:候选', '开篇固定使用主角限知视角。',
  77. '## 对话', '### 主角语气', '状态:候选', '主角遇险时避免长篇解释。',
  78. '## 灵感', '### 纸鹤回响', '状态:候选', '纸鹤可在卷末作为回响意象。',
  79. '',
  80. ].join('\n'),
  81. })
  82. const result = archiveChapter({
  83. bookRoot: root,
  84. packageDir: pkg,
  85. summary: '开篇任务入档',
  86. settlement: { 章节: key, 批准: true, 裁决记录: '作者批准首章沉淀' },
  87. })
  88. expect(result.ok).toBe(true)
  89. expect(fs.readFileSync(path.join(root, '账本/时间线.md'), 'utf-8')).toMatch(/城门异响|第0001章|作者批准首章沉淀/)
  90. expect(fs.readFileSync(path.join(root, '账本/故事线.md'), 'utf-8')).toMatch(/主线启动|实际落点:定稿\/卷01\/0001-开篇任务\.md/)
  91. for (const [file, item] of [['人物弧线', '主角警觉'], ['承诺', '查明异响'], ['线索', '纸鹤']]) {
  92. expect(fs.readFileSync(path.join(root, `账本/${file}.md`), 'utf-8')).toContain(item)
  93. }
  94. for (const [item, file] of [['克制叙述', '本书记忆/克制叙述.md'], ['开篇视角', '本书记忆/开篇视角.md'], ['主角语气', '本书记忆/主角语气.md'], ['纸鹤回响', '本书记忆/纸鹤回响.md']]) {
  95. expect(fs.readFileSync(path.join(root, file), 'utf-8')).toContain(item)
  96. }
  97. // 一事一文件:索引同步重建,旧 类 文件不被写入新条目
  98. expect(fs.readFileSync(path.join(root, '本书记忆/索引.md'), 'utf-8')).toContain('[克制叙述]')
  99. expect(fs.readFileSync(path.join(root, '本书记忆/文风.md'), 'utf-8')).not.toContain('克制叙述')
  100. const memories = queryMemory(root)
  101. expect(memories.ok).toBe(true)
  102. if (memories.ok) expect(memories.entries.find((entry) => entry.名称 === '克制叙述')?.正文).toContain('例:他没有回头。')
  103. const ledger = queryLedger(root)
  104. expect(ledger.ok).toBe(true)
  105. if (ledger.ok) expect(ledger.entries.find((entry) => entry.名称 === '主线启动')?.正文).toContain('备注:这一行属于正文')
  106. if (ledger.ok) {
  107. const timeline = ledger.entries.find((entry) => entry.分类 === '时间线' && entry.名称 === '城门异响')
  108. expect(timeline?.字段['章号']).toBe('第0001章')
  109. expect(timeline?.字段['来源']).toBe('定稿/卷01/0001-开篇任务.md')
  110. expect(timeline?.正文).toContain('发现异常')
  111. }
  112. const log = spawnSync('git', ['log', '-1', '--pretty=%s'], { cwd: root, encoding: 'utf-8', windowsHide: true })
  113. expect(log.stdout.trim()).toBe('ch: 开篇任务入档')
  114. })
  115. it('未知分类、非法线索字段和非空事实变更在写入前失败', () => {
  116. const cases: Array<{ candidate: Record<string, string>; error: RegExp }> = [
  117. { candidate: { '账本变更.md': '# 账本变更\n\n## 未知\n### 条目\n状态:进行中\n' }, error: /分类未知/ },
  118. { candidate: { '账本变更.md': '# 账本变更\n\n## 线索\n### 伏笔\n状态:已弃\n计划来源:卷纲#2\n埋设点:第1章\n预期兑现区间:卷一\n' }, error: /弃因/ },
  119. { candidate: { '账本变更.md': '# 账本变更\n\n## 承诺\n### 回乡\n状态:进行中\n' }, error: /计划来源/ },
  120. { candidate: { '事实变更.md': '# 事实变更\n\n## 世界规则\n### 新律\n新增规则\n' }, error: /不猜模块/ },
  121. ]
  122. for (const entry of cases) {
  123. const root = mkRoot()
  124. const pkg = putPackage(root, entry.candidate)
  125. const result = archiveChapter({
  126. bookRoot: root,
  127. packageDir: pkg,
  128. summary: '拒绝非法候选',
  129. settlement: { 章节: key, 批准: true, 裁决记录: '测试裁决' },
  130. })
  131. expect(result.ok).toBe(false)
  132. if (!result.ok) expect(result.reason).toMatch(entry.error)
  133. expect(fs.existsSync(path.join(root, '定稿/卷01/0001-开篇任务.md'))).toBe(false)
  134. }
  135. })
  136. it('只有分类与条目标题、没有正文的候选必须报错到条目,不静默当空', () => {
  137. const cases = [
  138. {
  139. name: '账本变更.md',
  140. candidate: { '账本变更.md': '# 账本变更\n\n## 线索\n\n### 纸鹤\n' },
  141. error: /线索\/纸鹤/,
  142. },
  143. {
  144. name: '本书层记忆候选.md',
  145. candidate: { '本书层记忆候选.md': '# 本书层记忆候选\n\n## 文风\n\n### 短句偏好\n' },
  146. error: /文风\/短句偏好/,
  147. },
  148. {
  149. name: '时间线变更.md',
  150. candidate: { '时间线变更.md': '# 时间线变更\n\n## 城门异响\n' },
  151. error: /城门异响/,
  152. },
  153. ]
  154. for (const entry of cases) {
  155. const root = mkRoot()
  156. initGit(root)
  157. const pkg = putPackage(root, entry.candidate)
  158. const result = archiveChapter({
  159. bookRoot: root,
  160. packageDir: pkg,
  161. summary: entry.name,
  162. settlement: { 章节: key, 批准: true, 裁决记录: '测试裁决' },
  163. })
  164. expect(result.ok).toBe(false)
  165. if (!result.ok) expect(result.reason).toMatch(entry.error)
  166. expect(fs.existsSync(path.join(root, '定稿/卷01/0001-开篇任务.md'))).toBe(false)
  167. }
  168. })
  169. it('只有标题的候选在无批准时也被当作有候选而拒绝', () => {
  170. const root = mkRoot()
  171. const pkg = putPackage(root, { '账本变更.md': '# 账本变更\n\n## 线索\n\n### 纸鹤\n' })
  172. const result = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '无批准' })
  173. expect(result.ok).toBe(false)
  174. if (!result.ok) expect(result.reason).toMatch(/缺少作者批准/)
  175. })
  176. it('空候选无需批准,同内容重复入档幂等放行(F7:无实质变更→git 层回报 written:true,不覆盖不重复提交)', () => { const root = mkRoot()
  177. initGit(root)
  178. const pkg = putPackage(root, {})
  179. expect(archiveChapter({ bookRoot: root, packageDir: pkg, summary: '空候选入档' }).ok).toBe(true)
  180. const second = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '重复入档' })
  181. expect(second.ok).toBe(false)
  182. if (!second.ok) {
  183. // F7:内容与已提交版完全一致 → 内容一致放行,git 层无实质变更 → 回报「已写入待提交」而非「目标已存在」
  184. expect(second.written).toBe(true)
  185. expect(second.reason).toMatch(/提交失败/)
  186. }
  187. // 作者期间新改(内容不同)仍被拒绝
  188. fs.writeFileSync(path.join(root, pkg, '正文.md'), serializeDocument({ 身份: { 卷: 1, 章: 1, 章名: '开篇任务' }, 角色: '已定稿' }, '作者改过的正文。'), 'utf-8')
  189. const third = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '冲突入档' })
  190. expect(third.ok).toBe(false)
  191. if (!third.ok) expect(third.reason).toMatch(/内容不同/)
  192. })
  193. })
  194. describe('吃书补偿沉淀', () => {
  195. function 已有线索(root: string): void {
  196. fs.writeFileSync(path.join(root, '账本', '线索.md'), [
  197. '# 线索',
  198. '',
  199. '## 纸鹤',
  200. '名称:纸鹤',
  201. '类型:线索',
  202. '状态:已埋',
  203. '计划来源:卷纲#4',
  204. '实际落点:定稿/卷01/0001-开篇任务.md',
  205. '来源:定稿/卷01/0001-开篇任务.md',
  206. '裁决记录:首章沉淀',
  207. '### 正文',
  208. '埋设点:第1章',
  209. '预期兑现区间:第8-12章',
  210. '',
  211. '## 铜铃',
  212. '状态:已埋',
  213. '计划来源:卷纲#5',
  214. '### 正文',
  215. '不该被这次吃书动到',
  216. '',
  217. ].join('\n'), 'utf-8')
  218. }
  219. it('retcon 清单直写账本同样被拒,必须走沉淀服务', () => {
  220. const root = mkRoot()
  221. initGit(root)
  222. const result = archiveRetcon({
  223. bookRoot: root,
  224. files: [{ 目标: '账本/线索.md', 内容: '# 线索\n\n## 纸鹤\n状态:随便写\n' }],
  225. summary: '绕过校验',
  226. })
  227. expect(result.ok).toBe(false)
  228. if (!result.ok) expect(result.reason).toMatch(/不得绕过沉淀服务/)
  229. expect(fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')).not.toContain('随便写')
  230. })
  231. it('retcon 更正既有条目:整条替换,同文件其他条目不动,走 retcon: 提交', () => {
  232. const root = mkRoot()
  233. initGit(root)
  234. 已有线索(root)
  235. const pkg = putPackage(root, {
  236. '账本变更.md': [
  237. '# 账本变更',
  238. '## 线索',
  239. '### 纸鹤',
  240. '状态:已弃',
  241. '计划来源:卷纲#4',
  242. '埋设点:第1章',
  243. '预期兑现区间:第8-12章',
  244. '弃因:改设定后纸鹤不再成立',
  245. '',
  246. ].join('\n'),
  247. })
  248. const result = archiveRetcon({
  249. bookRoot: root,
  250. packageDir: pkg,
  251. files: [{ 目标: '世界书/人物/甲.md', 内容: '更正\n' }],
  252. summary: '纸鹤改弃',
  253. settlement: { 章节: key, 批准: true, 裁决记录: '作者裁决:纸鹤线砍掉' },
  254. })
  255. expect(result.ok).toBe(true)
  256. const 线索 = fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')
  257. expect(线索).toContain('状态:已弃')
  258. expect(线索).toContain('改设定后纸鹤不再成立')
  259. expect(线索).not.toContain('状态:已埋\n计划来源:卷纲#4')
  260. // 同名条目只剩一条(整条替换而非追加)
  261. expect(线索.match(/^## 纸鹤$/gm)?.length).toBe(1)
  262. // 其他条目原样保留
  263. expect(线索).toContain('## 铜铃')
  264. expect(线索).toContain('不该被这次吃书动到')
  265. const log = spawnSync('git', ['log', '-1', '--pretty=%s'], { cwd: root, encoding: 'utf-8', windowsHide: true })
  266. expect(log.stdout.trim().startsWith('retcon:')).toBe(true)
  267. })
  268. it('retcon 更正的字段校验与正常沉淀同一套', () => {
  269. const cases: Array<{ candidate: string; error: RegExp }> = [
  270. { candidate: '# 账本变更\n## 线索\n### 纸鹤\n状态:已弃\n计划来源:卷纲#4\n埋设点:第1章\n预期兑现区间:第8-12章\n', error: /弃因/ },
  271. { candidate: '# 账本变更\n## 线索\n### 纸鹤\n状态:乱写\n计划来源:卷纲#4\n埋设点:第1章\n预期兑现区间:第8-12章\n', error: /状态非法/ },
  272. { candidate: '# 账本变更\n## 未知\n### 纸鹤\n状态:已弃\n', error: /分类未知/ },
  273. ]
  274. for (const entry of cases) {
  275. const root = mkRoot()
  276. initGit(root)
  277. 已有线索(root)
  278. const pkg = putPackage(root, { '账本变更.md': entry.candidate })
  279. const result = archiveRetcon({
  280. bookRoot: root,
  281. packageDir: pkg,
  282. summary: '非法更正',
  283. settlement: { 章节: key, 批准: true, 裁决记录: '测试裁决' },
  284. })
  285. expect(result.ok).toBe(false)
  286. if (!result.ok) expect(result.reason).toMatch(entry.error)
  287. expect(fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')).toContain('状态:已埋')
  288. }
  289. })
  290. it('retcon 更正不存在的条目被拒,不静默追加', () => {
  291. const root = mkRoot()
  292. initGit(root)
  293. 已有线索(root)
  294. const pkg = putPackage(root, {
  295. '账本变更.md': '# 账本变更\n## 线索\n### 从未存在\n状态:已收\n计划来源:卷纲#9\n埋设点:第1章\n预期兑现区间:第8-12章\n',
  296. })
  297. const result = archiveRetcon({
  298. bookRoot: root,
  299. packageDir: pkg,
  300. summary: '更正不存在的条目',
  301. settlement: { 章节: key, 批准: true, 裁决记录: '测试裁决' },
  302. })
  303. expect(result.ok).toBe(false)
  304. if (!result.ok) expect(result.reason).toMatch(/不存在/)
  305. expect(fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')).not.toContain('从未存在')
  306. })
  307. it('retcon 更正仍需作者批准', () => {
  308. const root = mkRoot()
  309. initGit(root)
  310. 已有线索(root)
  311. const pkg = putPackage(root, {
  312. '账本变更.md': '# 账本变更\n## 线索\n### 纸鹤\n状态:已收\n计划来源:卷纲#4\n埋设点:第1章\n预期兑现区间:第8-12章\n',
  313. })
  314. const result = archiveRetcon({ bookRoot: root, packageDir: pkg, summary: '无批准更正' })
  315. expect(result.ok).toBe(false)
  316. if (!result.ok) expect(result.reason).toMatch(/缺少作者批准/)
  317. expect(fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')).toContain('状态:已埋')
  318. })
  319. it('更正后账本仍可被账本查询解析,状态为更正后的当前态', () => {
  320. const root = mkRoot()
  321. initGit(root)
  322. 已有线索(root)
  323. const pkg = putPackage(root, {
  324. '账本变更.md': '# 账本变更\n## 线索\n### 纸鹤\n状态:已收\n计划来源:卷纲#4\n埋设点:第1章\n预期兑现区间:第8-12章\n',
  325. })
  326. expect(archiveRetcon({
  327. bookRoot: root,
  328. packageDir: pkg,
  329. summary: '纸鹤改收',
  330. settlement: { 章节: key, 批准: true, 裁决记录: '作者裁决' },
  331. }).ok).toBe(true)
  332. const ledger = queryLedger(root, { 分类: '线索' })
  333. expect(ledger.ok).toBe(true)
  334. if (!ledger.ok) return
  335. expect(ledger.entries.find((e) => e.名称 === '纸鹤')?.字段['状态']).toBe('已收')
  336. expect(ledger.entries.filter((e) => e.名称 === '纸鹤')).toHaveLength(1)
  337. })
  338. })