index.test.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {
  4. splitSentences,
  5. splitParagraphs,
  6. styleMetrics,
  7. extractImagery,
  8. extractFingerprint,
  9. windowTTR,
  10. } from '../../src/style-stats/index.js'
  11. // —— 分句 / 分段 ——
  12. test('分句 引号收尾/省略号/分号都断句,闭引号收编进句尾', () => {
  13. const r = splitSentences('「站住!」林晚喝道。她愣住……随即冷笑;转身便走。')
  14. assert.deepEqual(r, ['「站住', '林晚喝道', '她愣住', '随即冷笑', '转身便走'])
  15. })
  16. test('分句 空文本与无终结标点', () => {
  17. assert.deepEqual(splitSentences(''), [])
  18. assert.deepEqual(splitSentences(null), [])
  19. assert.deepEqual(splitSentences('还没写完的半句'), ['还没写完的半句'])
  20. })
  21. test('分段 换行断段,连续空行不产生空段', () => {
  22. assert.deepEqual(splitParagraphs('第一段\n\n\n第二段\n第三段'), ['第一段', '第二段', '第三段'])
  23. assert.deepEqual(splitParagraphs(''), [])
  24. })
  25. // —— 句式指标(小样本手算对照)——
  26. test('句式指标 均值方差手算对照:句长 [3,5,7] → 均 5 方差 8/3', () => {
  27. const m = styleMetrics('一二三。一二三四五。一二三四五六七。')
  28. assert.equal(m.句数, 3)
  29. assert.equal(m.平均句长, 5)
  30. assert.ok(Math.abs(m.句长方差 - 8 / 3) < 1e-12)
  31. })
  32. test('句式指标 段落数/平均段长/段落分布', () => {
  33. const m = styleMetrics('第一段有六字\n\n短段\n第三段落有七个字')
  34. assert.equal(m.段落数, 3)
  35. assert.ok(Math.abs(m.平均段长 - 16 / 3) < 1e-12)
  36. assert.equal(m.段落分布.短.段数, 3)
  37. assert.equal(m.段落分布.短.占比, 1)
  38. assert.equal(m.段落分布.超长.段数, 0)
  39. })
  40. test('句式指标 高频开头:人名前缀排除、句首引号跳过', () => {
  41. const m = styleMetrics('林晚冷笑。林晚转身。今日无事。今日有雨。“今日大吉。”', new Set(['林晚']))
  42. assert.deepEqual(m.高频开头, [{ 开头: '今日', 次数: 3, 占比: 1 }])
  43. })
  44. test('句式指标 空文本不炸,全零', () => {
  45. const m = styleMetrics('')
  46. assert.equal(m.句数, 0)
  47. assert.equal(m.平均句长, 0)
  48. assert.equal(m.句长方差, 0)
  49. assert.deepEqual(m.高频开头, [])
  50. })
  51. // —— 跨章高频意象 ——
  52. const imageryChapters = (n1, n2, n3, unit = '空气仿佛凝固,') => [
  53. { num: 1, text: unit.repeat(n1) },
  54. { num: 2, text: unit.repeat(n2) },
  55. { num: 3, text: unit.repeat(n3) },
  56. ]
  57. test('意象 阈值边界:全书 10 次报、9 次不报', () => {
  58. const hit = extractImagery(imageryChapters(4, 3, 3))
  59. assert.deepEqual(hit, [
  60. { phrase: '空气仿佛凝固', count: 10, chapterCount: 3, firstChapter: 1, lastChapter: 3 },
  61. ])
  62. assert.deepEqual(extractImagery(imageryChapters(3, 3, 3)), [])
  63. })
  64. test('意象 跨章条件:12 次但只出现在 2 章 → 不出', () => {
  65. const chapters = [
  66. { num: 1, text: '空气仿佛凝固,'.repeat(6) },
  67. { num: 2, text: '空气仿佛凝固,'.repeat(6) },
  68. { num: 3, text: '风平浪静。' },
  69. ]
  70. assert.deepEqual(extractImagery(chapters), [])
  71. })
  72. test('意象 专名排除:含「林晚」的短语与跨名碎片都不出', () => {
  73. const r = extractImagery(imageryChapters(4, 4, 4, '林晚冷笑一声,'), new Set(['林晚']))
  74. assert.ok(!r.some((x) => x.phrase.includes('林晚')), JSON.stringify(r))
  75. assert.ok(!r.some((x) => x.phrase.includes('晚冷')), JSON.stringify(r))
  76. // 名字切掉后剩余的通用搭配仍计数(12 次跨 3 章)
  77. assert.deepEqual(r.map((x) => x.phrase), ['冷笑一声'])
  78. })
  79. test('意象 最长优先去重:子串被父串覆盖,次数超父串 1.25 倍则独立保留', () => {
  80. const chapters = [
  81. { num: 1, text: '空气仿佛凝固,'.repeat(4) + '水面仿佛凝固,' },
  82. { num: 2, text: '空气仿佛凝固,'.repeat(3) + '水面仿佛凝固,' },
  83. { num: 3, text: '空气仿佛凝固,'.repeat(3) + '水面仿佛凝固,' },
  84. ]
  85. const r = extractImagery(chapters)
  86. // 仿佛凝固 13 次 > 10×1.25,突破父串覆盖独立成条;其余子串全被 空气仿佛凝固 覆盖
  87. assert.deepEqual(
  88. r.map((x) => ({ phrase: x.phrase, count: x.count })),
  89. [
  90. { phrase: '仿佛凝固', count: 13 },
  91. { phrase: '空气仿佛凝固', count: 10 },
  92. ]
  93. )
  94. })
  95. // —— 词汇丰富度(滑动窗口 TTR)——
  96. test('TTR 窗口平均与朴素 TTR 用长短两文本区分', () => {
  97. const base = '一二三四五六七八九十'.repeat(100) // 恰一个 1000 字窗
  98. assert.ok(Math.abs(windowTTR(base) - 0.01) < 1e-12)
  99. const longer = base + '百千万亿' // 第二窗 4 字全异 → TTR 1.0
  100. assert.ok(Math.abs(windowTTR(longer) - 0.505) < 1e-12)
  101. const naive = new Set([...longer]).size / longer.length
  102. assert.ok(naive < 0.02, '朴素 TTR 被文本长度压扁,窗口平均不受影响')
  103. })
  104. test('TTR 剥标点空白;空文本为 0', () => {
  105. assert.equal(windowTTR('一,二。三!\n'), 1)
  106. assert.equal(windowTTR(''), 0)
  107. assert.equal(windowTTR('……!!'), 0)
  108. })
  109. // —— 指纹 ——
  110. test('指纹 五常用列 + fingerprint_data 完整对象;章段内短语条件放宽为 ≥1 章', () => {
  111. const chapters = [{ num: 1, text: '空气仿佛凝固。'.repeat(10) }]
  112. const fp = extractFingerprint(chapters)
  113. assert.equal(fp.avg_sentence_length, 6)
  114. assert.equal(fp.sentence_length_variance, 0)
  115. assert.equal(fp.common_phrase_frequency['空气仿佛凝固'], 10)
  116. assert.ok(fp.vocabulary_richness > 0)
  117. assert.equal(fp.fingerprint_data.章数, 1)
  118. assert.equal(fp.fingerprint_data.总字数, 70)
  119. assert.ok(fp.fingerprint_data.段落分布)
  120. assert.ok(Array.isArray(fp.fingerprint_data.高频开头))
  121. })
  122. // —— 确定性(AC3 的根基)——
  123. test('确定性 同输入两次调用结果深等', () => {
  124. const chapters = [
  125. { num: 1, text: '空气仿佛凝固,'.repeat(4) + '林晚冷笑一声。水面仿佛凝固。' },
  126. { num: 2, text: '空气仿佛凝固,'.repeat(3) + '林晚转身离去。水面仿佛凝固。' },
  127. { num: 3, text: '空气仿佛凝固,'.repeat(3) + '今日无事发生。水面仿佛凝固。' },
  128. ]
  129. const ex = new Set(['林晚', '晚晚'])
  130. assert.deepStrictEqual(extractImagery(chapters, ex), extractImagery(chapters, ex))
  131. assert.deepStrictEqual(extractFingerprint(chapters, ex), extractFingerprint(chapters, ex))
  132. assert.deepStrictEqual(styleMetrics(chapters[0].text, ex), styleMetrics(chapters[0].text, ex))
  133. })