author-document.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { execFileSync } from 'node:child_process'
  6. import { authorDocumentPath, documentHash, readAuthorSave, saveAuthorDocument } from '../src/revise/document'
  7. import { parseDocument, serializeDocument } from '../src/repo/frontmatter'
  8. import { paths } from '../src/repo/paths'
  9. import { listChapterDrafts } from '../src/repo/drafts'
  10. import { removeSync } from '../src/repo/remove'
  11. let root: string
  12. const provenance = { sessionId: 'author-test', agentId: 'author-test', toolName: 'author-editor' }
  13. const operationId = 'author-operation-0001'
  14. const draftKey = { 卷: 1, 章: 1, 章名: '开篇' }
  15. function put(relative: string, text: string) {
  16. const target = path.join(root, relative)
  17. fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, text)
  18. return text
  19. }
  20. function git(...args: string[]) { return execFileSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true }) }
  21. function initGit() {
  22. git('init', '--quiet'); git('config', 'user.name', 'Author Test'); git('config', 'user.email', 'author@example.invalid')
  23. git('config', 'commit.gpgsign', 'false'); git('config', 'core.autocrlf', 'false')
  24. put('.gitignore', '草稿区/\n.webnovel/\n'); git('add', '.'); git('commit', '--quiet', '-m', 'design: fixture')
  25. }
  26. function draft(role = '待审稿', version = 1, file = '稿1.md') {
  27. const relative = path.posix.join(paths.草稿目录(1, '开篇'), file)
  28. const text = put(relative, serializeDocument({ 身份: draftKey, 版本: version, 父版本: version > 1 ? version - 1 : null, 角色: role, 选定: role === '待审稿', 来源快照: { 细纲: 1 } }, '原来的正文。\n\n## 草稿候选事实\n\n- 人物:林舟到港。'))
  29. return { relative, text }
  30. }
  31. beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-author-')) })
  32. afterEach(() => { removeSync(root) })
  33. describe('作者文档保存边界', () => {
  34. it('拒绝穿越、隐藏文件、盘符、备用数据流和目录别名', () => {
  35. for (const relative of ['../secret.md', '/absolute.md', 'C:/book.md', '.webnovel/x.md', 'a/node_modules/x.md', 'a/file.md:stream', 'a./x.md', 'a//x.md']) expect(() => authorDocumentPath(root, relative)).toThrow()
  36. fs.mkdirSync(path.join(root, '定稿'))
  37. fs.symlinkSync(path.join(root, '定稿'), path.join(root, '别名'), process.platform === 'win32' ? 'junction' : 'dir')
  38. expect(() => authorDocumentPath(root, '别名/正文.md')).toThrow(/链接或别名/)
  39. fs.unlinkSync(path.join(root, '别名'))
  40. })
  41. it('保护定稿、冻结构想、机器结构工件', () => {
  42. for (const relative of ['定稿/正文.md', '构想/初稿.md', '草稿区/审核/记录.md', '草稿区/材料包/说明.md', '世界书/机器.json']) {
  43. const text = put(relative, '原稿\n')
  44. expect(() => saveAuthorDocument(root, { path: relative, expectedHash: documentHash(text), body: '改动', provenance })).toThrow(/只读|补偿|冻结/)
  45. expect(fs.readFileSync(path.join(root, relative), 'utf8')).toBe(text)
  46. }
  47. })
  48. it('在写入前比较原始哈希,冲突不落盘', () => {
  49. put('大纲/骨架.md', '他人的最新修改\n')
  50. expect(() => saveAuthorDocument(root, { path: '大纲/骨架.md', expectedHash: documentHash('旧稿'), body: '我的修改', provenance })).toThrow(/已被修改/)
  51. expect(fs.readFileSync(path.join(root, '大纲/骨架.md'), 'utf8')).toBe('他人的最新修改\n')
  52. })
  53. it('待审稿新版本与降级在一次事务里,元数据和候选事实保留', () => {
  54. const { relative, text } = draft()
  55. const before = parseDocument(text)
  56. if (!before.ok) throw new Error(before.detail)
  57. const result = saveAuthorDocument(root, { path: relative, expectedHash: documentHash(text), body: before.data.body.replace('原来的正文', '作者的新正文'), provenance, operationId })
  58. const drafts = listChapterDrafts(root, draftKey)
  59. expect(result.path).toMatch(/稿2\.md$/)
  60. expect(result.version).toBe(2)
  61. expect(drafts.filter(item => item.角色 === '待审稿')).toHaveLength(1)
  62. expect(drafts.find(item => item.file === '稿1.md')?.body).toBe(before.data.body)
  63. expect(drafts.find(item => item.file === '稿2.md')?.body).toContain('人物:林舟到港')
  64. const saved = parseDocument(fs.readFileSync(path.join(root, result.path), 'utf8'))
  65. expect(saved.ok && saved.data.fields).toMatchObject({ 身份: draftKey, 父版本: 1, 生成模块: '作者手改', 来源快照: { 细纲: 1 } })
  66. })
  67. it('进程重试同一编号不重复生成新稿,保留原差异供通知使用', () => {
  68. const { relative, text } = draft()
  69. const input = { path: relative, expectedHash: documentHash(text), body: '作者修改。', provenance, operationId }
  70. const saved = saveAuthorDocument(root, input)
  71. const replay = saveAuthorDocument(root, input)
  72. expect(replay).toEqual(saved)
  73. expect(listChapterDrafts(root, draftKey)).toHaveLength(2)
  74. expect(readAuthorSave(root, operationId, provenance.sessionId).previousBody).toContain('原来的正文')
  75. expect(() => readAuthorSave(root, operationId, 'other-session')).toThrow(/当前会话/)
  76. expect(() => saveAuthorDocument(root, { ...input, body: '冒用编号' })).toThrow(/另一份修改/)
  77. })
  78. it('保存后又被外部改动,重放拒绝覆写新内容', () => {
  79. const { relative, text } = draft()
  80. const input = { path: relative, expectedHash: documentHash(text), body: '第一份修改', provenance, operationId }
  81. const result = saveAuthorDocument(root, input)
  82. put(result.path, '后续新内容\n')
  83. expect(() => saveAuthorDocument(root, input)).toThrow(/后来又有变动/)
  84. expect(fs.readFileSync(path.join(root, result.path), 'utf8')).toBe('后续新内容\n')
  85. })
  86. it('旧草稿即使原文件未变也不覆盖更新版本', () => {
  87. const { relative, text } = draft('草稿')
  88. draft('待审稿', 2, '稿2.md')
  89. expect(() => saveAuthorDocument(root, { path: relative, expectedHash: documentHash(text), body: '旧稿修改', provenance })).toThrow(/更新版本/)
  90. expect(listChapterDrafts(root, draftKey)).toHaveLength(2)
  91. })
  92. it('多份待审稿如实拒绝,不按文件名猜来源', () => {
  93. const { relative, text } = draft()
  94. draft('待审稿', 2, '稿2.md')
  95. expect(() => saveAuthorDocument(root, { path: relative, expectedHash: documentHash(text), body: '修改', provenance })).toThrow(/待审稿不唯一/)
  96. })
  97. it('暂存记录不能写入时,原稿和角色均保持原样', () => {
  98. const { relative, text } = draft()
  99. put('草稿区/.author-edits', '阻挡暂存目录')
  100. expect(() => saveAuthorDocument(root, { path: relative, expectedHash: documentHash(text), body: '不应落位', provenance })).toThrow()
  101. expect(fs.readFileSync(path.join(root, relative), 'utf8')).toBe(text)
  102. expect(listChapterDrafts(root, draftKey)).toHaveLength(1)
  103. })
  104. it('真源修改生成 fix 提交,重放不产生新版本或空提交', () => {
  105. const relative = '大纲/骨架.md'
  106. const text = put(relative, serializeDocument({ 版本: 2, 书id: 'test-book' }, '原规划'))
  107. initGit()
  108. const input = { path: relative, expectedHash: documentHash(text), body: '作者规划', provenance, operationId }
  109. const saved = saveAuthorDocument(root, input)
  110. expect(saved.commit?.ok).toBe(true)
  111. expect(git('log', '-1', '--format=%s')).toMatch(/^fix:/)
  112. const head = git('rev-parse', 'HEAD')
  113. expect(saveAuthorDocument(root, input).version).toBe(3)
  114. expect(git('rev-parse', 'HEAD')).toBe(head)
  115. })
  116. it('提交失败仍有已保存结果,可补提交且不重写正文', () => {
  117. const relative = '大纲/骨架.md'
  118. const text = put(relative, serializeDocument({ 版本: 1 }, '原规划'))
  119. const input = { path: relative, expectedHash: documentHash(text), body: '作者规划', provenance, operationId }
  120. const saved = saveAuthorDocument(root, input)
  121. expect(saved.changed).toBe(true)
  122. expect(saved.commit?.ok).toBe(false)
  123. initGit()
  124. expect(saveAuthorDocument(root, input).commit?.ok).toBe(true)
  125. expect(saveAuthorDocument(root, input).version).toBe(2)
  126. })
  127. it('共享资料只保存正文,不产生书仓版本或提交,可清空便签', () => {
  128. const text = put('知识库/便签.txt', '原便签\n')
  129. const saved = saveAuthorDocument(root, { path: '知识库/便签.txt', expectedHash: documentHash(text), body: '', shared: true, provenance, operationId })
  130. expect(saved.commit).toBeUndefined()
  131. expect(saved.version).toBeNull()
  132. expect(fs.readFileSync(path.join(root, saved.path), 'utf8')).toBe('\n')
  133. })
  134. })