script-no-write.spec.ts 4.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. /**
  2. * R21「脚本只算不写」锁死测试(2026-09-05 真机实锤后拆分:runReview 曾在脚本内直落审核记录)。
  3. *
  4. * computeReview 纯算零写盘;确定性模块的发现项经 ingestFindings 逐模块回写落盘——
  5. * 模块态置完成、发现项整批替换、空发现项也要回写(置完成),净效果与 runReview 写盘一致。
  6. */
  7. import { afterAll, beforeEach, describe, expect, it } from 'vitest'
  8. import * as fs from 'node:fs'
  9. import * as path from 'node:path'
  10. import * as os from 'node:os'
  11. import { seedMinDesign, serializeDocument, writeCandidate, confirmOutline } from '@webnovel/core'
  12. import { computeReview, ingestFindings, listChecks, registerDefaultChecks, resetChecks, runReview } from '../src/index'
  13. const roots: string[] = []
  14. function mkBook(): string {
  15. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-script-nowrite-'))
  16. roots.push(dir)
  17. seedMinDesign(dir)
  18. return dir
  19. }
  20. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放 */ } } })
  21. beforeEach(() => { resetChecks(); registerDefaultChecks() })
  22. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  23. function readyBookWithPendingDraft(root: string): void {
  24. const refs = ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1']
  25. const body = [
  26. '# 章细纲', '',
  27. '## 定位段', '',
  28. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  29. '### 章节功能', '',
  30. '- 〔硬〕开场必须点名主角现身', '',
  31. '### 视角与焦点', '主角视角', '',
  32. '### 时空锚定', '城门口,清晨', '',
  33. '### 起止边界', '从抵达城门到发现异状', '',
  34. '### 故事线与承诺分配', '推进主线承诺', '',
  35. '### 信息边界', '只披露主角所见', '',
  36. '### 情绪与节奏目标', '紧张后留钩子', '',
  37. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  38. '## 细纲段', '', '### 单元 1', '',
  39. '- 目标: 找到异常', '- 人物: 主角', '- 时空: 城门口',
  40. '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  41. ].join('\n')
  42. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: refs, body })
  43. const r = confirmOutline(root, key)
  44. if (!r.ok) throw new Error(`confirm failed:${r.gaps.join(';')}`)
  45. fs.mkdirSync(path.join(root, '草稿区/草稿/卷01-开篇任务'), { recursive: true })
  46. fs.writeFileSync(path.join(root, '草稿区/草稿/卷01-开篇任务/稿1.md'), serializeDocument({ 角色: '待审稿' }, '他走进雨里,数着街角的旗幡。'), 'utf-8')
  47. }
  48. describe('R21 脚本只算不写(computeReview 与 ingestFindings 分工)', () => {
  49. it('computeReview 不落盘:跑完检查,草稿区/审核 目录不存在', () => {
  50. const root = mkBook()
  51. readyBookWithPendingDraft(root)
  52. const r = computeReview(root, key)
  53. expect(r.ok).toBe(true)
  54. expect(r.record?.模块['文本规范检查']?.完成).toBe(true)
  55. expect(r.record?.模块['章节结构审读']?.待回写).toBe(true)
  56. expect(fs.existsSync(path.join(root, '草稿区/审核'))).toBe(false)
  57. })
  58. it('逐模块回写后记录落盘:确定性模块置完成,语义模块仍待回写(fail-closed)', () => {
  59. const root = mkBook()
  60. readyBookWithPendingDraft(root)
  61. const computed = computeReview(root, key)
  62. expect(computed.ok).toBe(true)
  63. const deterministic = listChecks().filter((c) => c.run !== undefined).map((c) => c.名称)
  64. for (const 模块名 of deterministic) {
  65. const 发现项 = (computed.record?.问题 ?? []).filter((f) => f.模块名 === 模块名)
  66. const w = ingestFindings(root, key, 模块名, 发现项)
  67. expect(w.ok).toBe(true)
  68. }
  69. const loaded = JSON.parse(fs.readFileSync(path.join(root, '草稿区/审核/卷01-开篇任务.json'), 'utf-8'))
  70. expect(loaded.模块['文本规范检查'].完成).toBe(true)
  71. // 语义模块未被 ingest 过,记录里无其行;完成判定按 names.every 仍是 false(fail-closed 不变)
  72. expect(loaded.模块['章节结构审读']?.完成 ?? false).toBe(false)
  73. expect(loaded.完成).toBe(false)
  74. })
  75. it('runReview(进程内写入路径)行为不变:写盘且模块态一致', () => {
  76. const root = mkBook()
  77. readyBookWithPendingDraft(root)
  78. const r = runReview(root, key)
  79. expect(r.ok).toBe(true)
  80. expect(fs.existsSync(path.join(root, '草稿区/审核/卷01-开篇任务.json'))).toBe(true)
  81. })
  82. })