1
0

cli-main-loop.test.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { promises as fs } from 'node:fs'
  6. import { fileURLToPath } from 'node:url'
  7. import { execFile } from 'node:child_process'
  8. import { promisify } from 'node:util'
  9. const exec = promisify(execFile)
  10. const __dirname = path.dirname(fileURLToPath(import.meta.url))
  11. const BIN = path.join(__dirname, '../../bin/webnovel-writer.js')
  12. /**
  13. * M5 出口判据 D2(AC5):写章八阶段每一步走宿主可调的 CLI 通道,全程子进程 spawn bin,
  14. * 零进程内调用。建书 → next → 细纲 → 草稿 → 备料 → 机检 → 审稿输入 → 两审入库 → 定稿 → next 不重抄。
  15. * 路径含中文(工作目录名/书名),兼做中文路径链路探针。
  16. */
  17. test('主循环全程 CLI:建书→写1章→两审→定稿→next 报第2章', async () => {
  18. const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-cli-工作目录-'))
  19. const run = (args, opts = {}) =>
  20. exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8', ...opts })
  21. const runFail = async (args) => {
  22. try {
  23. await run(args)
  24. return null
  25. } catch (err) {
  26. return err
  27. }
  28. }
  29. try {
  30. // 0. 模拟已安装的工作目录(安装器落位前的最小标记;init 集成随第 3 步任务补 CI 全链路)
  31. await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
  32. // 1. 空工作目录 next --json → 序1 建书引导(经真 bin,不是进程内)
  33. const r1 = await run(['next', '--json'])
  34. const s1 = JSON.parse(r1.stdout)
  35. assert.equal(s1.序, 1)
  36. assert.equal(s1.state, 'create-book')
  37. // 2. 建书(persist-book --file)→ 登记为当前书
  38. await fs.writeFile(
  39. path.join(workdir, '建书.json'),
  40. JSON.stringify({
  41. book: { spec_version: '7.0', 书名: '青云试剑', 卷规模: 40, 体检周期: 50 },
  42. 总纲: '# 总纲\n## 结局\n林晚登顶。',
  43. 卷纲: '# 第1卷\n入门与试炼。',
  44. }),
  45. 'utf8'
  46. )
  47. const r2 = await run(['persist-book', '--file=建书.json'])
  48. assert.ok(r2.stdout.includes('已登记为当前书'), r2.stdout)
  49. const repo = path.join(workdir, '青云试剑')
  50. await fs.access(path.join(repo, 'book.yaml'))
  51. await fs.access(path.join(repo, 'AGENTS.md'))
  52. // 3. list-books 看到当前书
  53. const r3 = await run(['list-books'])
  54. assert.ok(r3.stdout.includes('青云试剑'))
  55. // 4. next --json → 序6 起草第 1 章(建书产物已随 init commit,不误触序2 手改)
  56. const r4 = await run(['next', '--json'])
  57. const s4 = JSON.parse(r4.stdout)
  58. assert.equal(s4.序, 6, `建书后应序6,实际:${r4.stdout}`)
  59. assert.equal(s4.dto.nextChapter, 1)
  60. // 5. 细纲回流(persist-outline --file)
  61. await fs.writeFile(
  62. path.join(workdir, '细纲.json'),
  63. JSON.stringify({ 细纲: '## 本章要写到的事\n林晚初入青云宗,得玉佩。\n' }),
  64. 'utf8'
  65. )
  66. await run(['persist-outline', `--file=${path.join(workdir, '细纲.json')}`])
  67. await fs.access(path.join(repo, '工作区', '细纲.md'))
  68. // 6. 备料 + 草稿 + 机检(草稿是 AI 产物,落工作区不经 CLI)
  69. const r6 = await run(['prepare-chapter', '1'])
  70. assert.ok(r6.stdout.includes('本章写作材料'), r6.stdout)
  71. await fs.writeFile(
  72. path.join(repo, '工作区', '草稿-A.md'),
  73. '---\n章号: 1\n标题: 初入青云\n---\n林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
  74. 'utf8'
  75. )
  76. const r6b = await run(['mechanical-check', '1'])
  77. const mc = JSON.parse(r6b.stdout)
  78. assert.ok('pass' in mc && Array.isArray(mc.issues))
  79. // 7. 审稿输入 → 两审(桩产物)入库
  80. const r7 = await run(['review-input', '1'])
  81. assert.ok(r7.stdout.includes('审稿输入.json'))
  82. const input = JSON.parse(await fs.readFile(path.join(repo, '工作区', '审稿输入.json'), 'utf8'))
  83. assert.equal(input.章号, 1)
  84. await fs.writeFile(
  85. path.join(workdir, '两审.json'),
  86. JSON.stringify({
  87. 事实审查: { chapter: 1, issues: [] },
  88. 编辑审: { chapter: 1, issues: [] },
  89. 章摘要: '林晚入宗,玉佩异动。',
  90. }),
  91. 'utf8'
  92. )
  93. const r7b = await run(['save-review', '1', `--file=${path.join(workdir, '两审.json')}`])
  94. assert.ok(r7b.stdout.includes('0 个阻断'), r7b.stdout)
  95. await fs.access(path.join(repo, '工作区', '审稿.md'))
  96. // 8. 定稿(finalize --payload)→ 正文入档、工作区清理
  97. await fs.writeFile(
  98. path.join(workdir, '定稿包.json'),
  99. JSON.stringify({
  100. frontMatter: { 章号: 1, 标题: '初入青云', 卷: 1, 字数: 30, 章定位: '铺垫', 钩子: '悬念钩-中', 情绪定位: '铺垫' },
  101. body: '林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
  102. summary: '林晚入宗,玉佩异动。',
  103. commitLines: {},
  104. workspaceFiles: ['工作区/草稿-A.md', '工作区/细纲.md', '工作区/审稿输入.json', '工作区/审稿.md', '工作区/本章写作材料.md'],
  105. }),
  106. 'utf8'
  107. )
  108. const r8 = await run(['finalize', '1', `--payload=${path.join(workdir, '定稿包.json')}`])
  109. assert.ok(r8.stdout.includes('已定稿'), r8.stdout)
  110. await fs.access(path.join(repo, '定稿', '正文', '0001-初入青云.md'))
  111. await fs.access(path.join(repo, '定稿', '摘要', '章摘要', '0001.md'))
  112. await assert.rejects(fs.access(path.join(repo, '工作区', '草稿-A.md')), '定稿后草稿应清理')
  113. // 9. next --json → 序6 起草第 2 章(定稿刷新缓存,不重抄第 1 章)
  114. const r9 = await run(['next', '--json'])
  115. const s9 = JSON.parse(r9.stdout)
  116. assert.equal(s9.序, 6, `定稿后应序6,实际:${r9.stdout}`)
  117. assert.equal(s9.dto.nextChapter, 2, '定稿后 next 应起草第 2 章(不重抄)')
  118. // 10. 换书人话报错路径:switch-book 不存在的书 → 退出码非零 + 候选
  119. const err = await runFail(['switch-book', '不存在的书'])
  120. assert.ok(err, '应失败退出')
  121. assert.ok(String(err.stderr).includes('青云试剑'), `候选应含现有书:${err.stderr}`)
  122. } finally {
  123. await fs.rm(workdir, { recursive: true, force: true })
  124. }
  125. })