| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- /**
- * 票 75 离线闸门:报警效率与预警分析支持近 90 天(分窗取数 + 前端合并)。
- *
- * 背景:后端三个聚合端点契约都是「单请求 ≤31 天」(实测 90 天显式窗口 →
- * `400 efficiency window must not exceed 31 days`);`history-analysis` 前端已用
- * `historyWindows` 分窗合并(票 61),但 `fetchHandlingEfficiency`/`fetchEventAnalysis`
- * 遇 >31 天直接返回 UNAVAILABLE,界面挂「最多支持31天」提示 —— 维护者要求 90 天出真实数据。
- *
- * 证据载荷:2026-10-21 对 192.168.110.96:8301 按 30 天切窗实取的 4 窗响应
- * (`artifacts/t75/`,WARNING/ALARM 效率各 4 窗 + 分析 4 窗)。全部离线断言,不联网。
- *
- * 断言分组:
- * A 效率合并:计数求和 / 状态分布按码求和 / 处置率重算 / 趋势时序拼接去重 / 事件页拼接
- * B 分析合并:totalCount 求和 / 维度按码合并 / bucket 求和 + percentage 重算(不是相加)
- * C 服务级:真实 fetchAggregated 路径(注入 fetchImpl 按窗分发)单窗零变化 + 多窗走合并
- * D 提示撤除:31 天提示不再出现(不再是事实,就是误导)
- *
- * 变异有牙见 t75-mutations.mjs。
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t75-90d-multi-window.mjs
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import { registerHooks } from 'node:module'
- import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
- const SRC = '../../../src/modules/cockpit/'
- const readVerifier = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8')
- registerHooks({
- resolve(specifier, context, nextResolve) {
- const resolved = nextResolve(specifier, context)
- const parent = context.parentURL || ''
- if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
- return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
- }
- return resolved
- },
- load(url, context, nextLoad) {
- if (url.endsWith('.json')) {
- return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
- }
- if (url.endsWith('.css')) return { format: 'module', shortCircuit: true, source: 'export default {}' }
- const isClient = url.endsWith('.vue?client')
- if (url.endsWith('.vue') || isClient) {
- const path = isClient ? url.slice(0, -'?client'.length) : url
- const source = readFileSync(new URL(path), 'utf8')
- const { descriptor } = parse(source, { filename: path })
- const script = compileScript(descriptor, { id: 't75', genDefaultAs: '__sfc__' })
- const template = compileTemplate({
- source: descriptor.template.content,
- filename: path,
- id: 't75',
- ssr: !isClient,
- compilerOptions: { bindingMetadata: script.bindings },
- })
- assert.deepEqual(template.errors, [], `${path} template`)
- const tail = isClient
- ? '__sfc__.render = render; export default __sfc__;'
- : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
- return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
- }
- return nextLoad(url, context)
- },
- })
- const { fetchEventAnalysis, fetchHandlingEfficiency, mergeEfficiencyPayloads, mergeAnalysisPayloads } = await import(`${SRC}services/warningResponseApi.js`)
- const { toHandlingEfficiency, toEventAnalysis } = await import(`${SRC}domain/warningResponsePanels.js`)
- const { SECTION_STATE } = await import(`${SRC}domain/sectionState.js`)
- const { eventTimeWindow } = await import(`${SRC}domain/eventRange.js`)
- let passed = 0
- let failed = 0
- function check(name, condition, detail = '') {
- if (condition) {
- passed += 1
- console.log(`PASS ${name}`)
- } else {
- failed += 1
- console.error(`FAIL ${name}: ${detail}`)
- }
- }
- const loadWindow = (name) => JSON.parse(readVerifier(`./artifacts/t75/${name}.json`)).data
- const efficiencyWindows = ['he-w1-WARNING', 'he-w2-WARNING', 'he-w3-WARNING', 'he-w4-WARNING'].map(loadWindow)
- const alarmWindows = ['he-w1-ALARM', 'he-w2-ALARM', 'he-w3-ALARM', 'he-w4-ALARM'].map(loadWindow)
- const analysisWindows = ['md-w1', 'md-w2', 'md-w3', 'md-w4'].map(loadWindow)
- const FULL_RANGE = { startTime: '2026-07-22T16:00:00.000Z', endTime: '2026-10-21T08:00:00.000Z' }
- // ---- A. 效率合并(服务真实合并函数,经 toHandlingEfficiency 出口)----
- {
- const mergedDomain = toHandlingEfficiency(
- // merge 是模块私有函数;从服务导出的唯一入口是 fetchAggregated —— 这里直接用
- // `fetchHandlingEfficiency` 注入 fetchImpl 分窗分发(见 C 组),本组先用域出口核数字。
- // 为了直接测合并产物,这里通过服务再导出的最小途径拿不到 merge,所以用
- // fetchHandlingEfficiency + fetchImpl 的方式(C 组覆盖服务路径),
- // 本组改为直接断言域转换对合并载荷新增字段的处理 —— 合并器本身在 C 组全链路钉住。
- // 为让本组独立成立,这里手搓合并等价载荷(与 C 组服务输出互证)。
- mergeEfficiencyPayloads(efficiencyWindows, FULL_RANGE),
- )
- check(
- 'A1 计数跨窗求和(totalEvents=0+0+13+0=13,不由单窗冒充 90 天)',
- mergedDomain.totalEvents === 13,
- `totalEvents=${mergedDomain.totalEvents}`,
- )
- check(
- 'A2 趋势按时序拼接且去重(720×3+17=2177 个小时桶,升序)',
- mergedDomain.eventTrend.length === 2177 &&
- mergedDomain.eventTrend.every((row, index, rows) => index === 0 || String(rows[index - 1].bucket) <= String(row.bucket)),
- `buckets=${mergedDomain.eventTrend.length}`,
- )
- check(
- 'A3 处置率用合并后状态计数重算(分子=已结案4,分母=可计入9 → 44.44%)',
- mergedDomain.numerator === 4 && mergedDomain.denominator === 9 && mergedDomain.handlingRate === 0.4444,
- `num=${mergedDomain.numerator} den=${mergedDomain.denominator} rate=${mergedDomain.handlingRate}`,
- )
- check(
- 'A4 事件页各窗拼接(13 条全部到位;「已返回页 vs 全量」标记与页数一致)',
- mergedDomain.levelDistributionComplete === (13 >= 13) &&
- mergedDomain.levelDistribution.reduce((sum, row) => sum + row.count, 0) === 13,
- `complete=${mergedDomain.levelDistributionComplete} sum=${mergedDomain.levelDistribution.reduce((s, r) => s + r.count, 0)}`,
- )
- check(
- 'A5 无结案样本时平均时长如实占位(不画 0 冒充)',
- mergedDomain.closeDuration === null,
- `closeDuration=${JSON.stringify(mergedDomain.closeDuration)}`,
- )
- // ALARM 交叉验证(数字不同,防「只对 WARNING 硬编码」)
- const alarmMerged = toHandlingEfficiency(mergeEfficiencyPayloads(alarmWindows, FULL_RANGE))
- check(
- 'A6 ALARM 族同样合并(totalEvents=90,处置率=已结案4/可计入9)',
- alarmMerged.totalEvents === 90 && alarmMerged.numerator === 4 && alarmMerged.denominator === 9,
- `total=${alarmMerged.totalEvents} num=${alarmMerged.numerator} den=${alarmMerged.denominator}`,
- )
- }
- // ---- B. 分析合并 ----
- {
- // 分析合并在**原始载荷**层断言(TIME 维度不进域对象;types/levels 等才是域出口)
- const mergedRaw = mergeAnalysisPayloads(analysisWindows, FULL_RANGE)
- const timeDim = (mergedRaw.dimensions || []).find((row) => row.dimensionCode === 'TIME')
- check(
- 'B1 totalCount 跨窗求和(13)',
- mergedRaw.totalCount === 13,
- `totalCount=${mergedRaw.totalCount}`,
- )
- check(
- 'B2 维度按 dimensionCode 合并(7 个维度一个不少)',
- (mergedRaw.dimensions || []).length === 7,
- `dims=${(mergedRaw.dimensions || []).map((row) => row.dimensionCode).join(',')}`,
- )
- check(
- 'B3 时间维度按时序拼接(91 个日桶=30+30+30+当日,升序;窗口相接不重叠)',
- !!timeDim && timeDim.buckets.length === 91 &&
- timeDim.buckets.every((row, index, rows) => index === 0 || String(rows[index - 1].bucketCode) <= String(row.bucketCode)),
- `timeBuckets=${timeDim && timeDim.buckets.length}`,
- )
- check(
- 'B4 percentage 重算而不是各窗相加(EVENT_TYPE 首桶 = count/totalCount×100)',
- (mergedRaw.dimensions || []).every((row) => row.buckets.every((bucket) => {
- const expected = row.totalCount > 0 ? ((bucket.count / row.totalCount) * 100).toFixed(2) : '0.00'
- return bucket.percentage === expected
- })),
- `sample=${JSON.stringify(((mergedRaw.dimensions || [])[0] || {}).buckets && mergedRaw.dimensions[0].buckets[0])}`,
- )
- const mergedDomain = toEventAnalysis(mergedRaw)
- check(
- 'B5 域出口消费合并载荷(类型合计 13)',
- (mergedDomain.types || []).reduce((sum, row) => sum + row.count, 0) === 13,
- `sum=${(mergedDomain.types || []).reduce((s, r) => s + r.count, 0)}`,
- )
- }
- // ---- C. 服务级:真实 fetchAggregated 路径(fetchImpl 按 URL 分发录制的窗口载荷)----
- {
- // 服务按 **now** 算窗口(eventTimeWindow → historyWindows),闸门用同一套函数算出
- // 预期的请求键,再把录制的窗口载荷挂到这些键上 —— 键对齐才能命中。
- const { historyWindows } = await import(`${SRC}domain/eventRange.js`)
- const expected90 = historyWindows(eventTimeWindow('LAST_90_DAYS').startTime, eventTimeWindow('LAST_90_DAYS').endTime)
- // 最后一窗的 endTime 是 **now 基准**(每次调用毫秒级变化),不能进键;
- // 窗口相接不重叠 → **startTime 唯一**,按它分发即可。
- const windowByUrl = new Map()
- const register = (payloads) => {
- payloads.forEach((payload, index) => {
- const window = expected90[index]
- if (window) windowByUrl.set(window.startTime, payload)
- })
- }
- register(efficiencyWindows)
- const responses = []
- const fetchImpl = async (url) => {
- const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
- const payload = windowByUrl.get(startTime)
- responses.push(url)
- if (!payload) return { ok: false, status: 500, json: async () => ({}) }
- return { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
- }
- const options = {
- mode: 'WARNING',
- rangeType: 'LAST_90_DAYS',
- config: { dataSource: 'real', api: { baseUrl: 'http://contract.invalid' } },
- fetchImpl,
- }
- const result = await fetchHandlingEfficiency(options)
- check(
- 'C1 90 天触发分窗:恰好 4 次请求、每次 ≤30 天(不是一次打 90 天撞 400)',
- responses.length === 4,
- `requests=${responses.length}`,
- )
- check(
- 'C2 合并分区 SUCCESS 且域对象带 90 天真实计数(totalEvents=13)',
- result.state === SECTION_STATE.SUCCESS && result.data && result.data.totalEvents === 13,
- `state=${result.state} total=${result.data && result.data.totalEvents}`,
- )
- // 单窗(7 天)零变化:仍然只发 1 次请求、走 toHandlingEfficiency 直转
- responses.length = 0
- const single = await fetchHandlingEfficiency({
- ...options,
- rangeType: 'LAST_7_DAYS',
- // 7 天窗口按 now 算,录制载荷里没有对应键 —— 单窗路径不查表,直接回第一窗载荷即可
- fetchImpl: async (url) => {
- responses.push(url)
- return { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: efficiencyWindows[0] }) }
- },
- })
- check(
- 'C3 单窗零变化(7 天仍走原路径:1 次请求,不因重构变味)',
- responses.length === 1 && single.state === SECTION_STATE.SUCCESS,
- `requests=${responses.length} state=${single.state}`,
- )
- // 任一窗失败 → 整分区 FAILED(不把 3/4 窗冒充 90 天)
- const partial = await fetchHandlingEfficiency({
- ...options,
- fetchImpl: async (url) => {
- const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
- if (startTime === expected90[2].startTime) {
- return { ok: false, status: 500, json: async () => ({}) }
- }
- const payload = windowByUrl.get(startTime)
- return payload
- ? { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
- : { ok: false, status: 500, json: async () => ({}) }
- },
- })
- check(
- 'C4 一窗失败 → 整分区 FAILED(不拿 3/4 窗冒充 90 天)',
- partial.state === SECTION_STATE.FAILED,
- `state=${partial.state}`,
- )
- // 分析端点同样分窗(键同上:按 startTime 分发,末窗 endTime 是 now 基准不能进键)
- const analysisByUrl = new Map()
- analysisWindows.forEach((payload, index) => {
- const window = expected90[index]
- if (window) analysisByUrl.set(window.startTime, payload)
- })
- const analysisResult = await fetchEventAnalysis({
- ...options,
- fetchImpl: async (url) => {
- const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
- const payload = analysisByUrl.get(startTime)
- return payload
- ? { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
- : { ok: false, status: 500, json: async () => ({}) }
- },
- })
- check(
- 'C5 预警分析 90 天也分窗合并(域对象类型合计 13)',
- analysisResult.state === SECTION_STATE.SUCCESS &&
- (analysisResult.data.types || []).reduce((sum, row) => sum + row.count, 0) === 13,
- `state=${analysisResult.state} sum=${(analysisResult.data.types || []).reduce((s, r) => s + r.count, 0)}`,
- )
- }
- // ---- D. 提示撤除 ----
- {
- const paneSource = readFileSync(new URL('../../../src/modules/cockpit/components/WarningResponsePane.vue', import.meta.url), 'utf8')
- check(
- 'D1 「最多支持31天」提示已撤(不再成立;误导比没有提示更糟)',
- !/最多支持31天/.test(paneSource),
- 'pane 里仍残留 31 天提示',
- )
- const apiSource = readFileSync(new URL('../../../src/modules/cockpit/services/warningResponseApi.js', import.meta.url), 'utf8')
- check(
- 'D2 unsupportedWindow 拦截已移除(90 天不再被前端自己挡掉)',
- !/unsupportedWindow/.test(apiSource),
- 'warningResponseApi 仍有 unsupportedWindow',
- )
- }
- function countOf(value) {
- const n = Number(value)
- return Number.isFinite(n) ? n : 0
- }
- console.log('')
- console.log(`T75 90 天分窗合并:${passed}/${passed + failed} 项通过。`)
- if (failed) {
- console.log('未通过项见上方 FAIL。')
- process.exit(1)
- }
- console.log('T75 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')
|