progress.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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 { activeChapterLine, parseVolumeAllocations, renderBookProgress, scanDesign, scanDesignDetail, selectCurrentVolume, seedMinDesign, writePending, serializeDocument } from '../src/index'
  6. import type { LastCommitResult } from '../src/commit/history'
  7. const roots: string[] = []
  8. function mkBook(seed = false): string {
  9. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-progress-'))
  10. roots.push(dir)
  11. if (seed) seedMinDesign(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. describe('scanDesignDetail(现算明细)', () => {
  16. it('seed 后:契约六部全量、骨架九部、世界书计数、窗口余量', () => {
  17. const root = mkBook(true)
  18. const d = scanDesignDetail(root)
  19. expect(d.契约.分部).toHaveLength(6)
  20. expect(d.契约.分部.every((p) => p.状态 === '已确认')).toBe(true)
  21. expect(d.骨架.分部).toHaveLength(9)
  22. expect(d.世界书.各模块).toEqual(expect.arrayContaining([
  23. expect.objectContaining({ 名称: '人物档案', 条目数: 1, 已确认数: 1 }),
  24. ]))
  25. const v1 = d.卷规划.find((v) => v.卷号 === 1)
  26. expect(v1?.近期窗口.条目).toEqual([{ 名称: '开篇任务', 状态: '已确认' }])
  27. expect(v1?.近期窗口.余量).toBe(1)
  28. expect(d.章进度.活跃章).toEqual({ 卷: 1, 章: 0, 章名: '开篇任务' })
  29. })
  30. it('新建空书仓:各段为空、世界书未声明模块可见', () => {
  31. const root = mkBook(false)
  32. fs.mkdirSync(path.join(root, '世界书/未声明模块'), { recursive: true })
  33. const d = scanDesignDetail(root)
  34. expect(d.契约.分部).toEqual([])
  35. expect(d.卷规划).toEqual([])
  36. expect(d.世界书.未声明模块).toContain('未声明模块')
  37. expect(d.章进度.已定稿).toEqual([])
  38. })
  39. })
  40. describe('待补便签(算不出来的才落盘)', () => {
  41. it('写入、明细读出、清除;不存在的节报明确错误', () => {
  42. const root = mkBook(true)
  43. const rel = '大纲/故事骨架.md'
  44. const r = writePending(root, rel, '线索悬念伏笔', '第二卷前需补暗线落点')
  45. expect(r.ok).toBe(true)
  46. expect(fs.readFileSync(path.join(root, rel), 'utf-8')).toContain('待补:第二卷前需补暗线落点')
  47. const d = scanDesignDetail(root)
  48. const part = d.骨架.分部.find((p) => p.名称 === '线索悬念伏笔')
  49. expect(part?.待补).toEqual(['第二卷前需补暗线落点'])
  50. // 清除
  51. expect(writePending(root, rel, '线索悬念伏笔', null).ok).toBe(true)
  52. expect(fs.readFileSync(path.join(root, rel), 'utf-8')).not.toContain('待补:')
  53. expect(scanDesignDetail(root).骨架.分部.find((p) => p.名称 === '线索悬念伏笔')?.待补).toEqual([])
  54. // 不存在的节
  55. const bad = writePending(root, rel, '不存在的节', 'x')
  56. expect(bad.ok).toBe(false)
  57. if (!bad.ok) expect(bad.reason).toContain('没有分节')
  58. })
  59. })
  60. describe('书级进度卡渲染(纯函数,固定输入固定输出)', () => {
  61. const never: LastCommitResult = { ok: false, kind: 'never', detail: 'x' }
  62. const scoped: LastCommitResult = { ok: true, prefix: 'design(ch0007)', summary: '确认细纲', hash: 'abc', chapter: { 卷: null, 章: 7 } }
  63. it('按固定输入产出固定输出;余量≤2 标见底;从未提交显示未提交', () => {
  64. const root = mkBook(true)
  65. const detail = scanDesignDetail(root)
  66. const out = renderBookProgress(detail, { '大纲/故事骨架.md': never, '作品契约/契约.md': scoped }, ['卷末锚点未兑现'])
  67. expect(out).toContain('【书级进度卡】')
  68. expect(out).toContain('题材与读者定位〔已确认〕|第0007章')
  69. expect(out).toContain('主角目标与成长轨迹〔已确认〕|未提交')
  70. expect(out).toContain('近期窗口余量 1(见底,需滚动补充)')
  71. expect(out).toContain('本卷未决偏离:1 条(卷末锚点未兑现)')
  72. expect(out).not.toMatch(/\d{4}-\d{2}-\d{2}/)
  73. // 固定输入固定输出
  74. const again = renderBookProgress(detail, { '大纲/故事骨架.md': never, '作品契约/契约.md': scoped }, ['卷末锚点未兑现'])
  75. expect(again).toBe(out)
  76. })
  77. })
  78. describe('activeChapterLine(每轮 context 瘦身)', () => {
  79. it('一行固定形状;连续两次求值完全相同', () => {
  80. const root = mkBook(true)
  81. const first = activeChapterLine(root)
  82. expect(first.ok).toBe(true)
  83. if (!('行' in first)) return
  84. expect(first.行).toBe('卷01 第0000章 开篇任务 · 章细纲(待定位)')
  85. const second = activeChapterLine(root)
  86. expect(second).toEqual(first)
  87. })
  88. it('有多卷动静时当前卷取最近有定稿的卷', () => {
  89. const root = mkBook(true)
  90. fs.mkdirSync(path.join(root, '定稿/卷02'), { recursive: true })
  91. fs.writeFileSync(path.join(root, '定稿/卷02/0009-风波.md'), serializeDocument({ 状态: '已定稿' }, '正文\n'), 'utf-8')
  92. fs.mkdirSync(path.join(root, '大纲/卷规划/卷02'), { recursive: true })
  93. fs.writeFileSync(path.join(root, '大纲/卷规划/卷02/近期窗口.md'), '# 近期窗口\n\n- 第十章 〔已确认〕\n\n', 'utf-8')
  94. const r = activeChapterLine(root)
  95. if (!('ok' in r) || !r.ok || !('行' in r)) return
  96. expect(r.行).toContain('卷02')
  97. })
  98. })
  99. describe('activeChapterLine 窗口见底(任务19, F-002)', () => {
  100. /** 建「窗口全部已消费+本卷定稿 N 章」的见底现场;卷纲体量行按需声明本卷章数。 */
  101. function mkBottomedBook(input: { 卷?: number; 体量行: string | null; 定稿章号: number[]; 时间线?: string }): string {
  102. const 卷 = input.卷 ?? 1
  103. const root = mkBook(true)
  104. const 卷NN = String(卷).padStart(2, '0')
  105. const 卷目录 = path.join(root, `大纲/卷规划/卷${卷NN}`)
  106. fs.mkdirSync(卷目录, { recursive: true })
  107. const outline = ['# 卷纲', '']
  108. if (input.体量行 !== null) outline.push(input.体量行, '')
  109. outline.push('## 叙事结构 〔已确认〕', '', '占位', '')
  110. fs.writeFileSync(path.join(卷目录, '卷纲.md'), outline.join('\n'), 'utf-8')
  111. fs.writeFileSync(path.join(卷目录, '近期窗口.md'), '# 近期窗口\n\n- 已写完的章 〔已消费〕\n', 'utf-8')
  112. if (input.时间线 !== undefined) {
  113. fs.writeFileSync(path.join(卷目录, '计划时间线.md'), input.时间线, 'utf-8')
  114. }
  115. for (const n of input.定稿章号) {
  116. const dir = path.join(root, `定稿/卷${卷NN}`)
  117. fs.mkdirSync(dir, { recursive: true })
  118. fs.writeFileSync(path.join(dir, `${String(n).padStart(4, '0')}-第${n}章.md`), serializeDocument({ 状态: '已定稿' }, '正文\n'), 'utf-8')
  119. }
  120. return root
  121. }
  122. const range = (from: number, to: number): number[] => Array.from({ length: to - from + 1 }, (_, i) => from + i)
  123. it('卷纲明写17章、定稿11章、窗口耗尽 → 滚动补充(11/17),两次求值相同', () => {
  124. const root = mkBottomedBook({ 体量行: '卷01·改字无印(清河县)|4 案/17 章,单章约 2000 字', 定稿章号: range(1, 11) })
  125. const r = activeChapterLine(root)
  126. expect(r.ok).toBe(true)
  127. if (!('行' in r)) return
  128. expect(r.行).toContain('滚动补充')
  129. expect(r.行).toContain('11')
  130. expect(r.行).toContain('17')
  131. expect(r.行).toBe('卷01 已定稿 11 章,近期窗口无可进入条目,先滚动补充(卷纲 17 章)')
  132. expect(activeChapterLine(root)).toEqual(r)
  133. })
  134. it('定稿达到卷纲章数 → 已全部完成', () => {
  135. const root = mkBottomedBook({ 体量行: '卷01·改字无印(清河县)|4 案/17 章,单章约 2000 字', 定稿章号: range(1, 17) })
  136. const r = activeChapterLine(root)
  137. if (!('行' in r)) return
  138. expect(r.行).toContain('已全部完成')
  139. })
  140. it('卷纲无章数声明、窗口耗尽 → 完成情况未知(不带数字)', () => {
  141. const root = mkBottomedBook({ 体量行: '卷01·改字无印(清河县)|4 案,单章约 2000 字', 定稿章号: range(1, 11) })
  142. const r = activeChapterLine(root)
  143. if (!('行' in r)) return
  144. expect(r.行).toBe('卷01 近期窗口无可进入条目,本卷完成情况未知,请核对卷纲后滚动补充')
  145. expect(r.行).not.toContain('已定稿')
  146. expect(r.行).not.toContain('17')
  147. })
  148. it('反例:卷二明写15章、章号18–32全书连续、本卷15章定稿 → 判完成(不取最大章号)', () => {
  149. const root = mkBottomedBook({ 卷: 2, 体量行: '卷02·誊正(江州府城)|3 案/15 章,单章约 2000 字', 定稿章号: range(18, 32) })
  150. const r = activeChapterLine(root)
  151. if (!('行' in r)) return
  152. expect(r.行).toContain('卷02')
  153. expect(r.行).toContain('已全部完成')
  154. })
  155. it('反例:仅时间线局部锚点与卷内第N章提及、无明确章数 → 不判完成,报未知', () => {
  156. const root = mkBottomedBook({
  157. 体量行: '卷01·改字无印(清河县)|4 案,卷末第17章收束',
  158. 时间线: '# 计划时间线\n\n- 第17章 卷末锚点\n',
  159. 定稿章号: range(1, 11),
  160. })
  161. const r = activeChapterLine(root)
  162. if (!('行' in r)) return
  163. expect(r.行).toContain('完成情况未知')
  164. expect(r.行).not.toContain('已全部完成')
  165. expect(r.行).not.toContain('17')
  166. })
  167. it('反例:章数声明冲突 → 报未知', () => {
  168. const root = mkBottomedBook({ 体量行: '卷01|4 案/17 章,后改按 15 章收束', 定稿章号: range(1, 17) })
  169. const r = activeChapterLine(root)
  170. if (!('行' in r)) return
  171. expect(r.行).toContain('完成情况未知')
  172. expect(r.行).not.toContain('已全部完成')
  173. })
  174. // 2026-09-20 第二轮补证(带空格「第 N 章」形态,区分旧/最终排除正则):
  175. // 旧正则只查「第」紧邻前一字符,带空格时漏排除;最终正则 /第\s*$/ 覆盖带空格。
  176. it('反例:仅「卷末第 17 章收束」带空格引用、无本卷章数 → 报未知(不把17当章数)', () => {
  177. const root = mkBottomedBook({ 体量行: '卷01·改字无印(清河县)|4 案,卷末第 17 章收束', 定稿章号: range(1, 11) })
  178. const r = activeChapterLine(root)
  179. if (!('行' in r)) return
  180. expect(r.行).toContain('完成情况未知')
  181. expect(r.行).not.toContain('已全部完成')
  182. expect(r.行).not.toContain('17')
  183. })
  184. it('反例:明确15章同时引用「第 32 章」带空格 → 按15章判定(不冲突、不取32)', () => {
  185. const root = mkBottomedBook({ 卷: 2, 体量行: '卷02·誊正(江州府城)|3 案/15 章,卷末第 32 章收束', 定稿章号: range(18, 32) })
  186. const r = activeChapterLine(root)
  187. if (!('行' in r)) return
  188. expect(r.行).toContain('卷02')
  189. expect(r.行).toContain('已全部完成')
  190. expect(r.行).not.toContain('未知')
  191. expect(r.行).not.toContain('32 章')
  192. })
  193. })
  194. describe('卷行确认门槛(任务21, B2)', () => {
  195. function mkLayoutBook(分卷文: string): string {
  196. const root = mkBook(false)
  197. fs.mkdirSync(path.join(root, '大纲'), { recursive: true })
  198. fs.writeFileSync(path.join(root, '大纲/分卷布局.md'), 分卷文, 'utf-8')
  199. return root
  200. }
  201. const layout = (sectionState: string | null, rows: string[]): string => [
  202. '# 分卷布局', '',
  203. `## 故事阶段分配${sectionState === null ? '' : ` 〔${sectionState}〕`}`, '',
  204. ...rows, '',
  205. ].join('\n')
  206. it('行级/小节级/缺失/冲突八行输入期望表;中文卷号;首个出现卷行生效', () => {
  207. // 1. 旧行级已确认(小节留白)→ 行级优先
  208. let root = mkLayoutBook(layout('留白', ['- 卷01 〔已确认〕']))
  209. expect(scanDesign(root, 1).当前卷分配完整).toBe(true)
  210. // 2. 行级暂定(小节已确认)→ 暂定
  211. root = mkLayoutBook(layout('已确认', ['- 卷01 〔暂定〕']))
  212. expect(scanDesign(root, 1).当前卷分配完整).toBe(false)
  213. // 3. 行级留白 → 留白
  214. root = mkLayoutBook(layout('已确认', ['- 卷01 〔留白〕']))
  215. expect(scanDesign(root, 1).当前卷分配完整).toBe(false)
  216. // 4. 原书形态:小节已确认,无标注卷行(中文卷号)→ 继承
  217. root = mkLayoutBook(layout('已确认', ['- 卷一·改字无印(清河县,4 案/17 章):局部。', '- 卷二·誊正(江州府城,3 案/15 章):中间。']))
  218. expect(scanDesign(root, 1).当前卷分配完整).toBe(true)
  219. expect(scanDesign(root, 2).当前卷分配完整).toBe(true)
  220. // 5. 同上,小节暂定 → 暂定
  221. root = mkLayoutBook(layout('暂定', ['- 卷二·誊正(江州府城,3 案/15 章):中间。']))
  222. expect(scanDesign(root, 2).当前卷分配完整).toBe(false)
  223. // 6. 同上,小节无标注 → 缺失,不默认确认
  224. root = mkLayoutBook(layout(null, ['- 卷二·誊正(江州府城,3 案/15 章):中间。']))
  225. expect(scanDesign(root, 2).当前卷分配完整).toBe(false)
  226. // 7. 冲突:行级已确认+小节暂定 → 行级优先
  227. root = mkLayoutBook(layout('暂定', ['- 卷01 〔已确认〕']))
  228. expect(scanDesign(root, 1).当前卷分配完整).toBe(true)
  229. // 8. 冲突:行级暂定+小节已确认 → 行级优先
  230. root = mkLayoutBook(layout('已确认', ['- 卷01 〔暂定〕']))
  231. expect(scanDesign(root, 1).当前卷分配完整).toBe(false)
  232. // 解析面:行级/小节级字段齐全;同卷有效状态互斥的重复行呈报冲突(不按文件顺序放行)
  233. const allocs = parseVolumeAllocations(layout('暂定', ['- 卷01 〔已确认〕', '- 卷01 〔留白〕', '- 卷二·誊正(江州府城,3 案/15 章):中间。']))
  234. expect(allocs).toHaveLength(3)
  235. expect(allocs.filter((a) => a.卷 === 1)).toEqual([
  236. expect.objectContaining({ 行级状态: '已确认', 小节状态: '暂定', 有效状态: null, 冲突: true }),
  237. expect.objectContaining({ 行级状态: '留白', 小节状态: '暂定', 有效状态: null, 冲突: true }),
  238. ])
  239. expect(allocs.find((a) => a.卷 === 2)).toMatchObject({ 行级状态: null, 小节状态: '暂定', 有效状态: '暂定' })
  240. })
  241. it('其他小节误放行反例(F21-3):只认故事阶段分配小节;正式段缺失不借状态;合法旧格式兼容;冲突呈报', () => {
  242. // ①其他小节已确认在前、正式段暂定在后 → 不放行(其他小节不压过正式分配段)
  243. let root = mkLayoutBook(['# 分卷布局', '', '## 卷目标与卷末状态 〔已确认〕', '', '- 卷二·目标描述', '', '## 故事阶段分配 〔暂定〕', '', '- 卷二·准备中', ''].join('\n'))
  244. expect(scanDesign(root, 2).当前卷分配完整).toBe(false)
  245. // ②其他小节在后同样不影响正式段判定
  246. root = mkLayoutBook(['# 分卷布局', '', '## 故事阶段分配 〔暂定〕', '', '- 卷二·准备中', '', '## 卷目标与卷末状态 〔已确认〕', '', '- 卷二·目标描述', ''].join('\n'))
  247. expect(scanDesign(root, 2).当前卷分配完整).toBe(false)
  248. // ③正式段存在但空、其他小节有显式已确认卷行 → 仍不确认(不借其他小节状态)
  249. root = mkLayoutBook(['# 分卷布局', '', '## 故事阶段分配 〔已确认〕', '', '## 卷目标与卷末状态 〔留白〕', '', '- 卷01 〔已确认〕', ''].join('\n'))
  250. expect(scanDesign(root, 1).当前卷分配完整).toBe(false)
  251. // ④无故事阶段分配小节(legacy):显式行级已确认卷行仍兼容
  252. root = mkLayoutBook(['# 分卷布局', '', '## 卷目标与卷末状态 〔留白〕', '', '- 卷01 〔已确认〕', ''].join('\n'))
  253. expect(scanDesign(root, 1).当前卷分配完整).toBe(true)
  254. // ⑤同卷有效状态互斥重复行 → 呈报冲突,不默认确认
  255. root = mkLayoutBook(layout('已确认', ['- 卷01 〔已确认〕', '- 卷01 〔暂定〕']))
  256. expect(scanDesign(root, 1).当前卷分配完整).toBe(false)
  257. expect(scanDesignDetail(root).分卷.各卷).toEqual([{ 卷号: 1, 状态: '冲突' }])
  258. })
  259. it('scanDesignDetail 各卷与 scanDesign 同一解析口径(缺失→缺失,不默认确认)', () => {
  260. const root = mkLayoutBook(layout('已确认', ['- 卷一·改字无印(清河县,4 案/17 章):局部。', '- 卷二·誊正(江州府城,3 案/15 章):中间。', '- 卷三·代画指(府城·回清河,3 案/14 章):转折。']))
  261. const d = scanDesignDetail(root)
  262. expect(d.分卷.各卷).toEqual([
  263. { 卷号: 1, 状态: '已确认' },
  264. { 卷号: 2, 状态: '已确认' },
  265. { 卷号: 3, 状态: '已确认' },
  266. ])
  267. const missing = mkLayoutBook(layout(null, ['- 卷一·改字无印(清河县,4 案/17 章):局部。']))
  268. expect(scanDesignDetail(missing).分卷.各卷).toEqual([{ 卷号: 1, 状态: '缺失' }])
  269. expect(scanDesign(missing, 1).当前卷分配完整).toBe(false)
  270. })
  271. })
  272. describe('当前卷选择(任务21, B3 决策表15行)', () => {
  273. const 卷 = (n: number) => String(n).padStart(2, '0')
  274. interface VolSpec {
  275. readonly 体量行?: string | null
  276. readonly 有卷纲?: boolean
  277. readonly 定稿章号?: number[]
  278. readonly 窗口?: readonly (readonly [string, string])[]
  279. readonly 空目录?: boolean
  280. }
  281. function mkVolBook(specs: Readonly<Record<number, VolSpec>>, 候选卷?: number): string {
  282. const root = mkBook(false)
  283. for (const [v, spec] of Object.entries(specs)) {
  284. const n = Number(v)
  285. const dir = path.join(root, `大纲/卷规划/卷${卷(n)}`)
  286. fs.mkdirSync(dir, { recursive: true })
  287. if (spec.空目录 === true) continue
  288. if (spec.有卷纲 !== false) {
  289. const outline = ['# 卷纲', '']
  290. if (spec.体量行 !== undefined && spec.体量行 !== null) outline.push(spec.体量行, '')
  291. outline.push('## 叙事结构 〔已确认〕', '', 'x', '')
  292. fs.writeFileSync(path.join(dir, '卷纲.md'), outline.join('\n'), 'utf-8')
  293. }
  294. if (spec.窗口 !== undefined) {
  295. fs.writeFileSync(path.join(dir, '近期窗口.md'), ['# 近期窗口', '', ...spec.窗口.map(([name, state]) => `- ${name} 〔${state}〕`), ''].join('\n'), 'utf-8')
  296. }
  297. for (const c of spec.定稿章号 ?? []) {
  298. const fd = path.join(root, `定稿/卷${卷(n)}`)
  299. fs.mkdirSync(fd, { recursive: true })
  300. fs.writeFileSync(path.join(fd, `${String(c).padStart(4, '0')}-第${c}章.md`), serializeDocument({ 状态: '已定稿' }, '正文\n'), 'utf-8')
  301. }
  302. }
  303. if (候选卷 !== undefined) {
  304. const cd = path.join(root, '草稿区/章细纲')
  305. fs.mkdirSync(cd, { recursive: true })
  306. fs.writeFileSync(path.join(cd, `卷${卷(候选卷)}-第${候选卷 === 2 ? '十八' : '一'}章 新案.md`), serializeDocument({ 状态: '候选' }, '候选\n'), 'utf-8')
  307. }
  308. return root
  309. }
  310. const 章17 = '卷01·改字无印(清河县)|4 案/17 章,单章约 2000 字'
  311. const 章15 = '卷02·誊正(江州府城)|3 案/15 章,单章约 2000 字'
  312. it('15行决策表全量', () => {
  313. // 1. 空书(含只有空目录)
  314. expect(selectCurrentVolume(mkVolBook({ 1: { 空目录: true } }))).toEqual({ kind: 'empty' })
  315. // 2. 冷启动就绪
  316. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 窗口: [['第一章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 1 })
  317. // 3. 冷启动只预备(仅暂定)
  318. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 窗口: [['第一章', '暂定']] } }))).toEqual({ kind: 'planning', 已完成卷: null, 规划卷: 1 })
  319. // 4. 冷启动未来抢占反例:卷1仅候选(准备卷),卷2已确认窗口也不得越过
  320. expect(selectCurrentVolume(mkVolBook({ 1: { 窗口: [['第一章', '暂定']] }, 2: { 窗口: [['第十八章', '已确认']] } }, 1))).toEqual({ kind: 'planning', 已完成卷: null, 规划卷: 1 })
  321. // 5. 非卷1起步旧书
  322. expect(selectCurrentVolume(mkVolBook({ 2: { 体量行: 章15, 窗口: [['第十八章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 2 })
  323. // 6. 当前卷完成未知(卷2已确认窗口不抢)
  324. expect(selectCurrentVolume(mkVolBook({ 1: { 有卷纲: true, 定稿章号: [1, 2, 3] }, 2: { 窗口: [['第十八章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 1 })
  325. // 7. 当前卷未完(11/17,卷2已确认窗口不抢)
  326. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 11 }, (_, i) => i + 1) }, 2: { 窗口: [['第十八章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 1 })
  327. // 8. 完成后仅暂定窗口
  328. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 2: { 体量行: 章15, 窗口: [['第十八章', '暂定']] } }))).toEqual({ kind: 'planning', 已完成卷: 1, 规划卷: 2 })
  329. // 9. 完成后仅候选细纲(无卷规划目录)
  330. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) } }, 2))).toEqual({ kind: 'planning', 已完成卷: 1, 规划卷: 2 })
  331. // 10. 完成后仅卷纲无窗口
  332. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 2: { 体量行: 章15 } }))).toEqual({ kind: 'planning', 已完成卷: 1, 规划卷: 2 })
  333. // 11. 完成后就绪
  334. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 2: { 体量行: 章15, 窗口: [['第十八章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 2 })
  335. // 12. 多未来卷均就绪按卷序
  336. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 2: { 体量行: 章15, 窗口: [['第十八章', '已确认']] }, 3: { 体量行: '卷03|3 案/14 章', 窗口: [['第三十三章', '已确认']] } }))).toEqual({ kind: 'active', 卷: 2 })
  337. // 13. 中间卷未就绪不跳卷3
  338. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 3: { 体量行: '卷03|3 案/14 章', 窗口: [['第三十三章', '已确认']] } }))).toEqual({ kind: 'planning', 已完成卷: 1, 规划卷: 2 })
  339. // 14. 完成无下一卷
  340. expect(selectCurrentVolume(mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) } }))).toEqual({ kind: 'planning', 已完成卷: 1, 规划卷: 2 })
  341. // 15. 已进入后卷的旧书(卷2有定稿完成未知,卷1缺资料不倒退)
  342. expect(selectCurrentVolume(mkVolBook({ 2: { 有卷纲: true, 体量行: null, 定稿章号: [18, 19] } }))).toEqual({ kind: 'active', 卷: 2 })
  343. })
  344. it('消费映射:activeChapterLine planning/empty 行形状;连续两次求值相同', () => {
  345. const planningBook = mkVolBook({ 1: { 体量行: 章17, 定稿章号: Array.from({ length: 17 }, (_, i) => i + 1) }, 2: { 体量行: 章15, 窗口: [['第十八章', '暂定']] } })
  346. const r1 = activeChapterLine(planningBook)
  347. expect(r1.ok && '行' in r1 && r1.行).toBe('卷01 已全部完成;卷02 待规划(窗口未就绪或尚无材料)。')
  348. expect(activeChapterLine(planningBook)).toEqual(r1)
  349. const coldBook = mkVolBook({ 1: { 体量行: 章17, 窗口: [['第一章', '暂定']] } })
  350. const r2 = activeChapterLine(coldBook)
  351. expect(r2.ok && '行' in r2 && r2.行).toBe('卷01 尚未开写,待完成规划并确认窗口。')
  352. const emptyBook = mkVolBook({ 1: { 空目录: true } })
  353. const r3 = activeChapterLine(emptyBook)
  354. expect(r3.ok && '行' in r3 && r3.行).toContain('尚无已规划章节')
  355. })
  356. })