t20-overview-panels.mjs 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  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. // 票 42 起两族口径分叉:预警族只给自身生命周期(工单口径六字段 null),
  158. // 报警族保留工单口径并存 alarm_status 生命周期(后端 b047a60d)。
  159. const warnings = toOverviewWarningSettings({
  160. updatedAt: '2026-09-29T10:00:00+08:00',
  161. displayName: '预警设置',
  162. selectedMode: 'WARNING',
  163. range: { type: 'LAST_7_DAYS', start: '2026-09-23T00:00:00+08:00', end: '2026-09-30T00:00:00+08:00' },
  164. warning: {
  165. eventFamily: 'WARNING', state: 'READY', stateReason: 'WARNINGS_RESOLVED', total: 8,
  166. handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null,
  167. handlingEfficiency: { numerator: 5, denominator: 8, value: '0.6250', state: 'AVAILABLE', reason: null },
  168. workOrderStates: null,
  169. statusDistribution: [
  170. { code: 'DRAFT', label: '草稿', count: 0 }, { code: 'PENDING', label: '待办', count: 1 },
  171. { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 1 },
  172. { code: 'HANDLED', label: '已处置', count: 1 }, { code: 'CLOSED', label: '已解除', count: 5 },
  173. ],
  174. levelDistribution: [
  175. { code: '1', label: '1级', count: 2 }, { code: '2', label: '2级', count: 3 },
  176. { code: '3', label: '3级', count: 2 }, { code: '4', label: '4级', count: 1 },
  177. ],
  178. industryDistribution: [
  179. { code: 'GAS', label: '燃气', count: 3 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 },
  180. { code: 'DRAINAGE', label: '排水', count: 2 }, { code: 'MANHOLE', label: '窨井', count: 0 },
  181. { code: 'UNKNOWN', label: '未知', count: 1 },
  182. ],
  183. districtDistribution: [
  184. { code: '0', label: '城南', count: 3 }, { code: '1', label: '城北', count: 2 },
  185. { code: '2', label: '太常', count: 1 }, { code: 'UNASSIGNED', label: '未分区', count: 2 },
  186. ],
  187. trend: [{ bucket: '2026-09-28', total: 3, handled: 2 }],
  188. },
  189. alarm: {
  190. eventFamily: 'ALARM', state: 'READY', stateReason: 'EVENTS_RESOLVED', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0,
  191. handlingEfficiency: { numerator: 6, denominator: 9, value: '0.6667', state: 'AVAILABLE', reason: null },
  192. workOrderStates: { 0: 1, 5: 2 },
  193. statusDistribution: [
  194. { code: 'UNHANDLED', label: '未处理', count: 3 }, { code: 'HANDLED', label: '已处理', count: 6 },
  195. ],
  196. levelDistribution: [], industryDistribution: [], districtDistribution: [],
  197. trend: [{ bucket: '2026-09-28', total: 4, handled: 3 }],
  198. },
  199. })
  200. check(
  201. 'B3-both-families-come-from-one-payload',
  202. warnings.warning.total === 8 && warnings.alarm.total === 9 && warnings.warning.family === 'WARNING' && warnings.alarm.family === 'ALARM',
  203. `一次取数同时给两族:预警 ${warnings.warning.total} 条 / 报警 ${warnings.alarm.total} 条(故不需要切换)`,
  204. )
  205. check(
  206. 'B3b-efficiency-carries-state-alongside-value',
  207. warnings.warning.efficiency.handlingRate === 0.625 && warnings.warning.efficiency.handlingState === 'AVAILABLE',
  208. '处置率连 `state` 一起带出("分母为 0"与"真的是 0%"必须分得开)',
  209. )
  210. // 工单状态分布(票 42 起只在**报警族**):0–7 全铺,缺的态补 0(0 是真实计数,不是"没有数据")。
  211. check(
  212. 'B3c-work-order-states-cover-all-eight-codes',
  213. warnings.alarm.workOrderStates.length === 8 &&
  214. warnings.alarm.workOrderStates.map((row) => row.code).join(',') === '0,1,2,3,4,5,6,7' &&
  215. warnings.alarm.workOrderStates.find((row) => row.code === '5').count === 2 &&
  216. warnings.alarm.workOrderStates.find((row) => row.code === '1').count === 0,
  217. `0–7 全铺(8 个码):${warnings.alarm.workOrderStates.map((row) => `${row.code}:${row.count}`).join(' ')}`,
  218. )
  219. // 票 42:预警族工单口径字段原样 null —— 「不适用」绝不 numberish 成 0、也不补假零条。
  220. const warningStatusRows = warnings.warning.statusDistribution || []
  221. check(
  222. 'B3e-warning-family-carries-null-not-zeros',
  223. warnings.warning.workOrderStates === null && warnings.warning.handled === null && warnings.warning.pending === null &&
  224. warningStatusRows.length === 6 && warningStatusRows.reduce((total, row) => total + row.count, 0) === warnings.warning.total,
  225. `预警族工单口径全 null、状态六桶自洽(和=总数 ${warnings.warning.total})`,
  226. )
  227. // 票 42:分布桶直读不翻码 —— 后端给的 label 原样带到领域层(含 UNKNOWN 这类显式桶)。
  228. check(
  229. 'B3f-lifecycle-buckets-pass-through-with-labels',
  230. (warnings.warning.industryDistribution || []).some((row) => row.code === 'UNKNOWN' && row.label === '未知') &&
  231. (warnings.warning.districtDistribution || []).some((row) => row.code === 'UNASSIGNED' && row.label === '未分区'),
  232. '行业/片区分布含显式 UNKNOWN/UNASSIGNED 桶且 label 原样透传',
  233. )
  234. check(
  235. 'B3d-range-type-is-echoed',
  236. warnings.rangeType === 'LAST_7_DAYS',
  237. `窗口回显 ${warnings.rangeType}(验收第 1 条的「近 7 日」靠它声明)`,
  238. )
  239. const regions = toOverviewRegions({
  240. totals: { facilityCount: 5, lengthKm: '12.4', scale: 'medium' },
  241. regionDistribution: [{ code: '城区A', count: 3 }, { code: '未分区', count: 2 }],
  242. })
  243. check(
  244. 'B4-regions-come-from-the-sibling-endpoint',
  245. regions.regions.length === 2 && regions.regions[0].label === '城区A' && regions.lengthKm === 12.4,
  246. `区域分布借票 17 的端点:${regions.regions.map((row) => `${row.label}${row.count}`).join(' ')}`,
  247. )
  248. }
  249. // ================================================================ C 视图层
  250. {
  251. const data = {
  252. infrastructure: { facilityCount: 5, smartDeviceCount: 9, industries: [{ code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4 }], excluded: {} },
  253. regions: { regions: [{ code: '城区A', label: '城区A', count: 3 }], lengthKm: 12.4, scale: 'medium' },
  254. monitoring: {
  255. deviceCount: 9, monitoringPointCount: 7, monitoringFacilityCount: 3, infrastructureFacilityCount: 5,
  256. onlineRate: 0.75, onlineCount: 6, onlineDenominator: 8, onlineState: 'CURRENT',
  257. deviceStatus: { online: 6, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 8 },
  258. formalIndustries: ['GAS'], metricFreshness: { status: 'CURRENT', updatedAt: 'T' },
  259. typeDistribution: [{ industryLabel: '燃气', typeName: '燃气管线', deviceCount: 4 }],
  260. },
  261. warningSettings: {
  262. rangeType: 'LAST_7_DAYS',
  263. // 票 42 新契约领域对象:预警族工单口径全 null + 四组生命周期分布;报警族保留工单口径。
  264. warning: {
  265. family: 'WARNING', total: 8, handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null,
  266. efficiency: { handlingRate: 0.625, handlingState: 'AVAILABLE', numerator: 5, denominator: 8 },
  267. workOrderStates: null,
  268. statusDistribution: [
  269. { code: 'DRAFT', label: '草稿', count: 0 }, { code: 'PENDING', label: '待办', count: 1 },
  270. { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 1 },
  271. { code: 'HANDLED', label: '已处置', count: 1 }, { code: 'CLOSED', label: '已解除', count: 5 },
  272. ],
  273. levelDistribution: [{ code: '1', label: '1级', count: 2 }, { code: '2', label: '2级', count: 3 }, { code: '3', label: '3级', count: 2 }, { code: '4', label: '4级', count: 1 }],
  274. industryDistribution: [{ code: 'GAS', label: '燃气', count: 3 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 }, { code: 'DRAINAGE', label: '排水', count: 2 }, { code: 'MANHOLE', label: '窨井', count: 0 }, { code: 'UNKNOWN', label: '未知', count: 1 }],
  275. districtDistribution: [{ code: '0', label: '城南', count: 3 }, { code: '1', label: '城北', count: 2 }, { code: '2', label: '太常', count: 1 }, { code: 'UNASSIGNED', label: '未分区', count: 2 }],
  276. trend: [],
  277. },
  278. alarm: {
  279. family: 'ALARM', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0,
  280. efficiency: { handlingRate: 0.667, handlingState: 'AVAILABLE', numerator: 6, denominator: 9 },
  281. workOrderStates: WORK_ORDER_STATES.map((s) => ({ code: String(s.code), label: s.name, count: 0 })),
  282. statusDistribution: [{ code: 'UNHANDLED', label: '未处理', count: 3 }, { code: 'HANDLED', label: '已处理', count: 6 }],
  283. levelDistribution: [], industryDistribution: [], districtDistribution: [],
  284. trend: [],
  285. },
  286. },
  287. }
  288. const situation = situationSummaryPanel(data, ready(data))
  289. const kpiKeys = situation.blocks[0].items.map((item) => item.key)
  290. check(
  291. 'C1-situation-kpis-cover-the-acceptance-list',
  292. ['facilityCount', 'smartDeviceCount', 'deviceCount', 'monitoringPointCount', 'onlineRate', 'warningTotal', 'alarmTotal', 'handlingRate'].every((key) => kpiKeys.includes(key)),
  293. `跨行业 KPI:${kpiKeys.join(' / ')}`,
  294. )
  295. check(
  296. 'C1b-situation-has-both-trend-series',
  297. situation.blocks.filter((block) => block.kind === 'trend').length === 2 &&
  298. situation.rangeNote.includes('近 7 日'),
  299. `预警与报警两条趋势序列(${situation.blocks.filter((b) => b.kind === 'trend').map((b) => b.label).join(' / ')})`,
  300. )
  301. check(
  302. 'C1c-situation-handling-rate-is-a-link',
  303. situation.blocks[0].items.find((item) => item.key === 'handlingRate').link === 'EFFICIENCY' &&
  304. situation.blocks[0].items.find((item) => item.key === 'warningTotal').link === 'WARNING',
  305. '当前预警/报警/处置率三个读数带联动出口(验收第 5 条)',
  306. )
  307. // **分母为 0 不是 0%**:后端回 value="0.0000" + state=EMPTY 时界面必须显示占位符。
  308. const zeroRate = situationSummaryPanel(
  309. { ...data, monitoring: { ...data.monitoring, onlineRate: 0, onlineDenominator: 0, onlineState: 'EMPTY' } },
  310. ready(data),
  311. )
  312. check(
  313. 'C1d-zero-denominator-is-not-zero-percent',
  314. zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value === DETAIL_PLACEHOLDER &&
  315. zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').hint.includes('不适用'),
  316. `分母为 0 → ${zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value}(不是 0.0%)`,
  317. )
  318. const infra = infrastructureSummaryPanel(data, ready(data))
  319. check(
  320. 'C2-infrastructure-has-industry-and-region',
  321. infra.blocks.some((block) => block.label === '行业构成 · 设施') &&
  322. infra.blocks.some((block) => block.label === '区域分布') &&
  323. infra.blocks.find((block) => block.label === '区域分布').rows.length === 1,
  324. `设施底数 + 行业构成 + 区域分布(验收第 2 条三项齐)`,
  325. )
  326. check(
  327. 'C2b-industry-rows-are-actionable',
  328. infra.blocks.find((block) => block.label === '行业构成 · 设施').action === 'industry' &&
  329. infra.blocks.find((block) => block.label === '行业构成 · 设施').rows[0].actionKey === 'gas',
  330. '行业行带联动动作与前端筛选键(点它写全局行业筛选)',
  331. )
  332. check(
  333. 'C2c-region-gap-is-honest-when-missing',
  334. infrastructureSummaryPanel({ infrastructure: data.infrastructure, regions: null }, ready(data)).gap.includes('base-archives'),
  335. '区域取不到时如实说明它来自哪个端点,不假装有',
  336. )
  337. check(
  338. 'C2d-length-and-scale-come-from-the-sibling-endpoint',
  339. infra.blocks[0].items.find((item) => item.key === 'lengthKm').value === '12.4' &&
  340. infra.blocks[0].items.find((item) => item.key === 'scale').value === '中型',
  341. '里程与规模取自票 17 的档案端点(本概览端点不给),英文规模枚举被翻成中文',
  342. )
  343. const monitoring = monitoringSummaryPanel(data.monitoring, ready(data.monitoring))
  344. const monitoringKpis = monitoring.blocks[0].items.map((item) => item.key)
  345. check(
  346. 'C3-monitoring-shows-online-rate-and-four-buckets',
  347. ['onlineRate', 'online', 'offline', 'stale', 'unknownOrMissing'].every((key) => monitoringKpis.includes(key)),
  348. `在线率 + 四态桶:${monitoringKpis.join(' / ')}(验收第 3 条)`,
  349. )
  350. // ⚠️ 真机走查逮到的真缺陷,这条守卫就是它的护栏:
  351. // `monitoringSummaryPanel` 原先读 `data.monitoring`(以为拿到包装),而外壳传的是
  352. // **裸领域对象** `section.data` —— 于是整块读数全是 `—`,而当时逻辑闸门全绿,
  353. // 因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
  354. // 所以这里必须断言"喂真实形状时**真的画出数字**",而不只是"块在不在"。
  355. check(
  356. 'C3-contract-panel-reads-its-own-section-data',
  357. monitoringKpis.includes('onlineRate') &&
  358. monitoring.blocks[0].items.find((item) => item.key === 'onlineRate').value === '75.0%' &&
  359. monitoring.blocks[0].items.find((item) => item.key === 'online').value === '6' &&
  360. monitoring.blocks.some((block) => block.label === '监测覆盖'),
  361. `按外壳真实的调用形状(裸 section.data)喂进去,读数真的是数字而不是占位符:在线率 ${monitoring.blocks[0].items.find((i) => i.key === 'onlineRate').value} · 在线 ${monitoring.blocks[0].items.find((i) => i.key === 'online').value}`,
  362. )
  363. check(
  364. 'C3b-monitoring-has-coverage-and-type-distribution',
  365. monitoring.blocks.some((block) => block.label === '监测覆盖') && monitoring.blocks.some((block) => block.label === '类型分布 · 智能设备'),
  366. '监测覆盖 + 类型分布',
  367. )
  368. // 验收第 3 条点名「故障统计」,而契约没有该桶 —— 必须如实说明,不静默略过。
  369. check(
  370. 'C3c-fault-gap-is-declared',
  371. monitoring.gap === MONITORING_FAULT_GAP && monitoring.gap.includes('没有独立的「故障」桶'),
  372. '「故障」缺口如实写在面板上(不发明一个故障数)',
  373. )
  374. check(
  375. 'C3d-metric-freshness-row-is-present',
  376. monitoring.blocks.some((block) => block.label === '最近采集时间'),
  377. '逐设备最近采集时间有值时才画那一行',
  378. )
  379. const alert = alertSettingsPanel(data.warningSettings, ready(data.warningSettings))
  380. // 只有分布块有 `label`(KPI 块的 label 在 items 上),所以先滤掉没有 label 的块。
  381. const alertLabels = alert.blocks.map((block) => block.label).filter(Boolean)
  382. // 票 42:两族口径不同、块结构也不同 —— 预警族生命周期四分布,报警族工单分布 + 生命周期分布并存。
  383. const alarmKpiItems = alert.blocks.flatMap((block) => block.items || [])
  384. check(
  385. 'C4-alert-settings-shows-both-families',
  386. alertLabels.some((label) => label.includes('预警')) && alertLabels.some((label) => label.includes('报警')) &&
  387. alertLabels.some((label) => label.includes('生命周期状态')) && alertLabels.some((label) => label.includes('工单状态分布')),
  388. `两族统计同屏且口径分叉可见(票 42):${alertLabels.join(' / ')}`,
  389. )
  390. // 与 C3 同一族护栏:喂**裸领域对象**时 KPI 必须是真的数字。
  391. // (票 42 起块数量按族不同,改按 key 找,不按块下标。)
  392. check(
  393. 'C4-contract-alert-panel-reads-its-own-section-data',
  394. alert.blocks[0].items.find((item) => item.key === 'WARNING-total').value === '8' &&
  395. alarmKpiItems.find((item) => item.key === 'ALARM-total').value === '9',
  396. `按外壳真实的调用形状喂进去,两族总数真的画出来:预警 ${alert.blocks[0].items.find((i) => i.key === 'WARNING-total').value} 条 / 报警 ${alarmKpiItems.find((i) => i.key === 'ALARM-total').value} 条`,
  397. )
  398. check(
  399. 'C4b-alert-work-order-distribution-is-eight-codes',
  400. (alert.blocks.find((block) => block.label === '报警 · 工单状态分布') || { rows: [] }).rows.length === 8 &&
  401. (alert.blocks.find((block) => block.label === '预警 · 生命周期状态') || { rows: [] }).rows.length === 6,
  402. '报警工单分布 0–7 全铺(8 个码);预警生命周期状态六态全铺(票 42)',
  403. )
  404. // 票 42 验收第 4 条:预警族不再出现工单口径读数 —— KPI 只剩 总数 + 已解除率。
  405. const warningKpiKeys = alert.blocks[0].items.map((item) => item.key)
  406. check(
  407. 'C4d-warning-family-has-no-work-order-vocabulary',
  408. warningKpiKeys.join(',') === 'WARNING-total,WARNING-rate' &&
  409. !alertLabels.some((label) => label.startsWith('预警 · 工单')) &&
  410. alert.blocks[0].items.find((item) => item.key === 'WARNING-rate').label === '已解除率',
  411. `预警族只有 总数/已解除率(${warningKpiKeys.join(' / ')}),工单口径块只在报警族`,
  412. )
  413. check(
  414. 'C4c-alert-keeps-the-contract-name',
  415. alert.name === 'alertSettings' && alert.label === '预警设置',
  416. '面板名保持合同名「预警设置」(验收第 4 条原句)',
  417. )
  418. // 失败/空时仍返回结构完整的视图(面板标题不能跟着数据一起消失)。
  419. const failed = section(SECTION_STATE.FAILED, { data: null })
  420. check(
  421. 'C5-failed-section-still-returns-a-complete-view',
  422. ['situationSummary', 'infrastructureSummary', 'monitoringSummary', 'alertSettings'].every((panelId) => {
  423. const view = panelView(panelId, null, { ...failed, loaded: true })
  424. return view && Array.isArray(view.blocks) && view.hasContent === false && typeof view.placeholder === 'string'
  425. }),
  426. '四块在失败态都返回结构完整的视图(块在、占位说明在)',
  427. )
  428. }
  429. // ================================================================ D 分区独立(真契约 mock 走一遍)
  430. {
  431. setActivePinia(createPinia())
  432. const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js')
  433. const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js')
  434. const { builtinConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
  435. const store = useOverviewStore()
  436. store.reset()
  437. store.configure({ config: builtinConfig })
  438. resetMockTransportsForTest()
  439. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  440. await store.loadPanels({ industry: '' })
  441. const sections = store.sections
  442. check(
  443. 'D1-three-panels-load-independently',
  444. sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
  445. sections.monitoringSummary.state === SECTION_STATE.SUCCESS &&
  446. sections.alertSettings.state === SECTION_STATE.SUCCESS,
  447. `三块各自成功:${['infrastructureSummary', 'monitoringSummary', 'alertSettings'].map((id) => sections[id].state).join(' / ')}`,
  448. )
  449. check(
  450. 'D2-situation-is-composed-not-fetched',
  451. store.situationSection().state === SECTION_STATE.SUCCESS &&
  452. store.situationData().warningSettings !== null &&
  453. store.situationData().monitoring !== null,
  454. '态势总览由另外三块合成(它不自己发请求),故与预警设置同源',
  455. )
  456. // 同源的结构性证明:两处读的是**同一个对象引用**,不是"两次取数期望一致"。
  457. check(
  458. 'D2b-situation-and-alert-share-the-same-payload',
  459. store.situationData().warningSettings === sections.alertSettings.data,
  460. '态势总览与预警设置读同一份预警载荷(引用相同)—— 两处数字不可能对不上',
  461. )
  462. check(
  463. 'D3-regions-are-merged-into-infrastructure',
  464. store.infrastructureData().regions !== null &&
  465. Array.isArray(store.infrastructureData().regions.regions) &&
  466. store.infrastructureData().regions.regions.length > 0,
  467. `区域分布并进基础设施分区(${((store.infrastructureData().regions || {}).regions || []).length} 行)`,
  468. )
  469. // ⚠️ **端到端**:从 store 取数 → 经 `panelView()`(外壳用的那个入口)→ 断言读数是真的数字。
  470. //
  471. // 为什么这条必须存在(票 20 真机走查的教训):C 段那几条是**手喂 data** 的单元断言,
  472. // 而真机那次恰恰是"单元断言与实现犯了同一个错"(都以为 data 是包装对象)→ 全绿而界面全 `—`。
  473. // 只有走**外壳真实的调用路径**才拦得住这类错。
  474. //
  475. // 注意每块的 data 来源**不一样**,这里必须与 `CockpitShell.vue` 的 `panelViewOf()` 逐块一致:
  476. // 态势总览与基础设施面板吃的是 store 的**合成**数据,另外两块吃 `section.data`。
  477. // (这个"逐块不同"本身就是易错点,F6c 那条守卫专门钉它。)
  478. const shellDataSource = {
  479. situationSummary: () => store.situationData(),
  480. infrastructureSummary: () => store.infrastructureData(),
  481. monitoringSummary: () => store.sections.monitoringSummary.data,
  482. alertSettings: () => store.sections.alertSettings.data,
  483. }
  484. const endToEnd = [
  485. ['monitoringSummary', 'onlineRate', '75.0%'],
  486. ['alertSettings', 'WARNING-total', '8'],
  487. ['infrastructureSummary', 'facilityCount', '5'],
  488. ['situationSummary', 'warningTotal', '8'],
  489. ].map(([panelId, kpiKey, expected]) => {
  490. const section = panelId === 'situationSummary' ? store.situationSection() : store.sections[panelId]
  491. const view = panelView(panelId, shellDataSource[panelId](), section)
  492. const found = (view.blocks.find((block) => block.kind === 'kpi') || { items: [] }).items.find((item) => item.key === kpiKey)
  493. return { panelId, kpiKey, expected, actual: found ? found.value : '(无此 KPI)' }
  494. })
  495. check(
  496. 'D8-end-to-end-panels-really-draw-numbers',
  497. endToEnd.every((row) => row.actual === row.expected),
  498. `经外壳真实入口逐块读数:${endToEnd.map((r) => `${r.panelId}.${r.kpiKey}=${r.actual}`).join(' · ')}`,
  499. )
  500. // 只坏一块:另外两块照常成功(验收:任一分区失败不遮盖其它)。
  501. resetMockTransportsForTest()
  502. registerCockpitMocks({ scenario: 'failed', onlyPath: '/overview/monitoring', handlers: { registerMockTransport, resetMockTransportsForTest } })
  503. const partial = useOverviewStore()
  504. partial.reset()
  505. partial.configure({ config: builtinConfig })
  506. await partial.loadPanels({ industry: '' })
  507. check(
  508. 'D4-one-broken-panel-does-not-cover-others',
  509. partial.sections.monitoringSummary.state !== SECTION_STATE.SUCCESS &&
  510. partial.sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
  511. partial.sections.alertSettings.state === SECTION_STATE.SUCCESS,
  512. `只坏监测概览:监测 ${partial.sections.monitoringSummary.state},基础设施 ${partial.sections.infrastructureSummary.state},预警设置 ${partial.sections.alertSettings.state}`,
  513. )
  514. // 对抗式反证:全成功场景下三块都是 success,所以上面那条"一块坏"确实有信息量。
  515. check(
  516. 'D4b-control-all-success',
  517. sections.monitoringSummary.state === SECTION_STATE.SUCCESS,
  518. '对照组:同一份代码在 success 场景下监测概览是 success(故 D4 的判红有牙)',
  519. )
  520. // 空场景:不伪造 0,也不把"没有数据"画成"加载失败"。
  521. resetMockTransportsForTest()
  522. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  523. const empty = useOverviewStore()
  524. empty.reset()
  525. empty.configure({ config: builtinConfig })
  526. await empty.loadPanels({ industry: '' })
  527. check(
  528. 'D5-empty-is-not-failure',
  529. [empty.sections.infrastructureSummary.state, empty.sections.monitoringSummary.state, empty.sections.alertSettings.state].every((state) => state === SECTION_STATE.EMPTY),
  530. `空场景三块都是 ${SECTION_STATE.EMPTY}("没有数据"与"加载失败"分得开)`,
  531. )
  532. // 行业筛选:只有 `/overview/monitoring` 收该入参,所以它必须**真的**换内容。
  533. resetMockTransportsForTest()
  534. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  535. const filtered = useOverviewStore()
  536. filtered.reset()
  537. filtered.configure({ config: builtinConfig })
  538. await filtered.loadPanels({ industry: '' })
  539. const allDevices = filtered.sections.monitoringSummary.data.deviceCount
  540. await filtered.loadPanels({ industry: 'gas' })
  541. const gasDevices = filtered.sections.monitoringSummary.data.deviceCount
  542. check(
  543. 'D6-industry-filter-really-changes-the-numbers',
  544. gasDevices < allDevices && filtered.sections.monitoringSummary.data.formalIndustries.join(',') === 'GAS',
  545. `行业筛选真的重算:设备 ${allDevices} → ${gasDevices},正式范围 ${filtered.sections.monitoringSummary.data.formalIndustries.join(',')}`,
  546. )
  547. check(
  548. 'D6b-industry-filter-does-not-refetch-unfilterable-panels',
  549. filtered.sections.infrastructureSummary.data.facilityCount === 5,
  550. '另两个端点不收行业入参,故其读数不随筛选变化(不假装生效)',
  551. )
  552. // 趋势窗口:本票固定声明 LAST_7_DAYS,后端据此过滤桶。
  553. check(
  554. 'D7-range-is-declared-and-echoed',
  555. OVERVIEW_RANGE === 'LAST_7_DAYS' && filtered.sections.alertSettings.data.rangeType === 'LAST_7_DAYS',
  556. `窗口 ${filtered.sections.alertSettings.data.rangeType}(不声明时后端是无界 CURRENT_BASELINE,"近 7 日"就只是句空话)`,
  557. )
  558. }
  559. // ================================================================ E 联动
  560. {
  561. setActivePinia(createPinia())
  562. const store = useOverviewStore()
  563. const uiStore = useUiStore()
  564. const filterStore = useFilterStore()
  565. uiStore.setMode('overview')
  566. filterStore.setIndustry('all')
  567. const switched = store.linkToMode('warningResponse')
  568. check(
  569. 'E1-metric-link-switches-mode',
  570. switched === true && uiStore.mode === 'warningResponse',
  571. `点指标 → 一级模式切到 ${uiStore.mode}(落到真实事件列表,那里的事件带设备编码可再跳点位)`,
  572. )
  573. check(
  574. 'E1b-link-does-not-fake-a-map-locate',
  575. typeof store.locatePoint === 'undefined' && typeof store.locateCode === 'undefined',
  576. '概览 store 不提供任何地图定位入口(本模式端点不含点位编码,定位只能伪造)',
  577. )
  578. uiStore.setMode('overview')
  579. const linked = store.linkToIndustry('gas')
  580. check(
  581. 'E2-industry-link-writes-the-global-filter',
  582. linked === true && filterStore.industry === 'gas',
  583. `点行业行 → 全局行业筛选变成 ${filterStore.industry}(真的驱动一张图与各面板)`,
  584. )
  585. check(
  586. 'E2b-empty-action-is-refused',
  587. store.linkToIndustry('') === false && store.linkToMode('') === false,
  588. '空动作被拒(不静默写一个空筛选或切到未定义模式)',
  589. )
  590. filterStore.setIndustry('all')
  591. uiStore.setMode('overview')
  592. }
  593. // ================================================================ F 静态守卫
  594. {
  595. const domain = read('domain/overviewPanels.js')
  596. const service = read('services/overviewApi.js')
  597. const storeCode = read('stores/overviewStore.js')
  598. const renderer = read('components/PanelBlocks.vue')
  599. const shell = read('CockpitShell.vue')
  600. // 注释会解释被禁的东西,直接对全文正则会拿假阳性换通过率(t18 的教训)。
  601. const stripComments = (text) =>
  602. text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1').replace(/<!--[\s\S]*?-->/g, '')
  603. const domainCode = stripComments(domain)
  604. const storeClean = stripComments(storeCode)
  605. check(
  606. 'F1-domain-is-pure',
  607. !/\bfetch\s*\(|XMLHttpRequest/.test(domainCode) && !/from '\.\.\/services\//.test(domainCode),
  608. 'domain 不自己发请求、不反向依赖 service(层次不倒挂)',
  609. )
  610. check(
  611. 'F2-service-does-not-build-absolute-urls',
  612. !/https?:\/\//.test(stripComments(service)) && !/Authorization|Bearer/.test(stripComments(service)),
  613. 'service 只给根相对路径,地址与凭据归部署层(票 04 的唯一出口)',
  614. )
  615. // 守卫面与 t05 的 D1 **同宽**(票 20 的教训:我第一版只列了自己用到的几个词,
  616. // 于是 `rawDeviceStatistics` 这种"加了前缀的裸字段"漏过去了 ——
  617. // 变异 M12 实测全绿才发现。守卫比它要守的东西窄,就等于没守)。
  618. // 注意用 `\w*` 前后缀:裸字段名带前缀/后缀同样是泄漏(`rawDeviceStatistics`)。
  619. 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*/
  620. check(
  621. 'F3-store-has-no-raw-backend-fields',
  622. !RAW_FIELD.test(storeClean),
  623. 'store 里零后端裸字段名(字段改名归 domain)—— 与 t05 的 D1 守卫面同宽',
  624. )
  625. check(
  626. 'F4-store-has-no-write-operations',
  627. !/\b(POST|PUT|PATCH|DELETE)\b/.test(storeClean),
  628. '驾驶舱只读:本票不引入任何写操作',
  629. )
  630. // 渲染层**不认识任何 panelId**(票 18 解除的耦合,本票不得长回来)。
  631. check(
  632. 'F5-renderer-does-not-know-panel-ids',
  633. !/panelId/.test(stripComments(renderer)),
  634. '共享渲染层不认识 panelId(它只按 block.kind 与 block.action 分派)',
  635. )
  636. check(
  637. 'F5b-renderer-has-no-api-paths-or-fetch',
  638. !/\/overview\//.test(stripComments(renderer)) && !/fetch\(/.test(stripComments(renderer)),
  639. '渲染层不拼接口路径、不发请求',
  640. )
  641. // 外壳走同一条接缝:overview 四块必须走 PanelBlocks,而不是再长一套分支。
  642. check(
  643. 'F6-shell-routes-overview-through-the-shared-seam',
  644. (shell.match(/v-if="hasPanelView\(panelId/g) || []).length === 2 &&
  645. /!hasPanelView\(panelId\)\) return '面板占位'/.test(shell) &&
  646. /return '正在取数'/.test(shell),
  647. '外壳保持两处 hasPanelView 分支与既有的三态措辞(t18 的 H9 断言面)',
  648. )
  649. // 这一条守的是**接线本身**,不是"某个词出现过"。
  650. //
  651. // 票 20 的教训(变异 M13 实测全绿才发现):第一版只查 `linkToMode` / `linkToIndustry`
  652. // 在文件里**出现过** —— 而它们在 `handlePanelAction` 的定义里当然出现,
  653. // 于是"把 `@panel-action` 绑定删掉"(联动出口实际断掉)根本测不出来。
  654. // 现在改成断言**两处模板绑定**都在(左右栏各一处),以及取数入口在挂载里被调用。
  655. check(
  656. 'F6b-shell-wires-overview-store-and-links',
  657. (shell.match(/@panel-action="handlePanelAction"/g) || []).length === 2 &&
  658. /overviewStore\.loadPanels\(/.test(shell) &&
  659. /useOverviewStore/.test(shell) &&
  660. /overviewStore\.linkToMode\(/.test(shell) &&
  661. /overviewStore\.linkToIndustry\(/.test(shell),
  662. '外壳把概览接进共享接缝:左右栏各一处 @panel-action 绑定 + 取数入口 + 两条联动出口都真的接上',
  663. )
  664. // 这一条钉住"哪块面板吃哪份数据"—— 真机走查逮到的那类错正是**这一层错位**:
  665. // 视图函数以为 data 是包装对象、而外壳传的是裸 `section.data`,于是界面全是 `—` 而闸门全绿。
  666. // 所以把外壳里的分派逐块钉住:合成数据的两块必须显式传合成结果,另两块走 `section.data`。
  667. check(
  668. 'F6c-shell-per-panel-data-sources-are-pinned',
  669. /panelView\(panelId, overviewStore\.situationData\(\), section, extra\)/.test(shell) &&
  670. /panelView\(panelId, overviewStore\.infrastructureData\(\), section, extra\)/.test(shell) &&
  671. /return panelView\(panelId, section\.data, section, extra\)/.test(shell),
  672. '外壳逐块分派数据源:态势总览→situationData()、基础设施→infrastructureData()、其余→section.data(与各视图函数的期望一一对应)',
  673. )
  674. check(
  675. 'F7-domain-declares-its-own-paths-nowhere',
  676. !/\/overview\//.test(domainCode),
  677. 'domain 里不出现接口路径(路径归 services)',
  678. )
  679. check(
  680. 'F8-paths-live-in-one-place',
  681. OVERVIEW_PATHS.infrastructure === '/overview/infrastructure' &&
  682. OVERVIEW_PATHS.monitoring === '/overview/monitoring' &&
  683. OVERVIEW_PATHS.warningSettings === '/overview/warning-settings' &&
  684. OVERVIEW_PATHS.regions === '/infrastructure/base-archives',
  685. `四个端点路径只有一处声明:${Object.values(OVERVIEW_PATHS).join(' / ')}`,
  686. )
  687. }
  688. const passed = checks.filter(Boolean).length
  689. console.log(`\nT20 overview-panels: ${passed}/${checks.length}`)
  690. if (passed !== checks.length) process.exitCode = 1