| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138 |
- import { test } from 'node:test'
- import assert from 'node:assert/strict'
- import os from 'node:os'
- import path from 'node:path'
- import { promises as fs } from 'node:fs'
- import { fileURLToPath } from 'node:url'
- import { execFile } from 'node:child_process'
- import { promisify } from 'node:util'
- const exec = promisify(execFile)
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
- const BIN = path.join(__dirname, '../../bin/webnovel-writer.js')
- /**
- * M5 出口判据 D2(AC5):写章八阶段每一步走宿主可调的 CLI 通道,全程子进程 spawn bin,
- * 零进程内调用。建书 → next → 细纲 → 草稿 → 备料 → 机检 → 审稿输入 → 两审入库 → 定稿 → next 不重抄。
- * 路径含中文(工作目录名/书名),兼做中文路径链路探针。
- */
- test('主循环全程 CLI:建书→写1章→两审→定稿→next 报第2章', async () => {
- const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-cli-工作目录-'))
- const run = (args, opts = {}) =>
- exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8', ...opts })
- const runFail = async (args) => {
- try {
- await run(args)
- return null
- } catch (err) {
- return err
- }
- }
- try {
- // 0. 模拟已安装的工作目录(安装器落位前的最小标记;init 集成随第 3 步任务补 CI 全链路)
- await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
- // 1. 空工作目录 next --json → 序1 建书引导(经真 bin,不是进程内)
- const r1 = await run(['next', '--json'])
- const s1 = JSON.parse(r1.stdout)
- assert.equal(s1.序, 1)
- assert.equal(s1.state, 'create-book')
- // 2. 建书(persist-book --file)→ 登记为当前书
- await fs.writeFile(
- path.join(workdir, '建书.json'),
- JSON.stringify({
- book: { spec_version: '7.0', 书名: '青云试剑', 卷规模: 40, 体检周期: 50 },
- 总纲: '# 总纲\n## 结局\n林晚登顶。',
- 卷纲: '# 第1卷\n入门与试炼。',
- }),
- 'utf8'
- )
- const r2 = await run(['persist-book', '--file=建书.json'])
- assert.ok(r2.stdout.includes('已登记为当前书'), r2.stdout)
- const repo = path.join(workdir, '青云试剑')
- await fs.access(path.join(repo, 'book.yaml'))
- await fs.access(path.join(repo, 'AGENTS.md'))
- // 3. list-books 看到当前书
- const r3 = await run(['list-books'])
- assert.ok(r3.stdout.includes('青云试剑'))
- // 4. next --json → 序6 起草第 1 章(建书产物已随 init commit,不误触序2 手改)
- const r4 = await run(['next', '--json'])
- const s4 = JSON.parse(r4.stdout)
- assert.equal(s4.序, 6, `建书后应序6,实际:${r4.stdout}`)
- assert.equal(s4.dto.nextChapter, 1)
- // 5. 细纲回流(persist-outline --file)
- await fs.writeFile(
- path.join(workdir, '细纲.json'),
- JSON.stringify({ 细纲: '## 本章要写到的事\n林晚初入青云宗,得玉佩。\n' }),
- 'utf8'
- )
- await run(['persist-outline', `--file=${path.join(workdir, '细纲.json')}`])
- await fs.access(path.join(repo, '工作区', '细纲.md'))
- // 6. 备料 + 草稿 + 机检(草稿是 AI 产物,落工作区不经 CLI)
- const r6 = await run(['prepare-chapter', '1'])
- assert.ok(r6.stdout.includes('本章写作材料'), r6.stdout)
- await fs.writeFile(
- path.join(repo, '工作区', '草稿-A.md'),
- '---\n章号: 1\n标题: 初入青云\n---\n林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
- 'utf8'
- )
- const r6b = await run(['mechanical-check', '1'])
- const mc = JSON.parse(r6b.stdout)
- assert.ok('pass' in mc && Array.isArray(mc.issues))
- // 7. 审稿输入 → 两审(桩产物)入库
- const r7 = await run(['review-input', '1'])
- assert.ok(r7.stdout.includes('审稿输入.json'))
- const input = JSON.parse(await fs.readFile(path.join(repo, '工作区', '审稿输入.json'), 'utf8'))
- assert.equal(input.章号, 1)
- await fs.writeFile(
- path.join(workdir, '两审.json'),
- JSON.stringify({
- 事实审查: { chapter: 1, issues: [] },
- 编辑审: { chapter: 1, issues: [] },
- 章摘要: '林晚入宗,玉佩异动。',
- }),
- 'utf8'
- )
- const r7b = await run(['save-review', '1', `--file=${path.join(workdir, '两审.json')}`])
- assert.ok(r7b.stdout.includes('0 个阻断'), r7b.stdout)
- await fs.access(path.join(repo, '工作区', '审稿.md'))
- // 8. 定稿(finalize --payload)→ 正文入档、工作区清理
- await fs.writeFile(
- path.join(workdir, '定稿包.json'),
- JSON.stringify({
- frontMatter: { 章号: 1, 标题: '初入青云', 卷: 1, 字数: 30, 章定位: '铺垫', 钩子: '悬念钩-中', 情绪定位: '铺垫' },
- body: '林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
- summary: '林晚入宗,玉佩异动。',
- commitLines: {},
- workspaceFiles: ['工作区/草稿-A.md', '工作区/细纲.md', '工作区/审稿输入.json', '工作区/审稿.md', '工作区/本章写作材料.md'],
- }),
- 'utf8'
- )
- const r8 = await run(['finalize', '1', `--payload=${path.join(workdir, '定稿包.json')}`])
- assert.ok(r8.stdout.includes('已定稿'), r8.stdout)
- await fs.access(path.join(repo, '定稿', '正文', '0001-初入青云.md'))
- await fs.access(path.join(repo, '定稿', '摘要', '章摘要', '0001.md'))
- await assert.rejects(fs.access(path.join(repo, '工作区', '草稿-A.md')), '定稿后草稿应清理')
- // 9. next --json → 序6 起草第 2 章(定稿刷新缓存,不重抄第 1 章)
- const r9 = await run(['next', '--json'])
- const s9 = JSON.parse(r9.stdout)
- assert.equal(s9.序, 6, `定稿后应序6,实际:${r9.stdout}`)
- assert.equal(s9.dto.nextChapter, 2, '定稿后 next 应起草第 2 章(不重抄)')
- // 10. 换书人话报错路径:switch-book 不存在的书 → 退出码非零 + 候选
- const err = await runFail(['switch-book', '不存在的书'])
- assert.ok(err, '应失败退出')
- assert.ok(String(err.stderr).includes('青云试剑'), `候选应含现有书:${err.stderr}`)
- } finally {
- await fs.rm(workdir, { recursive: true, force: true })
- }
- })
|