t20-overview-panels.mjs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. /**
  2. * T20 总体概览模式面板 纯逻辑检查(票 20)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t20-overview-panels.mjs
  6. *
  7. * 六段:
  8. * A 面板映射与注册:四块与 ADR 0003 目录一致、不越界接其它模式、注册表覆盖四个模式共 16 块
  9. * B 契约适配:三份载荷翻领域对象、BigDecimal 字符串、缺失记账、工单状态 0–7 全铺
  10. * C 视图层:验收点名的画面元素逐项在场;分母为 0 显示占位符而不是 0.0%;缺口如实说明
  11. * D 分区独立:三块各自成败、一块坏不遮其它两块 + 对抗式反证;态势总览由三块合成
  12. * E 联动:指标点击 → 切模式;行业行 → 写全局行业筛选(都走 store 的唯一入口)
  13. * F 静态守卫:domain 纯函数、渲染层不认识 panelId、不拼路径、外壳走同一接缝
  14. *
  15. * 契约依据:后端 `CockpitOverview{Infrastructure,Monitoring,WarningSettings}Response`
  16. * 与三个 Service(`E:\pipe-ner-server\...\cockpit\overview\`)。
  17. */
  18. import { readFileSync } from 'node:fs'
  19. import { createPinia, setActivePinia } from 'pinia'
  20. import { columnsForMode, panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
  21. import {
  22. OVERVIEW_PANEL_IDS,
  23. MONITORING_FAULT_GAP,
  24. toOverviewInfrastructure,
  25. toOverviewMonitoring,
  26. toOverviewWarningSettings,
  27. toOverviewRegions,
  28. situationSummaryPanel,
  29. infrastructureSummaryPanel,
  30. monitoringSummaryPanel,
  31. alertSettingsPanel,
  32. OVERVIEW_PANEL_BUILDERS,
  33. } from '../../../src/modules/cockpit/domain/overviewPanels.js'
  34. import { hasPanelView, implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
  35. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  36. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  37. import { OVERVIEW_PATHS, OVERVIEW_RANGE } from '../../../src/modules/cockpit/services/overviewApi.js'
  38. import { WORK_ORDER_STATES } from '../../../src/modules/cockpit/domain/workOrder.js'
  39. import { useOverviewStore } from '../../../src/modules/cockpit/stores/overviewStore.js'
  40. import { useUiStore } from '../../../src/modules/cockpit/stores/uiStore.js'
  41. import { useFilterStore } from '../../../src/modules/cockpit/stores/filterStore.js'
  42. const checks = []
  43. function check(name, pass, evidence = '') {
  44. checks.push(pass)
  45. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  46. }
  47. const ready = (data) => ({ ...section(SECTION_STATE.SUCCESS, { data }), loaded: true })
  48. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  49. const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
  50. // ================================================================ A 面板映射与注册
  51. {
  52. check(
  53. 'A1-four-overview-panels-match-catalog',
  54. panelsForMode('overview').join(',') === 'situationSummary,infrastructureSummary,monitoringSummary,alertSettings',
  55. `目录:${panelsForMode('overview').join(' / ')}`,
  56. )
  57. const columns = columnsForMode('overview')
  58. check(
  59. 'A2-columns-are-two-and-two',
  60. columns.left.join(',') === 'situationSummary,infrastructureSummary' && columns.right.join(',') === 'monitoringSummary,alertSettings',
  61. `左栏 ${columns.left.join('/')} · 右栏 ${columns.right.join('/')}`,
  62. )
  63. check(
  64. 'A3-builders-cover-exactly-the-four',
  65. OVERVIEW_PANEL_IDS.join(',') === 'situationSummary,infrastructureSummary,monitoringSummary,alertSettings' &&
  66. Object.keys(OVERVIEW_PANEL_BUILDERS).join(',') === OVERVIEW_PANEL_IDS.join(','),
  67. `构造器与目录逐项一致(${Object.keys(OVERVIEW_PANEL_BUILDERS).length} 个)`,
  68. )
  69. check(
  70. 'A4-registry-covers-all-four-modes',
  71. OVERVIEW_PANEL_IDS.every(hasPanelView) &&
  72. ['archive', 'operation', 'management', 'emergency'].every(hasPanelView) &&
  73. ['monitoringOverview', 'monitoringDistribution', 'operationOverview', 'monitoringPointLedger'].every(hasPanelView) &&
  74. ['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].every(hasPanelView),
  75. `已接面板共 ${implementedPanelIds().length} 块(本票把四个模式都接上了)`,
  76. )
  77. check(
  78. 'A5-unknown-panel-still-returns-null',
  79. panelView('notAPanel', {}, ready({})) === null && hasPanelView('notAPanel') === false,
  80. '未注册的 panelId 仍回 null(调用方画占位),注册表不会假装认识它',
  81. )
  82. check(
  83. 'A6-overview-does-not-claim-other-modes-panels',
  84. !OVERVIEW_PANEL_IDS.includes('archive') && !OVERVIEW_PANEL_IDS.includes('currentEvents') && !OVERVIEW_PANEL_IDS.includes('monitoringOverview'),
  85. '本票只接总体概览那四块,不越界接其它模式的面板',
  86. )
  87. }
  88. // ================================================================ B 契约适配
  89. {
  90. const infra = toOverviewInfrastructure({
  91. updatedAt: '2026-09-29T10:00:00+08:00',
  92. totals: { facilityCount: 5, smartDeviceCount: 9 },
  93. industries: [
  94. { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: '12.4', scale: 'medium' }] },
  95. { code: 'DRAINAGE', key: 'drainage', facilityCount: 1, smartDeviceCount: 2 },
  96. ],
  97. excluded: { unknownTypeCount: 1, typeKindMismatchCount: 0 },
  98. })
  99. check(
  100. 'B1-infrastructure-totals-and-industries',
  101. infra.facilityCount === 5 && infra.smartDeviceCount === 9 && infra.industries.length === 2 && infra.excluded.unknownTypeCount === 1,
  102. `设施 ${infra.facilityCount} · 智能设备 ${infra.smartDeviceCount} · 行业 ${infra.industries.length}`,
  103. )
  104. // BigDecimal 在线上是字符串(`@JsonFormat(shape=STRING)`):必须认字符串,不能变假 0。
  105. check(
  106. 'B1b-bigdecimal-string-is-read',
  107. infra.industries[0].facilityTypes[0].lengthKm === 12.4 && infra.industries[0].facilityTypes[0].scale === 'medium',
  108. `lengthKm "12.4" → ${infra.industries[0].facilityTypes[0].lengthKm}(字符串被认下,不是 0)`,
  109. )
  110. check(
  111. 'B1c-industry-label-falls-back-to-dictionary',
  112. infra.industries[1].label === '排水',
  113. `缺 label 的行业按码翻:DRAINAGE → ${infra.industries[1].label}(不显示裸码)`,
  114. )
  115. // 本端点**没有** regionDistribution:这是契约现状,不是漏读。
  116. check(
  117. 'B1d-overview-infrastructure-has-no-regions',
  118. infra.regions === undefined,
  119. '`/overview/infrastructure` 不含区域分布(区域走票 17 的 base-archives,见文件头第 ② 条)',
  120. )
  121. const monitoring = toOverviewMonitoring({
  122. counts: {
  123. deviceCount: 9,
  124. monitoringPointCount: 7,
  125. monitoringFacilityCount: 3,
  126. infrastructureFacilityCount: 5,
  127. status: { online: 6, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 8 },
  128. onlineRate: { value: '0.7500', onlineCount: 6, denominator: 8, state: 'CURRENT' },
  129. },
  130. metricFreshness: { status: 'CURRENT', updatedAt: '2026-09-29T09:59:00+08:00' },
  131. formalScope: { industries: ['GAS', 'WATER_SUPPLY'], keys: ['gas', 'water'] },
  132. typeDistribution: [{ industry: { code: 'GAS', label: '燃气' }, deviceType: { code: 'PIPE', name: '燃气管线' }, deviceCount: 4 }],
  133. excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 },
  134. })
  135. check(
  136. 'B2-monitoring-counts-and-four-status-buckets',
  137. monitoring.deviceCount === 9 && monitoring.onlineRate === 0.75 && monitoring.deviceStatus.online === 6 &&
  138. monitoring.deviceStatus.offline === 1 && monitoring.deviceStatus.stale === 1 && monitoring.deviceStatus.unknownOrMissing === 1,
  139. `四态桶:在线 ${monitoring.deviceStatus.online} / 离线 ${monitoring.deviceStatus.offline} / 滞后 ${monitoring.deviceStatus.stale} / 未知 ${monitoring.deviceStatus.unknownOrMissing}`,
  140. )
  141. // 验收第 3 条点名的「故障统计」在契约里**不存在**独立桶 —— 适配层不得凭空造一个。
  142. check(
  143. 'B2b-no-fault-bucket-is-invented',
  144. monitoring.deviceStatus.fault === undefined && !('fault' in monitoring.deviceStatus),
  145. '不发明「故障」桶(后端只有 online/offline/stale/unknownOrMissing 四态)',
  146. )
  147. check(
  148. 'B2c-metric-freshness-is-kept',
  149. monitoring.metricFreshness.updatedAt === '2026-09-29T09:59:00+08:00' && monitoring.metricFreshness.status === 'CURRENT',
  150. '逐设备最近采集时间在本端点上(票 18 的 I2/BT-8 缺口在这里有值)',
  151. )
  152. check(
  153. 'B2d-formal-scope-is-kept',
  154. monitoring.formalIndustries.join(',') === 'GAS,WATER_SUPPLY',
  155. `正式行业范围回显:${monitoring.formalIndustries.join(',')}`,
  156. )
  157. const warnings = toOverviewWarningSettings({
  158. updatedAt: '2026-09-29T10:00:00+08:00',
  159. displayName: '预警设置',
  160. selectedMode: 'WARNING',
  161. range: { type: 'LAST_7_DAYS', start: '2026-09-23T00:00:00+08:00', end: '2026-09-30T00:00:00+08:00' },
  162. warning: {
  163. eventFamily: 'WARNING', state: 'READY', total: 8, handled: 5, pending: 2, cancelledCount: 1, unlinkedEventCount: 1,
  164. handlingEfficiency: { numerator: 5, denominator: 8, value: '0.6250', state: 'AVAILABLE' },
  165. workOrderStates: { 0: 1, 5: 2 },
  166. trend: [{ bucket: '2026-09-28', total: 3, handled: 2 }],
  167. },
  168. alarm: {
  169. eventFamily: 'ALARM', state: 'READY', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0,
  170. handlingEfficiency: { numerator: 6, denominator: 9, value: '0.6667', state: 'AVAILABLE' },
  171. workOrderStates: {},
  172. trend: [{ bucket: '2026-09-28', total: 4, handled: 3 }],
  173. },
  174. })
  175. check(
  176. 'B3-both-families-come-from-one-payload',
  177. warnings.warning.total === 8 && warnings.alarm.total === 9 && warnings.warning.family === 'WARNING' && warnings.alarm.family === 'ALARM',
  178. `一次取数同时给两族:预警 ${warnings.warning.total} 条 / 报警 ${warnings.alarm.total} 条(故不需要切换)`,
  179. )
  180. check(
  181. 'B3b-efficiency-carries-state-alongside-value',
  182. warnings.warning.efficiency.handlingRate === 0.625 && warnings.warning.efficiency.handlingState === 'AVAILABLE',
  183. '处置率连 `state` 一起带出("分母为 0"与"真的是 0%"必须分得开)',
  184. )
  185. // 工单状态分布:0–7 全铺,缺的态补 0(0 是真实计数,不是"没有数据")。
  186. check(
  187. 'B3c-work-order-states-cover-all-eight-codes',
  188. warnings.warning.workOrderStates.length === 8 &&
  189. warnings.warning.workOrderStates.map((row) => row.code).join(',') === '0,1,2,3,4,5,6,7' &&
  190. warnings.warning.workOrderStates.find((row) => row.code === '5').count === 2 &&
  191. warnings.warning.workOrderStates.find((row) => row.code === '1').count === 0,
  192. `0–7 全铺(8 个码):${warnings.warning.workOrderStates.map((row) => `${row.code}:${row.count}`).join(' ')}`,
  193. )
  194. check(
  195. 'B3d-range-type-is-echoed',
  196. warnings.rangeType === 'LAST_7_DAYS',
  197. `窗口回显 ${warnings.rangeType}(验收第 1 条的「近 7 日」靠它声明)`,
  198. )
  199. const regions = toOverviewRegions({
  200. totals: { facilityCount: 5, lengthKm: '12.4', scale: 'medium' },
  201. regionDistribution: [{ code: '城区A', count: 3 }, { code: '未分区', count: 2 }],
  202. })
  203. check(
  204. 'B4-regions-come-from-the-sibling-endpoint',
  205. regions.regions.length === 2 && regions.regions[0].label === '城区A' && regions.lengthKm === 12.4,
  206. `区域分布借票 17 的端点:${regions.regions.map((row) => `${row.label}${row.count}`).join(' ')}`,
  207. )
  208. }
  209. // ================================================================ C 视图层
  210. {
  211. const data = {
  212. infrastructure: { facilityCount: 5, smartDeviceCount: 9, industries: [{ code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4 }], excluded: {} },
  213. regions: { regions: [{ code: '城区A', label: '城区A', count: 3 }], lengthKm: 12.4, scale: 'medium' },
  214. monitoring: {
  215. deviceCount: 9, monitoringPointCount: 7, monitoringFacilityCount: 3, infrastructureFacilityCount: 5,
  216. onlineRate: 0.75, onlineCount: 6, onlineDenominator: 8, onlineState: 'CURRENT',
  217. deviceStatus: { online: 6, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 8 },
  218. formalIndustries: ['GAS'], metricFreshness: { status: 'CURRENT', updatedAt: 'T' },
  219. typeDistribution: [{ industryLabel: '燃气', typeName: '燃气管线', deviceCount: 4 }],
  220. },
  221. warningSettings: {
  222. rangeType: 'LAST_7_DAYS',
  223. warning: { family: 'WARNING', total: 8, handled: 5, pending: 2, cancelledCount: 1, unlinkedEventCount: 1, efficiency: { handlingRate: 0.625, handlingState: 'AVAILABLE', totalEvents: 8 }, workOrderStates: WORK_ORDER_STATES.map((s) => ({ code: String(s.code), label: s.name, count: 0 })), trend: [] },
  224. alarm: { family: 'ALARM', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0, efficiency: { handlingRate: 0.667, handlingState: 'AVAILABLE', totalEvents: 9 }, workOrderStates: WORK_ORDER_STATES.map((s) => ({ code: String(s.code), label: s.name, count: 0 })), trend: [] },
  225. },
  226. }
  227. const situation = situationSummaryPanel(data, ready(data))
  228. const kpiKeys = situation.blocks[0].items.map((item) => item.key)
  229. check(
  230. 'C1-situation-kpis-cover-the-acceptance-list',
  231. ['facilityCount', 'smartDeviceCount', 'deviceCount', 'monitoringPointCount', 'onlineRate', 'warningTotal', 'alarmTotal', 'handlingRate'].every((key) => kpiKeys.includes(key)),
  232. `跨行业 KPI:${kpiKeys.join(' / ')}`,
  233. )
  234. check(
  235. 'C1b-situation-has-both-trend-series',
  236. situation.blocks.filter((block) => block.kind === 'trend').length === 2 &&
  237. situation.rangeNote.includes('近 7 日'),
  238. `预警与报警两条趋势序列(${situation.blocks.filter((b) => b.kind === 'trend').map((b) => b.label).join(' / ')})`,
  239. )
  240. check(
  241. 'C1c-situation-handling-rate-is-a-link',
  242. situation.blocks[0].items.find((item) => item.key === 'handlingRate').link === 'EFFICIENCY' &&
  243. situation.blocks[0].items.find((item) => item.key === 'warningTotal').link === 'WARNING',
  244. '当前预警/报警/处置率三个读数带联动出口(验收第 5 条)',
  245. )
  246. // **分母为 0 不是 0%**:后端回 value="0.0000" + state=EMPTY 时界面必须显示占位符。
  247. const zeroRate = situationSummaryPanel(
  248. { ...data, monitoring: { ...data.monitoring, onlineRate: 0, onlineDenominator: 0, onlineState: 'EMPTY' } },
  249. ready(data),
  250. )
  251. check(
  252. 'C1d-zero-denominator-is-not-zero-percent',
  253. zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value === DETAIL_PLACEHOLDER &&
  254. zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').hint.includes('不适用'),
  255. `分母为 0 → ${zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value}(不是 0.0%)`,
  256. )
  257. const infra = infrastructureSummaryPanel(data, ready(data))
  258. check(
  259. 'C2-infrastructure-has-industry-and-region',
  260. infra.blocks.some((block) => block.label === '行业构成 · 设施') &&
  261. infra.blocks.some((block) => block.label === '区域分布') &&
  262. infra.blocks.find((block) => block.label === '区域分布').rows.length === 1,
  263. `设施底数 + 行业构成 + 区域分布(验收第 2 条三项齐)`,
  264. )
  265. check(
  266. 'C2b-industry-rows-are-actionable',
  267. infra.blocks.find((block) => block.label === '行业构成 · 设施').action === 'industry' &&
  268. infra.blocks.find((block) => block.label === '行业构成 · 设施').rows[0].actionKey === 'gas',
  269. '行业行带联动动作与前端筛选键(点它写全局行业筛选)',
  270. )
  271. check(
  272. 'C2c-region-gap-is-honest-when-missing',
  273. infrastructureSummaryPanel({ infrastructure: data.infrastructure, regions: null }, ready(data)).gap.includes('base-archives'),
  274. '区域取不到时如实说明它来自哪个端点,不假装有',
  275. )
  276. check(
  277. 'C2d-length-and-scale-come-from-the-sibling-endpoint',
  278. infra.blocks[0].items.find((item) => item.key === 'lengthKm').value === '12.4' &&
  279. infra.blocks[0].items.find((item) => item.key === 'scale').value === '中型',
  280. '里程与规模取自票 17 的档案端点(本概览端点不给),英文规模枚举被翻成中文',
  281. )
  282. const monitoring = monitoringSummaryPanel(data.monitoring, ready(data.monitoring))
  283. const monitoringKpis = monitoring.blocks[0].items.map((item) => item.key)
  284. check(
  285. 'C3-monitoring-shows-online-rate-and-four-buckets',
  286. ['onlineRate', 'online', 'offline', 'stale', 'unknownOrMissing'].every((key) => monitoringKpis.includes(key)),
  287. `在线率 + 四态桶:${monitoringKpis.join(' / ')}(验收第 3 条)`,
  288. )
  289. // ⚠️ 真机走查逮到的真缺陷,这条守卫就是它的护栏:
  290. // `monitoringSummaryPanel` 原先读 `data.monitoring`(以为拿到包装),而外壳传的是
  291. // **裸领域对象** `section.data` —— 于是整块读数全是 `—`,而当时逻辑闸门全绿,
  292. // 因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
  293. // 所以这里必须断言"喂真实形状时**真的画出数字**",而不只是"块在不在"。
  294. check(
  295. 'C3-contract-panel-reads-its-own-section-data',
  296. monitoringKpis.includes('onlineRate') &&
  297. monitoring.blocks[0].items.find((item) => item.key === 'onlineRate').value === '75.0%' &&
  298. monitoring.blocks[0].items.find((item) => item.key === 'online').value === '6' &&
  299. monitoring.blocks.some((block) => block.label === '监测覆盖'),
  300. `按外壳真实的调用形状(裸 section.data)喂进去,读数真的是数字而不是占位符:在线率 ${monitoring.blocks[0].items.find((i) => i.key === 'onlineRate').value} · 在线 ${monitoring.blocks[0].items.find((i) => i.key === 'online').value}`,
  301. )
  302. check(
  303. 'C3b-monitoring-has-coverage-and-type-distribution',
  304. monitoring.blocks.some((block) => block.label === '监测覆盖') && monitoring.blocks.some((block) => block.label === '类型分布 · 智能设备'),
  305. '监测覆盖 + 类型分布',
  306. )
  307. // 验收第 3 条点名「故障统计」,而契约没有该桶 —— 必须如实说明,不静默略过。
  308. check(
  309. 'C3c-fault-gap-is-declared',
  310. monitoring.gap === MONITORING_FAULT_GAP && monitoring.gap.includes('没有独立的「故障」桶'),
  311. '「故障」缺口如实写在面板上(不发明一个故障数)',
  312. )
  313. check(
  314. 'C3d-metric-freshness-row-is-present',
  315. monitoring.blocks.some((block) => block.label === '最近采集时间'),
  316. '逐设备最近采集时间有值时才画那一行',
  317. )
  318. const alert = alertSettingsPanel(data.warningSettings, ready(data.warningSettings))
  319. // 只有分布块有 `label`(KPI 块的 label 在 items 上),所以先滤掉没有 label 的块。
  320. const alertLabels = alert.blocks.map((block) => block.label).filter(Boolean)
  321. check(
  322. 'C4-alert-settings-shows-both-families',
  323. alertLabels.some((label) => label.includes('预警')) && alertLabels.some((label) => label.includes('报警')) &&
  324. alert.blocks.filter((block) => block.kind === 'bars').length === 2,
  325. `预警与报警两族统计同屏(验收第 4 条:保持合同名、只读展示):${alertLabels.join(' / ')}`,
  326. )
  327. // 与 C3 同一族护栏:喂**裸领域对象**时 KPI 必须是真的数字。
  328. check(
  329. 'C4-contract-alert-panel-reads-its-own-section-data',
  330. alert.blocks[0].items.find((item) => item.key === 'WARNING-total').value === '8' &&
  331. alert.blocks[2].items.find((item) => item.key === 'ALARM-total').value === '9',
  332. `按外壳真实的调用形状喂进去,两族总数真的画出来:预警 ${alert.blocks[0].items.find((i) => i.key === 'WARNING-total').value} 条 / 报警 ${alert.blocks[2].items.find((i) => i.key === 'ALARM-total').value} 条`,
  333. )
  334. check(
  335. 'C4b-alert-work-order-distribution-is-eight-codes',
  336. alert.blocks.filter((block) => block.kind === 'bars').every((block) => block.rows.length === 8),
  337. '工单状态分布 0–7 全铺(8 个码,不是旧的七态)',
  338. )
  339. check(
  340. 'C4c-alert-keeps-the-contract-name',
  341. alert.name === 'alertSettings' && alert.label === '预警设置',
  342. '面板名保持合同名「预警设置」(验收第 4 条原句)',
  343. )
  344. // 失败/空时仍返回结构完整的视图(面板标题不能跟着数据一起消失)。
  345. const failed = section(SECTION_STATE.FAILED, { data: null })
  346. check(
  347. 'C5-failed-section-still-returns-a-complete-view',
  348. ['situationSummary', 'infrastructureSummary', 'monitoringSummary', 'alertSettings'].every((panelId) => {
  349. const view = panelView(panelId, null, { ...failed, loaded: true })
  350. return view && Array.isArray(view.blocks) && view.hasContent === false && typeof view.placeholder === 'string'
  351. }),
  352. '四块在失败态都返回结构完整的视图(块在、占位说明在)',
  353. )
  354. }
  355. // ================================================================ D 分区独立(真契约 mock 走一遍)
  356. {
  357. setActivePinia(createPinia())
  358. const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js')
  359. const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js')
  360. const { builtinConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
  361. const store = useOverviewStore()
  362. store.reset()
  363. store.configure({ config: builtinConfig })
  364. resetMockTransportsForTest()
  365. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  366. await store.loadPanels({ industry: '' })
  367. const sections = store.sections
  368. check(
  369. 'D1-three-panels-load-independently',
  370. sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
  371. sections.monitoringSummary.state === SECTION_STATE.SUCCESS &&
  372. sections.alertSettings.state === SECTION_STATE.SUCCESS,
  373. `三块各自成功:${['infrastructureSummary', 'monitoringSummary', 'alertSettings'].map((id) => sections[id].state).join(' / ')}`,
  374. )
  375. check(
  376. 'D2-situation-is-composed-not-fetched',
  377. store.situationSection().state === SECTION_STATE.SUCCESS &&
  378. store.situationData().warningSettings !== null &&
  379. store.situationData().monitoring !== null,
  380. '态势总览由另外三块合成(它不自己发请求),故与预警设置同源',
  381. )
  382. // 同源的结构性证明:两处读的是**同一个对象引用**,不是"两次取数期望一致"。
  383. check(
  384. 'D2b-situation-and-alert-share-the-same-payload',
  385. store.situationData().warningSettings === sections.alertSettings.data,
  386. '态势总览与预警设置读同一份预警载荷(引用相同)—— 两处数字不可能对不上',
  387. )
  388. check(
  389. 'D3-regions-are-merged-into-infrastructure',
  390. store.infrastructureData().regions !== null &&
  391. Array.isArray(store.infrastructureData().regions.regions) &&
  392. store.infrastructureData().regions.regions.length > 0,
  393. `区域分布并进基础设施分区(${((store.infrastructureData().regions || {}).regions || []).length} 行)`,
  394. )
  395. // ⚠️ **端到端**:从 store 取数 → 经 `panelView()`(外壳用的那个入口)→ 断言读数是真的数字。
  396. //
  397. // 为什么这条必须存在(票 20 真机走查的教训):C 段那几条是**手喂 data** 的单元断言,
  398. // 而真机那次恰恰是"单元断言与实现犯了同一个错"(都以为 data 是包装对象)→ 全绿而界面全 `—`。
  399. // 只有走**外壳真实的调用路径**才拦得住这类错。
  400. //
  401. // 注意每块的 data 来源**不一样**,这里必须与 `CockpitShell.vue` 的 `panelViewOf()` 逐块一致:
  402. // 态势总览与基础设施面板吃的是 store 的**合成**数据,另外两块吃 `section.data`。
  403. // (这个"逐块不同"本身就是易错点,F6c 那条守卫专门钉它。)
  404. const shellDataSource = {
  405. situationSummary: () => store.situationData(),
  406. infrastructureSummary: () => store.infrastructureData(),
  407. monitoringSummary: () => store.sections.monitoringSummary.data,
  408. alertSettings: () => store.sections.alertSettings.data,
  409. }
  410. const endToEnd = [
  411. ['monitoringSummary', 'onlineRate', '75.0%'],
  412. ['alertSettings', 'WARNING-total', '8'],
  413. ['infrastructureSummary', 'facilityCount', '5'],
  414. ['situationSummary', 'warningTotal', '8'],
  415. ].map(([panelId, kpiKey, expected]) => {
  416. const section = panelId === 'situationSummary' ? store.situationSection() : store.sections[panelId]
  417. const view = panelView(panelId, shellDataSource[panelId](), section)
  418. const found = (view.blocks.find((block) => block.kind === 'kpi') || { items: [] }).items.find((item) => item.key === kpiKey)
  419. return { panelId, kpiKey, expected, actual: found ? found.value : '(无此 KPI)' }
  420. })
  421. check(
  422. 'D8-end-to-end-panels-really-draw-numbers',
  423. endToEnd.every((row) => row.actual === row.expected),
  424. `经外壳真实入口逐块读数:${endToEnd.map((r) => `${r.panelId}.${r.kpiKey}=${r.actual}`).join(' · ')}`,
  425. )
  426. // 只坏一块:另外两块照常成功(验收:任一分区失败不遮盖其它)。
  427. resetMockTransportsForTest()
  428. registerCockpitMocks({ scenario: 'failed', onlyPath: '/overview/monitoring', handlers: { registerMockTransport, resetMockTransportsForTest } })
  429. const partial = useOverviewStore()
  430. partial.reset()
  431. partial.configure({ config: builtinConfig })
  432. await partial.loadPanels({ industry: '' })
  433. check(
  434. 'D4-one-broken-panel-does-not-cover-others',
  435. partial.sections.monitoringSummary.state !== SECTION_STATE.SUCCESS &&
  436. partial.sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
  437. partial.sections.alertSettings.state === SECTION_STATE.SUCCESS,
  438. `只坏监测概览:监测 ${partial.sections.monitoringSummary.state},基础设施 ${partial.sections.infrastructureSummary.state},预警设置 ${partial.sections.alertSettings.state}`,
  439. )
  440. // 对抗式反证:全成功场景下三块都是 success,所以上面那条"一块坏"确实有信息量。
  441. check(
  442. 'D4b-control-all-success',
  443. sections.monitoringSummary.state === SECTION_STATE.SUCCESS,
  444. '对照组:同一份代码在 success 场景下监测概览是 success(故 D4 的判红有牙)',
  445. )
  446. // 空场景:不伪造 0,也不把"没有数据"画成"加载失败"。
  447. resetMockTransportsForTest()
  448. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  449. const empty = useOverviewStore()
  450. empty.reset()
  451. empty.configure({ config: builtinConfig })
  452. await empty.loadPanels({ industry: '' })
  453. check(
  454. 'D5-empty-is-not-failure',
  455. [empty.sections.infrastructureSummary.state, empty.sections.monitoringSummary.state, empty.sections.alertSettings.state].every((state) => state === SECTION_STATE.EMPTY),
  456. `空场景三块都是 ${SECTION_STATE.EMPTY}("没有数据"与"加载失败"分得开)`,
  457. )
  458. // 行业筛选:只有 `/overview/monitoring` 收该入参,所以它必须**真的**换内容。
  459. resetMockTransportsForTest()
  460. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  461. const filtered = useOverviewStore()
  462. filtered.reset()
  463. filtered.configure({ config: builtinConfig })
  464. await filtered.loadPanels({ industry: '' })
  465. const allDevices = filtered.sections.monitoringSummary.data.deviceCount
  466. await filtered.loadPanels({ industry: 'gas' })
  467. const gasDevices = filtered.sections.monitoringSummary.data.deviceCount
  468. check(
  469. 'D6-industry-filter-really-changes-the-numbers',
  470. gasDevices < allDevices && filtered.sections.monitoringSummary.data.formalIndustries.join(',') === 'GAS',
  471. `行业筛选真的重算:设备 ${allDevices} → ${gasDevices},正式范围 ${filtered.sections.monitoringSummary.data.formalIndustries.join(',')}`,
  472. )
  473. check(
  474. 'D6b-industry-filter-does-not-refetch-unfilterable-panels',
  475. filtered.sections.infrastructureSummary.data.facilityCount === 5,
  476. '另两个端点不收行业入参,故其读数不随筛选变化(不假装生效)',
  477. )
  478. // 趋势窗口:本票固定声明 LAST_7_DAYS,后端据此过滤桶。
  479. check(
  480. 'D7-range-is-declared-and-echoed',
  481. OVERVIEW_RANGE === 'LAST_7_DAYS' && filtered.sections.alertSettings.data.rangeType === 'LAST_7_DAYS',
  482. `窗口 ${filtered.sections.alertSettings.data.rangeType}(不声明时后端是无界 CURRENT_BASELINE,"近 7 日"就只是句空话)`,
  483. )
  484. }
  485. // ================================================================ E 联动
  486. {
  487. setActivePinia(createPinia())
  488. const store = useOverviewStore()
  489. const uiStore = useUiStore()
  490. const filterStore = useFilterStore()
  491. uiStore.setMode('overview')
  492. filterStore.setIndustry('all')
  493. const switched = store.linkToMode('warningResponse')
  494. check(
  495. 'E1-metric-link-switches-mode',
  496. switched === true && uiStore.mode === 'warningResponse',
  497. `点指标 → 一级模式切到 ${uiStore.mode}(落到真实事件列表,那里的事件带设备编码可再跳点位)`,
  498. )
  499. check(
  500. 'E1b-link-does-not-fake-a-map-locate',
  501. typeof store.locatePoint === 'undefined' && typeof store.locateCode === 'undefined',
  502. '概览 store 不提供任何地图定位入口(本模式端点不含点位编码,定位只能伪造)',
  503. )
  504. uiStore.setMode('overview')
  505. const linked = store.linkToIndustry('gas')
  506. check(
  507. 'E2-industry-link-writes-the-global-filter',
  508. linked === true && filterStore.industry === 'gas',
  509. `点行业行 → 全局行业筛选变成 ${filterStore.industry}(真的驱动一张图与各面板)`,
  510. )
  511. check(
  512. 'E2b-empty-action-is-refused',
  513. store.linkToIndustry('') === false && store.linkToMode('') === false,
  514. '空动作被拒(不静默写一个空筛选或切到未定义模式)',
  515. )
  516. filterStore.setIndustry('all')
  517. uiStore.setMode('overview')
  518. }
  519. // ================================================================ F 静态守卫
  520. {
  521. const domain = read('domain/overviewPanels.js')
  522. const service = read('services/overviewApi.js')
  523. const storeCode = read('stores/overviewStore.js')
  524. const renderer = read('components/PanelBlocks.vue')
  525. const shell = read('CockpitShell.vue')
  526. // 注释会解释被禁的东西,直接对全文正则会拿假阳性换通过率(t18 的教训)。
  527. const stripComments = (text) =>
  528. text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1').replace(/<!--[\s\S]*?-->/g, '')
  529. const domainCode = stripComments(domain)
  530. const storeClean = stripComments(storeCode)
  531. check(
  532. 'F1-domain-is-pure',
  533. !/\bfetch\s*\(|XMLHttpRequest/.test(domainCode) && !/from '\.\.\/services\//.test(domainCode),
  534. 'domain 不自己发请求、不反向依赖 service(层次不倒挂)',
  535. )
  536. check(
  537. 'F2-service-does-not-build-absolute-urls',
  538. !/https?:\/\//.test(stripComments(service)) && !/Authorization|Bearer/.test(stripComments(service)),
  539. 'service 只给根相对路径,地址与凭据归部署层(票 04 的唯一出口)',
  540. )
  541. // 守卫面与 t05 的 D1 **同宽**(票 20 的教训:我第一版只列了自己用到的几个词,
  542. // 于是 `rawDeviceStatistics` 这种"加了前缀的裸字段"漏过去了 ——
  543. // 变异 M12 实测全绿才发现。守卫比它要守的东西窄,就等于没守)。
  544. // 注意用 `\w*` 前后缀:裸字段名带前缀/后缀同样是泄漏(`rawDeviceStatistics`)。
  545. const RAW_FIELD = /\w*(monitoringPoints|unassociatedEquipment|coordinateValidation|pointStatusCode|deviceStatistics|aggregateState|timelineEntries|alarmWarningRelations|warningNo|alarmId|stateDictionary|stateReason|missingCycles|dataAgeSeconds|calibrationPeriodSeconds|staleReason|workOrders|deviceStatus|metricFreshness|workOrderStates|formalScope|typeDistribution)\w*/
  546. check(
  547. 'F3-store-has-no-raw-backend-fields',
  548. !RAW_FIELD.test(storeClean),
  549. 'store 里零后端裸字段名(字段改名归 domain)—— 与 t05 的 D1 守卫面同宽',
  550. )
  551. check(
  552. 'F4-store-has-no-write-operations',
  553. !/\b(POST|PUT|PATCH|DELETE)\b/.test(storeClean),
  554. '驾驶舱只读:本票不引入任何写操作',
  555. )
  556. // 渲染层**不认识任何 panelId**(票 18 解除的耦合,本票不得长回来)。
  557. check(
  558. 'F5-renderer-does-not-know-panel-ids',
  559. !/panelId/.test(stripComments(renderer)),
  560. '共享渲染层不认识 panelId(它只按 block.kind 与 block.action 分派)',
  561. )
  562. check(
  563. 'F5b-renderer-has-no-api-paths-or-fetch',
  564. !/\/overview\//.test(stripComments(renderer)) && !/fetch\(/.test(stripComments(renderer)),
  565. '渲染层不拼接口路径、不发请求',
  566. )
  567. // 外壳走同一条接缝:overview 四块必须走 PanelBlocks,而不是再长一套分支。
  568. check(
  569. 'F6-shell-routes-overview-through-the-shared-seam',
  570. (shell.match(/v-if="hasPanelView\(panelId/g) || []).length === 2 &&
  571. /!hasPanelView\(panelId\)\) return '面板占位'/.test(shell) &&
  572. /return '正在取数'/.test(shell),
  573. '外壳保持两处 hasPanelView 分支与既有的三态措辞(t18 的 H9 断言面)',
  574. )
  575. // 这一条守的是**接线本身**,不是"某个词出现过"。
  576. //
  577. // 票 20 的教训(变异 M13 实测全绿才发现):第一版只查 `linkToMode` / `linkToIndustry`
  578. // 在文件里**出现过** —— 而它们在 `handlePanelAction` 的定义里当然出现,
  579. // 于是"把 `@panel-action` 绑定删掉"(联动出口实际断掉)根本测不出来。
  580. // 现在改成断言**两处模板绑定**都在(左右栏各一处),以及取数入口在挂载里被调用。
  581. check(
  582. 'F6b-shell-wires-overview-store-and-links',
  583. (shell.match(/@panel-action="handlePanelAction"/g) || []).length === 2 &&
  584. /overviewStore\.loadPanels\(/.test(shell) &&
  585. /useOverviewStore/.test(shell) &&
  586. /overviewStore\.linkToMode\(/.test(shell) &&
  587. /overviewStore\.linkToIndustry\(/.test(shell),
  588. '外壳把概览接进共享接缝:左右栏各一处 @panel-action 绑定 + 取数入口 + 两条联动出口都真的接上',
  589. )
  590. // 这一条钉住"哪块面板吃哪份数据"—— 真机走查逮到的那类错正是**这一层错位**:
  591. // 视图函数以为 data 是包装对象、而外壳传的是裸 `section.data`,于是界面全是 `—` 而闸门全绿。
  592. // 所以把外壳里的分派逐块钉住:合成数据的两块必须显式传合成结果,另两块走 `section.data`。
  593. check(
  594. 'F6c-shell-per-panel-data-sources-are-pinned',
  595. /panelView\(panelId, overviewStore\.situationData\(\), section, extra\)/.test(shell) &&
  596. /panelView\(panelId, overviewStore\.infrastructureData\(\), section, extra\)/.test(shell) &&
  597. /return panelView\(panelId, section\.data, section, extra\)/.test(shell),
  598. '外壳逐块分派数据源:态势总览→situationData()、基础设施→infrastructureData()、其余→section.data(与各视图函数的期望一一对应)',
  599. )
  600. check(
  601. 'F7-domain-declares-its-own-paths-nowhere',
  602. !/\/overview\//.test(domainCode),
  603. 'domain 里不出现接口路径(路径归 services)',
  604. )
  605. check(
  606. 'F8-paths-live-in-one-place',
  607. OVERVIEW_PATHS.infrastructure === '/overview/infrastructure' &&
  608. OVERVIEW_PATHS.monitoring === '/overview/monitoring' &&
  609. OVERVIEW_PATHS.warningSettings === '/overview/warning-settings' &&
  610. OVERVIEW_PATHS.regions === '/infrastructure/base-archives',
  611. `四个端点路径只有一处声明:${Object.values(OVERVIEW_PATHS).join(' / ')}`,
  612. )
  613. }
  614. const passed = checks.filter(Boolean).length
  615. console.log(`\nT20 overview-panels: ${passed}/${checks.length}`)
  616. if (passed !== checks.length) process.exitCode = 1