/** * 票 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 未自动执行)。')