reliability-fix3.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import { afterAll, beforeEach, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import {
  6. classifyHardConstraint, computeMaterials, confirmOutline, draftHashOf,
  7. loadReviewRecord, paths, scanChapter, seedMinDesign, serializeDocument,
  8. writeCandidate, writeReviewRecord,
  9. } from '../src/index'
  10. import { ingestFindings, registerDefaultChecks, resetChecks, runCanonCheck, runReview } from '@webnovel/review'
  11. import { checkFidelity } from '@webnovel/polish'
  12. const roots: string[] = []
  13. const key = { 卷: 1, 章: 51, 章名: '审计章' } as const
  14. function put(root: string, rel: string, text: string): void {
  15. const file = path.join(root, rel)
  16. fs.mkdirSync(path.dirname(file), { recursive: true })
  17. fs.writeFileSync(file, text, 'utf8')
  18. }
  19. function makeBook(): string {
  20. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-rel3-'))
  21. roots.push(root)
  22. seedMinDesign(root)
  23. const outline = [
  24. '# 章细纲', '', '## 定位段', '',
  25. '### 来源窗口项及拆并关系', '审计章,单章承接', '',
  26. '### 章节功能', '', '- 〔硬〕本章必须出现铜符', '',
  27. '### 视角与焦点', '主角视角', '',
  28. '### 时空锚定', '城门口', '',
  29. '### 起止边界', '本章全程', '',
  30. '### 故事线与承诺分配', '推进主线', '',
  31. '### 信息边界', '只披露主角所见', '',
  32. '### 情绪与节奏目标', '平缓', '',
  33. '### 前置条件核对结果', '已核对', '',
  34. '## 细纲段', '', '### 单元 1', '',
  35. '- 目标: 推进', '- 人物: 主角', '- 时空: 城门',
  36. '- 行动/冲突: 观察', '- 信息披露: 无', '- 状态变化: 警觉', '',
  37. ].join('\n')
  38. writeCandidate(root, { 卷: key.卷, 章: key.章, 章名: key.章名, 来源引用: ['作品契约/契约.md@1'], body: outline })
  39. put(root, '大纲/卷规划/卷01/近期窗口.md', '# 近期窗口\n\n- 审计章 〔已确认〕\n')
  40. const confirmed = confirmOutline(root, key)
  41. if (!confirmed.ok) throw new Error(confirmed.gaps.join(';'))
  42. put(root, `${paths.草稿目录(key.卷, key.章名)}/稿1.md`, serializeDocument({ 角色: '待审稿', 选定: true }, '主角握住铜符,走进城门。'))
  43. return root
  44. }
  45. function finishSemanticModules(root: string): void {
  46. const result = runReview(root, key)
  47. if (!result.ok || result.record === null) throw new Error(result.reason ?? 'review failed')
  48. for (const name of Object.keys(result.record.模块)) {
  49. if (result.record.模块[name]?.待回写 === true) {
  50. const written = ingestFindings(root, key, name, [])
  51. if (!written.ok) throw new Error(written.reason ?? 'ingest failed')
  52. }
  53. }
  54. }
  55. beforeEach(() => { resetChecks(); registerDefaultChecks() })
  56. afterAll(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }) })
  57. describe('R1/R6/R7 review applicability', () => {
  58. it('rejects late findings after the pending draft changes', () => {
  59. const root = makeBook()
  60. finishSemanticModules(root)
  61. const recordPath = path.join(root, paths.审核记录(key.卷, key.章名))
  62. const draftPath = path.join(root, `${paths.草稿目录(key.卷, key.章名)}/稿1.md`)
  63. fs.writeFileSync(draftPath, serializeDocument({ 角色: '待审稿', 选定: true }, '完全不同的新稿。'), 'utf8')
  64. expect(scanChapter(root, key).审核证据过期).toBe(true)
  65. const late = ingestFindings(root, key, '章节结构审读', [])
  66. expect(late.ok).toBe(false)
  67. expect(scanChapter(root, key).审核证据过期).toBe(true)
  68. expect(JSON.parse(fs.readFileSync(recordPath, 'utf8')).审稿哈希).not.toBe(draftHashOf('完全不同的新稿。'))
  69. })
  70. it('does not wash an unbound legacy record into a completed current review', () => {
  71. const root = makeBook()
  72. finishSemanticModules(root)
  73. const record = loadReviewRecord(root, key)!
  74. const { 审稿哈希: _body, 审读指纹: _input, ...legacy } = record
  75. writeReviewRecord(root, key, legacy)
  76. const rerun = runReview(root, key)
  77. expect(rerun.ok).toBe(true)
  78. expect(rerun.record?.完成).toBe(false)
  79. expect(rerun.record?.模块['章节结构审读']?.待回写).toBe(true)
  80. })
  81. it('expires evidence when the consumed outline changes without changing prose', () => {
  82. const root = makeBook()
  83. finishSemanticModules(root)
  84. const outlinePath = path.join(root, paths.确认细纲(key.卷, key.章, key.章名))
  85. const outline = fs.readFileSync(outlinePath, 'utf8')
  86. fs.writeFileSync(outlinePath, `${outline}\n- 〔硬〕本章不得出现火枪\n`, 'utf8')
  87. expect(scanChapter(root, key).审核证据过期).toBe(true)
  88. expect(scanChapter(root, key).审核完成).toBe(false)
  89. })
  90. })
  91. describe('R2/R3 bounded material views', () => {
  92. it('keeps the earlier state when a later same-name ledger entry is out of range', () => {
  93. const root = makeBook()
  94. for (const category of ['时间线', '故事线', '人物弧线', '承诺', '线索'] as const) put(root, paths.账本(category), `# ${category}\n`)
  95. put(root, paths.账本('线索'), [
  96. '# 线索', '', '## 铜符线索', '状态:已埋', '埋设点:第50章', '预期兑现区间:第51-60章',
  97. '### 正文', '铜符尚未解开。', '', '## 铜符线索', '状态:已收', '来源:定稿/卷01/0199-解谜.md',
  98. '### 正文', '铜符谜底在199章揭晓。', '',
  99. ].join('\n'))
  100. const result = computeMaterials(root, key)
  101. expect(result.文件).toBeDefined()
  102. const section = result.文件!.find((file) => file.relPath.includes('故事线承诺线索'))!.content
  103. expect(section).toContain('铜符尚未解开')
  104. expect(section).not.toContain('199章揭晓')
  105. })
  106. it('filters future facts from worldbook, volume summary, and clue planting point', () => {
  107. const root = makeBook()
  108. for (const category of ['时间线', '故事线', '人物弧线', '承诺', '线索'] as const) put(root, paths.账本(category), `# ${category}\n`)
  109. put(root, '世界书/人物档案/主角.md', serializeDocument({ 状态: '已确认' }, '当前设定。\n\n### 事实@第0199章\n未来事实。'))
  110. put(root, paths.确认细纲(key.卷, key.章, key.章名), serializeDocument({ 状态: '已确认', 版本: 1, 来源引用: ['世界书/人物档案/主角.md@1'] }, fs.readFileSync(path.join(root, paths.确认细纲(key.卷, key.章, key.章名)), 'utf8').split('---\n').slice(-1)[0] ?? ''))
  111. put(root, paths.账本('线索'), '# 线索\n\n## 未来线索\n状态:已埋\n埋设点:第199章\n预期兑现区间:第200章\n### 正文\n未来线索正文。\n')
  112. put(root, paths.卷摘要(key.卷), '# 卷摘要\n\n第199章主角登基,第200章天下归一。')
  113. const result = computeMaterials(root, key)
  114. expect(result.文件).toBeDefined()
  115. const files = result.文件!
  116. expect(files.find((file) => file.relPath.includes('人物、关系'))!.content).not.toContain('未来事实')
  117. expect(files.find((file) => file.relPath.includes('故事线承诺线索'))!.content).not.toContain('未来线索')
  118. expect(files.find((file) => file.relPath.includes('近期正文衔接'))!.content).not.toContain('第199章')
  119. })
  120. })
  121. describe('R4/R5/R8 constraint contract', () => {
  122. it('does not parse semantic predicates or compound rules as a single literal rule', () => {
  123. expect(classifyHardConstraint('- 〔硬〕本章不得描写主角杀人').kind).toBe('需审读')
  124. expect(classifyHardConstraint('- 〔硬〕本章必须出现铜符,不得出现火枪').kind).toBe('需审读')
  125. const findings = runCanonCheck({
  126. bookRoot: '', key, 待审稿: '他举刀刺向敌人。',
  127. 细纲: '# 章细纲\n\n### 章节功能\n- 〔硬〕本章不得描写主角杀人\n',
  128. 审核编号: '审计', 材料版本: 'v1',
  129. })
  130. expect(findings).toHaveLength(1)
  131. expect(findings[0]!.证据位置).toContain('无确定性正文位置')
  132. })
  133. it('uses the same explicit rule contract for polish fidelity', () => {
  134. const semantic = checkFidelity({ draft: '他扶起伤者。', polished: '他扶起伤者。', outlineText: '# 章细纲\n\n- 〔硬〕主角本章不得杀人\n' })
  135. expect(semantic.some((risk) => risk.kind === '硬约束缺失')).toBe(false)
  136. const explicit = checkFidelity({ draft: '他走过城门。', polished: '他走过城门。', outlineText: '# 章细纲\n\n- 〔硬〕本章必须出现铜符\n' })
  137. expect(explicit.some((risk) => risk.kind === '硬约束缺失')).toBe(true)
  138. })
  139. })