t20-overview-panels.mjs 43 KB

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