overlay.test.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import path from 'node:path'
  4. import { promises as fs } from 'node:fs'
  5. import { stageChapter } from '../../src/staging/index.js'
  6. import { prepareChapterMaterials } from '../../src/prep/index.js'
  7. import { assembleReviewInput } from '../../src/review/index.js'
  8. import { mechanicalCheck } from '../../src/mechanical-check/index.js'
  9. import { repoCtx } from '../commands/_helper.js'
  10. // —— AC2 批内依赖:第 3 章预登记(新条目/新角色/时间线/信息差)→ 第 4 章备料/审稿输入/机检可见 ——
  11. const 定稿章 = (num) =>
  12. `---\n章号: ${num}\n标题: 第${num}章\n卷: 1\n视角: 林晚\n书内时间: 春月初${num}\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n\n林晚在第${num}章遇到了新的麻烦,她收剑而立。`
  13. const 角色卡 = `---\n姓名: 林晚\n别名:\n - 晚晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n最后变更章: 1\n---\n## 设定\n外门弟子。\n\n## 典型对话\n"本姑娘才不怕!"\n\n## 关系\n无。\n`
  14. const bookFiles = () => ({
  15. 'book.yaml':
  16. 'spec_version: "7.0"\n书名: 叠加测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: 8\n',
  17. '定稿/正文/0001-第1章.md': 定稿章(1),
  18. '定稿/正文/0002-第2章.md': 定稿章(2),
  19. '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 林晚 | 晚晚 | character | 1 |\n',
  20. '定稿/设定/角色/林晚.md': 角色卡,
  21. '定稿/设定/时间线/第01卷.md':
  22. '| 章 | 书内时间 | 一句话事件 | 在场 |\n|----|----|----|----|\n| 1 | 春月初一 | 遇袭 | 林晚 |\n| 2 | 春月初二 | 追查 | 林晚 |\n',
  23. '大纲/卷纲/第01卷.md': '# 第01卷\n追查古钟之谜。\n',
  24. '大纲/伏笔/伏笔-001-旧案.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n最后推进章: 2\n---\n## 描述\n旧案。\n\n## 履历\n- 第1章:埋下\n',
  25. })
  26. async function stageChapter3(ctx) {
  27. await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
  28. await fs.writeFile(
  29. path.join(ctx.repoPath, '工作区', '审稿.md'),
  30. '# 第 3 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n',
  31. 'utf8'
  32. )
  33. const payload = {
  34. frontMatter: {
  35. 章号: 3,
  36. 标题: '闻钟',
  37. 卷: 1,
  38. 视角: '林晚',
  39. 书内时间: '春月初三',
  40. 字数: 120,
  41. 章定位: '推进',
  42. 钩子: '悬念钩-强',
  43. 情绪定位: '铺垫',
  44. 伏笔: ['埋下 伏笔-009', '推进 伏笔-001'],
  45. },
  46. body: '林晚夜里听见后山钟声,古钟长老现身拦路。她记下了钟声的方位,决定天亮再探。',
  47. summary: '林晚闻钟遇古钟长老。',
  48. threadCreates: [
  49. {
  50. id: '伏笔-009',
  51. 短题: '古钟',
  52. frontMatter: { 强度: '中', 状态: '进行', 开启章: 3, 最后推进章: 3 },
  53. body: '## 描述\n古钟的来历。\n\n## 履历\n- 第3章:埋下\n',
  54. },
  55. ],
  56. threadUpdates: [{ id: '伏笔-001', updates: { 最后推进章: 3 }, history: '第3章:推进' }],
  57. rosterUpserts: [{ 正名: '古钟长老', 别名: '钟叟', 类型: 'character', 首现章: 3 }],
  58. characterUpdates: [{ name: '林晚', updates: { 最后变更章: 3, 境界: '练气四层' } }],
  59. timelineRows: [
  60. { volumeNum: 1, row: { 章: 3, 书内时间: '春月初三', 一句话事件: '闻钟遇长老', 在场: '林晚' } },
  61. ],
  62. secretWrites: [
  63. {
  64. id: '信息差-002-钟声',
  65. frontMatter: { 读者已知: false, 登记章: 3, 短题: '钟声有古怪', 知情人: ['古钟长老'], 关键词: ['古钟'] },
  66. content: '## 内容\n钟声是封印松动的征兆。\n',
  67. },
  68. ],
  69. commitLines: {},
  70. workspaceFiles: [],
  71. }
  72. const r = await stageChapter(ctx, { chapterNum: 3, payload })
  73. assert.equal(r.ok, true, r.error)
  74. }
  75. test('AC2 备料叠加:第 4 章材料含批内近况/结尾/时间线/信息差', async () => {
  76. const { ctx, cleanup } = await repoCtx(null, bookFiles())
  77. try {
  78. await stageChapter3(ctx)
  79. const r = await prepareChapterMaterials(ctx, { chapterNum: 4 })
  80. assert.equal(r.ok, true, r.error)
  81. assert.match(r.content, /批内已暂存:第 3-3 章共 1 章/)
  82. assert.match(r.content, /### 第3章结尾(批内暂存)\n[\s\S]*天亮再探/)
  83. assert.match(r.content, /### 第2章结尾/) // 不足两章回定稿补
  84. assert.match(r.content, /- 3 闻钟遇长老(批内预登记)/)
  85. assert.match(r.content, /- 信息差-002-钟声(批内预登记):知情人=古钟长老;关键词=古钟;内容:钟声是封印松动的征兆。/)
  86. } finally {
  87. await cleanup()
  88. }
  89. })
  90. test('AC2 审稿输入叠加:第 4 章相关条目/名册/角色/时间线/信息差看到第 3 章预登记', async () => {
  91. const { ctx, cleanup } = await repoCtx(null, bookFiles())
  92. try {
  93. await stageChapter3(ctx)
  94. const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
  95. await fs.writeFile(
  96. draftPath,
  97. '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 60\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n - 推进 伏笔-009\n---\n林晚天亮后去探古钟,山道上又见古钟长老。',
  98. 'utf8'
  99. )
  100. const r = await assembleReviewInput(ctx, { chapterNum: 4, draftPath })
  101. assert.equal(r.ok, true, r.error)
  102. const t = r.input.相关条目.find((x) => x.id === '伏笔-009')
  103. assert.ok(t, JSON.stringify(r.input.相关条目))
  104. assert.equal(t.批内预登记, true)
  105. assert.equal(t.开启章, 3)
  106. const old = r.input.相关条目.find((x) => x.id === '伏笔-001')
  107. assert.equal(old.最后推进章, 3, '批内推进要刷进相关条目')
  108. assert.ok(r.input.名册.some((x) => x.正名 === '古钟长老' && x.别名.includes('钟叟')))
  109. const 林晚 = r.input.相关角色.find((x) => x.正名 === '林晚')
  110. assert.ok(林晚, '草稿提到林晚应命中角色')
  111. assert.equal(林晚.境界, '练气四层', '批内角色变更要叠加')
  112. assert.ok(r.input.时间线片段.some((x) => String(x.事件).includes('批内预登记')))
  113. assert.ok(r.input.信息差候选.some((x) => x.id === '信息差-002-钟声'))
  114. assert.match(r.input.全书近况, /批内已暂存/)
  115. } finally {
  116. await cleanup()
  117. }
  118. })
  119. test('重审受影响章不倒灌:第 3 章自己的审稿输入看不到第 3 章预登记', async () => {
  120. const { ctx, cleanup } = await repoCtx(null, bookFiles())
  121. try {
  122. await stageChapter3(ctx)
  123. const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
  124. await fs.writeFile(
  125. draftPath,
  126. '---\n章号: 3\n标题: 闻钟\n卷: 1\n字数: 40\n章定位: 推进\n钩子: 悬念钩-强\n情绪定位: 铺垫\n---\n林晚夜里听见后山钟声。',
  127. 'utf8'
  128. )
  129. const r = await assembleReviewInput(ctx, { chapterNum: 3, draftPath })
  130. assert.equal(r.ok, true, r.error)
  131. assert.ok(!r.input.相关条目.some((x) => x.id === '伏笔-009'))
  132. assert.ok(!r.input.名册.some((x) => x.正名 === '古钟长老'))
  133. assert.doesNotMatch(r.input.全书近况, /批内已暂存/)
  134. } finally {
  135. await cleanup()
  136. }
  137. })
  138. test('AC2 机检叠加:推进批内新条目零误报、批内新角色不报新专名、批内信息差出候选', async () => {
  139. const { ctx, cleanup } = await repoCtx(null, bookFiles())
  140. try {
  141. await stageChapter3(ctx)
  142. const draftPath = path.join(ctx.repoPath, '工作区', '草稿-B.md')
  143. await fs.writeFile(
  144. draftPath,
  145. '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 3000\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n - 推进 伏笔-009\n---\n' +
  146. '古钟长老道:“你不该来。”林晚握紧短刀盯着古钟出神,山风吹动她的衣角。'.repeat(1) +
  147. '她沿着山道慢慢往上走,一步一步踩着碎石,心里盘算着夜里听到的那阵钟声到底从哪里来。'.repeat(1),
  148. 'utf8'
  149. )
  150. const r = await mechanicalCheck(ctx, { chapterNum: 4, draftPath })
  151. assert.equal(r.ok, true, r.error)
  152. assert.deepEqual(
  153. r.issues.filter((i) => i.check === '条目变动'),
  154. [],
  155. JSON.stringify(r.issues)
  156. )
  157. assert.ok(!r.candidates.some((c) => c.type === '新专名' && c.value === '古钟长老'), JSON.stringify(r.candidates))
  158. assert.ok(r.candidates.some((c) => c.type === '信息差候选' && c.value === '信息差-002-钟声'), JSON.stringify(r.candidates))
  159. } finally {
  160. await cleanup()
  161. }
  162. })
  163. test('AC7 缓存不变量:批次进行中删缓存全量重建,备料输出不变', async () => {
  164. const { ctx, cleanup } = await repoCtx(null, bookFiles())
  165. try {
  166. await stageChapter3(ctx)
  167. const before = await prepareChapterMaterials(ctx, { chapterNum: 4 })
  168. assert.equal(before.ok, true, before.error)
  169. const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
  170. assert.equal(rb.ok, true, rb.errors?.join(';'))
  171. const after = await prepareChapterMaterials(ctx, { chapterNum: 4 })
  172. assert.equal(after.ok, true, after.error)
  173. assert.equal(after.content, before.content)
  174. } finally {
  175. await cleanup()
  176. }
  177. })