export-checks.mjs 5.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { execFileSync } from 'node:child_process'
  5. import { pathToFileURL } from 'node:url'
  6. import { createHash } from 'node:crypto'
  7. const sha256 = (text) => createHash('sha256').update(text).digest('hex')
  8. export async function checkMinimalExport({ root, workspace, check, report }) {
  9. const core = await import(pathToFileURL(path.join(root, 'native-write-core.mjs')).href)
  10. await check('最小导出合集清单、范围冲突与确定重跑', async () => {
  11. const book = path.join(workspace, '导出验收书')
  12. core.makeFixtureBook(book, 3, { 每卷章数: 2 })
  13. const snapshot = () => {
  14. const out = {}
  15. const walk = (dir) => {
  16. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  17. const abs = path.join(dir, entry.name)
  18. if (entry.isDirectory()) walk(abs)
  19. else out[abs] = sha256(fs.readFileSync(abs))
  20. }
  21. }
  22. walk(book)
  23. return out
  24. }
  25. const before = snapshot()
  26. // Thin entry resolves ../../../lib/index.js from the packaged skills tree.
  27. fs.copyFileSync(path.join(root, 'lib/webnovel.mjs'), path.join(root, 'lib/index.js'))
  28. const script = path.join(root, 'skills/novel-export/scripts/最小导出.mjs')
  29. const run = (args, options = {}) => execFileSync(process.execPath, [script, '--book', book, ...args], { cwd: workspace, encoding: 'utf8', windowsHide: true, timeout: 30000, maxBuffer: 5 * 1024 * 1024, ...options })
  30. const whole = JSON.parse(run([]))
  31. assert.equal(whole.ok, true)
  32. assert.equal(whole.章数, 3)
  33. assert.equal(whole.书名, '导出验收书')
  34. assert.deepEqual(whole.文件.map((f) => f.名称), ['导出验收书-定稿合集.md', '导出验收书-来源清单.md'])
  35. const compendium = whole.文件[0].content
  36. const manifest = whole.文件[1].content
  37. assert.deepEqual([...compendium.matchAll(/^### 第(\d{4})章 /gm)].map((m) => m[1]), ['0001', '0002', '0003'])
  38. assert.deepEqual([...compendium.matchAll(/^## 卷(\d+)$/gm)].map((m) => m[1]), ['01', '02'])
  39. assert.ok(!compendium.includes('状态: 已定稿'))
  40. for (const entry of whole.章列表) {
  41. const source = fs.readFileSync(path.join(book, entry.相对路径), 'utf8')
  42. const parsed = core.parseDocument(source)
  43. assert.equal(parsed.ok, true)
  44. assert.ok(compendium.includes(parsed.data.body.replace(/\n+$/, '')), entry.相对路径)
  45. assert.equal(entry.内容哈希, sha256(source))
  46. assert.ok(manifest.includes(`${entry.相对路径} | ${entry.版本} | ${entry.内容哈希}`))
  47. }
  48. assert.ok(manifest.includes(`合集 SHA-256:${whole.合集哈希}`))
  49. assert.equal(sha256(compendium), whole.合集哈希)
  50. const scoped = JSON.parse(run(['--卷', '2']))
  51. assert.deepEqual(scoped.章列表.map((c) => c.章), [3])
  52. assert.throws(() => run(['--卷', '9'], { stdio: 'pipe' }), /exit code 1|Command failed/)
  53. const rerun = JSON.parse(run([]))
  54. assert.equal(rerun.合集哈希, whole.合集哈希)
  55. assert.equal(rerun.文件[1].content, manifest)
  56. assert.equal(rerun.文件[0].content, compendium)
  57. const target = path.join(workspace, '通读导出')
  58. const withTarget = JSON.parse(run(['--目标', target]))
  59. assert.equal(withTarget.目标.ok, true)
  60. assert.equal(withTarget.目标.已存在, false)
  61. assert.ok(!fs.existsSync(target))
  62. assert.throws(() => run(['--目标', path.join(book, '定稿')], { stdio: 'pipe' }))
  63. // Author-confirmed write: compendium first, manifest last; rerun verifies hash.
  64. fs.mkdirSync(target)
  65. fs.writeFileSync(path.join(target, whole.合集文件), compendium)
  66. fs.writeFileSync(path.join(target, whole.清单文件), manifest)
  67. assert.throws(() => run(['--目标', target], { stdio: 'pipe' }))
  68. const verify = JSON.parse(run(['--目标', target, '--校验', 'true']))
  69. assert.equal(verify.ok, true)
  70. assert.equal(verify.校验.ok, true)
  71. assert.ok(verify.校验.文件.every(file => file.实际哈希 === file.预期哈希))
  72. assert.equal(verify.合集哈希, whole.合集哈希)
  73. assert.deepEqual(verify.目标.冲突, [whole.合集文件, whole.清单文件])
  74. fs.writeFileSync(path.join(target, whole.合集文件), '截断的临时副本')
  75. assert.throws(() => run(['--目标', target, '--校验', 'true'], { stdio: 'pipe' }))
  76. fs.writeFileSync(path.join(target, whole.合集文件), compendium)
  77. fs.unlinkSync(path.join(target, whole.清单文件))
  78. assert.throws(() => run(['--目标', target, '--校验', 'true'], { stdio: 'pipe' }))
  79. fs.writeFileSync(path.join(target, whole.清单文件), manifest)
  80. for (const flag of ['--卷', '--起章', '--止章', '--目标', '--校验']) {
  81. assert.throws(() => run([flag], { stdio: 'pipe' }))
  82. }
  83. assert.throws(() => run(['--止章', '--卷', '1'], { stdio: 'pipe' }))
  84. const after = snapshot()
  85. assert.deepEqual(after, before)
  86. report.minimalExport = {
  87. chapters: whole.章数, volumes: 2, totalChars: whole.合计字数, compendiumBytes: Buffer.byteLength(compendium),
  88. manifestListed: true, deterministicRerun: true, targetChecks: true, bookUnchanged: true, externalModelTokens: null,
  89. }
  90. })
  91. }