t75-90d-multi-window.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * 票 75 离线闸门:报警效率与预警分析支持近 90 天(分窗取数 + 前端合并)。
  3. *
  4. * 背景:后端三个聚合端点契约都是「单请求 ≤31 天」(实测 90 天显式窗口 →
  5. * `400 efficiency window must not exceed 31 days`);`history-analysis` 前端已用
  6. * `historyWindows` 分窗合并(票 61),但 `fetchHandlingEfficiency`/`fetchEventAnalysis`
  7. * 遇 >31 天直接返回 UNAVAILABLE,界面挂「最多支持31天」提示 —— 维护者要求 90 天出真实数据。
  8. *
  9. * 证据载荷:2026-10-21 对 192.168.110.96:8301 按 30 天切窗实取的 4 窗响应
  10. * (`artifacts/t75/`,WARNING/ALARM 效率各 4 窗 + 分析 4 窗)。全部离线断言,不联网。
  11. *
  12. * 断言分组:
  13. * A 效率合并:计数求和 / 状态分布按码求和 / 处置率重算 / 趋势时序拼接去重 / 事件页拼接
  14. * B 分析合并:totalCount 求和 / 维度按码合并 / bucket 求和 + percentage 重算(不是相加)
  15. * C 服务级:真实 fetchAggregated 路径(注入 fetchImpl 按窗分发)单窗零变化 + 多窗走合并
  16. * D 提示撤除:31 天提示不再出现(不再是事实,就是误导)
  17. *
  18. * 变异有牙见 t75-mutations.mjs。
  19. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t75-90d-multi-window.mjs
  20. */
  21. import assert from 'node:assert/strict'
  22. import { readFileSync } from 'node:fs'
  23. import { registerHooks } from 'node:module'
  24. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  25. const SRC = '../../../src/modules/cockpit/'
  26. const readVerifier = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8')
  27. registerHooks({
  28. resolve(specifier, context, nextResolve) {
  29. const resolved = nextResolve(specifier, context)
  30. const parent = context.parentURL || ''
  31. if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
  32. return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
  33. }
  34. return resolved
  35. },
  36. load(url, context, nextLoad) {
  37. if (url.endsWith('.json')) {
  38. return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
  39. }
  40. if (url.endsWith('.css')) return { format: 'module', shortCircuit: true, source: 'export default {}' }
  41. const isClient = url.endsWith('.vue?client')
  42. if (url.endsWith('.vue') || isClient) {
  43. const path = isClient ? url.slice(0, -'?client'.length) : url
  44. const source = readFileSync(new URL(path), 'utf8')
  45. const { descriptor } = parse(source, { filename: path })
  46. const script = compileScript(descriptor, { id: 't75', genDefaultAs: '__sfc__' })
  47. const template = compileTemplate({
  48. source: descriptor.template.content,
  49. filename: path,
  50. id: 't75',
  51. ssr: !isClient,
  52. compilerOptions: { bindingMetadata: script.bindings },
  53. })
  54. assert.deepEqual(template.errors, [], `${path} template`)
  55. const tail = isClient
  56. ? '__sfc__.render = render; export default __sfc__;'
  57. : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
  58. return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
  59. }
  60. return nextLoad(url, context)
  61. },
  62. })
  63. const { fetchEventAnalysis, fetchHandlingEfficiency, mergeEfficiencyPayloads, mergeAnalysisPayloads } = await import(`${SRC}services/warningResponseApi.js`)
  64. const { toHandlingEfficiency, toEventAnalysis } = await import(`${SRC}domain/warningResponsePanels.js`)
  65. const { SECTION_STATE } = await import(`${SRC}domain/sectionState.js`)
  66. const { eventTimeWindow } = await import(`${SRC}domain/eventRange.js`)
  67. let passed = 0
  68. let failed = 0
  69. function check(name, condition, detail = '') {
  70. if (condition) {
  71. passed += 1
  72. console.log(`PASS ${name}`)
  73. } else {
  74. failed += 1
  75. console.error(`FAIL ${name}: ${detail}`)
  76. }
  77. }
  78. const loadWindow = (name) => JSON.parse(readVerifier(`./artifacts/t75/${name}.json`)).data
  79. const efficiencyWindows = ['he-w1-WARNING', 'he-w2-WARNING', 'he-w3-WARNING', 'he-w4-WARNING'].map(loadWindow)
  80. const alarmWindows = ['he-w1-ALARM', 'he-w2-ALARM', 'he-w3-ALARM', 'he-w4-ALARM'].map(loadWindow)
  81. const analysisWindows = ['md-w1', 'md-w2', 'md-w3', 'md-w4'].map(loadWindow)
  82. const FULL_RANGE = { startTime: '2026-07-22T16:00:00.000Z', endTime: '2026-10-21T08:00:00.000Z' }
  83. // ---- A. 效率合并(服务真实合并函数,经 toHandlingEfficiency 出口)----
  84. {
  85. const mergedDomain = toHandlingEfficiency(
  86. // merge 是模块私有函数;从服务导出的唯一入口是 fetchAggregated —— 这里直接用
  87. // `fetchHandlingEfficiency` 注入 fetchImpl 分窗分发(见 C 组),本组先用域出口核数字。
  88. // 为了直接测合并产物,这里通过服务再导出的最小途径拿不到 merge,所以用
  89. // fetchHandlingEfficiency + fetchImpl 的方式(C 组覆盖服务路径),
  90. // 本组改为直接断言域转换对合并载荷新增字段的处理 —— 合并器本身在 C 组全链路钉住。
  91. // 为让本组独立成立,这里手搓合并等价载荷(与 C 组服务输出互证)。
  92. mergeEfficiencyPayloads(efficiencyWindows, FULL_RANGE),
  93. )
  94. check(
  95. 'A1 计数跨窗求和(totalEvents=0+0+13+0=13,不由单窗冒充 90 天)',
  96. mergedDomain.totalEvents === 13,
  97. `totalEvents=${mergedDomain.totalEvents}`,
  98. )
  99. check(
  100. 'A2 趋势按时序拼接且去重(720×3+17=2177 个小时桶,升序)',
  101. mergedDomain.eventTrend.length === 2177 &&
  102. mergedDomain.eventTrend.every((row, index, rows) => index === 0 || String(rows[index - 1].bucket) <= String(row.bucket)),
  103. `buckets=${mergedDomain.eventTrend.length}`,
  104. )
  105. check(
  106. 'A3 处置率用合并后状态计数重算(分子=已结案4,分母=可计入9 → 44.44%)',
  107. mergedDomain.numerator === 4 && mergedDomain.denominator === 9 && mergedDomain.handlingRate === 0.4444,
  108. `num=${mergedDomain.numerator} den=${mergedDomain.denominator} rate=${mergedDomain.handlingRate}`,
  109. )
  110. check(
  111. 'A4 事件页各窗拼接(13 条全部到位;「已返回页 vs 全量」标记与页数一致)',
  112. mergedDomain.levelDistributionComplete === (13 >= 13) &&
  113. mergedDomain.levelDistribution.reduce((sum, row) => sum + row.count, 0) === 13,
  114. `complete=${mergedDomain.levelDistributionComplete} sum=${mergedDomain.levelDistribution.reduce((s, r) => s + r.count, 0)}`,
  115. )
  116. check(
  117. 'A5 无结案样本时平均时长如实占位(不画 0 冒充)',
  118. mergedDomain.closeDuration === null,
  119. `closeDuration=${JSON.stringify(mergedDomain.closeDuration)}`,
  120. )
  121. // ALARM 交叉验证(数字不同,防「只对 WARNING 硬编码」)
  122. const alarmMerged = toHandlingEfficiency(mergeEfficiencyPayloads(alarmWindows, FULL_RANGE))
  123. check(
  124. 'A6 ALARM 族同样合并(totalEvents=90,处置率=已结案4/可计入9)',
  125. alarmMerged.totalEvents === 90 && alarmMerged.numerator === 4 && alarmMerged.denominator === 9,
  126. `total=${alarmMerged.totalEvents} num=${alarmMerged.numerator} den=${alarmMerged.denominator}`,
  127. )
  128. }
  129. // ---- B. 分析合并 ----
  130. {
  131. // 分析合并在**原始载荷**层断言(TIME 维度不进域对象;types/levels 等才是域出口)
  132. const mergedRaw = mergeAnalysisPayloads(analysisWindows, FULL_RANGE)
  133. const timeDim = (mergedRaw.dimensions || []).find((row) => row.dimensionCode === 'TIME')
  134. check(
  135. 'B1 totalCount 跨窗求和(13)',
  136. mergedRaw.totalCount === 13,
  137. `totalCount=${mergedRaw.totalCount}`,
  138. )
  139. check(
  140. 'B2 维度按 dimensionCode 合并(7 个维度一个不少)',
  141. (mergedRaw.dimensions || []).length === 7,
  142. `dims=${(mergedRaw.dimensions || []).map((row) => row.dimensionCode).join(',')}`,
  143. )
  144. check(
  145. 'B3 时间维度按时序拼接(91 个日桶=30+30+30+当日,升序;窗口相接不重叠)',
  146. !!timeDim && timeDim.buckets.length === 91 &&
  147. timeDim.buckets.every((row, index, rows) => index === 0 || String(rows[index - 1].bucketCode) <= String(row.bucketCode)),
  148. `timeBuckets=${timeDim && timeDim.buckets.length}`,
  149. )
  150. check(
  151. 'B4 percentage 重算而不是各窗相加(EVENT_TYPE 首桶 = count/totalCount×100)',
  152. (mergedRaw.dimensions || []).every((row) => row.buckets.every((bucket) => {
  153. const expected = row.totalCount > 0 ? ((bucket.count / row.totalCount) * 100).toFixed(2) : '0.00'
  154. return bucket.percentage === expected
  155. })),
  156. `sample=${JSON.stringify(((mergedRaw.dimensions || [])[0] || {}).buckets && mergedRaw.dimensions[0].buckets[0])}`,
  157. )
  158. const mergedDomain = toEventAnalysis(mergedRaw)
  159. check(
  160. 'B5 域出口消费合并载荷(类型合计 13)',
  161. (mergedDomain.types || []).reduce((sum, row) => sum + row.count, 0) === 13,
  162. `sum=${(mergedDomain.types || []).reduce((s, r) => s + r.count, 0)}`,
  163. )
  164. }
  165. // ---- C. 服务级:真实 fetchAggregated 路径(fetchImpl 按 URL 分发录制的窗口载荷)----
  166. {
  167. // 服务按 **now** 算窗口(eventTimeWindow → historyWindows),闸门用同一套函数算出
  168. // 预期的请求键,再把录制的窗口载荷挂到这些键上 —— 键对齐才能命中。
  169. const { historyWindows } = await import(`${SRC}domain/eventRange.js`)
  170. const expected90 = historyWindows(eventTimeWindow('LAST_90_DAYS').startTime, eventTimeWindow('LAST_90_DAYS').endTime)
  171. // 最后一窗的 endTime 是 **now 基准**(每次调用毫秒级变化),不能进键;
  172. // 窗口相接不重叠 → **startTime 唯一**,按它分发即可。
  173. const windowByUrl = new Map()
  174. const register = (payloads) => {
  175. payloads.forEach((payload, index) => {
  176. const window = expected90[index]
  177. if (window) windowByUrl.set(window.startTime, payload)
  178. })
  179. }
  180. register(efficiencyWindows)
  181. const responses = []
  182. const fetchImpl = async (url) => {
  183. const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
  184. const payload = windowByUrl.get(startTime)
  185. responses.push(url)
  186. if (!payload) return { ok: false, status: 500, json: async () => ({}) }
  187. return { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
  188. }
  189. const options = {
  190. mode: 'WARNING',
  191. rangeType: 'LAST_90_DAYS',
  192. config: { dataSource: 'real', api: { baseUrl: 'http://contract.invalid' } },
  193. fetchImpl,
  194. }
  195. const result = await fetchHandlingEfficiency(options)
  196. check(
  197. 'C1 90 天触发分窗:恰好 4 次请求、每次 ≤30 天(不是一次打 90 天撞 400)',
  198. responses.length === 4,
  199. `requests=${responses.length}`,
  200. )
  201. check(
  202. 'C2 合并分区 SUCCESS 且域对象带 90 天真实计数(totalEvents=13)',
  203. result.state === SECTION_STATE.SUCCESS && result.data && result.data.totalEvents === 13,
  204. `state=${result.state} total=${result.data && result.data.totalEvents}`,
  205. )
  206. // 单窗(7 天)零变化:仍然只发 1 次请求、走 toHandlingEfficiency 直转
  207. responses.length = 0
  208. const single = await fetchHandlingEfficiency({
  209. ...options,
  210. rangeType: 'LAST_7_DAYS',
  211. // 7 天窗口按 now 算,录制载荷里没有对应键 —— 单窗路径不查表,直接回第一窗载荷即可
  212. fetchImpl: async (url) => {
  213. responses.push(url)
  214. return { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: efficiencyWindows[0] }) }
  215. },
  216. })
  217. check(
  218. 'C3 单窗零变化(7 天仍走原路径:1 次请求,不因重构变味)',
  219. responses.length === 1 && single.state === SECTION_STATE.SUCCESS,
  220. `requests=${responses.length} state=${single.state}`,
  221. )
  222. // 任一窗失败 → 整分区 FAILED(不把 3/4 窗冒充 90 天)
  223. const partial = await fetchHandlingEfficiency({
  224. ...options,
  225. fetchImpl: async (url) => {
  226. const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
  227. if (startTime === expected90[2].startTime) {
  228. return { ok: false, status: 500, json: async () => ({}) }
  229. }
  230. const payload = windowByUrl.get(startTime)
  231. return payload
  232. ? { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
  233. : { ok: false, status: 500, json: async () => ({}) }
  234. },
  235. })
  236. check(
  237. 'C4 一窗失败 → 整分区 FAILED(不拿 3/4 窗冒充 90 天)',
  238. partial.state === SECTION_STATE.FAILED,
  239. `state=${partial.state}`,
  240. )
  241. // 分析端点同样分窗(键同上:按 startTime 分发,末窗 endTime 是 now 基准不能进键)
  242. const analysisByUrl = new Map()
  243. analysisWindows.forEach((payload, index) => {
  244. const window = expected90[index]
  245. if (window) analysisByUrl.set(window.startTime, payload)
  246. })
  247. const analysisResult = await fetchEventAnalysis({
  248. ...options,
  249. fetchImpl: async (url) => {
  250. const startTime = decodeURIComponent(/startTime=([^&]+)/.exec(url)[1])
  251. const payload = analysisByUrl.get(startTime)
  252. return payload
  253. ? { ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: payload }) }
  254. : { ok: false, status: 500, json: async () => ({}) }
  255. },
  256. })
  257. check(
  258. 'C5 预警分析 90 天也分窗合并(域对象类型合计 13)',
  259. analysisResult.state === SECTION_STATE.SUCCESS &&
  260. (analysisResult.data.types || []).reduce((sum, row) => sum + row.count, 0) === 13,
  261. `state=${analysisResult.state} sum=${(analysisResult.data.types || []).reduce((s, r) => s + r.count, 0)}`,
  262. )
  263. }
  264. // ---- D. 提示撤除 ----
  265. {
  266. const paneSource = readFileSync(new URL('../../../src/modules/cockpit/components/WarningResponsePane.vue', import.meta.url), 'utf8')
  267. check(
  268. 'D1 「最多支持31天」提示已撤(不再成立;误导比没有提示更糟)',
  269. !/最多支持31天/.test(paneSource),
  270. 'pane 里仍残留 31 天提示',
  271. )
  272. const apiSource = readFileSync(new URL('../../../src/modules/cockpit/services/warningResponseApi.js', import.meta.url), 'utf8')
  273. check(
  274. 'D2 unsupportedWindow 拦截已移除(90 天不再被前端自己挡掉)',
  275. !/unsupportedWindow/.test(apiSource),
  276. 'warningResponseApi 仍有 unsupportedWindow',
  277. )
  278. }
  279. function countOf(value) {
  280. const n = Number(value)
  281. return Number.isFinite(n) ? n : 0
  282. }
  283. console.log('')
  284. console.log(`T75 90 天分窗合并:${passed}/${passed + failed} 项通过。`)
  285. if (failed) {
  286. console.log('未通过项见上方 FAIL。')
  287. process.exit(1)
  288. }
  289. console.log('T75 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')