t18-monitoring-panels.mjs 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  1. /**
  2. * T18 运行监测模式面板 纯逻辑检查(票 18)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t18-monitoring-panels.mjs
  6. *
  7. * 八段:
  8. * A 面板映射:四块面板齐、与目录一致、不越界接其它模式
  9. * B 契约适配:三份真形状载荷 → 领域对象(含 BigDecimal 字符串、region 未分区、全六态)
  10. * C 视图层:验收点名的画面元素都在(规模/在线率/类型分布/采集时间、覆盖/状态分布、
  11. * 趋势/离线/异常、台账卡片),且缺值显示占位符
  12. * D 分区独立:一块失败不遮盖其它块;空 / 过期 / 分不开的形态各有措辞
  13. * E 筛选联动:行业映码、状态白名单、范围走配置映射、**筛选真的进请求串**
  14. * F 地图联动:筛选真的下发到 map-snapshot(mock 真的过滤,不是只看请求串)
  15. * G 台账:与分布共用一次取数、按端点分页渐进加载、徽标 N+1 各自成败
  16. * H 静态守卫:组件不拼路径、零后端裸字段/代号;domain 不发请求;面板渲染层与模式解耦
  17. *
  18. * 契约依据:`CockpitMonitoring{Overview,Distribution,Situation}Response` 与服务
  19. * 逐行读过;见 `domain/monitoringPanels.js` 文件头的三处契约差异记录。
  20. */
  21. import { readFileSync } from 'node:fs'
  22. import { createPinia, setActivePinia } from 'pinia'
  23. import {
  24. METRIC_FRESHNESS_GAP,
  25. METRIC_STATUS_TEXT,
  26. MONITORING_PANEL_IDS,
  27. POINT_STATUS_MAP_GAP,
  28. bucketLabel,
  29. metricStatusText,
  30. monitoringDistributionPanel,
  31. monitoringOverviewPanel,
  32. monitoringPanelView,
  33. monitoringPointLedgerPanel,
  34. nextLedgerPage,
  35. operationOverviewPanel,
  36. shouldLoadBadge,
  37. toMonitoringDistribution,
  38. toMonitoringOverview,
  39. toMonitoringSituation,
  40. } from '../../../src/modules/cockpit/domain/monitoringPanels.js'
  41. import {
  42. DEFAULT_POINT_STATUS,
  43. REGION_MAP_GAP,
  44. hasRegionMapping,
  45. industryQuery,
  46. isKnownPointStatus,
  47. regionQuery,
  48. } from '../../../src/modules/cockpit/domain/filterOptions.js'
  49. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  50. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  51. import { panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
  52. import { hasPanelView, implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
  53. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  54. import { normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  55. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  56. import {
  57. fetchMonitoringDistribution,
  58. fetchMonitoringOverview,
  59. statusParam,
  60. } from '../../../src/modules/cockpit/services/monitoringApi.js'
  61. import { useMonitoringStore } from '../../../src/modules/cockpit/stores/monitoringStore.js'
  62. import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  63. import { useFilterStore } from '../../../src/modules/cockpit/stores/filterStore.js'
  64. const checks = []
  65. /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  66. function check(name, pass, evidence) {
  67. checks.push({ name, pass })
  68. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  69. }
  70. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  71. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  72. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  73. /** 去掉注释只留代码(注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
  74. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  75. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  76. const readCode = (relative) => code(read(relative))
  77. const readVueCode = (relative) => codeVue(read(relative))
  78. setActivePinia(createPinia())
  79. // ================================================================ A 面板映射
  80. {
  81. const monitoring = panelsForMode('monitoring')
  82. check(
  83. 'A1-four-panels-match-catalog',
  84. MONITORING_PANEL_IDS.join(',') === 'monitoringOverview,monitoringDistribution,operationOverview,monitoringPointLedger' &&
  85. monitoring.slice().sort().join(',') === MONITORING_PANEL_IDS.slice().sort().join(','),
  86. `${MONITORING_PANEL_IDS.length} 块:${MONITORING_PANEL_IDS.join(' / ')}(与 ADR 0003 的目录一致)`,
  87. )
  88. check(
  89. 'A2-panels-are-monitoring-mode-only',
  90. panelsForMode('infrastructure').every((id) => !MONITORING_PANEL_IDS.includes(id)) &&
  91. panelsForMode('overview').every((id) => !MONITORING_PANEL_IDS.includes(id)) &&
  92. panelsForMode('warningResponse').every((id) => !MONITORING_PANEL_IDS.includes(id)),
  93. '本票只接运行监测模式的四块,不越界接其它模式的面板',
  94. )
  95. // 注册表:两个模式的面板都认得,且**没有撞键**(撞了 panelViews 会在加载时抛错)。
  96. //
  97. // 票 38 修正:原来这里钉的是 `implementedPanelIds().length === 8`,但**那是个会漂的魔数** ——
  98. // 别的票接进新面板就会打红(票 13 接预警处置四块后变成 11,本票撞上)。
  99. // 断言的本意是"本票四块 + 基础设施四块在册、未接的仍回 null",与总数无关,
  100. // 所以改成按**清单成员**判,既不漏守也不误伤并行票。
  101. check(
  102. 'A3-registry-covers-both-modes-and-dispatch-is-by-panelid',
  103. MONITORING_PANEL_IDS.every((id) => hasPanelView(id)) &&
  104. hasPanelView('archive') &&
  105. !hasPanelView('currentEvents') &&
  106. ['archive', 'operation', 'management', 'emergency', ...MONITORING_PANEL_IDS].every((id) =>
  107. implementedPanelIds().includes(id),
  108. ),
  109. `已接数据的面板 ${implementedPanelIds().length} 块(含基础设施 4 + 运行监测 4);未接的(如 currentEvents)仍回 null 走占位`,
  110. )
  111. }
  112. // ================================================================ B 契约适配
  113. {
  114. // B1 监测概览:BigDecimal 字符串 + 分母为 0 的在线率。
  115. const overview = toMonitoringOverview({
  116. updatedAt: 'T',
  117. counts: { monitoringPointCount: 6, equipmentCount: 9 },
  118. status: { onlineCount: 8, offlineCount: 0, staleCount: 1, unknownOrMissingCount: 0, denominator: 9 },
  119. onlineRate: { value: '0.8889', numerator: 8, denominator: 9, state: 'AVAILABLE', reason: null },
  120. typeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 }],
  121. freshness: { status: 'CURRENT', updatedAt: 'T2', serverUpdatedAt: 'T3' },
  122. mapFilter: { industry: 'GAS', deviceType: null },
  123. excluded: {},
  124. })
  125. check(
  126. 'B1-overview-parses-string-bigdecimal',
  127. overview.onlineRate === 0.8889 && overview.onlineDenominator === 9 && overview.onlineState === 'AVAILABLE',
  128. `"0.8889" → ${overview.onlineRate}(BigDecimal 在线上是字符串);分母 ${overview.onlineDenominator}`,
  129. )
  130. check(
  131. 'B1b-overview-keeps-type-distribution-order-and-counts',
  132. overview.typeDistribution.length === 1 &&
  133. overview.typeDistribution[0].code === 'GAS_SENSOR' &&
  134. overview.typeDistribution[0].deviceCount === 3,
  135. `类型分布按后端顺序带出(${overview.typeDistribution[0].name} × ${overview.typeDistribution[0].deviceCount})`,
  136. )
  137. // 采集时间不在本端点上 —— 这一条是**如实记录的契约差异**,不是实现细节。
  138. check(
  139. 'B1c-metric-freshness-gap-is-declared',
  140. METRIC_FRESHNESS_GAP.includes('overview/monitoring') && METRIC_FRESHNESS_GAP.includes('采集时间'),
  141. '「逐设备最近采集时间不在本端点上」写成常量并由界面说明(不伪造一个假的采集时间)',
  142. )
  143. // B2 监测分布:三族分布 + 全六态状态计数 + `未分区` 是真实取值。
  144. const distribution = toMonitoringDistribution({
  145. updatedAt: 'T',
  146. freshness: { status: 'CURRENT' },
  147. regionDistribution: [{ region: '城区A', pointCount: 2, deviceCount: 1, statusCounts: { alarm: 1, warning: 0, workOrder: 0, normal: 1, offline: 0, empty: 0 } }],
  148. // 空区域归一为字面量「未分区」——它是**真实取值**,界面要照实显示。
  149. industryDistribution: [{ industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
  150. deviceTypeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
  151. pointStatusDistribution: [
  152. { status: 'ALARM', count: 1 },
  153. { status: 'WARNING', count: 1 },
  154. { status: 'WORK_ORDER', count: 0 },
  155. { status: 'NORMAL', count: 3 },
  156. { status: 'OFFLINE', count: 1 },
  157. { status: 'EMPTY', count: 1 },
  158. ],
  159. mapFilter: { industry: 'GAS', pointStatus: 'ALARM', region: '城区A' },
  160. pagination: { page: 1, pageSize: 3, total: 8, totalPages: 3, hasMore: true },
  161. monitoringPoints: [],
  162. unassociatedEquipment: [],
  163. excluded: { invalidCoordinateCount: 1 },
  164. })
  165. check(
  166. 'B2-distribution-three-dimensions-plus-status',
  167. distribution.regions.length === 1 &&
  168. distribution.industries[0].code === 'GAS' &&
  169. distribution.deviceTypes[0].code === 'GAS_SENSOR' &&
  170. distribution.pointStatuses.length === 6 &&
  171. distribution.pointStatuses[2].count === 0,
  172. `区域 ${distribution.regions.length} / 行业 ${distribution.industries.length} / 类型 ${distribution.deviceTypes.length} 族;状态 **6** 态全给(含 0 计数)`,
  173. )
  174. check(
  175. 'B2b-distribution-echoes-effective-filters',
  176. distribution.selectedIndustry === 'GAS' &&
  177. distribution.selectedPointStatus === 'ALARM' &&
  178. distribution.selectedRegion === '城区A',
  179. `生效范围回显:行业=${distribution.selectedIndustry} 状态=${distribution.selectedPointStatus} 范围=${distribution.selectedRegion}`,
  180. )
  181. // 台账与分布共用一次取数(Q4)→ 两者的分页必须来自同一份载荷。
  182. check(
  183. 'B2c-ledger-shares-distribution-pagination',
  184. distribution.pagination.total === 8 && distribution.pagination.hasMore === true && nextLedgerPage(distribution) === 2,
  185. `台账按端点分页推进:第 ${distribution.pagination.page} 页 / 共 ${distribution.pagination.total} 条 → 下一页 ${nextLedgerPage(distribution)}`,
  186. )
  187. // B3 运行概况:两条趋势**不混合**、全六态、异常清单。
  188. const situation = toMonitoringSituation({
  189. updatedAt: 'T',
  190. freshness: { status: 'CURRENT' },
  191. range: { type: 'TREND_WINDOW', startTime: 'S', endTime: 'E' },
  192. metricStatusDistribution: [
  193. { status: 'NORMAL', count: 6 },
  194. { status: 'WARNING', count: 1 },
  195. { status: 'ALARM', count: 1 },
  196. { status: 'EXPIRED', count: 0 },
  197. { status: 'OFFLINE', count: 1 },
  198. { status: 'UNKNOWN', count: 0 },
  199. ],
  200. warningTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 2 }],
  201. alarmTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 5 }],
  202. industryCoverage: [{ industry: 'GAS', deviceCount: 3, abnormalCount: 1, statusCounts: {} }],
  203. abnormalEquipment: [
  204. { deviceCode: 'EQ-1', deviceName: '报警设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', abnormalType: 'ALARM', status: 'ALARM', statusReasons: ['压力超高'], coordinateValidation: { renderable: true } },
  205. { deviceCode: 'EQ-2', deviceName: '离线设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', abnormalType: 'OFFLINE', status: 'OFFLINE', statusReasons: [], coordinateValidation: { renderable: false } },
  206. ],
  207. pagination: { page: 1, pageSize: 100, total: 2, totalPages: 1, hasMore: false },
  208. mapFilter: {},
  209. excluded: {},
  210. })
  211. check(
  212. 'B3-situation-keeps-warning-and-alarm-separate',
  213. situation.warningTrend[0].count === 2 && situation.alarmTrend[0].count === 5,
  214. `预警趋势 ${situation.warningTrend[0].count} 与报警趋势 ${situation.alarmTrend[0].count} **分两条**(后端注释明确两者不混合)`,
  215. )
  216. check(
  217. 'B3b-situation-six-metric-statuses-and-window',
  218. situation.metricStatuses.length === 6 &&
  219. situation.metricStatuses[3].count === 0 &&
  220. situation.rangeStart === 'S' &&
  221. situation.rangeEnd === 'E',
  222. `指标状态 6 态全给(含 0);窗口回显 ${situation.rangeStart} → ${situation.rangeEnd}`,
  223. )
  224. check(
  225. 'B3c-situation-abnormal-list-keeps-coordinate-state',
  226. situation.abnormalEquipment.length === 2 &&
  227. situation.abnormalEquipment[0].status === 'ALARM' &&
  228. situation.abnormalEquipment[1].code === 'EQ-2',
  229. `异常清单 ${situation.abnormalEquipment.length} 台(离线是它的子集,界面据此分开说)`,
  230. )
  231. // B4 指标状态字典:六态**全部登记**,认不出的回原码(不猜成正常)。
  232. check(
  233. 'B4-metric-status-dictionary-complete',
  234. ['NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN'].every((code) => METRIC_STATUS_TEXT[code] && METRIC_STATUS_TEXT[code] !== code) &&
  235. metricStatusText('SOMETHING_NEW') === 'SOMETHING_NEW',
  236. `六态全部翻成中文(${Object.values(METRIC_STATUS_TEXT).join('/')});未登记的码回原码,不猜`,
  237. )
  238. // 桶标签只做截断显示:日粒度与小时粒度都要认,解析不了**回原串**(不丢桶)。
  239. check(
  240. 'B4b-bucket-label-truncates-and-never-drops',
  241. bucketLabel('2026-09-02T01:00:00+08:00') === '01:00' &&
  242. bucketLabel('2026-09-02T01:00:00+08:00', true) === '9/2' &&
  243. bucketLabel('weird') === 'weird' &&
  244. bucketLabel('') === '',
  245. `小时桶 → ${bucketLabel('2026-09-02T01:00:00+08:00')};日桶 → ${bucketLabel('2026-09-02T01:00:00+08:00', true)};认不出的回原串(不静默丢数据)`,
  246. )
  247. }
  248. // ================================================================ C 视图层(验收点名的画面元素)
  249. {
  250. const overview = monitoringOverviewPanel(
  251. toMonitoringOverview({
  252. updatedAt: '2026-09-02T09:59:50+08:00',
  253. counts: { monitoringPointCount: 6, equipmentCount: 9 },
  254. status: { onlineCount: 8, offlineCount: 0, staleCount: 1, unknownOrMissingCount: 0, denominator: 9 },
  255. onlineRate: { value: '0.8889', numerator: 8, denominator: 9, state: 'AVAILABLE' },
  256. typeDistribution: [{ deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3 }],
  257. freshness: { status: 'CURRENT' },
  258. mapFilter: {},
  259. excluded: {},
  260. }),
  261. section(SECTION_STATE.SUCCESS),
  262. )
  263. const kpiLabels = overview.blocks[0].items.map((item) => item.label)
  264. check(
  265. 'C1-overview-shows-scale-rate-types-and-time',
  266. kpiLabels.join(',') === '监测点,监测设备,在线率,数据时间' &&
  267. overview.blocks[0].items[2].value === '88.9%' &&
  268. overview.blocks.some((b) => b.kind === 'bars' && b.label === '设备类型分布'),
  269. `验收四项:${kpiLabels.join(' / ')}(在线率 = ${overview.blocks[0].items[2].value},口径 ${overview.blocks[0].items[2].hint})`,
  270. )
  271. check(
  272. 'C1b-overview-has-no-locate-targets-and-declares-gap',
  273. overview.locate.length === 0 && !!overview.gap && overview.locateNote.includes('没有可定位对象'),
  274. '概览是聚合口径,如实说没有可定位对象;采集时间差异也如实说明(不造按钮)',
  275. )
  276. // 分母为 0 时**不能**画成 0.0%:那是票 16 明令禁止的虚假零值。
  277. const zeroDenominator = monitoringOverviewPanel(
  278. toMonitoringOverview({
  279. counts: { monitoringPointCount: 2, equipmentCount: 0 },
  280. status: { onlineCount: 0, offlineCount: 0, staleCount: 0, unknownOrMissingCount: 2, denominator: 0 },
  281. onlineRate: { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' },
  282. typeDistribution: [],
  283. freshness: { status: 'CURRENT' },
  284. mapFilter: {},
  285. excluded: {},
  286. }),
  287. section(SECTION_STATE.SUCCESS),
  288. )
  289. const zeroRate = zeroDenominator.blocks[0].items.find((item) => item.key === 'onlineRate')
  290. check(
  291. 'C1c-zero-denominator-rate-is-placeholder-not-zero',
  292. zeroRate.value === DETAIL_PLACEHOLDER && zeroRate.hint.includes('不适用'),
  293. `分母为 0 时在线率显示「${zeroRate.value}」并说明「${zeroRate.hint}」——**不**画成 0.0%`,
  294. )
  295. // 分布:覆盖情况三族 + 可点状态分布 + 口径回显 + 两处缺口说明。
  296. const distribution = monitoringDistributionPanel(
  297. toMonitoringDistribution({
  298. regionDistribution: [{ region: '城区A', pointCount: 2, deviceCount: 1, statusCounts: {} }],
  299. industryDistribution: [{ industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
  300. deviceTypeDistribution: [{ deviceType: { code: 'TS', name: '燃气浓度计' }, industry: 'GAS', pointCount: 2, deviceCount: 1, statusCounts: {} }],
  301. pointStatusDistribution: [
  302. { status: 'ALARM', count: 1 },
  303. { status: 'WARNING', count: 0 },
  304. { status: 'WORK_ORDER', count: 0 },
  305. { status: 'NORMAL', count: 3 },
  306. { status: 'OFFLINE', count: 1 },
  307. { status: 'EMPTY', count: 1 },
  308. ],
  309. mapFilter: { industry: 'GAS', pointStatus: 'ALARM', region: '城区A' },
  310. pagination: { page: 1, pageSize: 500, total: 6, totalPages: 1, hasMore: false },
  311. monitoringPoints: [],
  312. unassociatedEquipment: [],
  313. freshness: { status: 'CURRENT' },
  314. excluded: { invalidCoordinateCount: 1 },
  315. }),
  316. section(SECTION_STATE.SUCCESS),
  317. )
  318. const barLabels = distribution.blocks.filter((b) => b.kind === 'bars').map((b) => b.label)
  319. const statusBlock = distribution.blocks.find((b) => b.kind === 'statusFilter')
  320. check(
  321. 'C2-distribution-shows-coverage-and-status',
  322. barLabels.includes('覆盖情况 · 区域') &&
  323. barLabels.includes('覆盖情况 · 行业') &&
  324. barLabels.includes('覆盖情况 · 设备类型') &&
  325. !!statusBlock &&
  326. statusBlock.rows.length === 6,
  327. `验收「覆盖情况 + 状态分布」:${barLabels.join(' / ')};状态分布 ${statusBlock.rows.length} 行(可点)`,
  328. )
  329. // 状态词与色键必须来自票 09 那张表:`EMPTY` 在这里是「无设备」,不是新鲜度哨兵「无数据」。
  330. const emptyRow = statusBlock.rows.find((row) => row.code === 'EMPTY')
  331. check(
  332. 'C2b-status-words-come-from-point-status-table-not-freshness-sentinels',
  333. emptyRow.label === '无设备' && emptyRow.tone === 'neutral' && statusBlock.rows.find((r) => r.code === 'ALARM').label === '报警',
  334. `点位状态的中文来自 domain/pointStatus.js:EMPTY → 「${emptyRow.label}」**不是**新鲜度哨兵的「无数据」(两张字典合并就会把"无设备"画成"无数据")`,
  335. )
  336. check(
  337. 'C2c-distribution-declares-map-gap-and-region-scope',
  338. distribution.gap.includes('一张图') && distribution.gap.includes('范围筛选') && distribution.gap.includes(POINT_STATUS_MAP_GAP.slice(0, 10)),
  339. '面板如实说明:状态筛选不作用于一张图(后端缺口)+范围筛选只作用于本面板',
  340. )
  341. // 运行概况:KPI + 两条趋势 + 状态分布 + 异常清单(名单里带可定位按钮)。
  342. const situation = operationOverviewPanel(
  343. toMonitoringSituation({
  344. range: { type: 'TREND_WINDOW', startTime: '2026-09-01T10:00:00+08:00', endTime: '2026-09-02T10:00:00+08:00' },
  345. metricStatusDistribution: [
  346. { status: 'NORMAL', count: 6 },
  347. { status: 'WARNING', count: 1 },
  348. { status: 'ALARM', count: 1 },
  349. { status: 'EXPIRED', count: 0 },
  350. { status: 'OFFLINE', count: 2 },
  351. { status: 'UNKNOWN', count: 0 },
  352. ],
  353. warningTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 2 }],
  354. alarmTrend: [{ bucketStart: '2026-09-02T01:00:00+08:00', count: 5 }],
  355. industryCoverage: [],
  356. abnormalEquipment: [
  357. { deviceCode: 'EQ-OFF', deviceName: '离线设备', deviceType: { code: 'T', name: '类型' }, industry: 'GAS', region: '城区A', status: 'OFFLINE', statusReasons: [], coordinateValidation: { renderable: true } },
  358. ],
  359. pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false },
  360. freshness: { status: 'CURRENT' },
  361. mapFilter: {},
  362. excluded: {},
  363. }),
  364. section(SECTION_STATE.SUCCESS),
  365. )
  366. const trendLabels = situation.blocks.filter((b) => b.kind === 'trend').map((b) => b.label)
  367. check(
  368. 'C3-situation-shows-trends-offline-and-abnormal',
  369. trendLabels.join(',') === '预警趋势,报警趋势' &&
  370. situation.blocks.some((b) => b.kind === 'list' && b.label === '异常设备清单') &&
  371. situation.offlineNote.includes('离线 1 台'),
  372. `验收三项:事件趋势(${trendLabels.join(' / ')})、离线设备(${situation.offlineNote})、异常态势(状态分布 + 清单)`,
  373. )
  374. check(
  375. 'C3b-situation-range-note-reflects-backend-window',
  376. situation.rangeNote.includes('2026-09-01T10:00:00+08:00') && situation.rangeNote.includes('2026-09-02T10:00:00+08:00'),
  377. `窗口口径回显后端实际生效区间:${situation.rangeNote}`,
  378. )
  379. // 异常清单里的可定位对象与 `locate` 必须**同源**(不漏一个设备按钮)。
  380. check(
  381. 'C3c-situation-abnormal-entries-are-locatable',
  382. situation.locate.length === 1 && situation.locate[0].pointCode === 'EQ-OFF',
  383. `异常设备可定位(${situation.locate.map((e) => e.pointCode).join(', ')})`,
  384. )
  385. // 空场景与未取数:措辞分得开(票 16/17 的纪律)。
  386. check(
  387. 'C4-placeholders-per-state',
  388. monitoringOverviewPanel(null, section(SECTION_STATE.EMPTY)).placeholder === '该面板当前没有数据' &&
  389. monitoringOverviewPanel(null, section(SECTION_STATE.FAILED)).placeholder.includes('加载失败') &&
  390. monitoringOverviewPanel(null, section(SECTION_STATE.FAILED)).placeholder.includes('其它面板') &&
  391. monitoringOverviewPanel(null, { state: SECTION_STATE.EMPTY, loaded: false }).placeholder === '正在取数…',
  392. '空 / 失败 / 未取数三种措辞分得开(失败不牵连其它面板;未取数不说"没有数据")',
  393. )
  394. // 「进度」也要如实:台账的 pageNote 必须点明 total 含未关联设备,否则会被读成"还有些点没列出来"。
  395. const ledger = monitoringPointLedgerPanel(
  396. toMonitoringDistribution({
  397. pagination: { page: 1, pageSize: 3, total: 8, totalPages: 3, hasMore: true },
  398. monitoringPoints: [
  399. { pointCode: 'MP-001', pointName: '供水干管监测点', region: '城区B', industry: 'WATER_SUPPLY', deviceType: { code: 'T', name: '供水压力计' }, pointStatus: 'NORMAL', statusReasons: [] },
  400. ],
  401. unassociatedEquipment: [],
  402. freshness: { status: 'CURRENT' },
  403. excluded: {},
  404. }),
  405. section(SECTION_STATE.SUCCESS),
  406. {},
  407. )
  408. check(
  409. 'C5-ledger-page-note-explains-total-includes-equipment',
  410. ledger.pageNote.includes('含未关联设备') && ledger.hasMore === true,
  411. `分页口径说清楚:${ledger.pageNote}`,
  412. )
  413. }
  414. // ================================================================ D 分区独立(三块各自成败)
  415. {
  416. const store = useMonitoringStore()
  417. store.reset()
  418. store.configure({ config: mockConfig })
  419. resetMockTransportsForTest()
  420. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  421. await store.loadPanels({ industry: 'all' })
  422. const all = store.sections
  423. check(
  424. 'D1-all-four-panels-load',
  425. MONITORING_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS && all[id].loaded === true),
  426. MONITORING_PANEL_IDS.map((id) => `${id}=${all[id].state}`).join(' '),
  427. )
  428. // 只让**一个**端点失败:其余两块照常成功(票 11/17 的同一条纪律)。
  429. const isolated = useMonitoringStore()
  430. isolated.reset()
  431. isolated.configure({ config: mockConfig })
  432. resetMockTransportsForTest()
  433. registerCockpitMocks({
  434. scenario: 'failed',
  435. onlyPath: '/monitoring/overview',
  436. handlers: { registerMockTransport, resetMockTransportsForTest },
  437. })
  438. await isolated.loadPanels({ industry: 'all' })
  439. const sections = isolated.sections
  440. check(
  441. 'D2-one-panel-failure-does-not-cover-others',
  442. sections.monitoringOverview.state !== SECTION_STATE.SUCCESS &&
  443. sections.monitoringDistribution.state === SECTION_STATE.SUCCESS &&
  444. sections.operationOverview.state === SECTION_STATE.SUCCESS,
  445. `概览块=${sections.monitoringOverview.state},其余两块仍是 ${sections.monitoringDistribution.state}/${sections.operationOverview.state}`,
  446. )
  447. check(
  448. 'D3-guard-has-teeth',
  449. MONITORING_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS),
  450. '全成功场景四块全绿(因此 D2 的"一块坏"是有信息量的,不是恒真)',
  451. )
  452. // 空场景:三块都说"没有数据",不伪造 0。
  453. const emptyStore = useMonitoringStore()
  454. emptyStore.reset()
  455. emptyStore.configure({ config: mockConfig })
  456. resetMockTransportsForTest()
  457. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  458. await emptyStore.loadPanels({ industry: 'all' })
  459. const emptyViews = MONITORING_PANEL_IDS.map((id) => monitoringPanelView(id, emptyStore.sections[id].data, emptyStore.sections[id], {}))
  460. check(
  461. 'D4-empty-scenario-all-placeholders',
  462. emptyViews.every((view) => view.placeholder === '该面板当前没有数据') &&
  463. emptyViews.every((view) => !view.blocks.some((b) => (b.items || []).some((i) => i.value === '0'))),
  464. `空场景四块都是明确占位说明,且不显示伪造的 0 读数`,
  465. )
  466. // 过期场景:内容照画 + 保留标识(票 16 的立场),不是"没有数据"。
  467. const staleStore = useMonitoringStore()
  468. staleStore.reset()
  469. staleStore.configure({ config: mockConfig })
  470. resetMockTransportsForTest()
  471. registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
  472. await staleStore.loadPanels({ industry: 'all' })
  473. const staleOverview = staleStore.sections.monitoringOverview
  474. check(
  475. 'D5-stale-is-value-not-empty',
  476. staleOverview.state === SECTION_STATE.STALE &&
  477. staleOverview.retained === true &&
  478. monitoringPanelView('monitoringOverview', staleOverview.data, staleOverview).hasContent === true,
  479. `过期分区仍带着数据(监测点 ${staleOverview.data.monitoringPointCount}),界面照画并标过期`,
  480. )
  481. }
  482. // ================================================================ E 筛选联动(映射与白名单)
  483. {
  484. check(
  485. 'E1-industry-key-maps-to-backend-code',
  486. industryQuery('gas') === 'GAS' &&
  487. industryQuery('water') === 'WATER_SUPPLY' &&
  488. industryQuery('drainage') === 'DRAINAGE' &&
  489. // 票 18 更正(2026-09-28):`MANHOLE` 现已是后端第四个正式行业(全部 11 个 cockpit
  490. // 服务都收,含 `map-snapshot`),所以井盖**要下发** —— 原断言守的
  491. // 「井盖无契约位」已被后端推翻(那是票 17 立、票 25 第八轮记的旧事实)。
  492. industryQuery('manhole') === 'MANHOLE',
  493. `gas → ${industryQuery('gas')};water → ${industryQuery('water')};drainage → ${industryQuery('drainage')};manhole → ${industryQuery('manhole')}(四个正式行业全部下发)`,
  494. )
  495. check(
  496. 'E2-only-all-and-unknown-keys-send-nothing',
  497. industryQuery('all') === '' && industryQuery('nope') === '' && industryQuery(undefined) === '',
  498. '「全部」不传参;**认不出的键**也不传参(兜底:将来新增的前端选项若后端还没有这个码,不拿脏值去换 400)',
  499. )
  500. // 状态白名单:认不出的**不传参**(后端对认不出的状态直接 400,那会把一次点击变成一条报错)。
  501. check(
  502. 'E3-status-whitelist-blocks-unknown-codes',
  503. statusParam('ALARM') === 'ALARM' &&
  504. statusParam('alarm') === 'ALARM' &&
  505. statusParam('MAINTENANCE') === '' &&
  506. statusParam('') === '' &&
  507. isKnownPointStatus('ALARM') &&
  508. !isKnownPointStatus('MAINTENANCE'),
  509. `认得出的六态才下发(ALARM → ${statusParam('ALARM')});认不出的回空串=不传参(MAINTENANCE → "${statusParam('MAINTENANCE')}")`,
  510. )
  511. // 范围映射:**默认空**(后端 region 是自由串、无枚举可依据),配了才下发。
  512. check(
  513. 'E4-region-mapping-is-config-driven-and-empty-by-default',
  514. regionQuery('south', {}) === '' &&
  515. regionQuery('all', { south: '城区A' }) === '' &&
  516. regionQuery('south', { south: '城区A' }) === '城区A' &&
  517. hasRegionMapping('south', { south: '城区A' }) &&
  518. !hasRegionMapping('south', {}) &&
  519. REGION_MAP_GAP.includes('regionMap'),
  520. `未配置 → 不传参("${regionQuery('south', {})}")并在界面标缺口;配置后 → ${regionQuery('south', { south: '城区A' })};全域恒不传参`,
  521. )
  522. // 空区域是真实取值:配置里写「未分区」要能筛到(后端把空归一成这个字面量)。
  523. check(
  524. 'E4b-unassigned-region-is-a-real-filterable-value',
  525. regionQuery('north', { north: '未分区' }) === '未分区',
  526. '「未分区」在后端是空值归一后的**真实取值**,不是空值 —— 筛选它能筛到东西',
  527. )
  528. // **端到端:配置真的把映射送到**(票 18 自查补的断言)。
  529. //
  530. // 这一条是补一个**真缺陷**:`map.filters.regionMap` 原本只被外壳读过,而配置要先过
  531. // `normalizeConfig` 归一化 —— 归一化器不认这个键,于是外壳永远读到 `{}`,
  532. // **范围筛选端到端从不生效**。而 E4 只单独测了 `regionQuery()` 并**手喂**映射表,
  533. // 所以它当时全绿却什么也没证明(守卫比它要守的东西宽:测了函数、没测通路)。
  534. //
  535. // 判据必须**从原始配置载荷出发**(而不是手搓一个 regionMap 对象),
  536. // 否则测的还是同一个盲区。
  537. {
  538. const rawConfig = { dataSource: 'mock', map: { filters: { regionMap: { south: '城区A', north: '未分区' } } } }
  539. const normalized = normalizeConfig(rawConfig)
  540. const delivered = (normalized.config.map.filters && normalized.config.map.filters.regionMap) || {}
  541. check(
  542. 'E4c-config-really-delivers-the-region-map',
  543. delivered.south === '城区A' &&
  544. delivered.north === '未分区' &&
  545. regionQuery('south', delivered) === '城区A' &&
  546. // 没配的部署:默认空表,范围条件不下发(不猜映射)。
  547. Object.keys((normalizeConfig({ dataSource: 'mock' }).config.map.filters || {}).regionMap || {}).length === 0,
  548. `原始配置经归一化后送到外壳:${JSON.stringify(delivered)};未配置时是空表(范围不下发)`,
  549. )
  550. // 脏值逐项丢弃、**不清空整表**(一个拼错的键不该静默废掉其它正确的映射)。
  551. //
  552. // 判据要同时盖住**坏值**(数字/空白)与**坏键**(空串):
  553. // 只查 `length === 1` 会漏掉"坏键被原样留下"这一种(变异 M22 确认过:
  554. // 把 `regionMap[key] = value` 换成放原始 `scopeKey`/`regionValue`,闸门仍全绿)。
  555. const dirty = normalizeConfig({
  556. dataSource: 'mock',
  557. map: { filters: { regionMap: { south: '城区A', north: 123, taichang: ' ', '': 'X' } } },
  558. })
  559. const dirtyMap = dirty.config.map.filters.regionMap
  560. check(
  561. 'E4d-bad-region-map-entries-are-dropped-individually',
  562. dirtyMap.south === '城区A' &&
  563. Object.keys(dirtyMap).length === 1 &&
  564. // 坏键不得以任何形式留下(既不能是空串,也不能原样留着 `north`/`taichang`)。
  565. !Object.prototype.hasOwnProperty.call(dirtyMap, '') &&
  566. !Object.prototype.hasOwnProperty.call(dirtyMap, 'north') &&
  567. !Object.prototype.hasOwnProperty.call(dirtyMap, 'taichang') &&
  568. // 值必须已被 trim(配置里写 `' 城区A '` 也要能对上后端的精确串)。
  569. dirty.issues.length === 3,
  570. `坏项逐条丢弃、好项保留:${JSON.stringify(dirtyMap)}(记了 ${dirty.issues.length} 条 issue)`,
  571. )
  572. // 值的 trim 单独钉一条:范围是**精确匹配**的(后端 `matchesRegion` 用 `equals`),
  573. // 首尾空格会让映射永远匹配不上 —— 那是"配了却筛不出"的静默失败。
  574. const spaced = normalizeConfig({ dataSource: 'mock', map: { filters: { regionMap: { south: ' 城区A ' } } } })
  575. check(
  576. 'E4e-region-map-values-are-trimmed',
  577. spaced.config.map.filters.regionMap.south === '城区A' &&
  578. regionQuery('south', spaced.config.map.filters.regionMap) === '城区A',
  579. `配置里写 ' 城区A ' → 下发 "${spaced.config.map.filters.regionMap.south}"(后端是精确匹配,空格会让它永远筛不出)`,
  580. )
  581. }
  582. // 端到端:三个筛选**真的进请求串**(只看映射函数不够,得看请求本身)。
  583. const seen = []
  584. registerMockTransport('/monitoring/distribution', (context) => {
  585. seen.push(context.path)
  586. return { monitoringPoints: [], unassociatedEquipment: [], pagination: { page: 1, pageSize: 500, total: 0, hasMore: false }, freshness: { status: 'EMPTY' }, regionDistribution: [], industryDistribution: [], deviceTypeDistribution: [], pointStatusDistribution: [], mapFilter: {}, excluded: {} }
  587. })
  588. const store = useMonitoringStore()
  589. store.reset()
  590. store.configure({ config: mockConfig, regionMap: { south: '城区A' } })
  591. await store.loadPanels({ industry: 'gas', pointStatus: 'ALARM', region: 'south' })
  592. const path = seen[seen.length - 1] || ''
  593. check(
  594. 'E5-three-filters-reach-the-request',
  595. path.includes('industry=GAS') && path.includes('pointStatus=ALARM') && path.includes('region=%E5%9F%8E%E5%8C%BAA'),
  596. `请求串:${path}`,
  597. )
  598. // 对抗式反证:默认筛选下**三个参数一个都不该出现**(否则上面那条可能是恒真)。
  599. await store.loadPanels({ industry: 'all', pointStatus: '', region: 'all' })
  600. const plain = seen[seen.length - 1] || ''
  601. check(
  602. 'E6-default-filters-send-nothing',
  603. !plain.includes('industry=') && !plain.includes('pointStatus=') && !plain.includes('region='),
  604. `默认筛选下请求串零筛选参数:${plain}`,
  605. )
  606. // filterStore 的状态是**可清除**的(没有"全部"按钮可退回,所以再点一次要能取消)。
  607. const filters = useFilterStore()
  608. filters.reset()
  609. filters.togglePointStatus('ALARM')
  610. const toggledOn = filters.pointStatus
  611. filters.togglePointStatus('ALARM')
  612. const toggledOff = filters.pointStatus
  613. filters.setPointStatus('NOPE')
  614. const rejected = filters.pointStatus
  615. check(
  616. 'E7-point-status-is-removable-and-rejects-unknown',
  617. toggledOn === 'ALARM' && toggledOff === DEFAULT_POINT_STATUS && rejected === DEFAULT_POINT_STATUS,
  618. `点一次 → ${toggledOn};再点一次 → "${toggledOff}"(退回全部);脏值被忽略 → "${rejected}"`,
  619. )
  620. }
  621. // ================================================================ F 地图联动(筛选真的下发且真的生效)
  622. {
  623. // F0 **面板数字随筛选变化**:这一条是**真机走查补上来的**。
  624. // 逻辑闸门原来只验到"筛选进了请求串""映射函数对不对",而 mock 忽略筛选时
  625. // 请求串照样正确、断言照样全绿 —— 但界面上数字纹丝不动(走查 D1 正是这样红的)。
  626. // 所以这里断言的是**端到端的结果**:换行业后概览的点位/设备数必须真的变。
  627. {
  628. const all = await fetchMonitoringOverview({ industry: 'all', config: mockConfig })
  629. const gas = await fetchMonitoringOverview({ industry: 'gas', config: mockConfig })
  630. const drain = await fetchMonitoringOverview({ industry: 'drainage', config: mockConfig })
  631. const manhole = await fetchMonitoringOverview({ industry: 'manhole', config: mockConfig })
  632. check(
  633. 'F0-panel-numbers-follow-the-industry-filter',
  634. // 全部 = 3+3+3+2 = 11 台(票 18 更正后样例含井盖行业)。
  635. all.data.equipmentCount === 11 &&
  636. gas.data.equipmentCount === 3 &&
  637. drain.data.equipmentCount === 3 &&
  638. manhole.data.equipmentCount === 2 &&
  639. gas.data.monitoringPointCount === 2 &&
  640. manhole.data.monitoringPointCount === 1 &&
  641. gas.data.typeDistribution.length === 1 &&
  642. gas.data.typeDistribution[0].industry === 'GAS' &&
  643. gas.data.equipmentCount !== all.data.equipmentCount,
  644. `全部 ${all.data.monitoringPointCount} 点/${all.data.equipmentCount} 台 → 燃气 ${gas.data.monitoringPointCount} 点/${gas.data.equipmentCount} 台、排水 ${drain.data.equipmentCount} 台、井盖 ${manhole.data.monitoringPointCount} 点/${manhole.data.equipmentCount} 台(面板数字真的跟着筛选走,不只是请求串带着参数)`,
  645. )
  646. // 在线率也要跟着筛选重算,而不是照抄一份固定值。
  647. check(
  648. 'F0b-online-rate-is-recomputed-per-filter',
  649. all.data.onlineRate !== null && gas.data.onlineRate !== null && all.data.onlineRate !== gas.data.onlineRate,
  650. `全部在线率 ${all.data.onlineRate} ≠ 燃气在线率 ${gas.data.onlineRate}(按筛选后的分母重算)`,
  651. )
  652. }
  653. // F0c 台账的**现场页大小覆盖**真的生效:后端默认 500,样例 9 条会一页到底,
  654. // 那样「渐进加载」在真机上永远演示不出来(走查 B1 靠它)。
  655. {
  656. resetMockTransportsForTest()
  657. registerCockpitMocks({
  658. scenario: 'success',
  659. monitoring: { pageSize: 3 },
  660. handlers: { registerMockTransport, resetMockTransportsForTest },
  661. })
  662. const paged = await fetchMonitoringDistribution({ industry: 'all', config: mockConfig })
  663. // 样例是 7 个监测点 + 2 台未关联设备 = 9 条(一条总序列,与后端分页窗口同语义)。
  664. check(
  665. 'F0c-ledger-page-size-override-takes-effect',
  666. paged.data.pagination.pageSize === 3 &&
  667. paged.data.points.length === 3 &&
  668. paged.data.pagination.hasMore === true &&
  669. paged.data.pagination.total === 9,
  670. `现场页大小 3 生效:第 1 页拿到 ${paged.data.points.length} 个点位、共 ${paged.data.pagination.total} 条、还有下一页=${paged.data.pagination.hasMore}`,
  671. )
  672. }
  673. // F0d 井盖(`MANHOLE`)**真的能筛出东西**:它是后端第四个正式行业,
  674. // 样例必须带该类数据 —— 不带的话界面上永远筛出空,看起来像坏了。
  675. {
  676. resetMockTransportsForTest()
  677. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  678. const manhole = await fetchMonitoringDistribution({ industry: 'manhole', config: mockConfig })
  679. const overviewManhole = await fetchMonitoringOverview({ industry: 'manhole', config: mockConfig })
  680. // 防御式取值:样例若被删掉,`isEmpty` 会把分区收成 `EMPTY`(`data` 为 null)——
  681. // 那时这条断言必须**干净地判失败**,而不是抛 "Cannot read properties of null"
  682. // (抛异常虽然也是非零退出,但那证明不了守卫抓到了行为差异;
  683. // 本票已两次栽在"把崩溃当成红"上)。
  684. const points = (manhole.data && manhole.data.points) || []
  685. const overviewTypes = (overviewManhole.data && overviewManhole.data.typeDistribution) || []
  686. check(
  687. 'F0d-manhole-industry-filters-real-data',
  688. !!manhole.data &&
  689. manhole.data.selectedIndustry === 'MANHOLE' &&
  690. points.length > 0 &&
  691. points.every((point) => point.industry === 'MANHOLE') &&
  692. !!overviewManhole.data &&
  693. overviewManhole.data.equipmentCount === 2 &&
  694. overviewTypes.length === 1,
  695. `井盖筛选真的筛出 ${points.length} 个点位(${points.map((p) => p.code).join(',')})、概览 ${overviewManhole.data ? overviewManhole.data.equipmentCount : '(分区为空)'} 台设备(不再是「无契约位故不传参」)`,
  696. )
  697. }
  698. // F1 行业筛选**真的改变地图内容**。判据不能只看请求串 —— mock 已经照后端语义真的过滤,
  699. // 所以这里断言「上了图的点位集合确实变小了」,那才证明筛选生效(不是假绿)。
  700. const mapStore = useMapStore()
  701. mapStore.reset()
  702. resetMockTransportsForTest()
  703. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  704. mapStore.setup({ config: mockConfig, industry: 'gas' })
  705. await mapStore.advance()
  706. await mapStore.advance()
  707. await mapStore.advance()
  708. const gasCodes = mapStore.summary.objects.map((item) => item.code).sort()
  709. mapStore.reset()
  710. resetMockTransportsForTest()
  711. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  712. mapStore.setup({ config: mockConfig })
  713. await mapStore.advance()
  714. await mapStore.advance()
  715. await mapStore.advance()
  716. const allCodes = mapStore.summary.objects.map((item) => item.code).sort()
  717. check(
  718. 'F1-industry-filter-actually-changes-the-map',
  719. gasCodes.length > 0 && gasCodes.length < allCodes.length && gasCodes.every((code) => allCodes.includes(code)),
  720. `燃气筛选后上图 ${gasCodes.join(',')};不筛时 ${allCodes.join(',')}(筛选真的作用到地图内容,不是只在请求串里)`,
  721. )
  722. // F2 范围筛选(配了映射才生效)同样真的改变地图内容;未配置时**不改变**任何东西。
  723. mapStore.reset()
  724. resetMockTransportsForTest()
  725. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  726. mapStore.setup({ config: mockConfig, region: 'south', regionMap: { south: '城区A' } })
  727. await mapStore.advance()
  728. await mapStore.advance()
  729. await mapStore.advance()
  730. const regionCodes = mapStore.summary.objects.map((item) => item.code).sort()
  731. mapStore.reset()
  732. resetMockTransportsForTest()
  733. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  734. mapStore.setup({ config: mockConfig, region: 'south', regionMap: {} })
  735. await mapStore.advance()
  736. await mapStore.advance()
  737. await mapStore.advance()
  738. const unmappedCodes = mapStore.summary.objects.map((item) => item.code).sort()
  739. check(
  740. 'F2-region-filter-only-bites-when-mapped',
  741. regionCodes.length > 0 && regionCodes.length < allCodes.length && unmappedCodes.length === allCodes.length,
  742. `配了映射 → ${regionCodes.join(',')}(变小);没配映射 → ${unmappedCodes.length} 个(与不筛一致,因为根本没下发 region 参数)`,
  743. )
  744. // F3 换筛选=**换新快照重载**(Q9):已加载对象被丢弃,不留上一份范围的点。
  745. mapStore.reset()
  746. resetMockTransportsForTest()
  747. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  748. mapStore.setup({ config: mockConfig })
  749. await mapStore.advance()
  750. await mapStore.advance()
  751. await mapStore.advance()
  752. const beforeSwitch = mapStore.summary.objects.length
  753. await mapStore.applyFilters({ industry: 'gas' })
  754. const afterSwitch = mapStore.summary.objects.map((item) => item.code)
  755. check(
  756. 'F3-filter-change-reloads-from-page-1-and-drops-old-objects',
  757. beforeSwitch === 5 && afterSwitch.length === 1 && afterSwitch[0] === 'MP-002' && mapStore.summary.page === 1,
  758. `换行业前 ${beforeSwitch} 个对象 → 换后 ${afterSwitch.length} 个、回到第 ${mapStore.summary.page} 页(旧范围的点不留在图上)`,
  759. )
  760. // F4 筛选条件被**合并**进注入项,不被后到的 setup 抹掉(否则第一屏会带空筛选发出去)。
  761. const merged = useMapStore()
  762. merged.reset()
  763. merged.setup({ config: mockConfig, industry: 'gas' })
  764. merged.setup({ config: mockConfig })
  765. check(
  766. 'F4-setup-merges-instead-of-replacing-filters',
  767. merged.filters.industry === 'gas',
  768. `后到的 setup() 不抹掉先到的筛选条件(industry 仍是 ${merged.filters.industry})`,
  769. )
  770. // F5 状态筛选**不**下发到地图:`map-snapshot` 不收该入参(后端契约现状,缺口已记票 25)。
  771. const mapCode = readCode('services/mapApi.js')
  772. check(
  773. 'F5-status-filter-is-not-sent-to-map-snapshot',
  774. !/pointStatus/.test(mapCode) && POINT_STATUS_MAP_GAP.includes('map-snapshot'),
  775. '地图取数不出现状态入参(后端不收);缺口写成常量由界面说明,而不是假装跟随',
  776. )
  777. }
  778. // ================================================================ G 台账(共用取数 + 渐进加载 + 徽标)
  779. {
  780. const store = useMonitoringStore()
  781. store.reset()
  782. store.configure({ config: mockConfig })
  783. resetMockTransportsForTest()
  784. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  785. await store.loadPanels({ industry: 'all', pageSize: 3 })
  786. const distribution = store.sections.monitoringDistribution
  787. const ledger = store.sections.monitoringPointLedger
  788. // G1 共用一次取数:两块的分区来自**同一个响应对象**(Q4 裁定)。
  789. check(
  790. 'G1-ledger-and-distribution-share-one-fetch',
  791. ledger.data === distribution.data && ledger.data.points.length === distribution.data.points.length,
  792. `台账与分布的分区引用同一份载荷(${ledger.data.points.length} 个点位、共 ${ledger.data.pagination.total} 条),不是两次请求`,
  793. )
  794. // G2 渐进加载:翻下一页后点位**追加**,且总数口径不变。
  795. // 注意每次都要**重新读 store**:`sections` 是整体替换的 ref,抓在手里的旧对象不会跟着变
  796. // (第一版就是抓着翻页前的对象比,翻页后它当然还是 3 —— 那是脚本自己的错,不是实现的错)。
  797. const firstCount = store.sections.monitoringPointLedger.data.points.length
  798. const merged = await store.loadMoreLedger()
  799. check(
  800. 'G2-ledger-appends-next-page',
  801. merged.data.points.length > firstCount &&
  802. store.sections.monitoringPointLedger.data.points.length === merged.data.points.length &&
  803. merged.data.pagination.total === distribution.data.pagination.total,
  804. `翻页前 ${firstCount} 个 → 翻页后 ${merged.data.points.length} 个;总数仍为 ${merged.data.pagination.total}(追加而不是替换)`,
  805. )
  806. // G3 到底之后再翻是空操作(不无限发请求)。
  807. //
  808. // 判据用**分页终态**,不用"点数又涨了":分页窗口横跨「监测点 + 未关联设备」一条总序列,
  809. // 所以**最后一页可能整页都是设备**(点数一个不涨)—— 拿点数比会把一个正常的到底误判成失败
  810. // (第一版就是这么错的:样例 9 条、页大小 3,第 3 页恰好是 1 点 + 2 设备)。
  811. //
  812. // 也不能用**对象同一性**比"再调一次没变":pinia 的 `sections` 是响应式代理,
  813. // 每次读回来的都是新代理对象,`again === settled` 恒假(与 G4 徽标同一个坑)。
  814. const settled = await store.loadMoreLedger()
  815. const again = await store.loadMoreLedger()
  816. check(
  817. 'G3-ledger-stops-at-the-end',
  818. nextLedgerPage(settled.data) === null &&
  819. settled.data.pagination.hasMore === false &&
  820. again.data.points.length === settled.data.points.length &&
  821. again.data.pagination.page === settled.data.pagination.page,
  822. `翻到底后不再发请求(nextLedgerPage → ${nextLedgerPage(settled.data)}、hasMore=${settled.data.pagination.hasMore});再调一次页码与点数都不动(仍停在第 ${again.data.pagination.page} 页 / ${again.data.points.length} 个点位)`,
  823. )
  824. // G4 徽标按需取、各自成败、**取不到不给 0**。
  825. // 注意 `store.badges` 是 pinia 的响应式代理,取回来的是 **proxy** 而非原对象 ——
  826. // 所以断言比**字段值**,不比引用(第一版比引用,恒假)。
  827. const badge = await store.loadLedgerBadge('MP-001')
  828. const stored = store.badges['MP-001']
  829. check(
  830. 'G4-badge-loads-per-point-on-demand',
  831. badge.state === 'success' &&
  832. badge.total === 2 &&
  833. stored &&
  834. stored.state === 'success' &&
  835. stored.total === badge.total &&
  836. stored.alarm === badge.alarm,
  837. `MP-001 徽标:设备 ${badge.total} · 报警 ${badge.alarm}(逐点位取;已写进 store,字段与返回值一致)`,
  838. )
  839. check(
  840. 'G4b-badge-not-refetched',
  841. shouldLoadBadge(store.badges, 'MP-001') === false && shouldLoadBadge(store.badges, 'MP-999') === true,
  842. '已取过的点位不再发请求(卡片滚动时不反复拉);没取过的仍会取',
  843. )
  844. // 徽标失败隔离:只让**那一个点位**的详情端点坏,徽标记 failed 且**不给 0**。
  845. // `onlyPath` 比的是**注册模板**(`path === onlyPath`),带路径参数的接口模板里是 `*` ——
  846. // 写成解析后的真实路径匹配不上,异常样例根本不会生效(那会让这条断言变成假绿)。
  847. const failed = useMonitoringStore()
  848. failed.reset()
  849. failed.configure({ config: mockConfig })
  850. resetMockTransportsForTest()
  851. registerCockpitMocks({ scenario: 'failed', onlyPath: '/monitoring-points/*', handlers: { registerMockTransport, resetMockTransportsForTest } })
  852. const failedBadge = await failed.loadLedgerBadge('MP-001')
  853. check(
  854. 'G5-badge-failure-does-not-fake-zero',
  855. failedBadge.state !== 'success' && failedBadge.total === null,
  856. `徽标取不到时状态=${failedBadge.state}、值=${failedBadge.total}(**不是** 0 —— 0 会读成"确实没有设备")`,
  857. )
  858. // 对抗式反证:同样的判据在正常场景下必须拿到**真值**,否则上面那条可能只是"永远失败"。
  859. check(
  860. 'G5b-badge-guard-has-teeth',
  861. badge.state === 'success' && badge.total === 2,
  862. '同一判据在正常场景拿到真实计数(因此 G5 的"取不到"是有信息量的)',
  863. )
  864. // 徽标画不出来时组件说"取数中",而不是画一个 0。
  865. const viewWithPending = monitoringPointLedgerPanel(
  866. { points: [{ pointCode: 'MP-001', pointName: 'P', region: '城区A', industry: 'GAS', deviceType: null, pointStatus: 'NORMAL', statusReasons: [] }], pagination: { page: 1, hasMore: false } },
  867. section(SECTION_STATE.SUCCESS),
  868. {},
  869. )
  870. check(
  871. 'G6-pending-badge-is-not-zero',
  872. viewWithPending.blocks[0].rows[0].badge === null && viewWithPending.blocks[0].rows[0].badgePending === true,
  873. '徽标还没取到时 `badge` 为 null(组件画"取数中"),不画 0',
  874. )
  875. }
  876. // ================================================================ H 静态守卫
  877. {
  878. const panelVue = readVueCode('components/PanelBlocks.vue')
  879. const shellVue = readVueCode('CockpitShell.vue')
  880. const viewCode = readCode('domain/monitoringPanels.js')
  881. const blocksCode = readCode('domain/panelBlocks.js')
  882. const registryCode = readCode('domain/panelViews.js')
  883. const storeCode = readCode('stores/monitoringStore.js')
  884. const apiCode = readCode('services/monitoringApi.js')
  885. check(
  886. 'H1-renderer-has-no-api-paths-or-fetch',
  887. !/\/monitoring\//.test(panelVue) && !/\/infrastructure\//.test(panelVue) && !/fetch\(/.test(panelVue),
  888. '接口路径只在 services/*Api.js,共享渲染层不自己拼',
  889. )
  890. // 渲染层**不认识 panelId**:分发归注册表,它只按 block.kind 分派。
  891. // 这条是本票解除模式耦合的落点 —— 组件若又按 panelId 分支,票 19/20 就得再改它一次。
  892. check(
  893. 'H2-renderer-does-not-know-panel-ids',
  894. !/monitoringOverview|monitoringDistribution|operationOverview|monitoringPointLedger|archive|emergency/.test(panelVue) &&
  895. !/panelId/.test(panelVue),
  896. '共享渲染层里没有任何 panelId(吃算好的 view prop),因此票 19/20 接进来不用改它',
  897. )
  898. check(
  899. 'H3-renderer-has-no-backend-field-names',
  900. !/monitoringPointCount|onlineRate|typeDistribution|pointStatusDistribution|abnormalEquipment|warningTrend|bucketStart|monitoringPoints|industryDistribution/.test(panelVue) &&
  901. !/monitoringPointCount|pointStatusDistribution/.test(shellVue),
  902. '渲染层只读视图对象字段(后端裸字段名只出现在 domain 适配层)',
  903. )
  904. // 后端原因码/枚举值不许出现在渲染层(票 05/11/17 各逮到过一次同类泄漏)。
  905. const leaks = ['READY', 'NO_RESOLVED_DEVICES', 'AVAILABLE', 'TREND_WINDOW', 'CURRENT_BASELINE', 'GAS_SENSOR', 'SMART_DEVICE', 'NORMAL', 'OFFLINE', 'EXPIRED']
  906. const leaked = leaks.filter((token) => new RegExp(`\\b${token}\\b`).test(panelVue))
  907. check(
  908. 'H4-no-backend-codes-in-renderer',
  909. leaked.length === 0,
  910. leaked.length ? `渲染层出现后端代号:${leaked.join(', ')}` : '渲染层零后端代号(都是 domain 翻好的人话)',
  911. )
  912. check(
  913. 'H5-domain-is-pure-and-does-not-import-service',
  914. !/\bfetch\s*\(|XMLHttpRequest/.test(viewCode) &&
  915. !/from '\.\.\/services\//.test(viewCode) &&
  916. !/\bfetch\s*\(|XMLHttpRequest/.test(blocksCode) &&
  917. !/from '\.\.\/services\//.test(blocksCode) &&
  918. !/\bfetch\s*\(/.test(registryCode),
  919. 'domain 三个新模块都是纯函数层(不发请求、不 import service)',
  920. )
  921. check(
  922. 'H6-store-has-no-raw-fields-or-paths',
  923. !/\/monitoring\//.test(storeCode) && !/monitoringPoints|pointStatusDistribution|abnormalEquipment|onlineRate/.test(storeCode),
  924. 'store 只转发分区,不改名也不拼路径(裸字段名一个都不出现)',
  925. )
  926. check(
  927. 'H7-api-has-no-credential-and-no-write',
  928. !/Authorization|Bearer|api\.token/.test(apiCode) && !/method:\s*'(POST|PUT|PATCH|DELETE)'/.test(apiCode),
  929. '凭据只在 services/cockpitApi.js 附加(票 04 的接缝未被绕过);本票不引入写操作',
  930. )
  931. // 外壳必须把四块走**同一个**接缝,且不与具体模式耦合。
  932. check(
  933. 'H8-shell-routes-panels-through-the-shared-seam',
  934. /panelViewOf\(panelId\)/.test(shellVue) &&
  935. /PanelBlocks/.test(shellVue) &&
  936. /PanelPlaceholder v-else/.test(shellVue) &&
  937. !/InfrastructurePanel/.test(shellVue),
  938. '外壳按 panelId 问注册表要视图,一块组件渲染全部已接数据的面板;未接的走占位',
  939. )
  940. // 「已实现但还没取数」不得被判成「还没实现」:走组件还是走占位的判据必须是
  941. // `hasPanelView(panelId)`(注册表成员资格),**不能**是"视图算出来没有" ——
  942. // 后者在一个已接数据的面板还在加载时也为空,于是界面会印出「面板占位 · panelId」,
  943. // 那是在说"这块还没做"(本票自查时真的这么错过一次)。
  944. // 票 38 修正:原来钉的是精确串 `v-if="hasPanelView(panelId)"`,但票 19 在同一行尾追加了
  945. // 「除事件族外」的路由条件(`&& ![...,'eventAnalysis'].includes(panelId)`)——
  946. // **那是合法改动**,却让这条精确串匹配失效(断言测的是写法而不是不变式)。
  947. // 要守的不变式是:判据仍然以**注册表成员资格**开头(两栏各一处),且没退化成"视图算出来没有"。
  948. // 所以改成前缀匹配,写法可以演进、不变式不被放过。
  949. check(
  950. 'H9-placeholder-vs-pending-is-decided-by-registry-not-by-view-presence',
  951. (shellVue.match(/v-if="hasPanelView\(panelId\)/g) || []).length === 2 &&
  952. !/v-if="panelViewOf\(panelId\)"/.test(shellVue) &&
  953. /!hasPanelView\(panelId\)\) return '面板占位'/.test(shellVue.replace(/\s+/g, ' ')) &&
  954. /return '正在取数'/.test(shellVue),
  955. '「未实现」判据是注册表成员资格(两栏各一处);已实现但未取数显示「正在取数」而不是「面板占位」',
  956. )
  957. // 筛选必须**真的作用到数据上**,而不只是记在 store 里(CONTEXT.md 的全局条件)。
  958. check(
  959. 'H10-shell-follows-all-three-filters',
  960. /watch\(industry,/.test(shellVue) &&
  961. /watch\(scope,/.test(shellVue) &&
  962. /watch\(pointStatus,/.test(shellVue) &&
  963. /monitoringStore\.loadPanels\(/.test(shellVue) &&
  964. /refreshMapForFilters/.test(shellVue),
  965. '行业/范围/状态三个筛选变化都真的重取面板;行业与范围还换新快照重载地图',
  966. )
  967. // 状态筛选**不得**触发地图重载(后端不收 pointStatus,跟随就是空转一次请求而画面不变)。
  968. // 判据是**只截 watch(pointStatus) 自己的函数体**再查它有没有调 refreshMapForFilters ——
  969. // 第一版用固定字符窗口,把紧跟在后面的 refreshMapForFilters 函数定义也框进来了,是假红。
  970. const statusWatch = (/watch\(pointStatus,[\s\S]*?\n\}\)/.exec(shellVue) || [''])[0]
  971. const scopeWatch = (/watch\(scope,[\s\S]*?\n\}\)/.exec(shellVue) || [''])[0]
  972. check(
  973. 'H11-status-watch-does-not-reload-the-map',
  974. statusWatch.includes('monitoringStore.loadPanels({ pointStatus: next })') &&
  975. !statusWatch.includes('refreshMapForFilters') &&
  976. scopeWatch.includes('refreshMapForFilters'),
  977. `状态筛选的 watch 体只重取面板、不碰地图;而范围筛选的 watch 体**确实**调 refreshMapForFilters(对照组,证明判据有牙)`,
  978. )
  979. // 台账卡片必须走**与地图相同**的监测点选择入口,不另立一条路径。
  980. //
  981. // **断言在票 41 被改写**(成例:t01 的 A5 在票 17 被改写)。原文要求函数体里出现
  982. // `detailStore.openPoint(pointCode)` 且**不移动相机** —— 那是票 18 当时的语义;
  983. // 票 41 验收第 1、2 条改为「地图点击与台账选择使用**同一**监测点选择入口」且
  984. // 「有效点位坐标触发点位级完整视角定位」。于是判据换成:
  985. // ① 台账函数体调统一入口 `selectMonitoringPoint`;
  986. // ② 它**不再**自己碰相机(定位归那个统一入口,台账不另写第二条相机路径);
  987. // ③ 对照组:`locateCode`(异常设备联动)**仍然**用自己那套 `setCameraFocus` ——
  988. // 证明这条判据不是"全文没有 setCameraFocus 就绿"。
  989. //
  990. // 判据仍然**只截 `openLedgerPoint` 自己的函数体**:对整个文件做正则会被别处满足
  991. // (本票第二次栽在"守卫比它要守的东西宽"上,见票 18 的记录)。
  992. const ledgerBody = (/function openLedgerPoint[\s\S]*?\n \}/.exec(storeCode) || [''])[0]
  993. const locateBody = (/function locateCode[\s\S]*?\n \}/.exec(storeCode) || [''])[0]
  994. check(
  995. 'H12-ledger-cards-reuse-the-two-level-detail-entry',
  996. ledgerBody.includes('selectMonitoringPoint(pointCode)') &&
  997. !ledgerBody.includes('setCameraFocus') &&
  998. locateBody.includes('setCameraFocus'),
  999. '台账卡片走**统一选择入口**(票 41 改写:不再是不动相机的旁路),且不在台账里另写相机路径'
  1000. + `(对照:异常设备联动的 locateCode 仍用 setCameraFocus=${locateBody.includes('setCameraFocus')})`,
  1001. )
  1002. // 徽标是**按需**取的(Q14:卡片进视口才拉),所以 store 里的 `loadLedgerBadge` 必须真的
  1003. // 被界面上某处触发 —— 否则它是一段死代码,界面永远停在「设备统计取数中…」。
  1004. // 真机走查第一轮正是这样红的(B2):store 有函数、视图有徽标位,**没人调**。
  1005. //
  1006. // 判据必须**只截 IntersectionObserver 的回调体**:组件里有**两处** emit `badge-needed`
  1007. // —— 观察器回调(真正的按需路径)与 `IntersectionObserver` 不存在时的**退化全要**分支。
  1008. // 对整个文件做正则会被退化分支满足,于是"把按需触发拿掉"照样全绿
  1009. // (变异 M16 确认过:拿掉观察器里那次 emit,闸门仍是 67/67)。
  1010. // 这是本票**第三次**栽在"守卫比它要守的东西宽"上 —— 三处都已按函数体/回调体收窄。
  1011. const badgeVue = readVueCode('components/PanelBlocks.vue')
  1012. const observerCallback = (/new IntersectionObserver\(\s*\(entries\)[\s\S]*?\n \},/.exec(badgeVue) || [''])[0]
  1013. check(
  1014. 'H13-on-demand-badge-is-actually-wired-end-to-end',
  1015. /IntersectionObserver/.test(badgeVue) &&
  1016. observerCallback.includes("emit('badge-needed'") &&
  1017. observerCallback.includes('isIntersecting') &&
  1018. /data-ledger-card/.test(badgeVue) &&
  1019. /observer\.observe\(/.test(badgeVue) &&
  1020. /@badge-needed="loadLedgerBadge"/.test(shellVue) &&
  1021. /function loadLedgerBadge/.test(shellVue) &&
  1022. /monitoringStore\.loadLedgerBadge/.test(shellVue),
  1023. '徽标"按需取"真的接通:观察器**回调体里**把露出来的卡片发出去(不是靠退化分支蒙混),外壳接到 store',
  1024. )
  1025. }
  1026. const failed = checks.filter((item) => !item.pass)
  1027. console.log('')
  1028. console.log(`T18 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  1029. if (failed.length) {
  1030. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  1031. process.exit(1)
  1032. }