|
@@ -0,0 +1,187 @@
|
|
|
|
|
+import { test } from 'node:test'
|
|
|
|
|
+import assert from 'node:assert/strict'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { promises as fs } from 'node:fs'
|
|
|
|
|
+import { stageChapter } from '../../src/staging/index.js'
|
|
|
|
|
+import { prepareChapterMaterials } from '../../src/prep/index.js'
|
|
|
|
|
+import { assembleReviewInput } from '../../src/review/index.js'
|
|
|
|
|
+import { mechanicalCheck } from '../../src/mechanical-check/index.js'
|
|
|
|
|
+import { repoCtx } from '../commands/_helper.js'
|
|
|
|
|
+
|
|
|
|
|
+// —— AC2 批内依赖:第 3 章预登记(新条目/新角色/时间线/信息差)→ 第 4 章备料/审稿输入/机检可见 ——
|
|
|
|
|
+
|
|
|
|
|
+const 定稿章 = (num) =>
|
|
|
|
|
+ `---\n章号: ${num}\n标题: 第${num}章\n卷: 1\n视角: 林晚\n书内时间: 春月初${num}\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n\n林晚在第${num}章遇到了新的麻烦,她收剑而立。`
|
|
|
|
|
+
|
|
|
|
|
+const 角色卡 = `---\n姓名: 林晚\n别名:\n - 晚晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n最后变更章: 1\n---\n## 设定\n外门弟子。\n\n## 典型对话\n"本姑娘才不怕!"\n\n## 关系\n无。\n`
|
|
|
|
|
+
|
|
|
|
|
+const bookFiles = () => ({
|
|
|
|
|
+ 'book.yaml':
|
|
|
|
|
+ 'spec_version: "7.0"\n书名: 叠加测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: 8\n',
|
|
|
|
|
+ '定稿/正文/0001-第1章.md': 定稿章(1),
|
|
|
|
|
+ '定稿/正文/0002-第2章.md': 定稿章(2),
|
|
|
|
|
+ '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 林晚 | 晚晚 | character | 1 |\n',
|
|
|
|
|
+ '定稿/设定/角色/林晚.md': 角色卡,
|
|
|
|
|
+ '定稿/设定/时间线/第01卷.md':
|
|
|
|
|
+ '| 章 | 书内时间 | 一句话事件 | 在场 |\n|----|----|----|----|\n| 1 | 春月初一 | 遇袭 | 林晚 |\n| 2 | 春月初二 | 追查 | 林晚 |\n',
|
|
|
|
|
+ '大纲/卷纲/第01卷.md': '# 第01卷\n追查古钟之谜。\n',
|
|
|
|
|
+ '大纲/伏笔/伏笔-001-旧案.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n最后推进章: 2\n---\n## 描述\n旧案。\n\n## 履历\n- 第1章:埋下\n',
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+async function stageChapter3(ctx) {
|
|
|
|
|
+ await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
|
|
|
|
|
+ await fs.writeFile(
|
|
|
|
|
+ path.join(ctx.repoPath, '工作区', '审稿.md'),
|
|
|
|
|
+ '# 第 3 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n',
|
|
|
|
|
+ 'utf8'
|
|
|
|
|
+ )
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ frontMatter: {
|
|
|
|
|
+ 章号: 3,
|
|
|
|
|
+ 标题: '闻钟',
|
|
|
|
|
+ 卷: 1,
|
|
|
|
|
+ 视角: '林晚',
|
|
|
|
|
+ 书内时间: '春月初三',
|
|
|
|
|
+ 字数: 120,
|
|
|
|
|
+ 章定位: '推进',
|
|
|
|
|
+ 钩子: '悬念钩-强',
|
|
|
|
|
+ 情绪定位: '铺垫',
|
|
|
|
|
+ 伏笔: ['埋下 伏笔-009', '推进 伏笔-001'],
|
|
|
|
|
+ },
|
|
|
|
|
+ body: '林晚夜里听见后山钟声,古钟长老现身拦路。她记下了钟声的方位,决定天亮再探。',
|
|
|
|
|
+ summary: '林晚闻钟遇古钟长老。',
|
|
|
|
|
+ threadCreates: [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: '伏笔-009',
|
|
|
|
|
+ 短题: '古钟',
|
|
|
|
|
+ frontMatter: { 强度: '中', 状态: '进行', 开启章: 3, 最后推进章: 3 },
|
|
|
|
|
+ body: '## 描述\n古钟的来历。\n\n## 履历\n- 第3章:埋下\n',
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ threadUpdates: [{ id: '伏笔-001', updates: { 最后推进章: 3 }, history: '第3章:推进' }],
|
|
|
|
|
+ rosterUpserts: [{ 正名: '古钟长老', 别名: '钟叟', 类型: 'character', 首现章: 3 }],
|
|
|
|
|
+ characterUpdates: [{ name: '林晚', updates: { 最后变更章: 3, 境界: '练气四层' } }],
|
|
|
|
|
+ timelineRows: [
|
|
|
|
|
+ { volumeNum: 1, row: { 章: 3, 书内时间: '春月初三', 一句话事件: '闻钟遇长老', 在场: '林晚' } },
|
|
|
|
|
+ ],
|
|
|
|
|
+ secretWrites: [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: '信息差-002-钟声',
|
|
|
|
|
+ frontMatter: { 读者已知: false, 登记章: 3, 短题: '钟声有古怪', 知情人: ['古钟长老'], 关键词: ['古钟'] },
|
|
|
|
|
+ content: '## 内容\n钟声是封印松动的征兆。\n',
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ commitLines: {},
|
|
|
|
|
+ workspaceFiles: [],
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = await stageChapter(ctx, { chapterNum: 3, payload })
|
|
|
|
|
+ assert.equal(r.ok, true, r.error)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test('AC2 备料叠加:第 4 章材料含批内近况/结尾/时间线/信息差', async () => {
|
|
|
|
|
+ const { ctx, cleanup } = await repoCtx(null, bookFiles())
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stageChapter3(ctx)
|
|
|
|
|
+ const r = await prepareChapterMaterials(ctx, { chapterNum: 4 })
|
|
|
|
|
+ assert.equal(r.ok, true, r.error)
|
|
|
|
|
+ assert.match(r.content, /批内已暂存:第 3-3 章共 1 章/)
|
|
|
|
|
+ assert.match(r.content, /### 第3章结尾(批内暂存)\n[\s\S]*天亮再探/)
|
|
|
|
|
+ assert.match(r.content, /### 第2章结尾/) // 不足两章回定稿补
|
|
|
|
|
+ assert.match(r.content, /- 3 闻钟遇长老(批内预登记)/)
|
|
|
|
|
+ assert.match(r.content, /- 信息差-002-钟声(批内预登记):知情人=古钟长老;关键词=古钟;内容:钟声是封印松动的征兆。/)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await cleanup()
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test('AC2 审稿输入叠加:第 4 章相关条目/名册/角色/时间线/信息差看到第 3 章预登记', async () => {
|
|
|
|
|
+ const { ctx, cleanup } = await repoCtx(null, bookFiles())
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stageChapter3(ctx)
|
|
|
|
|
+ const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
|
|
|
|
|
+ await fs.writeFile(
|
|
|
|
|
+ draftPath,
|
|
|
|
|
+ '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 60\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n - 推进 伏笔-009\n---\n林晚天亮后去探古钟,山道上又见古钟长老。',
|
|
|
|
|
+ 'utf8'
|
|
|
|
|
+ )
|
|
|
|
|
+ const r = await assembleReviewInput(ctx, { chapterNum: 4, draftPath })
|
|
|
|
|
+ assert.equal(r.ok, true, r.error)
|
|
|
|
|
+ const t = r.input.相关条目.find((x) => x.id === '伏笔-009')
|
|
|
|
|
+ assert.ok(t, JSON.stringify(r.input.相关条目))
|
|
|
|
|
+ assert.equal(t.批内预登记, true)
|
|
|
|
|
+ assert.equal(t.开启章, 3)
|
|
|
|
|
+ const old = r.input.相关条目.find((x) => x.id === '伏笔-001')
|
|
|
|
|
+ assert.equal(old.最后推进章, 3, '批内推进要刷进相关条目')
|
|
|
|
|
+ assert.ok(r.input.名册.some((x) => x.正名 === '古钟长老' && x.别名.includes('钟叟')))
|
|
|
|
|
+ const 林晚 = r.input.相关角色.find((x) => x.正名 === '林晚')
|
|
|
|
|
+ assert.ok(林晚, '草稿提到林晚应命中角色')
|
|
|
|
|
+ assert.equal(林晚.境界, '练气四层', '批内角色变更要叠加')
|
|
|
|
|
+ assert.ok(r.input.时间线片段.some((x) => String(x.事件).includes('批内预登记')))
|
|
|
|
|
+ assert.ok(r.input.信息差候选.some((x) => x.id === '信息差-002-钟声'))
|
|
|
|
|
+ assert.match(r.input.全书近况, /批内已暂存/)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await cleanup()
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test('重审受影响章不倒灌:第 3 章自己的审稿输入看不到第 3 章预登记', async () => {
|
|
|
|
|
+ const { ctx, cleanup } = await repoCtx(null, bookFiles())
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stageChapter3(ctx)
|
|
|
|
|
+ const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
|
|
|
|
|
+ await fs.writeFile(
|
|
|
|
|
+ draftPath,
|
|
|
|
|
+ '---\n章号: 3\n标题: 闻钟\n卷: 1\n字数: 40\n章定位: 推进\n钩子: 悬念钩-强\n情绪定位: 铺垫\n---\n林晚夜里听见后山钟声。',
|
|
|
|
|
+ 'utf8'
|
|
|
|
|
+ )
|
|
|
|
|
+ const r = await assembleReviewInput(ctx, { chapterNum: 3, draftPath })
|
|
|
|
|
+ assert.equal(r.ok, true, r.error)
|
|
|
|
|
+ assert.ok(!r.input.相关条目.some((x) => x.id === '伏笔-009'))
|
|
|
|
|
+ assert.ok(!r.input.名册.some((x) => x.正名 === '古钟长老'))
|
|
|
|
|
+ assert.doesNotMatch(r.input.全书近况, /批内已暂存/)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await cleanup()
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test('AC2 机检叠加:推进批内新条目零误报、批内新角色不报新专名、批内信息差出候选', async () => {
|
|
|
|
|
+ const { ctx, cleanup } = await repoCtx(null, bookFiles())
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stageChapter3(ctx)
|
|
|
|
|
+ const draftPath = path.join(ctx.repoPath, '工作区', '草稿-B.md')
|
|
|
|
|
+ await fs.writeFile(
|
|
|
|
|
+ draftPath,
|
|
|
|
|
+ '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 3000\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n - 推进 伏笔-009\n---\n' +
|
|
|
|
|
+ '古钟长老道:“你不该来。”林晚握紧短刀盯着古钟出神,山风吹动她的衣角。'.repeat(1) +
|
|
|
|
|
+ '她沿着山道慢慢往上走,一步一步踩着碎石,心里盘算着夜里听到的那阵钟声到底从哪里来。'.repeat(1),
|
|
|
|
|
+ 'utf8'
|
|
|
|
|
+ )
|
|
|
|
|
+ const r = await mechanicalCheck(ctx, { chapterNum: 4, draftPath })
|
|
|
|
|
+ assert.equal(r.ok, true, r.error)
|
|
|
|
|
+ assert.deepEqual(
|
|
|
|
|
+ r.issues.filter((i) => i.check === '条目变动'),
|
|
|
|
|
+ [],
|
|
|
|
|
+ JSON.stringify(r.issues)
|
|
|
|
|
+ )
|
|
|
|
|
+ assert.ok(!r.candidates.some((c) => c.type === '新专名' && c.value === '古钟长老'), JSON.stringify(r.candidates))
|
|
|
|
|
+ assert.ok(r.candidates.some((c) => c.type === '信息差候选' && c.value === '信息差-002-钟声'), JSON.stringify(r.candidates))
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await cleanup()
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test('AC7 缓存不变量:批次进行中删缓存全量重建,备料输出不变', async () => {
|
|
|
|
|
+ const { ctx, cleanup } = await repoCtx(null, bookFiles())
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stageChapter3(ctx)
|
|
|
|
|
+ const before = await prepareChapterMaterials(ctx, { chapterNum: 4 })
|
|
|
|
|
+ assert.equal(before.ok, true, before.error)
|
|
|
|
|
+ const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
|
|
|
|
|
+ assert.equal(rb.ok, true, rb.errors?.join(';'))
|
|
|
|
|
+ const after = await prepareChapterMaterials(ctx, { chapterNum: 4 })
|
|
|
|
|
+ assert.equal(after.ok, true, after.error)
|
|
|
|
|
+ assert.equal(after.content, before.content)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await cleanup()
|
|
|
|
|
+ }
|
|
|
|
|
+})
|