/** * 票 75 变异测试:证明 90 天分窗合并的每条关键断言抓得住它要防的缺陷。 * * M1 合并丢趋势(只留首窗) → A2 必须红(90 天只显示 30 天) * M2 计数不求和(取首窗) → A1 必须红(13 变 0) * M3 处置率不重算(抄首窗百分比) → A3 必须红(0% 冒充 44%) * M4 分析 bucket 求和丢失 → B5 必须红(类型合计变 1 窗) * M5 百分比相加不重算 → B4 必须红(50%+25%≠75% 的老错误) * M6 分窗被拆掉(>31 天仍单请求) → C1 必须红(撞 400,界面「最多31天」复现) * M7 一窗失败仍合并其余 → C4 必须红(3/4 窗冒充 90 天) * M8 31 天提示复活 → D1 必须红(误导性提示回屏) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t75-mutations.mjs */ import assert from 'node:assert/strict' import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const GATE = fileURLToPath(new URL('t75-90d-multi-window.mjs', import.meta.url)) const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}` const reportDir = mkdtempSync(join(tmpdir(), 't75-mutations-')) function runGate() { const outPath = join(reportDir, 'stdout.txt') const errPath = join(reportDir, 'stderr.txt') const out = openSync(outPath, 'w') const err = openSync(errPath, 'w') let status = 0 try { status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status } finally { closeSync(out) closeSync(err) } return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status } } function failingNames(output) { return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1]) } let injected = 0 let proved = 0 function mutate(label, file, from, to, expectRed) { const path = srcPath(file) const original = readFileSync(path, 'utf8') const candidates = [from, from.replace(/\n/g, '\r\n')] const needle = candidates.find((candidate) => original.includes(candidate)) if (!needle) { console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`) injected += 1 return } injected += 1 writeFileSync(path, original.replace(needle, to), 'utf8') runAndJudge(label, original, path, expectRed) } function runAndJudge(label, original, path, expectRed) { let caught = false let how = '' try { const { output, status } = runGate() const red = failingNames(output) if (red.includes(expectRed)) { caught = true how = `${expectRed} 转红` } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) { caught = true how = '闸门整体加载失败(导出缺失/语法错)— 崩溃式抓住' } else if (status !== 0) { how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记` } else { how = '未转红(假绿!闸门仍全绿)' } console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`) if (caught) proved += 1 } finally { writeFileSync(path, original, 'utf8') assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`) } } // ---- 效率合并(services/warningResponseApi.js 的 mergeEfficiencyPayloads)---- mutate( 'M1-efficiency-merge-drops-trend', 'services/warningResponseApi.js', ` for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) { if (seenBuckets.has(row.timeBucket)) continue seenBuckets.add(row.timeBucket) durationTrend.push(row) }`, ``, 'A2 趋势按时序拼接且去重(720×3+17=2177 个小时桶,升序)', ) mutate( 'M2-efficiency-counts-take-last-window', 'services/warningResponseApi.js', ` totalEvents += countOf(summary.totalEvents)`, ` totalEvents = countOf(summary.totalEvents)`, // 注:`+=` 变 `||=` 的写法对本数据是**等价变异**(前两窗为 0),不采用;取「末窗覆盖」才可观测。 'A1 计数跨窗求和(totalEvents=0+0+13+0=13,不由单窗冒充 90 天)', ) mutate( 'M3-handling-rate-copied-not-recomputed', 'services/warningResponseApi.js', ` const numerator = stateCounts.filter((row) => row.stateCode === 5).reduce((sum, row) => sum + row.count, 0)`, ` const numerator = countOf((list[0].summary && list[0].summary.handlingEfficiency || {}).numerator)`, 'A3 处置率用合并后状态计数重算(分子=已结案4,分母=可计入9 → 44.44%)', ) // ---- 分析合并(mergeAnalysisPayloads)---- mutate( 'M4-analysis-total-count-takes-last-window', 'services/warningResponseApi.js', ` totalCount += countOf(payload.totalCount)`, ` totalCount = countOf(payload.totalCount)`, // 注 1:维度级 `entry.totalCount` 的「末窗覆盖」对本数据等价(w3=13、w4 无该维度), // 不可观测;B1 断言的是**载荷级** totalCount(末窗 w4=0 → 0 ≠ 13,才可观测)。 // 注 2:w1/w2 的维度桶为空数组,"丢弃后窗同码桶"同样不可观测。 'B1 totalCount 跨窗求和(13)', ) mutate( 'M5-analysis-percentage-added-not-recomputed', 'services/warningResponseApi.js', ` percentage: entry.totalCount > 0 ? ((bucket.count / entry.totalCount) * 100).toFixed(2) : '0.00',`, ` percentage: bucket.percentage,`, 'B4 percentage 重算而不是各窗相加(EVENT_TYPE 首桶 = count/totalCount×100)', ) // ---- 分窗机制 ---- mutate( 'M6-window-splitting-removed', 'services/warningResponseApi.js', ` const windows = historyWindows(range.startTime, range.endTime) if (windows.length <= 1) {`, ` const windows = historyWindows(range.startTime, range.endTime) if (true) {`, 'C1 90 天触发分窗:恰好 4 次请求、每次 ≤30 天(不是一次打 90 天撞 400)', ) mutate( 'M7-partial-failure-still-merges', 'services/warningResponseApi.js', ` const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state)) if (failed) return failed`, ` // 变异:窗失败被吞掉,拿其余窗冒充全量 const failed = null if (failed) return failed`, 'C4 一窗失败 → 整分区 FAILED(不拿 3/4 窗冒充 90 天)', ) // ---- 提示 ---- mutate( 'M8-31d-note-resurrected', 'components/WarningResponsePane.vue', `
{{ section.reason }}
`, `当前接口最多支持31天统计,请选择近7天或近30天;近90天历史趋势可正常查询。
{{ section.reason }}
`, 'D1 「最多支持31天」提示已撤(不再成立;误导比没有提示更糟)', ) console.log('') console.log(`T75 变异测试:${proved}/${injected} 处变异被对应断言抓住。`) rmSync(reportDir, { recursive: true, force: true }) if (proved < injected) process.exit(1)