t65-warning-efficiency-family-split.mjs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. /**
  2. * 票 65 离线闸门:预警效率面板按预警/报警分流 —— 预警族展示预警流程而非工单口径。
  3. *
  4. * 背景(票面):维护者 2026-10-19 反馈第 6 条 —— 预警处置「预警效率」面板没有做
  5. * 预警/报警模式的数据区分;预警模式下没有关联工单数据,应展示**预警流程**的信息。
  6. * 实测(2026-10-21 复验,`192.168.110.96:8301`):`?eventFamily=WARNING` 与 `=ALARM`
  7. * 返回**同一套工单口径字段**;预警族 `totalEvents=5` 而 `linkedWorkOrderCount=0`、
  8. * `missingWorkOrderCount=5` —— 预警不挂工单,照画工单口径就是「用一个不存在的对象统计预警」。
  9. *
  10. * 维护者裁定(方案 A):本票只做**诚实的语义分流**,不假装后端已有预警流程字段;
  11. * 预警族只画该端点里真实属于预警的读数(条数、逐事件等级、逐桶发生量、工单关联实况),
  12. * 工单处置率/工单状态分布/结案时长在预警模式下**不上屏**;契约缺口写进界面说明并另开后端票。
  13. *
  14. * 断言分组(都不启浏览器、不联网;渲染层用 Vue SSR 跑真实组件):
  15. * A 真实载荷两族:预警族读数全是真实计数、视图**不含**工单口径;报警族保留工单口径。
  16. * B 标题/口径说明/KPI 提示随切换一起换,词表复用 `eventListSwitchView` 单源。
  17. * C 分流依据:`data.family`(后端回显)优先于 `extra.mode`(切换参数),都不给才落报警口径。
  18. * D 渲染层 SSR:预警模式 DOM 无处置率/工单状态分布;报警模式有;口径说明在 DOM 里。
  19. * E 接线:面板组件把 `eventStore.mode` 交给 domain;外壳标题走 domain 单源。
  20. * F mock 同构:mock 两族与真实同形(含工单字段,不把缺口藏起来),过同一 domain 得同一结论。
  21. * G 缺失即占位:读数缺失画 `—`,不把「没有」画成 0(票 22/42 同一纪律)。
  22. *
  23. * 变异有牙见 t65-mutations.mjs。
  24. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t65-warning-efficiency-family-split.mjs
  25. */
  26. import assert from 'node:assert/strict'
  27. import { readFileSync } from 'node:fs'
  28. import { registerHooks } from 'node:module'
  29. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  30. import { createSSRApp, h } from 'vue'
  31. import { renderToString } from '@vue/server-renderer'
  32. const SRC = '../../../src/modules/cockpit/'
  33. const ROOT = new URL('../../../', import.meta.url)
  34. const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
  35. const readVerifier = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8')
  36. // .vue 一律走 SSR 编译钩子(t64 成例:共享子组件按 parentURL 传播,否则子组件只有 render)。
  37. registerHooks({
  38. resolve(specifier, context, nextResolve) {
  39. const resolved = nextResolve(specifier, context)
  40. const parent = context.parentURL || ''
  41. if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
  42. return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
  43. }
  44. return resolved
  45. },
  46. load(url, context, nextLoad) {
  47. if (url.endsWith('.json')) {
  48. return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
  49. }
  50. const isClient = url.endsWith('.vue?client')
  51. if (url.endsWith('.vue') || isClient) {
  52. const path = isClient ? url.slice(0, -'?client'.length) : url
  53. const source = readFileSync(new URL(path), 'utf8')
  54. const { descriptor } = parse(source, { filename: path })
  55. const script = compileScript(descriptor, { id: 't65', genDefaultAs: '__sfc__' })
  56. const template = compileTemplate({
  57. source: descriptor.template.content,
  58. filename: path,
  59. id: 't65',
  60. ssr: !isClient,
  61. compilerOptions: { bindingMetadata: script.bindings },
  62. })
  63. assert.deepEqual(template.errors, [], `${path} template`)
  64. const tail = isClient
  65. ? '__sfc__.render = render; export default __sfc__;'
  66. : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
  67. return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
  68. }
  69. return nextLoad(url, context)
  70. },
  71. })
  72. const {
  73. ALARM_EFFICIENCY_BASIS,
  74. WARNING_EFFICIENCY_GAP,
  75. responseEfficiencyPanel,
  76. responseEfficiencyTitle,
  77. toHandlingEfficiency,
  78. } = await import(`${SRC}domain/warningResponsePanels.js`)
  79. const { eventListSwitchView } = await import(`${SRC}domain/eventList.js`)
  80. const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
  81. const { default: PanelBlocks } = await import(`${SRC}components/PanelBlocks.vue`)
  82. let passed = 0
  83. let failed = 0
  84. function check(name, condition, detail = '') {
  85. if (condition) {
  86. passed += 1
  87. console.log(`PASS ${name}`)
  88. } else {
  89. failed += 1
  90. console.error(`FAIL ${name}: ${detail}`)
  91. }
  92. }
  93. const okSection = (data) => ({ ...section(SECTION_STATE.SUCCESS, { data }), loaded: true })
  94. const viewOf = (data, extra) => responseEfficiencyPanel(data, okSection(data), extra)
  95. const kpiOf = (view) => (view.blocks.find((block) => block.kind === 'kpi') || { items: [] }).items
  96. const kpiKeys = (view) => kpiOf(view).map((item) => item.key)
  97. const blockLabels = (view) => view.blocks.map((block) => block.label).filter(Boolean)
  98. const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
  99. const WORK_ORDER_ONLY_KEYS = ['handlingRate', 'responseDuration', 'closeDuration']
  100. const WARNING_ONLY_KEYS = ['warningTotal', 'warningLinked', 'warningMissing']
  101. // ================================================================ A. 真实载荷两族
  102. // 载荷来自 2026-10-21 对 192.168.110.96:8301 的只读实测(同目录 artifacts/t65/)。
  103. const liveWarning = JSON.parse(readVerifier('./artifacts/t65/handling-warning-live.json')).data
  104. const liveAlarm = JSON.parse(readVerifier('./artifacts/t65/handling-alarm-live.json')).data
  105. const warningData = toHandlingEfficiency(liveWarning)
  106. const alarmData = toHandlingEfficiency(liveAlarm)
  107. const warningView = viewOf(warningData, { mode: 'WARNING' })
  108. const alarmView = viewOf(alarmData, { mode: 'ALARM' })
  109. {
  110. check(
  111. 'A1 两族族别取自后端回显(同一响应类,靠 eventFamily 分流)',
  112. warningData.family === 'WARNING' && alarmData.family === 'ALARM',
  113. `warning=${warningData.family} alarm=${alarmData.family}`,
  114. )
  115. check(
  116. 'A2 预警族工单关联实况是真实计数(预警不挂工单:0 关联 / 全部未关联)',
  117. warningData.totalEvents === liveWarning.summary.totalEvents &&
  118. warningData.linkedWorkOrderCount === liveWarning.summary.linkedWorkOrderCount &&
  119. warningData.missingWorkOrderCount === liveWarning.summary.missingWorkOrderCount &&
  120. warningData.linkedWorkOrderCount === 0 &&
  121. warningData.missingWorkOrderCount === warningData.totalEvents,
  122. `total=${warningData.totalEvents} linked=${warningData.linkedWorkOrderCount} missing=${warningData.missingWorkOrderCount}`,
  123. )
  124. // A3 预警族 KPI 全是预警自身读数,读数取自真实载荷(不是编的、也不是工单口径)
  125. const keys = kpiKeys(warningView)
  126. const items = kpiOf(warningView)
  127. check(
  128. 'A3 预警族 KPI = 预警自身三项读数(条数/已关联/未关联)',
  129. JSON.stringify(keys) === JSON.stringify(WARNING_ONLY_KEYS) &&
  130. items[0].value === String(warningData.totalEvents) &&
  131. items[1].value === String(warningData.linkedWorkOrderCount) &&
  132. items[2].value === String(warningData.missingWorkOrderCount),
  133. `keys=${keys.join('/')} values=${items.map((item) => item.value).join('/')}`,
  134. )
  135. // A3b 「预警不挂工单」这句必须在 KPI 提示或口径说明里说出口(不能说了一半)。
  136. const warningText = JSON.stringify(warningView)
  137. check('A3b 预警族如实说明「预警不挂工单」', warningText.includes('预警不挂工单'), 'KPI hint / 口径说明都没说这句')
  138. // A4 预警模式下**不得**画工单口径:KPI 键、块标题、块类型三重判据
  139. const labels = blockLabels(warningView)
  140. check(
  141. 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)',
  142. !keys.some((key) => WORK_ORDER_ONLY_KEYS.includes(key)) &&
  143. !labels.some((label) => /工单状态分布|结案时长|处置率/.test(label)) &&
  144. !warningView.blocks.some((block) => block.kind === 'stacked'),
  145. `keys=${keys.join('/')} labels=${labels.join('/')} kinds=${warningView.blocks.map((b) => b.kind).join('/')}`,
  146. )
  147. // A4b 更硬的一条:把**画出来的块**(KPI 项/分布行/趋势点,即 view.blocks 本体)
  148. // 序列化后,工单口径的基线与词汇一概不出现 —— 防止「字段名换成中文但数值还是工单口径」
  149. // 这类改字不改数的假分流。范围**只含块**:口径说明(`view.note`)按设计要点名
  150. // 「工单处置率/工单状态分布」来说清它们为何不呈现,把它算进来是把「解释」当「读数」。
  151. check(
  152. 'A4b 预警模式画出来的块里零工单口径词汇与基线',
  153. !/CLOSED_WORK_ORDERS|可计入工单|已结案|工单状态/.test(JSON.stringify(warningView.blocks)),
  154. `blocks=${JSON.stringify(warningView.blocks).slice(0, 300)}`,
  155. )
  156. // A4c 反过来:预警族薪资的**真实**读数必须在(分流的另一半:不是把面板清空)。
  157. check(
  158. 'A4c 预警模式确有预警流程读数(等级分布 + 发生趋势)',
  159. warningView.blocks.some((block) => block.kind === 'bars' && block.label === '预警等级分布') &&
  160. warningView.blocks.some((block) => block.kind === 'trend'),
  161. `kinds=${warningView.blocks.map((b) => b.kind).join('/')}`,
  162. )
  163. // A5 等级分布逐级对真实事件行(4 行事件:1 级 4 条、2 级 1 条)
  164. const realLevels = [1, 2, 3, 4].map((level) => liveWarning.events.filter((item) => Number(item.level) === level).length)
  165. const barsBlockOfWarning = warningView.blocks.find((block) => block.kind === 'bars')
  166. check(
  167. 'A5 预警等级分布逐级等于真实事件行统计',
  168. JSON.stringify((barsBlockOfWarning.rows || []).map((row) => row.count)) === JSON.stringify(realLevels) &&
  169. (barsBlockOfWarning.rows || []).length === 4,
  170. `rows=${(barsBlockOfWarning.rows || []).map((row) => row.count).join('/')} want=${realLevels.join('/')}`,
  171. )
  172. // A6 发生趋势按日合并(真实 169 个小时桶 → 每自然日一个点;不重采样、不补点)。
  173. // 期望值由载荷自己算:桶数 = 覆盖的自然日数,非零点数 = 有事件的自然日数,
  174. // 求和 = 载荷逐桶 `eventCount` 的总和(受日合并后不丢量)。
  175. const trendBlockOfWarning = warningView.blocks.find((block) => block.kind === 'trend')
  176. const liveBuckets = liveWarning.durationTrend
  177. const dayOf = (bucket) => String(bucket).slice(0, 10)
  178. const expectDays = new Set(liveBuckets.map((row) => dayOf(row.timeBucket))).size
  179. const activeBuckets = liveBuckets.filter((row) => row.eventCount > 0)
  180. const expectEventDays = new Set(activeBuckets.map((row) => dayOf(row.timeBucket))).size
  181. const expectEventSum = activeBuckets.reduce((sum, row) => sum + row.eventCount, 0)
  182. const nonzeroTrend = (trendBlockOfWarning.points || []).filter((point) => point.count > 0)
  183. check(
  184. 'A6 预警发生趋势按日合并(小时桶→天,不重采样、不补点)',
  185. liveBuckets.length > 48 &&
  186. (trendBlockOfWarning.points || []).length === expectDays &&
  187. nonzeroTrend.length === expectEventDays &&
  188. nonzeroTrend.reduce((sum, point) => sum + point.count, 0) === expectEventSum,
  189. `buckets=${liveBuckets.length} points=${(trendBlockOfWarning.points || []).length}/${expectDays} nonzero=${nonzeroTrend.length}/${expectEventDays}`,
  190. )
  191. // A7 报警族保留工单口径(既有裁定 41/42/52 不重开)
  192. const alarmKeys = kpiKeys(alarmView)
  193. const alarmLabels = blockLabels(alarmView)
  194. check(
  195. 'A7 报警族保留工单口径(处置率/响应与关闭时长/工单状态分布)',
  196. JSON.stringify(alarmKeys) === JSON.stringify(WORK_ORDER_ONLY_KEYS) &&
  197. alarmLabels.includes('工单状态分布') &&
  198. alarmLabels.includes('平均结案时长趋势') &&
  199. alarmView.blocks.some((block) => block.kind === 'stacked'),
  200. `keys=${alarmKeys.join('/')} labels=${alarmLabels.join('/')}`,
  201. )
  202. // A7b 报警族的处置率读数与真实载荷一致(0/1 可计入 → 0.0%,不是「不适用」)
  203. const alarmRate = kpiOf(alarmView)[0]
  204. check(
  205. 'A7b 报警族处置率读数取自真实工单口径(0/1 → 0.0%)',
  206. alarmRate.value === '0.0%' &&
  207. liveAlarm.summary.handlingEfficiency.denominator === 1 &&
  208. liveAlarm.summary.handlingEfficiency.numerator === 0,
  209. `value=${alarmRate.value}`,
  210. )
  211. // A7c 报警模式不画预警自身读数(分流是对称的,不是只给预警加口径)
  212. check(
  213. 'A7c 报警模式不出现预警自身 KPI',
  214. !alarmKeys.some((key) => WARNING_ONLY_KEYS.includes(key)),
  215. `keys=${alarmKeys.join('/')}`,
  216. )
  217. }
  218. // ================================================================ B. 标题 / 口径说明 / KPI 提示随切换一起换
  219. {
  220. // B1 标题的词根来自**同一张切换词表**(改词表则标题跟着变 —— 行为断言,不是比对字面量)
  221. const warningSwitch = eventListSwitchView('WARNING')
  222. const alarmSwitch = eventListSwitchView('ALARM')
  223. check(
  224. 'B1 面板标题复用事件族单一词表(预警/报警 + 「效率」)',
  225. responseEfficiencyTitle('WARNING') === `${warningSwitch.label}效率` &&
  226. responseEfficiencyTitle('ALARM') === `${alarmSwitch.label}效率` &&
  227. responseEfficiencyTitle('WARNING') !== responseEfficiencyTitle('ALARM'),
  228. `warning=${responseEfficiencyTitle('WARNING')} alarm=${responseEfficiencyTitle('ALARM')}`,
  229. )
  230. check(
  231. 'B2 两族视图的面板标题跟着族换',
  232. warningView.label === responseEfficiencyTitle('WARNING') && alarmView.label === responseEfficiencyTitle('ALARM'),
  233. `warning=${warningView.label} alarm=${alarmView.label}`,
  234. )
  235. // B3 口径说明随族换,且各自说清本族口径(预警:不挂工单 + 缺口;报警:工单是处置手段)
  236. check(
  237. 'B3 口径说明随族换且各自如实',
  238. warningView.note === WARNING_EFFICIENCY_GAP &&
  239. alarmView.note === ALARM_EFFICIENCY_BASIS &&
  240. /预警不挂工单|没有预警自身的处置流程统计/.test(warningView.note) &&
  241. /工单为处置手段/.test(alarmView.note),
  242. `warningNote=${warningView.note}`,
  243. )
  244. // B4 两族窗口读数的落点不同:预警给条数/关联实况,报警给可计入工单/已结案
  245. check(
  246. 'B4 窗口读数随族换(预警=条数与关联实况;报警=工单张数)',
  247. /本窗口预警 5 条/.test(warningView.rangeNote) &&
  248. /已关联工单 0 条/.test(warningView.rangeNote) &&
  249. /可计入工单 1 张/.test(alarmView.rangeNote) &&
  250. warningView.rangeNote !== alarmView.rangeNote,
  251. `warning=${warningView.rangeNote} | alarm=${alarmView.rangeNote}`,
  252. )
  253. }
  254. // ================================================================ C. 分流依据优先级
  255. {
  256. // C1 后端回显优先:数据是预警族的,切换参数传错也不得画成报警口径。
  257. const echoed = viewOf(warningData, { mode: 'ALARM' })
  258. check(
  259. 'C1 data.family(后端回显)优先于 extra.mode(切换参数)',
  260. echoed.label === responseEfficiencyTitle('WARNING') &&
  261. !kpiKeys(echoed).some((key) => WORK_ORDER_ONLY_KEYS.includes(key)),
  262. `label=${echoed.label} keys=${kpiKeys(echoed).join('/')}`,
  263. )
  264. // C2 回显缺失时靠切换参数分流(mock / 旧载荷可能不给 eventFamily)
  265. const noFamily = { ...warningData, family: '' }
  266. const byMode = viewOf(noFamily, { mode: 'WARNING' })
  267. check(
  268. 'C2 回显缺失时按切换参数分流(extra.mode=WARNING → 预警口径)',
  269. byMode.label === responseEfficiencyTitle('WARNING') &&
  270. JSON.stringify(kpiKeys(byMode)) === JSON.stringify(WARNING_ONLY_KEYS),
  271. `label=${byMode.label} keys=${kpiKeys(byMode).join('/')}`,
  272. )
  273. // C3 两者都没有时落报警口径(工单口径是既有形态;旧调用点(t19/t16/t44)不因本票改签名而变空壳)
  274. const fallback = viewOf({ ...alarmData, family: '' }, undefined)
  275. check(
  276. 'C3 回显与切换参数都没有时保持既有报警口径(旧调用点不退化)',
  277. fallback.label === responseEfficiencyTitle('ALARM') &&
  278. JSON.stringify(kpiKeys(fallback)) === JSON.stringify(WORK_ORDER_ONLY_KEYS),
  279. `label=${fallback.label} keys=${kpiKeys(fallback).join('/')}`,
  280. )
  281. }
  282. // ================================================================ D. 渲染层 SSR
  283. {
  284. const warningHtml = await renderPanel(warningView)
  285. const alarmHtml = await renderPanel(alarmView)
  286. check(
  287. 'D1 预警模式 DOM 有预警读数、无处置率与工单状态分布',
  288. /data-kpi="warningTotal"/.test(warningHtml) &&
  289. /data-kpi="warningLinked"/.test(warningHtml) &&
  290. !/data-kpi="handlingRate"/.test(warningHtml) &&
  291. !/data-panel-block="stacked"/.test(warningHtml),
  292. '预警 DOM 里仍出现工单口径块',
  293. )
  294. check(
  295. 'D2 报警模式 DOM 有处置率与工单状态分布、无预警读数',
  296. /data-kpi="handlingRate"/.test(alarmHtml) &&
  297. /data-panel-block="stacked"/.test(alarmHtml) &&
  298. !/data-kpi="warningTotal"/.test(alarmHtml),
  299. '报警 DOM 与工单口径不一致',
  300. )
  301. // D3 口径说明走票 56 的折叠摘要:文本在 DOM 里(v-show 折叠不丢信息),断言面是 data-panel-note。
  302. check(
  303. 'D3 预警口径缺口说明在 DOM 里(折叠不丢文本)',
  304. /data-panel-note/.test(warningHtml) && warningHtml.includes('预警不挂工单'),
  305. '说明字段没进折叠摘要(会在界面上隐形 —— 票 56 记过的同类缺陷)',
  306. )
  307. // D4 预警模式面板标题在 DOM 里随族换
  308. check(
  309. 'D4 预警模式 DOM 标题为「预警效率」,报警模式为「报警效率」',
  310. warningHtml.includes('预警效率摘要') && alarmHtml.includes('报警效率摘要'),
  311. '标题没随族换',
  312. )
  313. }
  314. // ================================================================ E. 接线(源码级判据)
  315. {
  316. const pane = read('components/WarningResponsePane.vue')
  317. const shell = read('CockpitShell.vue')
  318. check(
  319. 'E1 面板组件把当前切换族交给 domain(否则预警接线永远落回报警口径)',
  320. // 票 65 追加需求后 `extra` 多了 `workOrders`(报警模式的工单列表数据源),
  321. // 所以这里不再钉字面量写法——只钉**行为**:panelView 的第四参里出现
  322. // `mode: eventStore.mode`(顺序、同行与否都不影响语义,避免"改格式就误报")。
  323. /panelView\(\s*props\.panelId,\s*current\.data,\s*current,\s*\{[\s\S]*?mode:\s*eventStore\.mode[\s\S]*?\}\s*\)/.test(pane),
  324. 'WarningResponsePane 没传 extra.mode ⇒ 预警模式仍画工单口径',
  325. )
  326. check(
  327. 'E2 外壳面板标题走 domain 单源(responseEfficiencyTitle),不另起一张词表',
  328. /function panelTitle\(panelId\)/.test(shell) &&
  329. /responseEfficiencyTitle\(eventStore\.mode\)/.test(shell) &&
  330. /:title="panelTitle\(panelId\)"/.test(shell),
  331. '外壳标题没接 domain 单源',
  332. )
  333. // E3 词表只有一份:面板标题不得另写一份 `预警效率`/`报警效率` 字面量(domain 之外零字面量)
  334. const shellWithoutImport = shell.replace(/import \{[^}]*responseEfficiencyTitle[^}]*\} from '\.\/domain\/warningResponsePanels\.js'/, '')
  335. check(
  336. 'E3 预警/报警效率标题字面量只存在于 domain(外壳零第二份)',
  337. !/'预警效率'|'报警效率'|`预警效率`|`报警效率`/.test(shellWithoutImport) && !/'报警效率'/.test(pane),
  338. '外壳/组件里又写了一份标题字面量(早晚漂移)',
  339. )
  340. }
  341. // ================================================================ F. mock 同构
  342. {
  343. const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
  344. const transports = {}
  345. registerCockpitMocks({ handlers: { registerMockTransport: (path, handler) => { transports[path] = handler } } })
  346. // mock 处理器交出的是**业务载荷**本身(信封由 transport 层包),与真实 `data` 同形。
  347. const mockOf = async (query) => transports['/events/handling-efficiency']({ path: `/events/handling-efficiency${query}` })
  348. const rawWarning = await mockOf('?eventFamily=WARNING')
  349. const rawAlarm = await mockOf('?eventFamily=ALARM')
  350. const mockWarningData = toHandlingEfficiency(rawWarning)
  351. const mockAlarmData = toHandlingEfficiency(rawAlarm)
  352. const mockWarningView = viewOf(mockWarningData, { mode: 'WARNING' })
  353. const mockAlarmView = viewOf(mockAlarmData, { mode: 'ALARM' })
  354. check(
  355. 'F1 mock 预警族与真实同形(不挂工单:0 关联 / 全部未关联)',
  356. rawWarning.eventFamily === 'WARNING' &&
  357. mockWarningData.linkedWorkOrderCount === 0 &&
  358. mockWarningData.missingWorkOrderCount === mockWarningData.totalEvents &&
  359. mockWarningData.totalEvents > 0,
  360. `total=${mockWarningData.totalEvents} linked=${mockWarningData.linkedWorkOrderCount}`,
  361. )
  362. // F2 mock **不**替后端把工单字段藏掉:契约缺口必须在 mock 上也看得见(否则真机才会炸)。
  363. check(
  364. 'F2 mock 两族仍保留工单口径字段(契约现状,不伪装成已分流)',
  365. !!rawWarning.summary.handlingEfficiency &&
  366. Array.isArray(rawWarning.workOrderStateCounts) &&
  367. rawWarning.workOrderStateCounts.length === 8 &&
  368. !!rawAlarm.summary.handlingEfficiency,
  369. 'mock 把工单字段藏起来了 —— 缺口被 mock 掩盖',
  370. )
  371. // F3 同一 domain 消费 mock → 与真实同一个结论(预警不画工单口径;报警保留)
  372. check(
  373. 'F3 mock 走同一 domain 得同一结论(预警口径 / 报警口径)',
  374. mockWarningView.label === responseEfficiencyTitle('WARNING') &&
  375. !kpiKeys(mockWarningView).some((key) => WORK_ORDER_ONLY_KEYS.includes(key)) &&
  376. mockAlarmView.label === responseEfficiencyTitle('ALARM') &&
  377. JSON.stringify(kpiKeys(mockAlarmView)) === JSON.stringify(WORK_ORDER_ONLY_KEYS),
  378. `mockWarning=${kpiKeys(mockWarningView).join('/')} mockAlarm=${kpiKeys(mockAlarmView).join('/')}`,
  379. )
  380. // F4 mock 报警族有真实的关联工单读数(与预警族的 0 形成对照,证明分流不是「两族都画 0」)
  381. check(
  382. 'F4 mock 报警族有承接工单(与预警族 0 关联形成对照)',
  383. mockAlarmData.linkedWorkOrderCount > 0 && mockWarningData.linkedWorkOrderCount === 0,
  384. `alarm linked=${mockAlarmData.linkedWorkOrderCount} warning linked=${mockWarningData.linkedWorkOrderCount}`,
  385. )
  386. }
  387. // ================================================================ G. 缺失即占位(不把「没有」画成 0)
  388. {
  389. const bare = toHandlingEfficiency({ eventFamily: 'WARNING', durationTrend: [], events: [] })
  390. const view = viewOf(bare, { mode: 'WARNING' })
  391. const items = kpiOf(view)
  392. check(
  393. 'G1 预警族读数缺失画占位符(不把「没有」画成 0 条)',
  394. bare.totalEvents === null && items.every((item) => item.value === '—'),
  395. `values=${items.map((item) => item.value).join('/')}`,
  396. )
  397. check(
  398. 'G2 缺失时不编造窗口读数(rangeNote 为空串而不是「0 条」)',
  399. view.rangeNote === '',
  400. `rangeNote=${view.rangeNote}`,
  401. )
  402. }
  403. // ================================================================ H. 报警族「当前工单列表」(2026-10-21 追加需求 1)
  404. /**
  405. * 背景:维护者追加要求「报警效率页面的报警模式,除了报警数据外还需直接展示当前工单列表
  406. * (卡片形式,可点开查看工单详情)」。
  407. *
  408. * 先说清数据来源,这是本组所有断言的前提:`handling-efficiency` **不返回工单实体**,
  409. * 只有状态计数(实测算术:报警族八态合计 0,但同一台设备 `/equipments/…/work-orders`
  410. * 能查到 7 张工单)。所以列表必须来自另一个端点,本组按 store 真实传入的形状喂它。
  411. */
  412. {
  413. const listOrders = [
  414. { code: 'WO-A', stateCode: 0, stateName: '待派单', description: '监测报警,请现场核实', createTime: '2026-10-14T13:43:50+08:00', orderTypeName: '故障维修', associatedWithCurrentEvent: true },
  415. { code: 'WO-B', stateCode: 5, stateName: '已结案', description: '已处理', createTime: '2026-10-12T11:03:50+08:00', orderTypeName: '', associatedWithCurrentEvent: false },
  416. ]
  417. const loadedList = {
  418. loaded: true,
  419. state: 'success',
  420. data: { orders: listOrders, pagination: { page: 1, pageSize: 5, total: 7, totalPages: 2 } },
  421. }
  422. const alarmWithList = viewOf(alarmData, { mode: 'ALARM', workOrderList: loadedList })
  423. const listBlockOf = (view) => view.blocks.find((block) => block.kind === 'list') || null
  424. const block = listBlockOf(alarmWithList)
  425. check(
  426. 'H1 报警模式出现「当前工单列表」块,且排在统计图之后(先结论后明细)',
  427. !!block &&
  428. block.label === '当前工单列表' &&
  429. alarmWithList.blocks.indexOf(block) === alarmWithList.blocks.length - 1,
  430. `blocks=${blockLabels(alarmWithList).join('/')}`,
  431. )
  432. check(
  433. 'H2 卡片行带可点契约与状态词表(code 触发渲染层 open-card;tone 来自既有色键)',
  434. !!block &&
  435. block.rows.length === 2 &&
  436. block.rows[0].code === 'WO-A' &&
  437. block.rows[0].toneLabel === '待派单' &&
  438. // 色键必须是 StatusTag 认识的封闭词表成员,否则标签会退回中性灰而不报错
  439. ['lv1', 'lv2', 'lv3', 'lv4', 'neutral'].includes(block.rows[0].tone),
  440. `row0=${JSON.stringify(block && block.rows[0])}`,
  441. )
  442. check(
  443. 'H3 分页读后端回显(共 7 张 / 2 页),不由本页 2 条反推',
  444. !!block && block.serverPage.page === 1 && block.serverPage.pageCount === 2 && block.serverPage.total === 7,
  445. `serverPage=${JSON.stringify(block && block.serverPage)}`,
  446. )
  447. check(
  448. 'H4 口径说明点明「与工单状态分布是同一批工单」(时间窗对齐后不再说"两个粒度")',
  449. !!block && /同一批工单/.test(block.pageNote) && /共 7 张/.test(block.pageNote) && /创建时间/.test(block.pageNote),
  450. `pageNote=${block && block.pageNote}`,
  451. )
  452. // H5–H7:三态如实——「正在取数」「未取到」「确实没有」是三句不同的话(票 11/18 分区纪律)
  453. const pendingView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: false, state: 'empty', data: null } })
  454. check(
  455. 'H5 未取到时不画假空列表(说「正在取数」而不是「当前没有工单」)',
  456. /正在取数/.test((listBlockOf(pendingView) || {}).emptyText || ''),
  457. `emptyText=${(listBlockOf(pendingView) || {}).emptyText}`,
  458. )
  459. const failedView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: true, state: 'failed', data: null } })
  460. check(
  461. 'H6 取数失败说「未取到」,与「确实没有」分开(不把缺数据画成 0 张)',
  462. /未取到/.test((listBlockOf(failedView) || {}).emptyText || ''),
  463. `emptyText=${(listBlockOf(failedView) || {}).emptyText}`,
  464. )
  465. const emptyView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: true, state: 'success', data: { orders: [], pagination: { page: 1, pageSize: 5, total: 0, totalPages: 1 } } } })
  466. check(
  467. 'H7 真的 0 张时说「当前没有工单」(真实业务结论,不是加载失败)',
  468. (listBlockOf(emptyView) || {}).emptyText === '当前没有工单',
  469. `emptyText=${(listBlockOf(emptyView) || {}).emptyText}`,
  470. )
  471. // H8:预警族**不得**出现工单列表 —— 预警不挂工单,列出来就是上一轮同样要防的假读数
  472. const warningWithList = viewOf(warningData, { mode: 'WARNING', workOrderList: loadedList })
  473. check(
  474. 'H8 预警模式不出现工单列表(预警不挂工单,与「不画工单口径」同一条纪律)',
  475. !listBlockOf(warningWithList) && !blockLabels(warningWithList).includes('当前工单列表'),
  476. `blocks=${blockLabels(warningWithList).join('/')}`,
  477. )
  478. // H9:状态色调覆盖全码表且不新增词表 —— 8 个码都要落在 StatusTag 认识的色键上
  479. const { workOrderStateTone } = await import(`${SRC}domain/warningResponsePanels.js`)
  480. const tones = [0, 1, 2, 3, 4, 5, 6, 7, null, 99].map((code) => workOrderStateTone(code))
  481. check(
  482. 'H9 工单状态色调对 8 个码 + 缺失/未知都给既有色键(不新增第二张词表、不返回 undefined)',
  483. tones.every((tone) => ['lv1', 'lv2', 'lv3', 'lv4', 'neutral'].includes(tone)),
  484. `tones=${tones.join('/')}`,
  485. )
  486. check(
  487. 'H9b 色调随状态语义分档(待办≠在办≠已结案≠已取消)',
  488. workOrderStateTone(0) === 'lv1' &&
  489. workOrderStateTone(3) === 'lv3' &&
  490. workOrderStateTone(5) === 'lv4' &&
  491. workOrderStateTone(6) === 'lv2' &&
  492. workOrderStateTone(7) === 'neutral',
  493. `0=${workOrderStateTone(0)} 3=${workOrderStateTone(3)} 5=${workOrderStateTone(5)} 6=${workOrderStateTone(6)} 7=${workOrderStateTone(7)}`,
  494. )
  495. }
  496. // ================================================================ I. 预警口径说明文案(2026-10-21 追加需求 2)
  497. /**
  498. * 背景:维护者看到预警模式摘要仍写着「本端点对预警族仍只返回工单口径字段…」,
  499. * 要求「确认预警数据是否正确」。复核结论是**数据没变、文案该改**:
  500. * 后端源码 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`,
  501. * 注释原句「范围内全部工单(不要求关联报警/事件)」;实测预警族 `linkedWorkOrderCount=0`、
  502. * 八态合计 0 —— 预警族**确实没有**预警自身的处置流程统计。
  503. *
  504. * 但旧文案有三处毛病:① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏);
  505. * ② 先讲"没有字段",读者要读两遍才知道面板上的数能不能信;
  506. * ③ 「工单关联实况」这类内部说法对值班人员无意义。本组钉新文案的三条底线。
  507. */
  508. {
  509. check(
  510. 'I1 预警口径说明不含接口词汇(「本端点」「该端点」「/events/」等不上大屏)',
  511. !/本端点|该端点|接口返回|\/events\//.test(WARNING_EFFICIENCY_GAP),
  512. `gap=${WARNING_EFFICIENCY_GAP}`,
  513. )
  514. check(
  515. 'I2 前两句先说清「这些数是什么、能不能信」(先讲读数再讲缺口)',
  516. /预警不挂工单/.test(WARNING_EFFICIENCY_GAP) && /真实读数/.test(WARNING_EFFICIENCY_GAP),
  517. `gap=${WARNING_EFFICIENCY_GAP}`,
  518. )
  519. check(
  520. 'I3 如实说明预警自身的处置流程统计尚未提供,并指向需求池(不假装已有)',
  521. /处置流程统计/.test(WARNING_EFFICIENCY_GAP) && /尚未提供/.test(WARNING_EFFICIENCY_GAP) && /需求池/.test(WARNING_EFFICIENCY_GAP),
  522. `gap=${WARNING_EFFICIENCY_GAP}`,
  523. )
  524. check(
  525. 'I4 两族口径说明都不含接口词汇(报警那句同一纪律)',
  526. !/端点|接口返回/.test(ALARM_EFFICIENCY_BASIS),
  527. `basis=${ALARM_EFFICIENCY_BASIS}`,
  528. )
  529. }
  530. // ================================================================ J. 全量工单端点契约(2026-10-21 追加需求 1)
  531. /**
  532. * 背景:报警模式的工单列表来自**新端点** `GET /api/cockpit/v1/work-orders`
  533. * (后端 `CockpitWorkOrderReadController.workOrderList`,与契约测试同源)。
  534. * 本组钉住**前端消费侧**对那份响应的解析:字段名、分页四件套、关联标记。
  535. *
  536. * 为什么必须有这一组:后端代码今天已在 checkout 里改好并通过 31 条契约测试,
  537. * 但**尚未部署**(实测该路径仍 404)。若不在这里钉住前端解析,
  538. * 等后端上线那天才发现字段对不上,就又要一轮返工。
  539. */
  540. {
  541. const { fetchWorkOrderList } = await import(`${SRC}services/detailApi.js`)
  542. const contractPayload = JSON.parse(readVerifier('./artifacts/t65b/work-order-list-contract.json'))
  543. let parsed = null
  544. let parseError = null
  545. try {
  546. parsed = await fetchWorkOrderList({ page: 1, pageSize: 5 }, {
  547. // REAL 模式才走 fetchImpl(mock 模式走 transport),这样能直接喂真实形状载荷;
  548. // 只要 AJAX 外壳,不需要真网络。
  549. config: { dataSource: 'real', api: { baseUrl: 'http://contract.invalid' } },
  550. fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: contractPayload }) }),
  551. })
  552. } catch (error) {
  553. parseError = error
  554. }
  555. check(
  556. 'J1 前端能解析新端点的真实载荷(state/工单行/分页四件套都不丢)',
  557. !parseError && parsed && parsed.state === 'success' && parsed.data.orders.length === 1 &&
  558. parsed.data.orders[0].code === contractPayload.workOrders[0].workOrderCode,
  559. parseError ? `解析抛错:${parseError.message}` : `pagination=${JSON.stringify(parsed && parsed.data && parsed.data.pagination)}`,
  560. )
  561. check(
  562. 'J2 分页四件套逐项读后端回显(page/pageSize/total/totalPages,不由行数反推)',
  563. !!parsed && parsed.data.pagination.page === 1 && parsed.data.pagination.pageSize === 5 &&
  564. parsed.data.pagination.total === 7 && parsed.data.pagination.totalPages === 2,
  565. `pagination=${JSON.stringify(parsed && parsed.data && parsed.data.pagination)}`,
  566. )
  567. check(
  568. 'J3 工单的关联标记如实透传(列表里能分辨「关联了报警」与「纯设备工单」)',
  569. !!parsed && parsed.data.orders[0].associatedWithCurrentEvent === true,
  570. `assoc=${parsed && parsed.data && parsed.data.orders[0] && parsed.data.orders[0].associatedWithCurrentEvent}`,
  571. )
  572. check(
  573. 'J4 长描述完整保留(截断责任在渲染层,不在解析层丢信息)',
  574. !!parsed && parsed.data.orders[0].description === contractPayload.workOrders[0].orderDesc,
  575. `desc=${parsed && parsed.data && parsed.data.orders[0] && parsed.data.orders[0].description}`,
  576. )
  577. // J5:mock 与真实**同形**——mock 若没有 pagination,前端翻页与「共 N 张」永远测不到。
  578. const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`)
  579. const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
  580. resetMockTransportsForTest()
  581. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  582. const mockPage1 = await fetchWorkOrderList({ page: 1, pageSize: 5 })
  583. const mockPage2 = await fetchWorkOrderList({ page: 2, pageSize: 2 })
  584. check(
  585. 'J5 mock 全量工单与真实同形(有 pagination;换 pageSize 后翻页真的切窗口,不是永远回全量)',
  586. mockPage1.state === 'success' && !!mockPage1.data.pagination &&
  587. mockPage1.data.pagination.total === 4 && mockPage1.data.orders.length === 4 &&
  588. mockPage2.state === 'success' && mockPage2.data.orders.length === 2,
  589. `p1=${JSON.stringify(mockPage1.data && mockPage1.data.pagination)} rows=${mockPage1.data && mockPage1.data.orders.length} p2rows=${mockPage2.data && mockPage2.data.orders.length}`,
  590. )
  591. resetMockTransportsForTest()
  592. }
  593. console.log('')
  594. console.log(`T65 预警效率两族语义分流:${passed}/${passed + failed} 项通过。`)
  595. if (failed) {
  596. console.log('未通过项见上方 FAIL。')
  597. process.exit(1)
  598. }
  599. console.log('T65 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')