t18-monitoring-panels.mjs 68 KB

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