t75-mutations.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * 票 75 变异测试:证明 90 天分窗合并的每条关键断言抓得住它要防的缺陷。
  3. *
  4. * M1 合并丢趋势(只留首窗) → A2 必须红(90 天只显示 30 天)
  5. * M2 计数不求和(取首窗) → A1 必须红(13 变 0)
  6. * M3 处置率不重算(抄首窗百分比) → A3 必须红(0% 冒充 44%)
  7. * M4 分析 bucket 求和丢失 → B5 必须红(类型合计变 1 窗)
  8. * M5 百分比相加不重算 → B4 必须红(50%+25%≠75% 的老错误)
  9. * M6 分窗被拆掉(>31 天仍单请求) → C1 必须红(撞 400,界面「最多31天」复现)
  10. * M7 一窗失败仍合并其余 → C4 必须红(3/4 窗冒充 90 天)
  11. * M8 31 天提示复活 → D1 必须红(误导性提示回屏)
  12. *
  13. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t75-mutations.mjs
  14. */
  15. import assert from 'node:assert/strict'
  16. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  17. import { spawnSync } from 'node:child_process'
  18. import { tmpdir } from 'node:os'
  19. import { join } from 'node:path'
  20. import { fileURLToPath } from 'node:url'
  21. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  22. const GATE = fileURLToPath(new URL('t75-90d-multi-window.mjs', import.meta.url))
  23. const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
  24. const reportDir = mkdtempSync(join(tmpdir(), 't75-mutations-'))
  25. function runGate() {
  26. const outPath = join(reportDir, 'stdout.txt')
  27. const errPath = join(reportDir, 'stderr.txt')
  28. const out = openSync(outPath, 'w')
  29. const err = openSync(errPath, 'w')
  30. let status = 0
  31. try {
  32. status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
  33. } finally {
  34. closeSync(out)
  35. closeSync(err)
  36. }
  37. return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
  38. }
  39. function failingNames(output) {
  40. return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1])
  41. }
  42. let injected = 0
  43. let proved = 0
  44. function mutate(label, file, from, to, expectRed) {
  45. const path = srcPath(file)
  46. const original = readFileSync(path, 'utf8')
  47. const candidates = [from, from.replace(/\n/g, '\r\n')]
  48. const needle = candidates.find((candidate) => original.includes(candidate))
  49. if (!needle) {
  50. console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
  51. injected += 1
  52. return
  53. }
  54. injected += 1
  55. writeFileSync(path, original.replace(needle, to), 'utf8')
  56. runAndJudge(label, original, path, expectRed)
  57. }
  58. function runAndJudge(label, original, path, expectRed) {
  59. let caught = false
  60. let how = ''
  61. try {
  62. const { output, status } = runGate()
  63. const red = failingNames(output)
  64. if (red.includes(expectRed)) {
  65. caught = true
  66. how = `${expectRed} 转红`
  67. } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
  68. caught = true
  69. how = '闸门整体加载失败(导出缺失/语法错)— 崩溃式抓住'
  70. } else if (status !== 0) {
  71. how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
  72. } else {
  73. how = '未转红(假绿!闸门仍全绿)'
  74. }
  75. console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
  76. if (caught) proved += 1
  77. } finally {
  78. writeFileSync(path, original, 'utf8')
  79. assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
  80. }
  81. }
  82. // ---- 效率合并(services/warningResponseApi.js 的 mergeEfficiencyPayloads)----
  83. mutate(
  84. 'M1-efficiency-merge-drops-trend',
  85. 'services/warningResponseApi.js',
  86. ` for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) {
  87. if (seenBuckets.has(row.timeBucket)) continue
  88. seenBuckets.add(row.timeBucket)
  89. durationTrend.push(row)
  90. }`,
  91. ``,
  92. 'A2 趋势按时序拼接且去重(720×3+17=2177 个小时桶,升序)',
  93. )
  94. mutate(
  95. 'M2-efficiency-counts-take-last-window',
  96. 'services/warningResponseApi.js',
  97. ` totalEvents += countOf(summary.totalEvents)`,
  98. ` totalEvents = countOf(summary.totalEvents)`,
  99. // 注:`+=` 变 `||=` 的写法对本数据是**等价变异**(前两窗为 0),不采用;取「末窗覆盖」才可观测。
  100. 'A1 计数跨窗求和(totalEvents=0+0+13+0=13,不由单窗冒充 90 天)',
  101. )
  102. mutate(
  103. 'M3-handling-rate-copied-not-recomputed',
  104. 'services/warningResponseApi.js',
  105. ` const numerator = stateCounts.filter((row) => row.stateCode === 5).reduce((sum, row) => sum + row.count, 0)`,
  106. ` const numerator = countOf((list[0].summary && list[0].summary.handlingEfficiency || {}).numerator)`,
  107. 'A3 处置率用合并后状态计数重算(分子=已结案4,分母=可计入9 → 44.44%)',
  108. )
  109. // ---- 分析合并(mergeAnalysisPayloads)----
  110. mutate(
  111. 'M4-analysis-total-count-takes-last-window',
  112. 'services/warningResponseApi.js',
  113. ` totalCount += countOf(payload.totalCount)`,
  114. ` totalCount = countOf(payload.totalCount)`,
  115. // 注 1:维度级 `entry.totalCount` 的「末窗覆盖」对本数据等价(w3=13、w4 无该维度),
  116. // 不可观测;B1 断言的是**载荷级** totalCount(末窗 w4=0 → 0 ≠ 13,才可观测)。
  117. // 注 2:w1/w2 的维度桶为空数组,"丢弃后窗同码桶"同样不可观测。
  118. 'B1 totalCount 跨窗求和(13)',
  119. )
  120. mutate(
  121. 'M5-analysis-percentage-added-not-recomputed',
  122. 'services/warningResponseApi.js',
  123. ` percentage: entry.totalCount > 0 ? ((bucket.count / entry.totalCount) * 100).toFixed(2) : '0.00',`,
  124. ` percentage: bucket.percentage,`,
  125. 'B4 percentage 重算而不是各窗相加(EVENT_TYPE 首桶 = count/totalCount×100)',
  126. )
  127. // ---- 分窗机制 ----
  128. mutate(
  129. 'M6-window-splitting-removed',
  130. 'services/warningResponseApi.js',
  131. ` const windows = historyWindows(range.startTime, range.endTime)
  132. if (windows.length <= 1) {`,
  133. ` const windows = historyWindows(range.startTime, range.endTime)
  134. if (true) {`,
  135. 'C1 90 天触发分窗:恰好 4 次请求、每次 ≤30 天(不是一次打 90 天撞 400)',
  136. )
  137. mutate(
  138. 'M7-partial-failure-still-merges',
  139. 'services/warningResponseApi.js',
  140. ` const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state))
  141. if (failed) return failed`,
  142. ` // 变异:窗失败被吞掉,拿其余窗冒充全量
  143. const failed = null
  144. if (failed) return failed`,
  145. 'C4 一窗失败 → 整分区 FAILED(不拿 3/4 窗冒充 90 天)',
  146. )
  147. // ---- 提示 ----
  148. mutate(
  149. 'M8-31d-note-resurrected',
  150. 'components/WarningResponsePane.vue',
  151. ` <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>`,
  152. ` <p v-if="!eventStore.loading && eventStore.range === '90d' && panelId !== 'historyAnalysis'" class="warning-response-pane__range-status" role="status">
  153. 当前接口最多支持31天统计,请选择近7天或近30天;近90天历史趋势可正常查询。
  154. </p>
  155. <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>`,
  156. 'D1 「最多支持31天」提示已撤(不再成立;误导比没有提示更糟)',
  157. )
  158. console.log('')
  159. console.log(`T75 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  160. rmSync(reportDir, { recursive: true, force: true })
  161. if (proved < injected) process.exit(1)