index.test.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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 { runHealthCheck } from '../../src/health-check/index.js'
  6. import { repoCtx } from '../commands/_helper.js'
  7. // 专用小书 fixture:5 章定稿,「空气仿佛凝固」全书 12 次跨 5 章;基线 1-2 / 周期 3 → 近段 3-5
  8. function chapterFile(num, { 时间 = `1023春月初${num}`, imagery = 0 } = {}) {
  9. const fm = [
  10. `章号: ${num}`,
  11. `标题: 第${num}章`,
  12. '卷: 1',
  13. '视角: 林晚',
  14. ...(时间 ? [`书内时间: ${时间}`] : []),
  15. '字数: 100',
  16. '章定位: 推进',
  17. '钩子: 危机钩-强',
  18. '情绪定位: 铺垫',
  19. ]
  20. const 意象 = '空气仿佛凝固,'.repeat(imagery)
  21. const body = [
  22. `林晚在旧案卷宗里翻到了第${num}条线索。`,
  23. `${意象}殿内落针可闻。`,
  24. '线索到这里又断了,她收起卷宗望向窗外。',
  25. ].join('\n')
  26. return `---\n${fm.join('\n')}\n---\n\n${body}\n`
  27. }
  28. const timeline = (nums) =>
  29. [
  30. '| 章 | 书内时间 | 一句话事件 | 在场 |',
  31. '|----|----------|------------|------|',
  32. ...nums.map((n) => `| ${n} | 1023春月初${n} | 第${n}章事件 | 林晚 |`),
  33. ].join('\n') + '\n'
  34. function fixtureFiles({ bookYaml, chapters = {}, timelineChapters = [1, 2, 3, 4, 5] } = {}) {
  35. const imageryPlan = { 1: 3, 2: 3, 3: 2, 4: 2, 5: 2 }
  36. const files = {
  37. 'book.yaml':
  38. bookYaml ??
  39. 'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n文体基线起: 1\n文体基线止: 2\n体检周期: 3\n',
  40. '定稿/设定/名册.md':
  41. '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 林晚 | 晚晚 | character | 1 |\n',
  42. '定稿/设定/时间线/第01卷.md': timeline(timelineChapters),
  43. }
  44. for (let n = 1; n <= 5; n++) {
  45. files[`定稿/正文/000${n}-第${n}章.md`] = chapterFile(n, {
  46. imagery: imageryPlan[n],
  47. ...(chapters[n] || {}),
  48. })
  49. }
  50. return files
  51. }
  52. test('体检 报告四节:高频意象/句式/指纹漂移/缺时间锚点(AC1 后半 + AC2 前半)', async () => {
  53. const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
  54. try {
  55. const r = await runHealthCheck(ctx)
  56. assert.equal(r.ok, true, r.error)
  57. assert.equal(r.maxChapter, 5)
  58. const report = await fs.readFile(r.filePath, 'utf8')
  59. assert.match(report, /## 高频意象(跨章)/)
  60. assert.match(report, /「空气仿佛凝固」:全书 12 次,5 章出现/)
  61. assert.match(report, /## 句式体检(第 3-5 章)/)
  62. assert.match(report, /句长方差/)
  63. assert.match(report, /段落长度分布/)
  64. assert.match(report, /高频句式开头/)
  65. assert.match(report, /## 文体指纹漂移/)
  66. assert.match(report, /基线(第 1-2 章)/)
  67. assert.match(report, /近段(第 3-5 章)/)
  68. assert.match(report, /漂移:平均句长/)
  69. assert.match(report, /## 缺时间锚点/)
  70. assert.match(report, /无(每章都有书内时间/)
  71. // meta:意象清单入缓存、体检章号照记
  72. const imagery = JSON.parse(
  73. (await ctx.cache.query("SELECT value FROM meta WHERE key = 'imagery_top'"))[0].value
  74. )
  75. assert.equal(imagery[0].phrase, '空气仿佛凝固')
  76. assert.equal(imagery[0].count, 12)
  77. const last = await ctx.cache.query(
  78. "SELECT value FROM meta WHERE key = 'last_health_check_chapter'"
  79. )
  80. assert.equal(last[0].value, '5')
  81. } finally {
  82. await cleanup()
  83. }
  84. })
  85. test('体检 指纹两行入表;删缓存全量重建后再体检逐字段一致(AC3)', async () => {
  86. const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
  87. try {
  88. const r1 = await runHealthCheck(ctx)
  89. assert.equal(r1.ok, true, r1.error)
  90. const q =
  91. 'SELECT * FROM fingerprints ORDER BY is_baseline DESC, chapter_range_start, chapter_range_end'
  92. const rows1 = await ctx.cache.query(q)
  93. assert.deepEqual(
  94. rows1.map((x) => [x.chapter_range_start, x.chapter_range_end, x.is_baseline]),
  95. [
  96. [1, 2, 1],
  97. [3, 5, 0],
  98. ]
  99. )
  100. // 全量重建:指纹表清空(重建器不填),meta 跨重建保留
  101. const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
  102. assert.equal(rb.ok, true, rb.errors?.join(';'))
  103. assert.equal((await ctx.cache.query('SELECT COUNT(*) AS c FROM fingerprints'))[0].c, 0)
  104. const r2 = await runHealthCheck(ctx)
  105. assert.equal(r2.ok, true, r2.error)
  106. const rows2 = await ctx.cache.query(q)
  107. assert.deepStrictEqual(rows2, rows1)
  108. } finally {
  109. await cleanup()
  110. }
  111. })
  112. test('体检 缺时间锚点:缺「书内时间」与时间线漏行各列出章号(AC5)', async () => {
  113. const files = fixtureFiles({
  114. chapters: { 3: { 时间: null, imagery: 2 } },
  115. timelineChapters: [1, 2, 3, 5],
  116. })
  117. const { ctx, cleanup } = await repoCtx(null, files)
  118. try {
  119. const r = await runHealthCheck(ctx)
  120. assert.equal(r.ok, true, r.error)
  121. const report = await fs.readFile(r.filePath, 'utf8')
  122. assert.match(report, /- 第 3 章:front matter 无「书内时间」/)
  123. assert.match(report, /- 第 4 章:时间线没有这章的行/)
  124. assert.deepEqual(r.data.缺时间锚点, [
  125. { 章: 3, 缺: ['front matter 无「书内时间」'] },
  126. { 章: 4, 缺: ['时间线没有这章的行'] },
  127. ])
  128. } finally {
  129. await cleanup()
  130. }
  131. })
  132. test('体检 返回结构化 data,形状稳定(AC7,M6 对接面)', async () => {
  133. const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
  134. try {
  135. const { data: d, ok, error } = await runHealthCheck(ctx)
  136. assert.equal(ok, true, error)
  137. assert.ok(Array.isArray(d.高频意象) && d.高频意象.length > 0)
  138. for (const k of ['phrase', 'count', 'chapterCount', 'firstChapter', 'lastChapter']) {
  139. assert.ok(k in d.高频意象[0], `高频意象缺 ${k}`)
  140. }
  141. assert.deepEqual(d.句式.窗口, [3, 5])
  142. assert.equal(typeof d.句式.平均句长, 'number')
  143. assert.equal(typeof d.句式.句长方差, 'number')
  144. assert.ok(d.句式.段落分布)
  145. assert.ok(Array.isArray(d.句式.高频开头))
  146. assert.deepEqual(d.指纹.基线.范围, [1, 2])
  147. assert.deepEqual(d.指纹.近段.范围, [3, 5])
  148. for (const k of ['平均句长', '句长方差', '平均段长', '词汇丰富度']) {
  149. assert.equal(typeof d.指纹.delta[k], 'number', `delta 缺 ${k}`)
  150. }
  151. assert.ok(Array.isArray(d.缺时间锚点))
  152. assert.ok(Array.isArray(d.悬了太久))
  153. assert.ok(Array.isArray(d.条目活跃率))
  154. assert.equal(typeof d.连续弱钩, 'number')
  155. } finally {
  156. await cleanup()
  157. }
  158. })
  159. test('体检 单项失败不炸整体:正文文件读不到 → 三统计节如实降级,报告照落、章号照记', async () => {
  160. const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
  161. try {
  162. await fs.rm(path.join(ctx.repoPath, '定稿', '正文', '0005-第5章.md'))
  163. const r = await runHealthCheck(ctx)
  164. assert.equal(r.ok, true, r.error)
  165. const report = await fs.readFile(r.filePath, 'utf8')
  166. assert.match(report, /该项计算失败/)
  167. assert.match(report, /## 缺时间锚点/)
  168. assert.equal(r.data.高频意象, null)
  169. assert.equal(r.data.指纹, null)
  170. const last = await ctx.cache.query(
  171. "SELECT value FROM meta WHERE key = 'last_health_check_chapter'"
  172. )
  173. assert.equal(last[0].value, '5')
  174. } finally {
  175. await cleanup()
  176. }
  177. })
  178. test('体检 基线段与近段重合(全书尚在基线区间)→ 只落基线行,如实说明', async () => {
  179. const files = fixtureFiles({
  180. bookYaml:
  181. 'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n文体基线起: 1\n文体基线止: 30\n体检周期: 50\n',
  182. })
  183. const { ctx, cleanup } = await repoCtx(null, files)
  184. try {
  185. const r = await runHealthCheck(ctx)
  186. assert.equal(r.ok, true, r.error)
  187. const rows = await ctx.cache.query('SELECT * FROM fingerprints')
  188. assert.equal(rows.length, 1)
  189. assert.equal(rows[0].is_baseline, 1)
  190. assert.equal(rows[0].chapter_range_start, 1)
  191. assert.equal(rows[0].chapter_range_end, 5)
  192. const report = await fs.readFile(r.filePath, 'utf8')
  193. assert.match(report, /基线与近段重合,暂无漂移可比/)
  194. assert.equal(r.data.指纹.近段, null)
  195. assert.equal(r.data.指纹.delta, null)
  196. } finally {
  197. await cleanup()
  198. }
  199. })