assembly-scale.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  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 { assembleMaterials, paths, readManifest, serializeDocument, writeFileAtomic } from '../src/index'
  6. import { makeFixtureBook } from './helpers/fixture-book'
  7. const roots: string[] = []
  8. function mkDir(prefix: string): string {
  9. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  10. roots.push(dir)
  11. return dir
  12. }
  13. afterAll(() => { for (const root of roots) { try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  14. const KEY51 = { 卷: 2, 章: 51, 章名: '章51' } as const
  15. function sectionText(root: string, 章名: string, name: string): string {
  16. const dir = paths.材料包目录(KEY51.卷, 章名)
  17. const files = fs.readdirSync(path.join(root, dir))
  18. const file = files.find((f) => f.includes(name))
  19. expect(file, `材料包段文件存在:${name}`).toBeTruthy()
  20. return fs.readFileSync(path.join(root, dir, file!), 'utf-8')
  21. }
  22. describe('材料包切片与规模不变量', () => {
  23. it('200 章书:时间线段只含最近 20 条+命中项;活跃账本项一行制;清单每段有字数(A5)', () => {
  24. const root = mkDir('webnovel-scale200-')
  25. makeFixtureBook(root, 200)
  26. const r = assembleMaterials(root, KEY51)
  27. expect(r.ok).toBe(true)
  28. const tl = sectionText(root, KEY51.章名, '当前事实与连续性')
  29. const tlEntries = tl.match(/^## /gm) ?? []
  30. expect(tlEntries.length).toBeLessThanOrEqual(21) // 20 最近 + 命中的城门异响(若不在最近 20 内)
  31. const ledger = sectionText(root, KEY51.章名, '故事线承诺线索')
  32. const roster = ledger.split('### 其余活跃条目')[1] ?? ''
  33. const rosterLines = roster.split('\n').filter((l) => /^- .+〔.+〕$/.test(l.trim()))
  34. expect(rosterLines.length).toBeGreaterThan(0)
  35. expect(rosterLines.length).toBeLessThanOrEqual(100) // 1200 条账本里活跃项一行制,不随书规模倾倒全文
  36. const manifest = readManifest(root, { 卷: KEY51.卷, 章名: KEY51.章名 })
  37. expect(manifest).toBeTruthy()
  38. expect(manifest!.段).toHaveLength(10)
  39. for (const s of manifest!.段) {
  40. expect(typeof s.字数, `段「${s.段}」有字数`).toBe('number')
  41. }
  42. expect(manifest!.合计字数).toBe(manifest!.段.reduce((sum, s) => sum + s.字数, 0))
  43. })
  44. it('第 51 章衔接段含第 50 章末尾与 48-50 章摘要;第 1 章标无上一章定稿(A6)', () => {
  45. const root = mkDir('webnovel-scale-link-')
  46. makeFixtureBook(root, 200)
  47. const r = assembleMaterials(root, KEY51)
  48. expect(r.ok).toBe(true)
  49. const link = sectionText(root, KEY51.章名, '近期正文衔接')
  50. expect(link).toContain('本章收束于城门,主角正要进城')
  51. expect(link).toContain('### 第0048章 章48')
  52. expect(link).toContain('### 第0049章 章49')
  53. expect(link).toContain('### 第0050章 章50')
  54. expect(link).not.toContain('### 第0047章')
  55. const first = assembleMaterials(root, { 卷: 1, 章: 1, 章名: '章1' })
  56. expect(first.ok).toBe(true)
  57. const dir1 = paths.材料包目录(1, '章1')
  58. const linkFile = fs.readdirSync(path.join(root, dir1)).find((f) => f.includes('近期正文衔接'))!
  59. const link1 = fs.readFileSync(path.join(root, dir1, linkFile), 'utf-8')
  60. expect(link1).toContain('(无上一章定稿)')
  61. const manifest1 = readManifest(root, { 卷: 1, 章名: '章1' })
  62. expect(manifest1!.段.find((s) => s.段 === '近期正文衔接')!.完整性).toBe('空')
  63. })
  64. it('2 章书与 200 章书材料包合计字数比值 ≤ 1.5(A7)', () => {
  65. const small = mkDir('webnovel-scale2-')
  66. makeFixtureBook(small, 2)
  67. expect(assembleMaterials(small, { 卷: 1, 章: 2, 章名: '章2' }).ok).toBe(true)
  68. const smallTotal = readManifest(small, { 卷: 1, 章名: '章2' })!.合计字数
  69. const big = mkDir('webnovel-scale200b-')
  70. makeFixtureBook(big, 200)
  71. expect(assembleMaterials(big, KEY51).ok).toBe(true)
  72. const bigTotal = readManifest(big, { 卷: KEY51.卷, 章名: KEY51.章名 })!.合计字数
  73. expect(bigTotal / smallTotal).toBeLessThanOrEqual(1.5)
  74. })
  75. it('超原段上限的段不再截断:正文逐字保留、完整性非截断、清单无原始字数', () => {
  76. const root = mkDir('webnovel-scale-cap-')
  77. makeFixtureBook(root, 1)
  78. const 基调正文 = '基调要求:句子短促,画面先行,不解释情绪。'.repeat(200)
  79. const 巨型契约 = serializeDocument({ 状态: '已确认' }, [
  80. '# 契约', '', '## 叙事方式与文风基调', '', 基调正文, '',
  81. '## 创作禁区与不可妥协项', '', '- 〔硬〕主角不得死亡', '',
  82. ].join('\n'))
  83. writeFileAtomic(root, paths.契约(), 巨型契约)
  84. expect(assembleMaterials(root, { 卷: 1, 章: 1, 章名: '章1' }).ok).toBe(true)
  85. const manifest = readManifest(root, { 卷: 1, 章名: '章1' })!
  86. const 暂定 = manifest.段.find((s) => s.段 === '暂定与警告')!
  87. expect(暂定.字数).toBeGreaterThan(2500) // 超原 2500 上限,证明确实进了取消上限的路径
  88. expect(暂定.完整性).not.toBe('截断')
  89. expect(暂定).not.toHaveProperty('原始字数')
  90. const dir = paths.材料包目录(1, '章1')
  91. const files = fs.readdirSync(path.join(root, dir))
  92. const file = files.find((f) => f.includes('暂定与警告'))!
  93. const body = fs.readFileSync(path.join(root, dir, file), 'utf-8')
  94. expect(body).toContain(基调正文) // 逐字保留,未被裁到上限
  95. })
  96. })