chain-wiring.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  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 { createNovelTools } from '../src/novel-tools'
  7. import { nativeWriteStub } from './fixtures/native-write-stub'
  8. const roots: string[] = []
  9. function mkWs(): string {
  10. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-chain-'))
  11. roots.push(dir)
  12. return dir
  13. }
  14. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放 */ } } })
  15. function makeTools(ws: string) {
  16. const tools = createNovelTools({
  17. nativeWrite: nativeWriteStub,
  18. workspaceRoot: () => ws,
  19. bookRootOfBookId: (id) => path.join(ws, '链路书'),
  20. })
  21. const call = async (name: string, args: Record<string, unknown>) => {
  22. const tool = tools.find((t) => t.name === name)!
  23. expect(tool, `工具 ${name} 应已注册`).toBeDefined()
  24. return (await tool.execute(args, { agent: { id: 'agent-1', session: { append: () => {} } } })) as Record<string, unknown> & { ok: boolean }
  25. }
  26. return { call }
  27. }
  28. const CONCEPT = {
  29. 状态: '已确认',
  30. 核心创意: 'x', 题材与目标读者: 'x', 主角核心欲望: 'x', 主要冲突: 'x',
  31. 核心看点: 'x', 差异化方向: 'x', 明确不要什么: 'x',
  32. }
  33. function candidateBody(): string {
  34. return [
  35. '# 章细纲', '',
  36. '## 定位段', '',
  37. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  38. '### 章节功能', '', '- 〔硬〕开场必须点名主角现身', '',
  39. '### 视角与焦点', '主角视角', '',
  40. '### 时空锚定', '城门口,清晨', '',
  41. '### 起止边界', '从抵达城门到发现异状', '',
  42. '### 故事线与承诺分配', '推进主线承诺', '',
  43. '### 信息边界', '只披露主角所见', '',
  44. '### 情绪与节奏目标', '紧张后留钩子', '',
  45. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  46. '## 细纲段', '',
  47. '### 单元 1', '',
  48. '- 目标: 开场', '- 人物: 主角', '- 时空: 城门口', '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  49. ].join('\n')
  50. }
  51. function git(root: string, args: string[]) {
  52. return spawnSync('git', ['-c', 'core.quotepath=false', ...args], { cwd: root, encoding: 'utf-8' })
  53. }
  54. async function makeBook(ws: string, call: (name: string, args: Record<string, unknown>) => Promise<Record<string, unknown> & { ok: boolean }>) {
  55. const created = await call('novel_create_book', { bookName: '链路书', concept: CONCEPT })
  56. expect(created.ok).toBe(true)
  57. const bookId = (created as { bookId: string }).bookId
  58. await call('novel_select_book', { bookId })
  59. const seeded = await call('novel_seed_min_design', { bookId })
  60. expect(seeded.ok).toBe(true)
  61. return { ...created, bookId }
  62. }
  63. describe('运行链路接线(工具面验收)', () => {
  64. it('确认细纲:design(chNNNN): 提交;批次文件同提交;幂等重放不重写不重复提交', async () => {
  65. const ws = mkWs()
  66. const { call } = makeTools(ws)
  67. const { bookId } = await makeBook(ws, call)
  68. const bookRoot = path.join(ws, '链路书')
  69. const count = () => git(bookRoot, ['rev-list', '--count', 'HEAD']).stdout?.trim()
  70. // 候选细纲(草稿区,不过工具提交)
  71. const draft = await call('novel_new_outline_draft', { bookId, 卷: 1, 章: 1, 章名: '开篇任务', 正文: candidateBody(), 来源引用: ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'] })
  72. expect(draft.ok).toBe(true)
  73. const candidateCount = count()
  74. // 同批整改文件:先改动分卷布局(上游提案的下游整改),随确认整批一次提交
  75. const layout = path.join(bookRoot, '大纲/分卷布局.md')
  76. fs.writeFileSync(layout, `${fs.readFileSync(layout, 'utf-8')}- 卷02 〔留白〕\n`, 'utf-8')
  77. const confirmed = await call('novel_confirm_outline', {
  78. bookId, 卷: 1, 章: 1, 章名: '开篇任务', 批次文件: ['大纲/分卷布局.md'],
  79. })
  80. expect(confirmed.ok).toBe(true)
  81. const subject = git(bookRoot, ['log', '-1', '--pretty=%s', '--', '大纲/卷规划/卷01/章细纲/0001-开篇任务.md']).stdout?.trim()
  82. expect(subject).toBe('design(ch0001): 确认细纲·开篇任务')
  83. const names = git(bookRoot, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:', 'HEAD']).stdout?.split('\n').map((l) => l.trim())
  84. expect(names).toContain('大纲/分卷布局.md')
  85. expect(Number(count())).toBe(Number(candidateCount) + 1)
  86. // 幂等重放:候选已删、真源已落位 → 补提交通道跑一次,无改动无新提交
  87. const replay = await call('novel_confirm_outline', { bookId, 卷: 1, 章: 1, 章名: '开篇任务' })
  88. expect(replay.ok).toBe(true)
  89. expect(String(replay.message)).toMatch(/幂等重放|无改动/)
  90. expect(count()).toBe(String(Number(candidateCount) + 1))
  91. expect(fs.existsSync(path.join(bookRoot, '草稿区/章细纲/卷01-开篇任务.md'))).toBe(false)
  92. })
  93. it('窗口滚动与设计侧更新产生 design: 提交;新建书状态含 design 段', async () => {
  94. const ws = mkWs()
  95. const { call } = makeTools(ws)
  96. const { bookId } = await makeBook(ws, call)
  97. const bookRoot = path.join(ws, '链路书')
  98. const status = await call('novel_get_story_status', { bookId })
  99. expect(status.ok).toBe(true)
  100. // R1:get_story_status 返回事实清单形状——design 段含逐项事实与建议
  101. const design = status.design as { 事实项?: ReadonlyArray<{ 名称: string; 事实: boolean }>; 建议?: string }
  102. expect(Array.isArray(design.事实项)).toBe(true)
  103. expect(design.事实项!.length).toBeGreaterThan(0)
  104. expect(typeof design.建议).toBe('string')
  105. const roll = await call('novel_roll_window', { bookId, 卷: 1, action: '标已消费', 名称: '开篇任务' })
  106. expect(roll.ok).toBe(true)
  107. const log = git(bookRoot, ['log', '-1', '--pretty=%s', '--', '大纲/卷规划/卷01/近期窗口.md']).stdout?.trim()
  108. expect(log).toMatch(/^design: 近期窗口·标已消费/)
  109. const append = await call('novel_roll_window', { bookId, 卷: 1, action: '追加', 名称: '第二章风波' })
  110. expect(append.ok).toBe(true)
  111. const skeleton = await call('novel_update_skeleton', { bookId, 正文: '# 故事骨架\n\n- 第一幕 〔已确认〕\n' })
  112. expect(skeleton.ok).toBe(true)
  113. expect(git(bookRoot, ['log', '-1', '--pretty=%s', '--', '大纲/故事骨架.md']).stdout?.trim()).toMatch(/^design: 故事骨架/)
  114. const worldbook = await call('novel_confirm_worldbook_entry', {
  115. bookId, 模块: '人物档案', 名称: '配角甲', 类型: '人物', 性质: '计划', 状态: '已确认', 来源: '对谈共创',
  116. })
  117. expect(worldbook.ok).toBe(true)
  118. expect(git(bookRoot, ['log', '-1', '--pretty=%s', '--', '世界书/人物档案/配角甲.md']).stdout?.trim()).toMatch(/^design: 世界书条目·配角甲/)
  119. })
  120. it('novel_record_review_findings 回写与未注册模块拒绝', async () => {
  121. const ws = mkWs()
  122. const { call } = makeTools(ws)
  123. const { bookId } = await makeBook(ws, call)
  124. const bad = await call('novel_record_review_findings', { bookId, 卷: 1, 章: 1, 章名: '开篇任务', 模块名: '不存在的模块', 发现项: [] })
  125. expect(bad.ok).toBe(false)
  126. // Review evidence must have a current manuscript to bind to.
  127. const draftDir = path.join(ws, '链路书', '草稿区/草稿/卷01-开篇任务')
  128. fs.mkdirSync(draftDir, { recursive: true })
  129. fs.writeFileSync(path.join(draftDir, '稿1.md'), '---\n角色: 待审稿\n---\n\n主角走进城门。\n', 'utf-8')
  130. const r = await call('novel_record_review_findings', {
  131. bookId, 卷: 1, 章: 1, 章名: '开篇任务', 模块名: '章节结构审读',
  132. 发现项: [{ 严重程度: '中', 证据位置: '草稿区/草稿/卷01-开篇任务/稿1.md:1', 问题说明: '开场未点名主角', 所依据材料及版本: '稿1@1', 影响范围: '本章', 修改建议: '补点名', 建议返回节点: '改稿', 建议复审模块: '', 不确定性说明: '', 材料完整性: '完整' }],
  133. })
  134. expect(r.ok).toBe(true)
  135. // 仅回写一个模块:其余模块未回写,审核不判完成
  136. expect(r.完成).toBe(false)
  137. })
  138. it('novel_record_memory 落书房,不进书仓 git;索引与条目一致', async () => {
  139. const ws = mkWs()
  140. const { call } = makeTools(ws)
  141. const { bookId } = await makeBook(ws, call)
  142. const bookRoot = path.join(ws, '链路书')
  143. const r = await call('novel_record_memory', {
  144. bookId, 名称: '反派动机-折剑人', 描述: '作者拍板:折剑人毁剑是为阻止剑灾', 类: '决策',
  145. 标签: ['链路书', '卷01', '人物动机'], 来源: '对谈', 正文: '[[主角]] 与折剑人的冲突根源。',
  146. })
  147. expect(r.ok).toBe(true)
  148. const entry = path.join(ws, '书房/作者记忆/反派动机-折剑人.md')
  149. expect(fs.existsSync(entry)).toBe(true)
  150. const index = fs.readFileSync(path.join(ws, '书房/作者记忆/索引.md'), 'utf-8')
  151. expect(index).toContain('反派动机-折剑人')
  152. expect(index).toContain('作者拍板:折剑人毁剑是为阻止剑灾')
  153. // 作者层资产不在书仓工作树状态中
  154. const status = git(bookRoot, ['status', '--porcelain']).stdout ?? ''
  155. expect(status).not.toContain('书房')
  156. expect(git(bookRoot, ['ls-files']).stdout ?? '').not.toContain('作者记忆')
  157. // 进度卡:未决偏离 = 对账偏离 − 作者层已处置(拍板 4)
  158. const progress1 = await call('novel_get_book_progress', { bookId })
  159. expect(progress1.ok).toBe(true)
  160. const before = (progress1.未决偏离 as string[]).length
  161. expect(before).toBeGreaterThan(0)
  162. const devName = (progress1.未决偏离 as string[])[0]!
  163. await call('novel_record_memory', {
  164. bookId, 名称: `偏离处置-${devName}`, 描述: `作者接受偏离:${devName}`, 类: '决策',
  165. 标签: ['链路书', '卷01'], 来源: '对谈', 正文: `偏离项「${devName}」处置:接受偏离,不改后续计划。`,
  166. })
  167. const progress2 = await call('novel_get_book_progress', { bookId }) as { ok: boolean; 未决偏离: string[] }
  168. expect(progress2.ok).toBe(true)
  169. expect(progress2.未决偏离).not.toContain(devName)
  170. expect(progress2.未决偏离.length).toBe(before - 1)
  171. const rendered = String(progress1.渲染)
  172. expect(rendered).toContain('【书级进度卡】')
  173. expect(rendered).toContain('未绑定章')
  174. })
  175. it('任务21:卷摘要确认(design:/版本协议)+进度卡待核对/规划卷+设计检查规划卷', async () => {
  176. const ws = mkWs()
  177. const { call } = makeTools(ws)
  178. const { bookId } = await makeBook(ws, call)
  179. const bookRoot = path.join(ws, '链路书')
  180. // 件一:卷摘要 首次 v1→design: 提交;原样重跑复用;变更 v2 父版本 1
  181. const body1 = '# 卷摘要\n\n## 章摘要汇编\n\n### 第0001章 开篇任务\n\n摘要:开场。\n'
  182. const c1 = await call('novel_confirm_volume_outline', { bookId, 卷: 1, 目标: '卷摘要', 正文: body1, summary: '卷摘要·卷01' })
  183. expect(c1.ok).toBe(true)
  184. const sumRel = '大纲/卷规划/卷01/卷摘要.md'
  185. const sum1 = fs.readFileSync(path.join(bookRoot, sumRel), 'utf-8')
  186. expect(sum1).toMatch(/版本:\s*1/)
  187. expect(sum1).toMatch(/状态:\s*已确认/)
  188. expect(sum1).toMatch(/生成模块:\s*卷摘要确认/)
  189. expect(git(bookRoot, ['log', '-1', '--pretty=%s', '--', sumRel]).stdout?.trim()).toMatch(/^design: 卷摘要·卷01/)
  190. const c2 = await call('novel_confirm_volume_outline', { bookId, 卷: 1, 目标: '卷摘要', 正文: body1 })
  191. expect(c2.ok).toBe(true)
  192. const c3 = await call('novel_confirm_volume_outline', { bookId, 卷: 1, 目标: '卷摘要', 正文: `${body1}\n补一行修订。\n` })
  193. expect(c3.ok).toBe(true)
  194. const sum3 = fs.readFileSync(path.join(bookRoot, sumRel), 'utf-8')
  195. expect(sum3).toMatch(/版本:\s*2/)
  196. expect(sum3).toMatch(/父版本:\s*1/)
  197. // B3 消费映射:卷1证完成(3章定稿)→ planning(1,2);B1:章级计划无事实→待核对
  198. fs.writeFileSync(path.join(bookRoot, '大纲/卷规划/卷01/卷纲.md'), ['# 卷纲', '', '卷01|1 案/3 章,单章约 100 字', '', '## 叙事结构 〔已确认〕', '', 'x', '', '## 弧线 〔留白〕', '', '## 线索推进 〔留白〕', '', '## 卷末兑现 〔留白〕', ''].join('\n'), 'utf-8')
  199. for (let n = 1; n <= 3; n += 1) {
  200. const fin = path.join(bookRoot, `定稿/卷01/${String(n).padStart(4, '0')}-第${n}章.md`)
  201. fs.mkdirSync(path.dirname(fin), { recursive: true })
  202. fs.writeFileSync(fin, `---\n状态: 已定稿\n---\n第${n}章正文\n`, 'utf-8')
  203. }
  204. fs.writeFileSync(path.join(bookRoot, '大纲/卷规划/卷01/计划时间线.md'), '# 计划时间线\n\n## 窗口覆盖\n- 第三章 渡口\n', 'utf-8')
  205. const progress = await call('novel_get_book_progress', { bookId }) as { ok: boolean; 卷: number; 规划卷?: number; 待核对?: { 名称: string; 章号: number; 本章事实: string[] }[]; 渲染: string }
  206. expect(progress.ok).toBe(true)
  207. expect(progress.卷).toBe(1) // 默认对账保留已完成卷
  208. expect(progress.规划卷).toBe(2) // 呈报规划目标卷
  209. expect(progress.待核对).toEqual([{ 名称: '第三章 渡口', 章号: 3, 本章事实: [] }])
  210. expect(progress.渲染).toContain('章级待核对:1 条')
  211. expect(progress.渲染).not.toContain('无偏离')
  212. // scanDesign 消费规划卷(2):卷2 未分配 → 建议停在分卷布局;若错用卷1 会报当前卷规划
  213. const status = await call('novel_get_story_status', { bookId })
  214. expect(status.ok).toBe(true)
  215. const design = status.design as { 建议?: string }
  216. expect(design.建议).toBe('分卷布局')
  217. })
  218. })