export.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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 { createHash } from 'node:crypto'
  6. import {
  7. computeExport, inspectExportTarget, verifyExportTarget, parseDocument, paths, serializeDocument,
  8. } from '../src/index'
  9. import { removeSync } from '../src/repo/remove'
  10. import { makeFixtureBook } from './helpers/fixture-book'
  11. const roots: string[] = []
  12. afterAll(() => roots.forEach((root) => removeSync(root)))
  13. function fixture(chapters = 5, opts?: { 每卷章数?: number; 书名?: string }) {
  14. const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-export-'))
  15. roots.push(workspace)
  16. const root = path.join(workspace, opts?.书名 ?? '测试书')
  17. makeFixtureBook(root, chapters, { 每卷章数: opts?.每卷章数 ?? 2 })
  18. return { workspace, root }
  19. }
  20. function sha256(text: string): string {
  21. return createHash('sha256').update(text).digest('hex')
  22. }
  23. describe('最小 Markdown 导出(任务 13)', () => {
  24. it('默认全书合集:卷/章标题分隔、连续章号排序、正文逐字节一致且确定', () => {
  25. const { root } = fixture(5, { 每卷章数: 2 })
  26. const result = computeExport(root)
  27. expect(result.ok, result.gaps.join(';')).toBe(true)
  28. expect(result.章列表.map((c) => c.章)).toEqual([1, 2, 3, 4, 5])
  29. expect(result.范围).toBe('全书')
  30. // 卷标题按连续章号顺序切换:卷01(1-2章)、卷02(3-4章)、卷03(第5章)
  31. const volumeOrder = [...result.合集.matchAll(/^## 卷(\d+)$/gm)].map((m) => m[1])
  32. expect(volumeOrder).toEqual(['01', '02', '03'])
  33. const chapterOrder = [...result.合集.matchAll(/^### 第(\d{4})章 (.+)$/gm)].map((m) => [m[1], m[2]])
  34. expect(chapterOrder).toEqual([['0001', '章1'], ['0002', '章2'], ['0003', '章3'], ['0004', '章4'], ['0005', '章5']])
  35. // 正文与定稿逐字节一致(frontmatter 不进合集)
  36. for (const c of result.章列表) {
  37. const source = fs.readFileSync(path.join(root, c.相对路径), 'utf-8')
  38. const parsed = parseDocument(source)
  39. expect(parsed.ok).toBe(true)
  40. if (parsed.ok) expect(result.合集).toContain(parsed.data.body.replace(/\n+$/, ''))
  41. expect(result.合集).not.toContain('状态: 已定稿')
  42. expect(c.内容哈希).toBe(sha256(source))
  43. expect(c.版本).toBe(1)
  44. }
  45. // 确定性:同状态重算字节相同
  46. const again = computeExport(root)
  47. expect(again.合集).toBe(result.合集)
  48. expect(again.合集哈希).toBe(result.合集哈希)
  49. expect(again.清单).toBe(result.清单)
  50. })
  51. it('范围选择:单卷、起止章号与空选择', () => {
  52. const { root } = fixture(5, { 每卷章数: 2 })
  53. const volume = computeExport(root, { 卷: 2 })
  54. expect(volume.ok).toBe(true)
  55. expect(volume.章列表.map((c) => c.章)).toEqual([3, 4])
  56. expect(volume.范围).toBe('卷02')
  57. const span = computeExport(root, { 起章: 2, 止章: 3 })
  58. expect(span.ok).toBe(true)
  59. expect(span.章列表.map((c) => c.章)).toEqual([2, 3])
  60. expect(span.范围).toBe('第0002–0003章')
  61. const combined = computeExport(root, { 卷: 1, 起章: 2 })
  62. expect(combined.章列表.map((c) => c.章)).toEqual([2])
  63. const empty = computeExport(root, { 卷: 9 })
  64. expect(empty.ok).toBe(false)
  65. expect(empty.gaps.join(';')).toContain('空选择')
  66. expect(computeExport(root, { 卷: 0 }).ok).toBe(false)
  67. expect(computeExport(root, { 起章: 4, 止章: 2 }).ok).toBe(false)
  68. // 无定稿的书也是空选择,不是异常
  69. const blank = fixture(0)
  70. const none = computeExport(blank.root)
  71. expect(none.ok).toBe(false)
  72. expect(none.gaps.join(';')).toContain('空选择')
  73. })
  74. it('来源清单注明相对路径与版本/哈希;导出前后源仓字节不变', () => {
  75. const { root } = fixture(3)
  76. const snapshot = new Map<string, string>()
  77. const walk = (dir: string): void => {
  78. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  79. const abs = path.join(dir, entry.name)
  80. if (entry.isDirectory()) walk(abs)
  81. else snapshot.set(abs, sha256(fs.readFileSync(abs, 'utf-8')))
  82. }
  83. }
  84. walk(root)
  85. const result = computeExport(root)
  86. expect(result.ok).toBe(true)
  87. for (const c of result.章列表) {
  88. expect(result.清单).toContain(`${c.相对路径} | ${c.版本 ?? '无'} | ${c.内容哈希}`)
  89. }
  90. expect(result.清单).toContain(`合集 SHA-256:${result.合集哈希}`)
  91. expect(result.清单).toContain('最后落盘')
  92. expect(sha256(result.合集)).toBe(result.合集哈希)
  93. walk(root)
  94. for (const [file, hash] of snapshot) expect(sha256(fs.readFileSync(file, 'utf-8')), file).toBe(hash)
  95. })
  96. it('中文书名进入导出文件名;不合法书名回退', () => {
  97. const { root } = fixture(1, { 书名: '安装验收书' })
  98. const result = computeExport(root)
  99. expect(result.合集文件).toBe('安装验收书-定稿合集.md')
  100. expect(result.清单文件).toBe('安装验收书-来源清单.md')
  101. expect(result.合集).toContain('# 《安装验收书》定稿合集')
  102. const odd = fixture(1, { 书名: 'CON' })
  103. expect(computeExport(odd.root).合集文件).toBe('书稿-定稿合集.md')
  104. })
  105. it('目标目录只读检查:定稿目录、文件目标、链接与既有冲突', () => {
  106. const { workspace, root } = fixture(2)
  107. const result = computeExport(root)
  108. const names = [result.合集文件, result.清单文件]
  109. // 目标位于定稿目录内(含自身)拒绝
  110. expect(inspectExportTarget(root, path.join(root, '定稿'), names).ok).toBe(false)
  111. expect(inspectExportTarget(root, path.join(root, '定稿', '卷01'), names).ok).toBe(false)
  112. // 目标是已存在文件
  113. const fileTarget = path.join(root, paths.契约())
  114. const asFile = inspectExportTarget(root, fileTarget, names)
  115. expect(asFile.ok).toBe(false)
  116. expect(asFile.problems.join(';')).toContain('不是目录')
  117. // 不存在的目录可用;已存在目录列出冲突但不默认覆盖
  118. const fresh = inspectExportTarget(root, path.join(workspace, '导出'), names)
  119. expect(fresh.ok).toBe(true)
  120. expect(fresh.已存在).toBe(false)
  121. expect(fresh.冲突).toEqual([])
  122. const occupied = path.join(workspace, '已占')
  123. fs.mkdirSync(occupied)
  124. fs.writeFileSync(path.join(occupied, result.合集文件), '旧导出')
  125. const conflict = inspectExportTarget(root, occupied, names)
  126. expect(conflict.ok).toBe(false)
  127. expect(conflict.冲突).toEqual([result.合集文件])
  128. // 链接目标解析到真实落点:报告披露真实目录,别名指向定稿仍拒绝
  129. const link = path.join(workspace, '链接')
  130. fs.symlinkSync(occupied, link, 'junction')
  131. const aliased = inspectExportTarget(root, link, names)
  132. expect(aliased.ok).toBe(false)
  133. expect(aliased.目标目录).toBe(conflict.目标目录)
  134. expect(aliased.冲突).toEqual([result.合集文件])
  135. fs.symlinkSync(path.join(root, '定稿'), path.join(workspace, '定稿别名'), 'junction')
  136. expect(inspectExportTarget(root, path.join(workspace, '定稿别名'), names).ok).toBe(false)
  137. // 检查本身零写盘
  138. expect(fs.existsSync(path.join(workspace, '导出'))).toBe(false)
  139. })
  140. it('完整性异常 fail-closed:坏 frontmatter、非已定稿、章号撞卷、链接章', () => {
  141. const { root } = fixture(2)
  142. // 坏 frontmatter
  143. fs.writeFileSync(path.join(root, '定稿/卷01/0001-章1.md'), '---\n[坏\n---\n正文')
  144. let result = computeExport(root)
  145. expect(result.ok).toBe(false)
  146. expect(result.gaps.join(';')).toContain('frontmatter 解析失败')
  147. // 状态不是已定稿
  148. fs.writeFileSync(path.join(root, '定稿/卷01/0001-章1.md'), serializeDocument({ 状态: '待复核' }, '正文'))
  149. result = computeExport(root)
  150. expect(result.ok).toBe(false)
  151. expect(result.gaps.join(';')).toContain('状态不是已定稿')
  152. // 同一章号出现在两个卷
  153. fs.writeFileSync(path.join(root, '定稿/卷01/0001-章1.md'), serializeDocument({ 状态: '已定稿', 版本: 1 }, '正文'))
  154. fs.mkdirSync(path.join(root, '定稿/卷03'), { recursive: true })
  155. fs.writeFileSync(path.join(root, '定稿/卷03/0001-撞号.md'), serializeDocument({ 状态: '已定稿', 版本: 1 }, '另一卷同章号'))
  156. result = computeExport(root)
  157. expect(result.ok).toBe(false)
  158. expect(result.gaps.join(';')).toContain('章号全书不连续')
  159. fs.unlinkSync(path.join(root, '定稿/卷03/0001-撞号.md'))
  160. fs.rmdirSync(path.join(root, '定稿/卷03'))
  161. // 链接章文件
  162. fs.unlinkSync(path.join(root, '定稿/卷01/0001-章1.md'))
  163. if (process.platform === 'win32') {
  164. // Junctions exercise the real reparse-point guard without Windows file-symlink privileges.
  165. fs.symlinkSync(path.join(root, '定稿/卷01'), path.join(root, '定稿/卷03'), 'junction')
  166. } else {
  167. fs.symlinkSync(path.join(root, '定稿/卷01/0002-章2.md'), path.join(root, '定稿/卷01/0001-章1.md'))
  168. }
  169. result = computeExport(root)
  170. expect(result.ok).toBe(false)
  171. })
  172. it('落盘验收读取两个文件,拒绝缺清单、损坏和旧清单配新合集;重跑不覆盖', () => {
  173. const { root, workspace } = fixture(2)
  174. const result = computeExport(root)
  175. const target = path.join(workspace, '验收')
  176. fs.mkdirSync(target)
  177. const verify = () => verifyExportTarget(root, target, result)
  178. expect(verify().ok).toBe(false)
  179. fs.writeFileSync(path.join(target, result.合集文件), result.合集)
  180. expect(verify().ok).toBe(false)
  181. fs.writeFileSync(path.join(target, result.清单文件), result.清单)
  182. expect(verify().ok).toBe(true)
  183. fs.writeFileSync(path.join(target, result.合集文件), '截断')
  184. expect(verify().problems.join(';')).toContain('内容不一致')
  185. fs.writeFileSync(path.join(target, result.合集文件), result.合集)
  186. fs.writeFileSync(path.join(target, result.清单文件), computeExport(root, { 止章: 1 }).清单)
  187. expect(verify().ok).toBe(false)
  188. const previous = fs.readFileSync(path.join(target, result.清单文件))
  189. expect(inspectExportTarget(root, target, [result.合集文件, result.清单文件]).ok).toBe(false)
  190. expect(fs.readFileSync(path.join(target, result.清单文件))).toEqual(previous)
  191. expect(verifyExportTarget(root, path.join(root, '定稿'), result).ok).toBe(false)
  192. })
  193. it('导出往返比对:合集拆回各章正文与真源一致,合计字数正确', () => {
  194. const { root } = fixture(3, { 每卷章数: 3 })
  195. const result = computeExport(root)
  196. expect(result.ok).toBe(true)
  197. const bodies = new Map<number, string>()
  198. for (const c of result.章列表) {
  199. const parsed = parseDocument(fs.readFileSync(path.join(root, c.相对路径), 'utf-8'))
  200. if (parsed.ok) bodies.set(c.章, parsed.data.body.replace(/\n+$/, ''))
  201. }
  202. const sections = result.合集.split(/^### 第(\d{4})章 .+$/m).slice(1)
  203. for (let i = 0; i < sections.length; i += 2) {
  204. const chapter = Number(sections[i])
  205. const body = sections[i + 1]!.replace(/^\n+/, '').replace(/\n+$/, '')
  206. expect(body, `第${chapter}章`).toBe(bodies.get(chapter))
  207. }
  208. expect(result.合计字数).toBe([...bodies.values()].reduce((sum, b) => sum + [...b].length, 0))
  209. })
  210. })