t17-infrastructure-panels.mjs 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. /**
  2. * T17 基础设施模式面板 纯逻辑检查(票 17)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t17-infrastructure-panels.mjs
  6. *
  7. * 七段:
  8. * A 面板映射:四块面板齐、各自对应一个端点、与目录的 panelId 一致
  9. * B 契约适配:四份真形状载荷 → 领域对象(含 BigDecimal 字符串、三类维度共用的 Distribution)
  10. * C 视图层:验收点名的画面元素都在(设施规模/行业分类/区域分布/档案摘要、三类能力、
  11. * 管理单位/权属单位、危险源/防护目标/应急资源),且缺值显示占位符
  12. * D 分区独立:一块失败不遮盖其它块;后端 UNAVAILABLE 与 EMPTY 分得开
  13. * E 行业筛选:前端键 → 后端码;无契约位的键不传参(不拿脏值去换 400)
  14. * F 地图联动:三种"点不动"分得开;坐标准入只认后端 renderable(不重算)
  15. * G 静态守卫:组件不拼路径、不认后端裸字段、不泄原因码;domain 不发请求、不 import service
  16. *
  17. * 契约依据:`CockpitInfrastructure{BaseArchives,OperatingInformation,ManagementInformation,
  18. * EmergencySupport}Response` 与 `docs/cockpit/infrastructure-*-demo.html`(逐类读过)。
  19. */
  20. import { readFileSync } from 'node:fs'
  21. import { createPinia, setActivePinia } from 'pinia'
  22. import {
  23. INFRASTRUCTURE_PANEL_IDS,
  24. MANAGEMENT_REGION_GAP,
  25. archivePanel,
  26. codeText,
  27. emergencyPanel,
  28. managementPanel,
  29. numberish,
  30. numberText,
  31. operationPanel,
  32. panelLocateEntries,
  33. ratioText,
  34. regionText,
  35. sectionHasContent,
  36. sectionPending,
  37. sectionPlaceholder,
  38. toBaseArchives,
  39. toEmergencySupport,
  40. toManagementInformation,
  41. toOperatingInformation,
  42. } from '../../../src/modules/cockpit/domain/infrastructurePanels.js'
  43. import { SECTION_STATE, section, sectionFromResult } from '../../../src/modules/cockpit/domain/sectionState.js'
  44. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  45. import { INDUSTRY_CODES, industryQuery } from '../../../src/modules/cockpit/domain/filterOptions.js'
  46. import { LOCATE_RESULT, findMapObject, locateCameraTarget, planLocate } from '../../../src/modules/cockpit/domain/mapLocate.js'
  47. import { panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
  48. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  49. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  50. import { useInfrastructureStore } from '../../../src/modules/cockpit/stores/infrastructureStore.js'
  51. const checks = []
  52. /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  53. function check(name, pass, evidence) {
  54. checks.push({ name, pass })
  55. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  56. }
  57. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  58. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  59. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  60. /** 去掉注释只留代码(注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
  61. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  62. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  63. const readCode = (relative) => code(read(relative))
  64. const readVueCode = (relative) => codeVue(read(relative))
  65. setActivePinia(createPinia())
  66. // ================================================================ A 面板映射
  67. {
  68. const infra = panelsForMode('infrastructure')
  69. check(
  70. 'A1-four-panels-match-catalog',
  71. INFRASTRUCTURE_PANEL_IDS.join(',') === 'archive,operation,management,emergency' &&
  72. infra.slice().sort().join(',') === INFRASTRUCTURE_PANEL_IDS.slice().sort().join(','),
  73. `${INFRASTRUCTURE_PANEL_IDS.length} 块:${INFRASTRUCTURE_PANEL_IDS.join(' / ')}(与 ADR 0003 的目录一致)`,
  74. )
  75. check(
  76. 'A2-panels-are-infrastructure-mode-only',
  77. panelsForMode('monitoring').every((id) => !INFRASTRUCTURE_PANEL_IDS.includes(id)) &&
  78. panelsForMode('warningResponse').every((id) => !INFRASTRUCTURE_PANEL_IDS.includes(id)),
  79. '本票只接基础设施模式的四块,不越界接其它模式的面板',
  80. )
  81. }
  82. // ================================================================ B 契约适配
  83. {
  84. // B1 BigDecimal 的线上形状是**字符串**(照抄 demo 载荷),必须认。
  85. check(
  86. 'B1-numberish-accepts-string-and-number',
  87. numberish('0.32') === 0.32 && numberish(1200.5) === 1200.5 && numberish('') === null && numberish(null) === null,
  88. `"0.32" → ${numberish('0.32')};1200.5 → ${numberish(1200.5)};"" → ${numberish('')}`,
  89. )
  90. // B2 真 0 是有效读数(票 16 的验收原句),认不出才回占位符。
  91. check(
  92. 'B2-zero-is-a-value-not-missing',
  93. numberish('0') === 0 && numberText('0', 'm3/h') === '0 m3/h' && numberText(null, 'm3/h') === DETAIL_PLACEHOLDER,
  94. `"0" → ${numberText('0', 'm3/h')};null → ${numberText(null, 'm3/h')}(不拼出半截读数)`,
  95. )
  96. check(
  97. 'B2b-ratio-text',
  98. ratioText('0.8000') === '80.0%' && ratioText('1.2500') === '125%' && ratioText(null) === DETAIL_PLACEHOLDER,
  99. `0.8 → ${ratioText('0.8000')};1.25 → ${ratioText('1.2500')}(超载超过 100% 如实显示)`,
  100. )
  101. // B3 基础档案:设施规模 / 行业分类 / 区域分布 / 档案摘要。
  102. const archives = toBaseArchives({
  103. updatedAt: 'T',
  104. range: { type: 'CURRENT_BASELINE' },
  105. totals: { facilityCount: 5, lengthKm: '12.40', scale: 'medium' },
  106. industryDistribution: [{ code: 'GAS', count: 2 }],
  107. facilityTypeDistribution: [{ code: 'WATER_PLANT', name: '水厂', count: 1 }],
  108. ageDistribution: [{ code: 'UNDER_5_YEARS', count: 1 }],
  109. ageReference: { asOfDate: '2026-09-02', commissionedCount: 3, builtCount: 2, unknownCount: 0 },
  110. regionDistribution: [{ code: 'CN', count: 3 }],
  111. selectedFilters: { industry: null },
  112. excluded: { unknownTypeCount: 0 },
  113. })
  114. check(
  115. 'B3-base-archives-four-dimensions',
  116. archives.facilityCount === 5 &&
  117. archives.lengthKm === 12.4 &&
  118. archives.industries[0].count === 2 &&
  119. archives.regions[0].count === 3 &&
  120. archives.ages[0].count === 1 &&
  121. archives.ageReference.commissionedCount === 3,
  122. `设施 ${archives.facilityCount} / 里程 ${archives.lengthKm} / 行业 ${archives.industries.length} 类 / 区域 ${archives.regions.length} 类 / 年限 ${archives.ages.length} 桶`,
  123. )
  124. // B4 行业码翻人话(后端不给中文标签,本层直译);认不出的**回原码**而不是留白。
  125. check(
  126. 'B4-industry-code-text',
  127. archives.industries[0].label === '燃气' && codeText('SOMETHING_NEW') === 'SOMETHING_NEW' && regionText(null) === '未分区',
  128. `GAS → ${archives.industries[0].label};未知码 → ${codeText('SOMETHING_NEW')}(照原样带出,不猜也不留白;区域空值 → ${regionText(null)})`,
  129. )
  130. // 真机走查逮到的泄漏:`scale` 是后端**透传**的自由字符串(契约里没有中文口径),
  131. // 漏译它界面就会印出英文枚举(当时是「规模=medium」)。这里把**已知全量取值**逐条钉住 ——
  132. // 只查"有没有翻译"不够,必须逐值确认,否则漏一个词就又漏到大屏上。
  133. check(
  134. 'B4b-scale-codes-translated',
  135. ['large', 'medium', 'small'].every((code) => codeText(code) !== code) &&
  136. codeText('medium') === '中型' &&
  137. codeText('small') === '小型' &&
  138. codeText('large') === '大型',
  139. `large/medium/small → ${codeText('large')}/${codeText('medium')}/${codeText('small')}(三个已知取值全部翻成中文)`,
  140. )
  141. // 归纳版守卫:**凡是字典里登记过的码,翻译结果都不能等于原码**(防止将来加词时漏一半)。
  142. // 认不出的自由值仍允许回原码(那是数据值,见 CODE_TEXT 的说明)。
  143. const registered = ['GAS', 'WATER_SUPPLY', 'DRAINAGE', 'SUPPLY', 'FLOW', 'PRESSURE', 'QUALITY', 'CAPACITY', 'NORMAL', 'WATCH', 'OVERLOAD', 'CURRENT', 'STALE', 'EXPIRED', 'OWNED', 'LEASED', 'ENTRUSTED', 'EMERGENCY_RESOURCE', 'HAZARD_SOURCE', 'PROTECTION_TARGET', 'UNKNOWN', 'EMPTY', 'large', 'medium', 'small']
  144. const untranslated = registered.filter((code) => codeText(code) === code)
  145. check(
  146. 'B4c-no-registered-code-passes-through',
  147. untranslated.length === 0 && codeText('SOMETHING_NEW') === 'SOMETHING_NEW',
  148. untranslated.length
  149. ? `这些登记过的码没有被翻译:${untranslated.join(', ')}`
  150. : `${registered.length} 个登记码全部翻成中文;未登记的自由值仍回原码(${codeText('SOMETHING_NEW')})`,
  151. )
  152. // B5 运行信息:三类能力(SUPPLY/FLOW/CAPACITY)+ 指标占位。
  153. const operation = toOperatingInformation({
  154. updatedAt: 'T',
  155. range: { type: 'LAST_24_HOURS', start: 'S', end: 'E' },
  156. selectedIndustry: { code: 'GAS', label: '燃气' },
  157. industries: [
  158. {
  159. industry: { code: 'GAS', key: 'gas', label: '燃气' },
  160. state: 'READY',
  161. stateReason: 'READY',
  162. abnormalMetricCount: 1,
  163. metrics: [
  164. { metricCode: 'p', metricName: '压力', category: 'SUPPLY', unit: 'MPa', value: '0.32', capacityValue: '0.40', loadRatio: '0.8000', loadState: 'WATCH', pointCodes: ['MP-001'] },
  165. { metricCode: 'f', metricName: '流量', category: 'FLOW', unit: 'm3/h', value: '1200.5', capacityValue: null, loadRatio: null, loadState: 'UNKNOWN', pointCodes: [] },
  166. { metricCode: 'c', metricName: '承载', category: 'CAPACITY', unit: 'm3', value: '800', capacityValue: '2000', loadRatio: '0.4000', loadState: 'NORMAL', pointCodes: [] },
  167. ],
  168. },
  169. ],
  170. excluded: {},
  171. })
  172. check(
  173. 'B5-operating-three-capabilities',
  174. operation.industries.length === 1 &&
  175. operation.industries[0].metrics.length === 3 &&
  176. operation.industries[0].metrics.map((m) => m.category).join(',') === 'SUPPLY,FLOW,CAPACITY',
  177. `三类能力:${operation.industries[0].metrics.map((m) => m.category).join(' / ')}`,
  178. )
  179. check(
  180. 'B5b-operating-keeps-point-codes',
  181. operation.industries[0].metrics[0].pointCodes[0] === 'MP-001',
  182. '指标带关联点位编码(坐标要靠地图回联,见 F 段)',
  183. )
  184. // B6 管理信息:管理单位 + 权属单位;**责任区域在后端契约里不存在**,如实为缺口。
  185. const management = toManagementInformation({
  186. updatedAt: 'T',
  187. totals: { organizationCount: 3, facilityCount: 3, equipmentCount: 2, unmaintainedCount: 1 },
  188. organizations: [
  189. { organizationCode: 'ORG-WS-A', organizationName: '供水一公司', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1, unmaintained: false, ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 1, equipmentCount: 0, total: 1 }] },
  190. { organizationCode: 'UNMAINTAINED', organizationName: '未维护', industry: 'DRAINAGE', facilityCount: 1, equipmentCount: 0, unmaintained: true, ownershipDistribution: [] },
  191. ],
  192. ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 2, equipmentCount: 1, total: 3 }],
  193. excluded: {},
  194. })
  195. check(
  196. 'B6-management-units-and-ownership',
  197. management.organizationCount === 3 &&
  198. management.organizations.length === 2 &&
  199. management.organizations[1].unmaintained === true &&
  200. management.ownership[0].total === 3,
  201. `${management.organizations.length} 家管理单位(含"无管理关系"显式桶);权属 ${management.ownership.length} 类`,
  202. )
  203. check(
  204. 'B6b-management-region-is-a-declared-gap',
  205. MANAGEMENT_REGION_GAP.includes('责任区域') && MANAGEMENT_REGION_GAP.includes('没有字段') &&
  206. managementPanel(management, section(SECTION_STATE.SUCCESS)).gap === MANAGEMENT_REGION_GAP,
  207. '「责任区域」如实标为契约缺口,不拿行业冒充区域',
  208. )
  209. // B7 应急保障:三个分区 + **三类维度共用的 Distribution**(值分别在 industry / area / category 上)。
  210. const emergency = toEmergencySupport({
  211. updatedAt: 'T',
  212. totals: { emergencyResourceCount: 2, hazardSourceCount: 1, protectionTargetCount: 1, totalObjectCount: 4 },
  213. sections: {
  214. hazardSources: { state: 'READY', stateReason: 'READY', items: [{ objectCode: 'HS-1', objectName: '危险源', category: 'HAZARD_SOURCE', industry: 'WATER_SUPPLY', area: 'TC', pointCodes: ['MP-003'], eventAssociations: { warningCount: 1, alarmCount: 1, workOrderCount: 1 } }] },
  215. protectionTargets: { state: 'UNAVAILABLE', stateReason: 'SOURCE_UNAVAILABLE', items: [] },
  216. emergencyResources: { state: 'READY', stateReason: 'READY', items: [{ objectCode: 'ER-1', objectName: '物资库', category: 'EMERGENCY_RESOURCE', industry: 'GAS', area: null, pointCodes: [], eventAssociations: { warningCount: 0, alarmCount: 0, workOrderCount: 0 } }] },
  217. },
  218. eventSummary: { warningCount: 1, alarmCount: 1, workOrderCount: 1 },
  219. // 照抄后端:三类维度共用结构,`code` 一律 null。
  220. industryDistribution: [{ category: null, industry: 'WATER_SUPPLY', area: null, count: 2 }],
  221. areaDistribution: [{ category: null, industry: null, area: 'TC', count: 1 }],
  222. categoryDistribution: [{ category: 'HAZARD_SOURCE', industry: null, area: null, count: 1 }],
  223. excluded: {},
  224. })
  225. check(
  226. 'B7-emergency-three-sections',
  227. emergency.totals.hazardSourceCount === 1 &&
  228. emergency.sections.hazardSources.items.length === 1 &&
  229. emergency.sections.protectionTargets.items.length === 0 &&
  230. emergency.sections.emergencyResources.items.length === 1,
  231. `危险源 ${emergency.sections.hazardSources.items.length} / 防护目标 ${emergency.sections.protectionTargets.items.length} / 应急资源 ${emergency.sections.emergencyResources.items.length}`,
  232. )
  233. check(
  234. 'B7b-shared-distribution-reads-per-dimension-key',
  235. emergency.industries[0].code === 'WATER_SUPPLY' &&
  236. emergency.industries[0].count === 2 &&
  237. emergency.areas[0].code === 'TC' &&
  238. emergency.areas[0].label === 'TC',
  239. `行业分布取值在 industry 上(${emergency.industries[0].label})、区域分布在 area 上(${emergency.areas[0].label})—— 一律读 code 会全部落空`,
  240. )
  241. check(
  242. 'B7c-unknown-region-code-survives',
  243. regionText('CN') === 'CN' && regionText('TC') === 'TC' && regionText(null) === '未分区',
  244. `无字典的区域码照原样带出(${regionText('CN')} / ${regionText('TC')})—— 空白会被读成"没有数据";空值则明说「${regionText(null)}」`,
  245. )
  246. // 但**哨兵值**不是数据值:`UNKNOWN`/`EMPTY` 是后端表示"这一维没有值"的占位,
  247. // 原样印到大屏上就是把接口词汇漏给值班人员(票 05 的真机走查逮到过同类泄漏)。
  248. check(
  249. 'B7d-sentinel-codes-translated-not-leaked',
  250. codeText('UNKNOWN') === '未知' && codeText('EMPTY') === '无数据' && codeText('CURRENT') === '新鲜',
  251. `哨兵值翻人话:UNKNOWN → ${codeText('UNKNOWN')};EMPTY → ${codeText('EMPTY')};CURRENT → ${codeText('CURRENT')}`,
  252. )
  253. }
  254. // ================================================================ C 视图层(验收点名的画面元素)
  255. {
  256. const archives = archivePanel(
  257. toBaseArchives({
  258. totals: { facilityCount: 5, lengthKm: '12.40', scale: 'medium' },
  259. industryDistribution: [{ code: 'GAS', count: 2 }],
  260. regionDistribution: [{ code: 'CN', count: 3 }],
  261. ageDistribution: [{ code: 'UNDER_5_YEARS', count: 1 }],
  262. ageReference: { asOfDate: 'D', commissionedCount: 3, builtCount: 2, unknownCount: 0 },
  263. facilityTypeDistribution: [],
  264. selectedFilters: {},
  265. excluded: {},
  266. }),
  267. section(SECTION_STATE.SUCCESS),
  268. )
  269. const archiveBars = archives.blocks.filter((b) => b.kind === 'bars').map((b) => b.label)
  270. check(
  271. 'C1-archive-shows-scale-industry-region-age',
  272. archives.blocks[0].items.some((i) => i.label === '设施总数') &&
  273. archiveBars.includes('行业分类') &&
  274. archiveBars.includes('区域分布') &&
  275. archiveBars.some((label) => label.includes('建成年限')),
  276. `验收四项都在:${['设施规模', ...archiveBars].join(' / ')}`,
  277. )
  278. check(
  279. 'C1b-archive-has-no-locate-targets',
  280. archives.locate.length === 0 && archives.locateNote.includes('没有可定位对象'),
  281. '基础档案是聚合分布口径(后端不返回逐设施坐标),如实说没有可定位对象',
  282. )
  283. const operation = operationPanel(
  284. toOperatingInformation({
  285. industries: [
  286. {
  287. industry: { code: 'GAS', label: '燃气' },
  288. state: 'READY',
  289. metrics: [
  290. { metricCode: 'p', metricName: '压力', category: 'SUPPLY', unit: 'MPa', value: '0.32', capacityValue: '0.40', loadRatio: '0.8000', loadState: 'WATCH', pointCodes: ['MP-001'] },
  291. { metricCode: 'f', metricName: '流量', category: 'FLOW', unit: 'm3/h', value: '1', capacityValue: null, loadRatio: null, loadState: 'UNKNOWN', pointCodes: [] },
  292. { metricCode: 'c', metricName: '承载', category: 'CAPACITY', unit: 'm3', value: '1', capacityValue: '2', loadRatio: '0.5', loadState: 'NORMAL', pointCodes: [] },
  293. ],
  294. },
  295. ],
  296. excluded: {},
  297. }),
  298. section(SECTION_STATE.SUCCESS),
  299. )
  300. const kpiLabels = operation.blocks[0].items.map((i) => i.label)
  301. check(
  302. 'C2-operation-shows-supply-flow-capacity',
  303. kpiLabels.join(',') === '供应,输送,承载' && operation.blocks[0].items[0].value === '0.32 MPa',
  304. `验收三类能力:${kpiLabels.join(' / ')}(供应 = ${operation.blocks[0].items[0].value})`,
  305. )
  306. check(
  307. 'C2b-operation-missing-capacity-shows-placeholder',
  308. operation.blocks[0].items[1].hint.includes('无能力基准') && numberText(null, 'm3/h') === DETAIL_PLACEHOLDER,
  309. '能力基准缺失时说"无能力基准",不显示虚假 0 负荷',
  310. )
  311. check(
  312. 'C2c-operation-locates-metric-points',
  313. operation.locate.length === 1 && operation.locate[0].pointCode === 'MP-001',
  314. `指标关联点位可定位:${operation.locate.map((e) => e.pointCode).join(', ')}`,
  315. )
  316. // 色键必须**成对给文字**(票 06:状态标签不能只靠颜色,灰度与色盲下要读得出)。
  317. // 缺了 toneLabel,组件会画出一个只有颜色的空标签 —— 那条纪律就在这一层被悄悄破掉。
  318. const tonedRows = [
  319. ...operation.blocks.filter((b) => b.kind === 'list').flatMap((b) => b.rows),
  320. ...managementPanel(
  321. toManagementInformation({
  322. totals: {},
  323. organizations: [{ organizationCode: 'O', organizationName: 'N', industry: 'GAS', unmaintained: true, ownershipDistribution: [] }],
  324. ownershipDistribution: [],
  325. excluded: {},
  326. }),
  327. section(SECTION_STATE.SUCCESS),
  328. ).blocks.filter((b) => b.kind === 'list').flatMap((b) => b.rows),
  329. ].filter((row) => row.tone)
  330. check(
  331. 'C2d-every-tone-carries-a-text-label',
  332. tonedRows.length >= 2 && tonedRows.every((row) => !!row.toneLabel && row.toneLabel.trim().length > 0),
  333. `带色键的条目 ${tonedRows.length} 条,每条都带文字(${tonedRows.map((r) => `${r.tone}:${r.toneLabel}`).join(' / ')})`,
  334. )
  335. const management = managementPanel(
  336. toManagementInformation({
  337. totals: { organizationCount: 1 },
  338. organizations: [{ organizationCode: 'ORG-1', organizationName: '供水一公司', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1, unmaintained: false, ownershipDistribution: [] }],
  339. ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 1, equipmentCount: 0, total: 1 }],
  340. excluded: {},
  341. }),
  342. section(SECTION_STATE.SUCCESS),
  343. )
  344. check(
  345. 'C3-management-shows-units-and-ownership-plus-gap',
  346. management.blocks.some((b) => b.kind === 'bars' && b.label.includes('权属')) &&
  347. management.blocks.some((b) => b.kind === 'list' && b.label === '管理单位') &&
  348. !!management.gap,
  349. '管理单位 / 权属单位都画出;责任区域以缺口说明呈现(后端无该字段)',
  350. )
  351. const emergency = emergencyPanel(
  352. toEmergencySupport({
  353. totals: { hazardSourceCount: 1, protectionTargetCount: 1, emergencyResourceCount: 1, totalObjectCount: 3 },
  354. sections: {
  355. hazardSources: { state: 'READY', items: [{ objectCode: 'HS-1', objectName: '危险源', category: 'HAZARD_SOURCE', industry: 'WATER_SUPPLY', area: 'TC', pointCodes: ['MP-003'], eventAssociations: {} }] },
  356. protectionTargets: { state: 'READY', items: [{ objectCode: 'PT-1', objectName: '防护目标', category: 'PROTECTION_TARGET', industry: 'DRAINAGE', area: null, pointCodes: [], eventAssociations: {} }] },
  357. emergencyResources: { state: 'READY', items: [{ objectCode: 'ER-1', objectName: '应急资源', category: 'EMERGENCY_RESOURCE', industry: 'GAS', area: 'CN', pointCodes: ['MP-001'], eventAssociations: {} }] },
  358. },
  359. eventSummary: { warningCount: 1, alarmCount: 2, workOrderCount: 3 },
  360. industryDistribution: [],
  361. areaDistribution: [],
  362. excluded: {},
  363. }),
  364. section(SECTION_STATE.SUCCESS),
  365. )
  366. const listLabels = emergency.blocks.filter((b) => b.kind === 'list').map((b) => b.label)
  367. check(
  368. 'C4-emergency-shows-hazard-target-resource',
  369. listLabels.includes('危险源') && listLabels.includes('防护目标') && listLabels.includes('应急资源'),
  370. `验收三项:${listLabels.join(' / ')}`,
  371. )
  372. check(
  373. 'C4b-emergency-locates-linked-points',
  374. emergency.locate.length === 2 && panelLocateEntries(emergency).length >= 2,
  375. `危险源与应急资源的关联点位可定位:${emergency.locate.map((e) => e.pointCode).join(', ')}`,
  376. )
  377. check(
  378. 'C5-placeholder-text-per-state',
  379. sectionPlaceholder(section(SECTION_STATE.EMPTY)).includes('没有数据') &&
  380. sectionPlaceholder(section(SECTION_STATE.FAILED)).includes('加载失败') &&
  381. sectionPlaceholder(section(SECTION_STATE.UNAVAILABLE)).includes('不可用') &&
  382. sectionPlaceholder(section(SECTION_STATE.FAILED)).includes('其它面板') &&
  383. sectionPlaceholder(section(SECTION_STATE.STALE)) === '',
  384. '空 / 失败 / 不可用各有明确措辞;过期是有值(不回占位说明)',
  385. )
  386. check(
  387. 'C6-content-states',
  388. sectionHasContent(section(SECTION_STATE.SUCCESS)) &&
  389. sectionHasContent(section(SECTION_STATE.STALE)) &&
  390. !sectionHasContent(section(SECTION_STATE.EMPTY)) &&
  391. !sectionHasContent(section(SECTION_STATE.FAILED)),
  392. '过期算有值(内容照画),空与失败才算没内容',
  393. )
  394. // 还没取过数时说"正在取数",**不能说"没有数据"** —— 那是在报告一件还没问过的事。
  395. // (首帧就是这个状态:配置还没落地、请求还没发出。)
  396. check(
  397. 'C7-pending-says-loading-not-empty',
  398. sectionPlaceholder({ state: SECTION_STATE.EMPTY, loaded: false }) === '正在取数…' &&
  399. sectionPending({ state: SECTION_STATE.EMPTY, loaded: false }) === true &&
  400. sectionPending({ state: SECTION_STATE.EMPTY, loaded: true }) === false &&
  401. sectionPlaceholder({ state: SECTION_STATE.EMPTY, loaded: true }) === '该面板当前没有数据',
  402. '未取数 → 正在取数;取过且为空 → 该面板当前没有数据(两种状态分得开)',
  403. )
  404. // 整段缺失(null)也是"还没有",**不是**"加载失败":
  405. // "这个分区还没有位置"与"取了但坏了"是两件事,只有后者才有资格说"加载失败"。
  406. check(
  407. 'C7b-missing-section-is-pending-not-failed',
  408. sectionPending(null) === true &&
  409. sectionPlaceholder(null) === '正在取数…' &&
  410. sectionPlaceholder({ state: SECTION_STATE.FAILED, loaded: true }) === '该面板加载失败(其它面板不受影响)',
  411. `null → 「${sectionPlaceholder(null)}」;真失败 → 「${sectionPlaceholder({ state: SECTION_STATE.FAILED, loaded: true })}」`,
  412. )
  413. }
  414. // ================================================================ D 分区独立(四块各自成败)
  415. {
  416. const store = useInfrastructureStore()
  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. INFRASTRUCTURE_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS && all[id].loaded === true),
  426. INFRASTRUCTURE_PANEL_IDS.map((id) => `${id}=${all[id].state}`).join(' '),
  427. )
  428. // 只让**一个**端点失败:认证类失败只影响那一块(票 05 验收第 6 条的同一条纪律)。
  429. const isolated = useInfrastructureStore()
  430. isolated.reset()
  431. isolated.configure({ config: mockConfig })
  432. resetMockTransportsForTest()
  433. registerCockpitMocks({
  434. scenario: 'failed',
  435. onlyPath: '/infrastructure/management-information',
  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.management.state !== SECTION_STATE.SUCCESS &&
  443. sections.archive.state === SECTION_STATE.SUCCESS &&
  444. sections.operation.state === SECTION_STATE.SUCCESS &&
  445. sections.emergency.state === SECTION_STATE.SUCCESS,
  446. `管理块=${sections.management.state},其余三块仍是 ${sections.archive.state}/${sections.operation.state}/${sections.emergency.state}`,
  447. )
  448. // 对抗式反证:同样的判据在全成功场景下必须全绿,否则上面那条可能是"全都失败了"而恒真。
  449. check(
  450. 'D3-guard-has-teeth',
  451. INFRASTRUCTURE_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS),
  452. '全成功场景四块全绿(因此 D2 的"一块坏"是有信息量的)',
  453. )
  454. // 空场景:四块都说"没有数据",不伪造 0。
  455. const emptyStore = useInfrastructureStore()
  456. emptyStore.reset()
  457. emptyStore.configure({ config: mockConfig })
  458. resetMockTransportsForTest()
  459. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  460. await emptyStore.loadPanels({ industry: 'all' })
  461. const emptyViews = INFRASTRUCTURE_PANEL_IDS.map((id) =>
  462. require_placeholder(id, emptyStore.sections[id]),
  463. )
  464. check(
  465. 'D4-empty-scenario-all-placeholders',
  466. emptyViews.every((text) => text === '该面板当前没有数据'),
  467. `空场景四块都是明确占位说明:${emptyViews.join(' / ')}`,
  468. )
  469. // 过期场景:内容照画 + 保留标识(票 16 的立场),不是"没有数据"。
  470. const staleStore = useInfrastructureStore()
  471. staleStore.reset()
  472. staleStore.configure({ config: mockConfig })
  473. resetMockTransportsForTest()
  474. registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
  475. await staleStore.loadPanels({ industry: 'all' })
  476. const staleArchive = staleStore.sections.archive
  477. check(
  478. 'D5-stale-is-value-not-empty',
  479. staleArchive.state === SECTION_STATE.STALE &&
  480. staleArchive.retained === true &&
  481. sectionHasContent(staleArchive) &&
  482. !!staleArchive.data &&
  483. staleArchive.data.facilityCount === 5,
  484. `过期分区仍带着数据(设施 ${staleArchive.data.facilityCount}),界面照画并标过期`,
  485. )
  486. // 后端 `UNAVAILABLE`(没取到)与 `EMPTY`(确实没有)必须分得开。
  487. const partialStore = useInfrastructureStore()
  488. partialStore.reset()
  489. partialStore.configure({ config: mockConfig })
  490. resetMockTransportsForTest()
  491. registerCockpitMocks({ scenario: 'infrastructure-partial', handlers: { registerMockTransport, resetMockTransportsForTest } })
  492. await partialStore.loadPanels({ industry: 'all' })
  493. const emergency = emergencyPanel(partialStore.sections.emergency.data, partialStore.sections.emergency)
  494. const targetBlock = emergency.blocks.find((b) => b.label === '防护目标')
  495. const hazardBlock = emergency.blocks.find((b) => b.label === '危险源')
  496. check(
  497. 'D6-unavailable-vs-empty-in-child-sections',
  498. targetBlock.stateText === '该段数据未取到' &&
  499. targetBlock.rows.length === 0 &&
  500. hazardBlock.rows.length === 1,
  501. `防护目标段=${targetBlock.stateText}(没取到、0 条),危险源段照常画出 ${hazardBlock.rows.length} 条`,
  502. )
  503. // 段状态与空列表提示**互斥**:有 stateText 就必然没有 rows(组件据此把 stateText 放前面)。
  504. // 钉住这条,免得将来有人让两者同时成立而使组件里的 v-if/v-else-if 顺序失去意义。
  505. check(
  506. 'D6b-section-state-and-empty-list-are-mutually-exclusive',
  507. emergency.blocks
  508. .filter((b) => b.kind === 'list')
  509. .every((b) => !(b.stateText && b.rows.length > 0)),
  510. '有段状态说明的段一定没有条目(所以组件可以安全地用 stateText 优先于空列表提示)',
  511. )
  512. }
  513. /** 取一个面板在空场景下的占位说明(复用视图层,不读组件)。 */
  514. function require_placeholder(panelId, sectionValue) {
  515. const builders = { archive: archivePanel, operation: operationPanel, management: managementPanel, emergency: emergencyPanel }
  516. const view = builders[panelId](sectionValue.data, sectionValue)
  517. return view.placeholder
  518. }
  519. // ================================================================ E 行业筛选
  520. {
  521. check(
  522. 'E1-industry-key-maps-to-backend-code',
  523. INDUSTRY_CODES.gas === 'GAS' &&
  524. INDUSTRY_CODES.water === 'WATER_SUPPLY' &&
  525. INDUSTRY_CODES.drainage === 'DRAINAGE' &&
  526. // 票 18 更正(2026-09-28):`MANHOLE` 现已是后端第四个正式行业(全部 11 个 cockpit
  527. // 服务都收,含 `map-snapshot`),井盖**要下发**。本断言原来守的
  528. // 「井盖无契约位」已被后端推翻 —— 那是票 17 立、票 25 第八轮记的旧事实。
  529. INDUSTRY_CODES.manhole === 'MANHOLE',
  530. `gas → ${INDUSTRY_CODES.gas};water → ${INDUSTRY_CODES.water};drainage → ${INDUSTRY_CODES.drainage};manhole → ${INDUSTRY_CODES.manhole}(四个正式行业全部下发)`,
  531. )
  532. check(
  533. 'E2-only-all-and-unmapped-keys-send-nothing',
  534. industryQuery('all') === '' && industryQuery('nope') === '',
  535. '「全部」不传参;**认不出的键**也不传参(兜底:将来新增的前端选项若后端还没有这个码,不拿脏值去换 400)',
  536. )
  537. // 端到端:传行业后请求串里带上后端码;传 all 时不带。
  538. let seenPath = ''
  539. registerMockTransport('/infrastructure/base-archives', (context) => {
  540. seenPath = context.path
  541. return { totals: { facilityCount: 1 }, industryDistribution: [{ code: 'GAS', count: 1 }], regionDistribution: [], ageDistribution: [], ageReference: {}, facilityTypeDistribution: [], selectedFilters: {}, excluded: {} }
  542. })
  543. const store = useInfrastructureStore()
  544. store.reset()
  545. store.configure({ config: mockConfig })
  546. await store.loadPanels({ industry: 'gas' })
  547. const withIndustry = seenPath
  548. await store.loadPanels({ industry: 'all' })
  549. const withoutIndustry = seenPath
  550. check(
  551. 'E3-industry-reaches-the-request',
  552. withIndustry.includes('industry=GAS') && !withoutIndustry.includes('industry='),
  553. `按行业取数时:${withIndustry};全部行业时:${withoutIndustry}`,
  554. )
  555. }
  556. // ================================================================ F 地图联动
  557. {
  558. const objects = [
  559. { kind: 'monitoring-point', code: 'MP-001', name: '供水干管监测点', longitude: 110.141, latitude: 28.452, coordinate: { renderable: true, validated: true } },
  560. { kind: 'monitoring-point', code: 'MP-003', name: '污水排口监测点', longitude: 110.143, latitude: 28.454, coordinate: { renderable: false, validated: true, reason: 'UNVERIFIED_COORDINATE' } },
  561. ]
  562. check(
  563. 'F1-find-object-by-point-code',
  564. findMapObject(objects, 'MP-001').name === '供水干管监测点' && findMapObject(objects, 'NOPE') === null && findMapObject(objects, '') === null,
  565. '按点位编码回联已加载的地图对象;找不到回 null(不猜)',
  566. )
  567. check(
  568. 'F2-locate-ok-path',
  569. planLocate({ objects, pointCode: 'MP-001', mapReady: true }).result === LOCATE_RESULT.OK &&
  570. locateCameraTarget(planLocate({ objects, pointCode: 'MP-001' }).object).lon === 110.141,
  571. '可上图且地图就绪 → 给出相机目标',
  572. )
  573. check(
  574. 'F3-locate-missing-vs-blocked-vs-unavailable',
  575. planLocate({ objects, pointCode: 'NOPE', mapReady: true }).result === LOCATE_RESULT.MISSING &&
  576. planLocate({ objects, pointCode: 'MP-003', mapReady: true }).result === LOCATE_RESULT.BLOCKED &&
  577. planLocate({ objects, pointCode: 'MP-001', mapReady: false }).result === LOCATE_RESULT.UNAVAILABLE,
  578. '三种"点不动"分得开:不在已加载范围 / 后端判定不可上图 / 地图未就绪',
  579. )
  580. check(
  581. 'F4-blocked-carries-readable-reason',
  582. planLocate({ objects, pointCode: 'MP-003' }).reasonText === '坐标未经校验',
  583. `不可上图的文案来自票 07 的同一张词表:${planLocate({ objects, pointCode: 'MP-003' }).reasonText}`,
  584. )
  585. // 坐标准入**只认后端 renderable**:前端不重算经纬度(票 07 的验收原句)。
  586. check(
  587. 'F5-admission-only-reads-backend-renderable',
  588. planLocate({ objects: [{ kind: 'monitoring-point', code: 'X', longitude: 999, latitude: 999, coordinate: { renderable: false, validated: true } }], pointCode: 'X' }).result === LOCATE_RESULT.BLOCKED &&
  589. planLocate({ objects: [{ kind: 'monitoring-point', code: 'Y', longitude: 999, latitude: 999, coordinate: { renderable: true, validated: true } }], pointCode: 'Y' }).result === LOCATE_RESULT.OK,
  590. '越界坐标只要后端说可上图就放行,合法坐标只要后端说不可上图就拦下 —— 前端不重算',
  591. )
  592. // store 的定位联动:三种结果各走一遍,只有成功那一次才动相机并开详情。
  593. const store = useInfrastructureStore()
  594. store.reset()
  595. const focusCalls = []
  596. const openCalls = []
  597. const deps = {
  598. objects,
  599. mapReady: true,
  600. focus: (target) => {
  601. focusCalls.push(target)
  602. return true
  603. },
  604. openPoint: (code) => {
  605. openCalls.push(code)
  606. },
  607. }
  608. const ok = await store.locatePoint('MP-001', deps)
  609. const blocked = await store.locatePoint('MP-003', deps)
  610. const missing = await store.locatePoint('NOPE', deps)
  611. check(
  612. 'F6-store-locate-only-flies-on-success',
  613. ok.result === LOCATE_RESULT.OK &&
  614. blocked.result === LOCATE_RESULT.BLOCKED &&
  615. missing.result === LOCATE_RESULT.MISSING &&
  616. focusCalls.length === 1 &&
  617. openCalls.length === 1 &&
  618. openCalls[0] === 'MP-001',
  619. `相机只被下发 ${focusCalls.length} 次、详情只被打开 ${openCalls.length} 次(不可上图与找不到都不动相机)`,
  620. )
  621. check(
  622. 'F7-store-reports-unavailable-when-camera-refuses',
  623. (await store.locatePoint('MP-001', { ...deps, focus: () => false })).result === LOCATE_RESULT.UNAVAILABLE,
  624. '相机没吃下指令时如实降级成"地图尚未就绪",不假装定位成功',
  625. )
  626. }
  627. // ================================================================ G 静态守卫
  628. {
  629. // 票 18:共享渲染层改名 `InfrastructurePanel.vue` → `PanelBlocks.vue`(解除模式耦合、
  630. // 吃算好的 view),分发改由 `domain/panelViews.js` 的注册表承担。票 17 要守的三条
  631. // 契约(组件不拼路径 / 零后端裸字段 / 零原因码)一字不变,只是文件换了名字。
  632. const panelVue = readVueCode('components/PanelBlocks.vue')
  633. const shellVue = readVueCode('CockpitShell.vue')
  634. const viewCode = readCode('domain/infrastructurePanels.js')
  635. const locateCode = readCode('domain/mapLocate.js')
  636. const storeCode = readCode('stores/infrastructureStore.js')
  637. const apiCode = readCode('services/infrastructureApi.js')
  638. check(
  639. 'G1-component-does-not-build-api-paths',
  640. !/\/infrastructure\//.test(panelVue) && !/fetch\(/.test(panelVue),
  641. '接口路径只在 services/infrastructureApi.js,组件不自己拼',
  642. )
  643. check(
  644. 'G2-component-has-no-backend-field-names',
  645. !/facilityCount|organizationCode|ownershipDistribution|pointCodes|metricCode|stateReason|emergencyResourceCount|hazardSourceCount|protectionTargetCount|industryDistribution|areaDistribution/.test(panelVue) &&
  646. !/facilityCount|organizationCode|ownershipDistribution|pointCodes|metricCode|emergencyResourceCount/.test(shellVue),
  647. '组件只读视图对象字段(后端裸字段名只出现在 domain 适配层)',
  648. )
  649. // 后端原因码不许出现在界面上(票 05/11 的真机走查各逮到过一次)。
  650. const reasonCodes = ['READY', 'NO_OBJECTS', 'NO_METRICS', 'SAMPLE_CURRENT', 'SOURCE_UNAVAILABLE', 'TWO_UPDATE_CYCLES_MISSED', 'SUCCESS', 'FAILED', 'EMPTY', 'UNAVAILABLE']
  651. const leaked = reasonCodes.filter((token) => new RegExp(`\\b${token}\\b`).test(panelVue))
  652. check(
  653. 'G3-no-backend-reason-codes-in-ui',
  654. leaked.length === 0,
  655. leaked.length ? `组件里出现后端代号:${leaked.join(', ')}` : '组件零后端代号(都是 domain 翻好的人话)',
  656. )
  657. check(
  658. 'G4-domain-does-not-fetch-or-import-service',
  659. !/\bfetch\s*\(|XMLHttpRequest/.test(viewCode) &&
  660. !/from '\.\.\/services\//.test(viewCode) &&
  661. !/\bfetch\s*\(|XMLHttpRequest/.test(locateCode) &&
  662. !/from '\.\.\/services\//.test(locateCode),
  663. 'domain 纯函数层不发请求、不 import service(层次不倒挂)',
  664. )
  665. check(
  666. 'G5-store-has-no-raw-fields-or-paths',
  667. !/\/infrastructure\//.test(storeCode) && !/facilityCount|organizations|hazardSources/.test(storeCode),
  668. 'store 只转发分区,不改名也不拼路径',
  669. )
  670. check(
  671. 'G6-api-has-no-credential',
  672. !/Authorization|Bearer|api\.token/.test(apiCode),
  673. '凭据只在 services/cockpitApi.js 附加(票 04 的接缝未被绕过)',
  674. )
  675. check(
  676. 'G7-no-write-operations',
  677. !/method:\s*'(POST|PUT|PATCH|DELETE)'/.test(apiCode) && !/@click="[^"]*(save|submit|update|delete)/i.test(panelVue),
  678. '驾驶舱只读:本票不引入任何写操作',
  679. )
  680. check(
  681. 'G8-shell-routes-four-panels-through-one-seam',
  682. /panelViewOf\(panelId\)/.test(shellVue) &&
  683. /PanelBlocks/.test(shellVue) &&
  684. /PanelPlaceholder v-else/.test(shellVue),
  685. '四块走共享的面板渲染层(分发归 domain/panelViews.js),其余面板仍是占位(占位文案是"哪几块还没接数据"的信号)',
  686. )
  687. check(
  688. 'G9-locate-seam-is-shared-not-per-panel',
  689. /planLocate/.test(locateCode) && /isRenderable/.test(locateCode) && /blockReason/.test(locateCode),
  690. '定位判定收在 domain/mapLocate.js 一处(复用语义来自票 07 的坐标准入)',
  691. )
  692. // 全局行业筛选必须**真的作用到面板数据上**(CONTEXT.md:它是作用于整个驾驶舱的全局条件),
  693. // 而不只是记在 store 里。跟随的判据是「industry 变了就按新值重取」,
  694. // 且只在取数就绪后跟随(抢跑会带着空配置发请求,票 04 的纪律)。
  695. // 就绪标记原叫 `mockReady`(只在 mock 分支置位),同构启动后两种数据源都会置位,
  696. // 已按语义改名为 `lifecycleReady` —— 守卫跟到新名,判据意图不变。
  697. check(
  698. 'G10-shell-follows-global-industry-filter',
  699. /watch\(industry,/.test(shellVue) &&
  700. /infrastructureStore\.loadPanels\(\{ industry: next \}\)/.test(shellVue) &&
  701. /lifecycleReady/.test(shellVue),
  702. '行业筛选变化 → 四块面板按新行业重取(配置就绪后才跟随)',
  703. )
  704. }
  705. const failed = checks.filter((item) => !item.pass)
  706. console.log('')
  707. console.log(`T17 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  708. if (failed.length) {
  709. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  710. process.exit(1)
  711. }