draft.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  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 {
  6. assembleMaterials,
  7. confirmOutline,
  8. deriveChapterFacts,
  9. scanChapter,
  10. seedMinDesign,
  11. writeCandidate,
  12. } from '@webnovel/core'
  13. import { loadDraftContext, writeDraft } from '../src/index'
  14. const roots: string[] = []
  15. function mkBook(): string {
  16. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-draft-'))
  17. roots.push(dir)
  18. seedMinDesign(dir)
  19. return dir
  20. }
  21. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  22. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  23. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'] as const
  24. function confirmReady(root: string): void {
  25. const body = [
  26. '# 章细纲', '', '## 定位段', '',
  27. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  28. '### 章节功能', '建立开场冲突', '- 〔软〕节奏平稳', '',
  29. '### 视角与焦点', '主角视角', '',
  30. '### 时空锚定', '城门口,清晨', '',
  31. '### 起止边界', '从抵达城门到发现异状', '',
  32. '### 故事线与承诺分配', '推进主线承诺', '',
  33. '### 信息边界', '只披露主角所见', '',
  34. '### 情绪与节奏目标', '紧张后留钩子', '',
  35. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  36. '## 细纲段', '', '### 单元 1', '',
  37. '- 目标: 找到异常', '- 人物: 主角', '- 时空: 城门口',
  38. '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  39. ].join('\n')
  40. writeCandidate(root, { 卷: 1, 章: 1, 章名: '开篇任务', 来源引用: refs, body })
  41. const r = confirmOutline(root, key)
  42. if (!r.ok) throw new Error(`confirm failed:${r.gaps.join(';')}`)
  43. }
  44. describe('单稿写作(插件规格 §5 草稿协议)', () => {
  45. it('R1 播报:细纲未确认/材料包未组装不拒绝写稿,播报随结果返回', () => {
  46. const root = mkBook()
  47. const r = writeDraft(root, { ...key, body: '他走进雨里。' })
  48. expect(r.ok).toBe(true)
  49. expect(r.播报).toContain('细纲未确认')
  50. expect(r.播报).toContain('材料包未组装')
  51. expect(fs.existsSync(path.join(root, '草稿区/草稿/卷01-开篇任务/稿1.md'))).toBe(true)
  52. })
  53. it('after assemble + writeDraft → derive 润色 (有草稿, no 待审稿)', () => {
  54. const root = mkBook()
  55. confirmReady(root)
  56. const assembled = assembleMaterials(root, key)
  57. expect(assembled.ok).toBe(true)
  58. const r = writeDraft(root, { ...key, body: '他走进雨里。', 选定: true })
  59. expect(r.ok).toBe(true)
  60. expect(r.relPath).toBe('草稿区/草稿/卷01-开篇任务/稿1.md')
  61. const text = fs.readFileSync(path.join(root, r.relPath), 'utf-8')
  62. expect(text).not.toMatch(/角色:\s*待审稿/)
  63. expect(text).toMatch(/选定:\s*true/)
  64. const facts = scanChapter(root, key)
  65. expect(facts.有草稿).toBe(true)
  66. expect(facts.唯一待审稿).toBe(false)
  67. expect(deriveChapterFacts(facts).建议.环节).toBe('润色')
  68. })
  69. it('context loader does not include 审核 files', () => {
  70. const root = mkBook()
  71. confirmReady(root)
  72. assembleMaterials(root, key)
  73. fs.mkdirSync(path.join(root, '草稿区/审核'), { recursive: true })
  74. fs.writeFileSync(path.join(root, '草稿区/审核/卷01-开篇任务.json'), '{"schemaVersion":1,"完成":false,"问题":[]}', 'utf-8')
  75. const ctx = loadDraftContext(root, key)
  76. expect(ctx.细纲).toMatch(/章细纲/)
  77. expect(Object.keys(ctx.材料段).some((k) => k.includes('本章任务'))).toBe(true)
  78. const dumped = JSON.stringify(ctx)
  79. expect(dumped).not.toContain('草稿区/审核')
  80. expect(dumped).not.toContain('卷01-开篇任务.json')
  81. expect(dumped).not.toMatch(/"完成"\s*:/)
  82. })
  83. it('候选事实 stays in workspace draft only', () => {
  84. const root = mkBook()
  85. confirmReady(root)
  86. assembleMaterials(root, key)
  87. const r = writeDraft(root, {
  88. ...key,
  89. body: '他走进雨里。\n\n## 草稿候选事实\n\n- 巷口多了一盏灯',
  90. })
  91. expect(r.ok).toBe(true)
  92. const draft = fs.readFileSync(path.join(root, r.relPath), 'utf-8')
  93. expect(draft).toContain('巷口多了一盏灯')
  94. expect(fs.existsSync(path.join(root, '世界书'))).toBe(true)
  95. const world = fs.readFileSync(path.join(root, '世界书/人物档案/主角.md'), 'utf-8')
  96. expect(world).not.toContain('巷口多了一盏灯')
  97. expect(fs.existsSync(path.join(root, '大纲/卷规划/卷01/章细纲/0001-开篇任务.md'))).toBe(true)
  98. const outline = fs.readFileSync(path.join(root, '大纲/卷规划/卷01/章细纲/0001-开篇任务.md'), 'utf-8')
  99. expect(outline).not.toContain('巷口多了一盏灯')
  100. })
  101. it('「草稿候选事实补充」这类同前缀标题不被当成候选事实分隔线', () => {
  102. const root = mkBook()
  103. confirmReady(root)
  104. assembleMaterials(root, key)
  105. const r = writeDraft(root, {
  106. ...key,
  107. body: '他走进雨里。\n\n## 草稿候选事实补充说明\n\n这段是正文的一部分。',
  108. })
  109. expect(r.ok).toBe(true)
  110. const draft = fs.readFileSync(path.join(root, r.relPath), 'utf-8')
  111. expect(draft).toContain('## 草稿候选事实补充说明')
  112. expect(draft).toContain('这段是正文的一部分。')
  113. expect(draft).not.toMatch(/^- 这段是正文的一部分。$/m)
  114. })
  115. })