scan-derive.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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 { canonicalizeChapterKeys, listChapters, scanChapter } from '../src/derive/scan'
  6. import { deriveChapterFacts } from '../src/derive/derive'
  7. import { draftHashOf, reviewInputFingerprintOf } from '../src/evidence/record'
  8. import { parseDocument } from '../src/repo/frontmatter'
  9. import { serializeDocument } from '../src/repo/frontmatter'
  10. const roots: string[] = []
  11. function mkBook(): string {
  12. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-book-'))
  13. roots.push(dir)
  14. return dir
  15. }
  16. function put(root: string, rel: string, content: string): void {
  17. fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true })
  18. fs.writeFileSync(path.join(root, rel), content, 'utf-8')
  19. }
  20. function md(fields: Record<string, unknown>, body = '内容'): string {
  21. return serializeDocument(fields, body)
  22. }
  23. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  24. const key = { 卷: 1, 章: 3, 章名: '初见' } as const
  25. describe('扫描器→推导端到端(真实文件树)', () => {
  26. it('链路推进:窗口→候选→确认→材料→草稿→待审→审核→包→裁决→入档', () => {
  27. const root = mkBook()
  28. // 窗口就绪
  29. put(root, '大纲/卷规划/卷01/近期窗口.md', '# 近期窗口\n\n- 初见 〔已确认〕\n- 铺垫 〔暂定〕\n')
  30. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('章细纲(待定位)')
  31. // 候选细纲(草稿区)
  32. put(root, '草稿区/章细纲/卷01-初见.md', md({ 状态: '候选', 身份: { 卷: 1, 章: 3, 章名: '初见' } }))
  33. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('章细纲(待确认)')
  34. // 确认入真源区
  35. put(root, '大纲/卷规划/卷01/章细纲/0003-初见.md', md({ 状态: '已确认' }))
  36. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('写作备料')
  37. // 材料包可写
  38. put(root, '草稿区/材料包/卷01-初见/材料清单.json', JSON.stringify({ schemaVersion: 1, 状态: '段齐备' }))
  39. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('写稿')
  40. // 草稿
  41. put(root, '草稿区/草稿/卷01-初见/稿1.md', md({ 状态: '候选' }, '第一章正文'))
  42. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('润色')
  43. // 唯一待审稿
  44. put(root, '草稿区/草稿/卷01-初见/稿1.md', md({ 角色: '待审稿' }, '第一章正文'))
  45. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('审核')
  46. const draftBody = parseDocument(md({ 角色: '待审稿' }, '第一章正文')).data.body
  47. const binding = {
  48. 审稿哈希: draftHashOf(draftBody),
  49. 审读指纹: reviewInputFingerprintOf({
  50. 正文: draftBody,
  51. 细纲: md({ 状态: '已确认' }),
  52. 材料清单: JSON.stringify({ schemaVersion: 1, 状态: '段齐备' }),
  53. }),
  54. }
  55. // 审核记录(有问题未处置)→改稿
  56. put(root, '草稿区/审核/卷01-初见.json', JSON.stringify({ schemaVersion: 1, 完成: true, ...binding, 问题: [{ 处置: '' }] }))
  57. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('改稿')
  58. // 处置齐→定稿准备
  59. put(root, '草稿区/审核/卷01-初见.json', JSON.stringify({ schemaVersion: 1, 完成: true, ...binding, 问题: [{ 处置: '已解决' }], 模块: { 文本规范检查: { 完成: true, 失败: false } } }))
  60. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('定稿准备与沉淀')
  61. // 待定稿包七件+清单
  62. for (const f of ['正文.md', '事实变更.md', '时间线变更.md', '账本变更.md', '章摘要.md', '卷对账.md', '本书层记忆候选.md']) {
  63. put(root, `草稿区/定稿准备/卷01-初见/${f}`, 'x')
  64. }
  65. put(root, '草稿区/定稿准备/卷01-初见/清单.json', JSON.stringify({ schemaVersion: 1, 文件: [] }))
  66. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('作者定稿裁决')
  67. // 裁决批准→定稿入档
  68. put(root, '草稿区/定稿准备/卷01-初见/清单.json', JSON.stringify({ schemaVersion: 1, 文件: [], 裁决: '已批准' }))
  69. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('定稿入档')
  70. // 入档完成
  71. put(root, '定稿/卷01/0003-初见.md', md({ 状态: '已定稿' }))
  72. expect(deriveChapterFacts(scanChapter(root, key)).建议.环节).toBe('完成')
  73. })
  74. it('叠加标记:细纲需复核被带出且不改位置', () => {
  75. const root = mkBook()
  76. put(root, '大纲/卷规划/卷01/章细纲/0003-初见.md', md({ 状态: '需复核' }))
  77. put(root, '草稿区/材料包/卷01-初见/材料清单.json', JSON.stringify({ schemaVersion: 1, 状态: '段齐备' }))
  78. const r = deriveChapterFacts(scanChapter(root, key))
  79. expect(r.建议.环节).toBe('写稿') // 行 4 照常(建议仅供参考)
  80. expect(r.叠加标记).toContain('细纲')
  81. })
  82. it('B4 落地:审核记录解析失败视为「有记录未完成」而非无记录', () => {
  83. const root = mkBook()
  84. put(root, '草稿区/草稿/卷01-初见/稿1.md', md({ 角色: '待审稿' }))
  85. put(root, '草稿区/审核/卷01-初见.json', '{坏的 json')
  86. const scan = scanChapter(root, key)
  87. expect(scan.有审核记录).toBe(true)
  88. expect(scan.审核完成).toBe(false)
  89. // F5:记录不可读=无哈希=证据过期;恢复视图停在审核(须重审),不冒充改稿处置
  90. expect(scan.审核证据过期).toBe(true)
  91. expect(deriveChapterFacts(scan).建议.环节).toBe('审核')
  92. })
  93. it('章枚举:listChapters 跨目录收集且去重', () => {
  94. const root = mkBook()
  95. put(root, '大纲/卷规划/卷01/章细纲/0003-初见.md', md({ 状态: '已确认' }))
  96. put(root, '草稿区/章细纲/卷01-铺垫.md', md({ 状态: '候选' }))
  97. put(root, '定稿/卷01/0001-序幕.md', md({ 状态: '已定稿' }))
  98. const keys = listChapters(root)
  99. expect(keys).toHaveLength(3)
  100. expect(keys.map((k) => k.章名).sort()).toEqual(['初见', '序幕', '铺垫'].sort())
  101. })
  102. it('章枚举:审核记录等机器态 JSON 不产生幽灵章键(20章连写真机发现)', () => {
  103. const root = mkBook()
  104. put(root, '草稿区/审核/卷01-初见.json', '{"schemaVersion":1}')
  105. put(root, '草稿区/章细纲/卷01-铺垫.md', md({ 状态: '候选' }))
  106. const keys = listChapters(root)
  107. expect(keys.map((k) => k.章名).sort()).toEqual(['铺垫'])
  108. })
  109. })
  110. describe('章键归并与窗口失效过滤(拍板 7 配套)', () => {
  111. it('窗口第七章 + 真源 0007-第七章 → 只返回一条键(章=7);已消费/已失效窗口项不产生键', () => {
  112. const root = mkBook()
  113. const 卷规划 = path.join(root, '大纲/卷规划/卷01')
  114. fs.mkdirSync(卷规划, { recursive: true })
  115. fs.writeFileSync(path.join(卷规划, '近期窗口.md'), [
  116. '# 近期窗口',
  117. '',
  118. '- 第七章 〔已确认〕',
  119. '- 旧章 〔已消费〕',
  120. '- 断章 〔已失效〕',
  121. '',
  122. ].join('\n'), 'utf-8')
  123. fs.mkdirSync(path.join(卷规划, '章细纲'), { recursive: true })
  124. fs.writeFileSync(path.join(卷规划, '章细纲/0007-第七章.md'), serializeDocument({ 状态: '已确认' }, '# 细纲\n'), 'utf-8')
  125. const keys = listChapters(root)
  126. expect(keys).toEqual([{ 卷: 1, 章: 7, 章名: '第七章' }])
  127. // 近况行推进:确认细纲后不再是「待定位」
  128. const facts = scanChapter(root, { 卷: 1, 章: 7, 章名: '第七章' })
  129. expect(deriveChapterFacts(facts).建议.环节).not.toBe('章细纲(待定位)')
  130. })
  131. it('canonicalizeChapterKeys:全角括号章名归一合并;归一后仍不同保留两条', () => {
  132. const merged = canonicalizeChapterKeys([
  133. { 卷: 1, 章: 0, 章名: '重逢(上)' },
  134. { 卷: 1, 章: 8, 章名: '重逢(上)' },
  135. { 卷: 1, 章: 0, 章名: '重逢 上' },
  136. ])
  137. // 全角括号归一后「重逢(上)」并入真源键;「重逢 上」归一为「重逢上」仍是另一章,如实保留
  138. expect(merged).toEqual([
  139. { 卷: 1, 章: 0, 章名: '重逢 上' },
  140. { 卷: 1, 章: 8, 章名: '重逢(上)' },
  141. ])
  142. const kept = canonicalizeChapterKeys([
  143. { 卷: 1, 章: 0, 章名: '重逢' },
  144. { 卷: 1, 章: 9, 章名: '重逢(下)' },
  145. ])
  146. expect(kept).toHaveLength(2)
  147. })
  148. })