create-book.spec.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { afterAll, 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 { spawnSync } from 'node:child_process'
  6. import { createBook, generateBookId } from '../src/book/create'
  7. import { deriveDesign, scanDesign } from '../src/derive/design'
  8. import type { Concept } from '../src/inspire/concept'
  9. const roots: string[] = []
  10. function mkRoot(): string {
  11. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-create-'))
  12. roots.push(dir)
  13. return dir
  14. }
  15. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  16. function git(cwd: string, args: readonly string[]): ReturnType<typeof spawnSync> {
  17. return spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true })
  18. }
  19. const sampleConcept: Concept = {
  20. 状态: '已确认',
  21. 核心创意: '夜航船上的时空异变与修仙长生',
  22. 题材与目标读者: '古典仙侠,志怪探索读者',
  23. 主角核心欲望: '求长生与揭开航船秘密',
  24. 主要冲突: '古修遗骸苏醒与船规反噬',
  25. 核心看点: '怪诞水域与微冲突信息差',
  26. 差异化方向: '水路密闭空间下的规则怪谈仙侠',
  27. 明确不要什么: '无脑系统打脸与退婚流',
  28. }
  29. describe('建书动作(PRD §3.2 / R20 / N2 构想确认)', () => {
  30. it('快乐路径:脚手架+构想快照+首提交,无登记文件,契约含书id→作品定调', () => {
  31. const ws = mkRoot()
  32. const r = createBook({ workspaceRoot: ws, 书名: '夜航船', concept: sampleConcept })
  33. expect(r.ok).toBe(true)
  34. if (!r.ok) return
  35. const root = r.bookRoot
  36. expect(fs.existsSync(path.join(root, '作品契约/契约.md'))).toBe(true)
  37. expect(fs.existsSync(path.join(root, '构想/构想快照.md'))).toBe(true)
  38. expect(fs.existsSync(path.join(root, '大纲/故事骨架.md'))).toBe(true)
  39. expect(fs.existsSync(path.join(root, '大纲/分卷布局.md'))).toBe(true)
  40. expect(fs.existsSync(path.join(root, '大纲/卷规划/卷01/卷纲.md'))).toBe(true)
  41. expect(fs.existsSync(path.join(root, '大纲/卷规划/卷01/计划时间线.md'))).toBe(true)
  42. expect(fs.existsSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'))).toBe(true)
  43. expect(fs.existsSync(path.join(root, '世界书/模块声明.md'))).toBe(true)
  44. expect(fs.existsSync(path.join(root, '定稿/卷01'))).toBe(true)
  45. expect(fs.existsSync(path.join(root, '账本/时间线.md'))).toBe(true)
  46. expect(fs.existsSync(path.join(root, '本书记忆/灵感.md'))).toBe(true)
  47. expect(fs.existsSync(path.join(root, '草稿区/提案'))).toBe(true)
  48. // 书id: b- 前缀且写入契约 frontmatter
  49. expect(r.bookId).toMatch(/^b-[a-z0-9]+$/)
  50. const contract = fs.readFileSync(path.join(root, '作品契约/契约.md'), 'utf-8')
  51. expect(contract).toContain(`书id: ${r.bookId}`)
  52. // 快照含版本与已确认内容
  53. const snap = fs.readFileSync(path.join(root, '构想/构想快照.md'), 'utf-8')
  54. expect(snap).toContain('版本:')
  55. expect(snap).toContain('夜航船上的时空异变与修仙长生')
  56. // git 首提交
  57. const log = git(root, ['log', '--oneline'])
  58. expect(log.status).toBe(0)
  59. const lines = (log.stdout ?? '').trim().split('\n').filter((l) => l !== '')
  60. expect(lines).toHaveLength(1)
  61. expect(lines[0]).toMatch(/vol:.*建书/)
  62. // 草稿区不入版本库(拍板 1):.gitignore 排除、无 .gitkeep、git status 干净
  63. expect(fs.readFileSync(path.join(root, '.gitignore'), 'utf-8')).toContain('草稿区/')
  64. const tracked = git(root, ['-c', 'core.quotepath=false', 'ls-files']).stdout ?? ''
  65. expect(tracked).not.toContain('草稿区')
  66. expect(tracked).toContain('定稿/卷01/.gitkeep')
  67. expect(git(root, ['status', '--porcelain']).stdout?.trim()).toBe('')
  68. expect(fs.existsSync(path.join(root, '草稿区/草稿'))).toBe(true)
  69. // R16:无登记文件
  70. expect(fs.existsSync(path.join(ws, 'books/登记.jsonl'))).toBe(false)
  71. expect(deriveDesign(scanDesign(root)).建议).toBe('作品定调')
  72. })
  73. it('N2: 拒绝未确认或残缺构想建书', () => {
  74. const ws = mkRoot()
  75. const incompleteConcept: Concept = {
  76. 状态: '候选',
  77. 核心创意: '脑洞',
  78. 题材与目标读者: '留白',
  79. 主角核心欲望: '留白',
  80. 主要冲突: '留白',
  81. 核心看点: '留白',
  82. 差异化方向: '留白',
  83. 明确不要什么: '留白',
  84. }
  85. const r = createBook({ workspaceRoot: ws, 书名: '半成品', concept: incompleteConcept })
  86. expect(r.ok).toBe(false)
  87. if (!r.ok) {
  88. expect(r.reason).toContain('构想未确认或未完整')
  89. }
  90. })
  91. it('同书id 前缀稳定且不随书名漂移(生成即固定)', () => {
  92. const a = generateBookId('书甲')
  93. const b = generateBookId('书甲')
  94. expect(a).toMatch(/^b-[a-z0-9]+$/)
  95. expect(a).toBe(b) // 同输入同结果(确定性)
  96. const c = generateBookId('书乙')
  97. expect(c).not.toBe(a) // 不同书名不同 id(大概率)
  98. })
  99. it('拒绝非法书名', () => {
  100. const ws = mkRoot()
  101. expect(createBook({ workspaceRoot: ws, 书名: 'a/b', concept: sampleConcept }).ok).toBe(false)
  102. expect(createBook({ workspaceRoot: ws, 书名: 'CON', concept: sampleConcept }).ok).toBe(false)
  103. })
  104. it('拒绝同名目录,失败无残留', () => {
  105. const ws = mkRoot()
  106. fs.mkdirSync(path.join(ws, '已有书'))
  107. const r = createBook({ workspaceRoot: ws, 书名: '已有书', concept: sampleConcept })
  108. expect(r.ok).toBe(false)
  109. expect(fs.existsSync(path.join(ws, '已有书'))).toBe(true) // 原目录保留
  110. expect(fs.existsSync(path.join(ws, 'books/登记.jsonl'))).toBe(false)
  111. })
  112. it('工作范围不存在拒绝', () => {
  113. const r = createBook({ workspaceRoot: path.join(mkRoot(), 'ghost'), 书名: '幽灵书', concept: sampleConcept })
  114. expect(r.ok).toBe(false)
  115. })
  116. })
  117. // 原「书登记(registry 自身)」describe 块随 src/book/registry.ts 删除(2026-09-01,审计丁4):
  118. // spec §12 明写「原 books/登记.jsonl 删除」,该模块零生产调用方、仅自身测试养着。
  119. // 书目录改由扫描工作范围发现,覆盖它的断言在上方「无登记文件」一条。