t11-equipment-detail.mjs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. /**
  2. * T11 设备详情聚合与分区状态 纯逻辑检查(票 11)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail.mjs
  6. *
  7. * 六段:
  8. * A 分区映射:后端四态 → 前端分区词汇;每个分区各带自己的 state/reason/message
  9. * B 视图层:六块面板计划、缺值占位符、空/失败/不可用的措辞、字段缺失清单
  10. * C store:按**设备编码**取数、换设备竞态收口、已答过的同一台不重复取、失败可重试
  11. * D 分区独立:一块失败不遮盖其它成功块(含对抗式反证:真的只坏了一块)
  12. * E 最新图片:只展示后端指定的那一张(结构化验证:前端没有"挑一张"的路径)
  13. * F 静态守卫:组件不拼路径、不认后端裸字段、不排序、不抢票 12/13 的活
  14. *
  15. * 契约依据:`CockpitEquipmentDetailAggregateResponse`(后端演示页
  16. * `equipment-detail-aggregate-demo.html`)—— 每个分区自带 `state/reason/message/data`。
  17. */
  18. import { readFileSync } from 'node:fs'
  19. import { createPinia, setActivePinia } from 'pinia'
  20. import {
  21. EQUIPMENT_SECTIONS,
  22. toEquipmentDetailAggregate,
  23. toDeviceImage,
  24. toSection,
  25. } from '../../../src/modules/cockpit/domain/equipmentDetail.js'
  26. import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js'
  27. import {
  28. DEFERRED_SECTIONS,
  29. EQUIPMENT_SECTION_PANELS,
  30. archiveRows,
  31. collectMissing,
  32. emptyListText,
  33. aggregateDeviceCode,
  34. aggregateUnavailable,
  35. equipmentDetailMatches,
  36. equipmentPanels,
  37. eventRows,
  38. imageView,
  39. metricRows,
  40. metricsSourceText,
  41. missingNote,
  42. quantityText,
  43. sectionHasContent,
  44. sectionPlaceholder,
  45. valueText,
  46. workOrderRows,
  47. } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
  48. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  49. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  50. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  51. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  52. const checks = []
  53. /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  54. function check(name, pass, evidence) {
  55. checks.push({ name, pass })
  56. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  57. }
  58. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  59. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  60. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  61. /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
  62. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  63. const readCode = (relative) => code(read(relative))
  64. /**
  65. * `.vue` 的注释是 `<!-- -->`(HTML),上面那个只剥 JS 注释的 `code()` 剥不掉 ——
  66. * 组件的抬头注释里**故意**写着 `FAILED / STALE / EMPTY` 这些后端词来解释语义,
  67. * 不剥就会把"解释"当成"泄漏"(本守卫第一版正是这么假红的)。
  68. */
  69. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  70. const readVueCode = (relative) => codeVue(read(relative))
  71. setActivePinia(createPinia())
  72. /** 造一份后端分区(`CockpitSectionResult`)。 */
  73. const rawSection = (state, { reason = 'SUCCESS', message = null, data = null } = {}) => ({ state, reason, message, data })
  74. /** 造一份后端聚合载荷;只给想给的分区(其余留空=后端没给,前端应算失败)。 */
  75. const rawAggregate = (deviceCode, sections = {}) => ({
  76. deviceCode,
  77. aggregateState: 'READY',
  78. serverTime: '2026-09-02T10:00:00+08:00',
  79. ...sections,
  80. })
  81. // ================================================================ A 分区映射
  82. {
  83. const aggregate = toEquipmentDetailAggregate({
  84. deviceCode: 'EQ-A',
  85. aggregateState: 'PARTIAL',
  86. archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }),
  87. metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-A', state: 'CURRENT', metrics: [] } }),
  88. warnings: rawSection('SUCCESS', { data: [] }),
  89. alarms: rawSection('EMPTY', { reason: 'NO_EVENTS' }),
  90. workOrders: rawSection('STALE', { reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] }),
  91. latestImage: rawSection('SUCCESS', { data: { url: '/cockpit/x.webp', capturedAt: '2026-08-30T10:20:00+08:00' } }),
  92. })
  93. check(
  94. 'A1-all-eight-sections-present',
  95. EQUIPMENT_SECTIONS.length === 8 && EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]),
  96. `${EQUIPMENT_SECTIONS.length} 个分区都有位置(后端没给的也留位)`,
  97. )
  98. check(
  99. 'A2-backend-state-mapped-1to1',
  100. aggregate.sections.archive.state === SECTION_STATE.FAILED &&
  101. aggregate.sections.metrics.state === SECTION_STATE.SUCCESS &&
  102. aggregate.sections.alarms.state === SECTION_STATE.EMPTY &&
  103. aggregate.sections.workOrders.state === SECTION_STATE.STALE,
  104. JSON.stringify({
  105. archive: aggregate.sections.archive.state,
  106. metrics: aggregate.sections.metrics.state,
  107. alarms: aggregate.sections.alarms.state,
  108. workOrders: aggregate.sections.workOrders.state,
  109. }),
  110. )
  111. check(
  112. 'A3-failed-section-keeps-backend-message',
  113. aggregate.sections.archive.reason === '档案读不到',
  114. `界面文案取后端 message:「${aggregate.sections.archive.reason}」`,
  115. )
  116. check(
  117. 'A4-stale-keeps-value-and-flag',
  118. aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1',
  119. '过期分区保留最后已知值,并带 retained 标识(票 16:不得伪装成实时)',
  120. )
  121. check(
  122. 'A5-empty-has-no-data',
  123. aggregate.sections.alarms.data === null,
  124. '空分区不伪造空数组冒充数据',
  125. )
  126. check(
  127. 'A6-missing-section-counts-as-failed',
  128. aggregate.sections.timelines.state === SECTION_STATE.FAILED,
  129. '后端整段缺失的分区算失败,不算「成功但没内容」',
  130. )
  131. check(
  132. 'A7-aggregate-state-not-authoritative',
  133. aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS,
  134. '界面分区状态以各分区自己为准,整体态漏标也不会拖垮成功分区',
  135. )
  136. check(
  137. 'A8-unknown-state-falls-to-failed',
  138. toSection('archive', { state: 'SOMETHING_NEW', data: { deviceCode: 'X' } }).state === SECTION_STATE.FAILED,
  139. '认不出的后端状态不静默混进「已就绪」',
  140. )
  141. }
  142. // ================================================================ B 视图层
  143. {
  144. // 缺值 vs 真 0:这是票 16 与票 11 验收第 4 条的交汇点。
  145. check(
  146. 'B1-zero-is-a-value-not-missing',
  147. valueText(0) === '0' && valueText(18.2) === '18.2' && quantityText(0, 'm³/h') === '0 m³/h',
  148. '后端真给的 0 是有效读数,照原样显示',
  149. )
  150. check(
  151. 'B2-missing-shows-placeholder',
  152. valueText(null) === DETAIL_PLACEHOLDER &&
  153. valueText(undefined) === DETAIL_PLACEHOLDER &&
  154. valueText('') === DETAIL_PLACEHOLDER &&
  155. quantityText(null, 'mg/L') === DETAIL_PLACEHOLDER,
  156. `缺值显示占位符(${DETAIL_PLACEHOLDER}),且不拼出「— mg/L」这种半截读数`,
  157. )
  158. check(
  159. 'B3-placeholder-text-per-state',
  160. sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('没有数据') &&
  161. sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('加载失败') &&
  162. sectionPlaceholder({ state: SECTION_STATE.UNAVAILABLE }).includes('不可用') &&
  163. sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('其它分区') === false &&
  164. sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('其它分区') === true,
  165. '空 / 失败 / 不可用各有明确且不同的措辞(失败态明确说「不影响其它分区」)',
  166. )
  167. check(
  168. 'B4-stale-and-success-have-content',
  169. sectionHasContent({ state: SECTION_STATE.SUCCESS }) === true &&
  170. sectionHasContent({ state: SECTION_STATE.STALE }) === true &&
  171. sectionHasContent({ state: SECTION_STATE.EMPTY }) === false &&
  172. sectionHasContent({ state: SECTION_STATE.FAILED }) === false,
  173. '过期是有值(内容照画),空与失败才是没内容',
  174. )
  175. // 档案行:字段缺失显示占位符,不是空白、也不是 0。
  176. const archive = archiveRows({ code: 'EQ-1', name: '污水设备', assetValue: 128000, maintainer: '' })
  177. check(
  178. 'B5-archive-rows-placeholder',
  179. archive.find((row) => row.key === 'assetValue').value === '128000' &&
  180. archive.find((row) => row.key === 'maintainer').value === DETAIL_PLACEHOLDER &&
  181. archive.find((row) => row.key === 'model').value === DETAIL_PLACEHOLDER,
  182. '有值的照原样、缺值的占位符',
  183. )
  184. // 指标行:真 0 与缺值必须区分开(同一张表里)。
  185. const metrics = metricRows({
  186. metrics: [
  187. { code: 'FLOW', name: '流量', value: 0, unit: 'm³/h' },
  188. { code: 'COD', name: 'COD' },
  189. ],
  190. })
  191. check(
  192. 'B6-metric-zero-vs-missing',
  193. metrics[0].value === '0' && metrics[1].value === DETAIL_PLACEHOLDER,
  194. `真 0 → ${metrics[0].value};缺 value → ${metrics[1].value}`,
  195. )
  196. // 工单行:状态名缺失时按七态字典补;未结案的真实 null 显示占位符。
  197. const orders = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 4, realFinishTime: null }, { workOrderCode: 'WO-2', stateCode: 99 }])
  198. check(
  199. 'B7-work-order-state-from-dictionary',
  200. orders[0].stateName === '处理中' && orders[1].stateName === DETAIL_PLACEHOLDER,
  201. `码 4 → ${orders[0].stateName};认不出的码 99 → ${orders[1].stateName}(不猜)`,
  202. )
  203. check(
  204. 'B8-unclosed-order-placeholder',
  205. orders[0].realFinishTime === DETAIL_PLACEHOLDER,
  206. '未结案的真实 null 与字段缺失一样显示占位符',
  207. )
  208. // 事件行:等级只出文本,配色原样带出后端值(配色归票 13/19)。
  209. const events = eventRows([
  210. { warningNo: 'WRN-1', warningName: 'COD 预警', level: 2, levelColor: 'YELLOW', value: 18.2, unit: 'mg/L', eventTime: 'T' },
  211. { warningNo: 'WRN-2', warningName: '缺等级预警' },
  212. ])
  213. check(
  214. 'B9-event-level-and-color',
  215. events[0].levelText === '2 级' && events[0].levelColor === 'YELLOW' && events[0].quantity === '18.2 mg/L' && events[1].levelText === DETAIL_PLACEHOLDER,
  216. '等级出文本、配色如实带后端值、缺等级占位符',
  217. )
  218. // 字段缺失清单:要能穿透到嵌套层(单个指标自己记的 missing)。
  219. const missing = collectMissing({
  220. code: 'EQ-1',
  221. missing: ['archive.deviceName'],
  222. metrics: [{ code: 'COD', missing: ['metric.value'] }],
  223. })
  224. check(
  225. 'B10-missing-collected-recursively',
  226. missing.includes('archive.deviceName') && missing.includes('metric.value') && missing.length === 2,
  227. `嵌套缺失也收上来:${missing.join(', ')}`,
  228. )
  229. check(
  230. 'B11-missing-collector-survives-cycles',
  231. collectMissing((() => {
  232. const node = { missing: ['self'] }
  233. node.self = node
  234. return node
  235. })())[0] === 'self',
  236. '自引用形状不炸(契约缺口造出的怪数据也不能让界面挂掉)',
  237. )
  238. // 给人读的缺失说明:只报条数,字段路径进 tooltip。
  239. // 票 05 的真机走查逮到过「界面漏出内部代号」,所以这条是**回归**,不是新要求。
  240. const note = missingNote({ missing: ['archive.deviceName', 'metric.value'] })
  241. check(
  242. 'B11b-missing-note-hides-field-paths',
  243. note.count === 2 &&
  244. note.text === '后端未提供 2 项字段' &&
  245. !note.text.includes('archive.') &&
  246. note.tooltip.includes('archive.deviceName'),
  247. `界面文案「${note.text}」不含字段路径;路径只在 tooltip:「${note.tooltip}」`,
  248. )
  249. // 指标来源文案:认得出的翻人话,认不出的**回空串**(绝不把 SAMPLE_OUTDATED 这类代号画上去)。
  250. check(
  251. 'B11c-source-reason-never-leaks-codes',
  252. metricsSourceText({ stateReason: 'READY' }) === '' &&
  253. metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' }) === '采样已超过配置周期' &&
  254. metricsSourceText({ stateReason: 'SOMETHING_NEW' }) === '' &&
  255. !metricsSourceText({ stateReason: 'SOMETHING_NEW' }).includes('SOMETHING_NEW'),
  256. `SAMPLE_OUTDATED → 「${metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' })}」;未知码 → 空串(不泄漏)`,
  257. )
  258. // 空列表提示:SUCCESS 但列表为空,仍是明确 UI 而不是一片空白。
  259. check(
  260. 'B12-empty-list-is-explicit',
  261. emptyListText('工单') === '工单当前没有记录',
  262. `「${emptyListText('工单')}」`,
  263. )
  264. }
  265. // ================================================================ C store 取数与竞态
  266. {
  267. const store = useDetailStore()
  268. store.reset()
  269. store.configure({ config: mockConfig })
  270. let aggregateRequests = 0
  271. resetMockTransportsForTest()
  272. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  273. // 计数用:精确路径优先于 `/equipments/*/detail-aggregate` 模板。
  274. registerMockTransport('/equipments/EQ-COUNT/detail-aggregate', () => {
  275. aggregateRequests += 1
  276. return rawAggregate('EQ-COUNT', {
  277. archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', deviceName: '计数设备' } }),
  278. metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', state: 'CURRENT', metrics: [] } }),
  279. })
  280. })
  281. const selection = await store.selectDevice('EQ-COUNT')
  282. check(
  283. 'C1-select-device-loads-by-code',
  284. selection.level === 'equipment' &&
  285. selection.deviceCode === 'EQ-COUNT' &&
  286. store.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS &&
  287. store.sections.equipmentAggregate.loaded === true &&
  288. aggregateRequests === 1,
  289. `层级 ${selection.level}、请求 ${aggregateRequests} 次`,
  290. )
  291. check(
  292. 'C2-aggregate-carries-requested-code',
  293. store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT',
  294. '按设备编码取数(传错编码能看出来)',
  295. )
  296. // 已答过的同一台设备:不再重复取数。
  297. await store.selectDevice('EQ-COUNT')
  298. check(
  299. 'C3-same-device-does-not-refetch',
  300. aggregateRequests === 1,
  301. `再选同一台后请求数仍为 ${aggregateRequests}`,
  302. )
  303. // 返回点位级再点回来:仍不重复取数(已加载且是同一台)。
  304. store.selectDevice('EQ-COUNT')
  305. store.backToPoint()
  306. await store.selectDevice('EQ-COUNT')
  307. check(
  308. 'C4-back-and-forth-does-not-refetch',
  309. aggregateRequests === 1 && store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT',
  310. `来回一次后请求数仍为 ${aggregateRequests}`,
  311. )
  312. // 换设备:A 慢、B 快 —— B 必须赢,A 迟到不得覆盖。
  313. let releaseA = null
  314. registerMockTransport('/equipments/EQ-SLOW-A/detail-aggregate', () => new Promise((resolve) => {
  315. releaseA = () => resolve(rawAggregate('EQ-SLOW-A', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-SLOW-A', deviceName: 'A 设备' } }) }))
  316. }))
  317. registerMockTransport('/equipments/EQ-FAST-B/detail-aggregate', () =>
  318. rawAggregate('EQ-FAST-B', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FAST-B', deviceName: 'B 设备' } }) }),
  319. )
  320. const pendingA = store.selectDevice('EQ-SLOW-A')
  321. await store.selectDevice('EQ-FAST-B')
  322. const afterB = store.sections.equipmentAggregate.data.deviceCode
  323. releaseA()
  324. await pendingA
  325. await new Promise((resolve) => setTimeout(resolve, 0))
  326. check(
  327. 'C5-race-latest-wins',
  328. afterB === 'EQ-FAST-B' &&
  329. store.selection.deviceCode === 'EQ-FAST-B' &&
  330. store.sections.equipmentAggregate.data.deviceCode === 'EQ-FAST-B',
  331. `B 落地后聚合里是 ${store.sections.equipmentAggregate.data.deviceCode}(A 迟到被丢弃)`,
  332. )
  333. check(
  334. 'C6-stale-aggregate-not-shown-for-new-device',
  335. equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-SLOW-A') === false &&
  336. equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-FAST-B') === true,
  337. 'interface 的匹配判据挡住了「把 A 的载荷挂在 B 表头下」',
  338. )
  339. // 失败留恢复路径:第一次抛错、第二次成功(同一个入口重试)。
  340. let flakyAttempts = 0
  341. resetMockTransportsForTest()
  342. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  343. registerMockTransport('/equipments/EQ-FLAKY/detail-aggregate', () => {
  344. flakyAttempts += 1
  345. if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败')
  346. return rawAggregate('EQ-FLAKY', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FLAKY', deviceName: '重试设备' } }) })
  347. })
  348. const retryStore = useDetailStore()
  349. retryStore.reset()
  350. retryStore.configure({ config: mockConfig })
  351. await retryStore.selectDevice('EQ-FLAKY')
  352. const firstState = retryStore.sections.equipmentAggregate.state
  353. await retryStore.selectDevice('EQ-FLAKY')
  354. check(
  355. 'C7-failed-device-can-be-retried',
  356. firstState === SECTION_STATE.FAILED &&
  357. flakyAttempts === 2 &&
  358. retryStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS,
  359. `第一次 ${firstState} → 再选一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.equipmentAggregate.state}`,
  360. )
  361. // 关闭详情:在途请求作废,选择回空。
  362. const closeStore = useDetailStore()
  363. closeStore.reset()
  364. closeStore.configure({ config: mockConfig })
  365. const inFlight = closeStore.selectDevice('EQ-SLOW-A')
  366. const closed = closeStore.closeDetail()
  367. await inFlight
  368. check(
  369. 'C8-close-invalidates-in-flight',
  370. closed.level === 'none' && closeStore.selection.deviceCode === '' && closeStore.loading === false && closeStore.deviceLoading === false,
  371. JSON.stringify({ level: closed.level, deviceLoading: closeStore.deviceLoading }),
  372. )
  373. // 未关联设备直接进设备级:同样按编码取数(没有点位摘要这一层)。
  374. const deviceStore = useDetailStore()
  375. deviceStore.reset()
  376. deviceStore.configure({ config: mockConfig })
  377. registerMockTransport('/equipments/EQ-FREE-9/detail-aggregate', () =>
  378. rawAggregate('EQ-FREE-9', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FREE-9', deviceName: '未关联设备' } }) }),
  379. )
  380. await deviceStore.openDevice('EQ-FREE-9')
  381. check(
  382. 'C9-unassociated-device-loads-aggregate',
  383. deviceStore.selection.pointCode === '' &&
  384. deviceStore.selection.deviceCode === 'EQ-FREE-9' &&
  385. deviceStore.sections.equipmentAggregate.loaded === true,
  386. JSON.stringify(deviceStore.selection),
  387. )
  388. // 没有编码:不发请求,也不把分区卡在加载中。
  389. const blankStore = useDetailStore()
  390. blankStore.reset()
  391. blankStore.configure({ config: mockConfig })
  392. await blankStore.selectDevice('')
  393. check(
  394. 'C10-blank-code-does-not-load',
  395. blankStore.sections.equipmentAggregate.loaded === false && blankStore.deviceLoading === false,
  396. '缺编码不取数、不卡加载态',
  397. )
  398. // 预载(loadSharedSections)与用户进级抢同一个分区:慢的那份不得覆盖快的那份。
  399. // 场景是外壳挂载时预载演示设备,用户抢先点了另一台 —— 预载的迟到响应必须被丢弃。
  400. resetMockTransportsForTest()
  401. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  402. let releasePreload = null
  403. registerMockTransport('/equipments/TC-PS-WS-202609020012/detail-aggregate', () => new Promise((resolve) => {
  404. releasePreload = () => resolve(rawAggregate('TC-PS-WS-202609020012', { archive: rawSection('SUCCESS', { data: { deviceCode: 'TC-PS-WS-202609020012', deviceName: '预载演示设备' } }) }))
  405. }))
  406. const raceStore = useDetailStore()
  407. raceStore.reset()
  408. raceStore.configure({ config: mockConfig })
  409. const preloading = raceStore.loadSharedSections({ config: mockConfig })
  410. await raceStore.selectDevice('EQ-USER')
  411. const userCode = raceStore.sections.equipmentAggregate.data.deviceCode
  412. releasePreload()
  413. await preloading
  414. await new Promise((resolve) => setTimeout(resolve, 0))
  415. check(
  416. 'C11-preload-does-not-clobber-user-device',
  417. userCode === 'EQ-USER' && raceStore.sections.equipmentAggregate.data.deviceCode === 'EQ-USER',
  418. `用户选的 ${userCode} 没被预载的演示设备覆盖(预载迟到被丢弃)`,
  419. )
  420. // 整包失败时的界面判据:它要说"这台没取到",而不是逐块说"其它分区不受影响"。
  421. // 用真 store 走一遍失败,断言判据在这一刻成立、且重试入口能恢复。
  422. let downAttempts = 0
  423. resetMockTransportsForTest()
  424. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  425. registerMockTransport('/equipments/EQ-DOWN/detail-aggregate', () => {
  426. downAttempts += 1
  427. if (downAttempts === 1) throw new Error('mock: 整包读取失败')
  428. return rawAggregate('EQ-DOWN', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-DOWN', deviceName: '恢复设备' } }) })
  429. })
  430. const downStore = useDetailStore()
  431. downStore.reset()
  432. downStore.configure({ config: mockConfig })
  433. await downStore.selectDevice('EQ-DOWN')
  434. const downSection = downStore.sections.equipmentAggregate
  435. check(
  436. 'C12-whole-failure-shows-single-truthful-state',
  437. downSection.state === SECTION_STATE.FAILED &&
  438. aggregateUnavailable(downSection) === true &&
  439. downSection.data === null,
  440. '整包失败:判据成立(界面据此只说一句实话,不逐块谎报"其它分区不受影响")',
  441. )
  442. // 重试入口:再点一次同一台即可恢复(失败不算"已答过")。
  443. await downStore.selectDevice('EQ-DOWN')
  444. check(
  445. 'C13-whole-failure-retry-recovers',
  446. downAttempts === 2 &&
  447. downStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS &&
  448. aggregateUnavailable(downStore.sections.equipmentAggregate) === false,
  449. `重试后第 ${downAttempts} 次请求 → ${downStore.sections.equipmentAggregate.state}(界面回到六块分区)`,
  450. )
  451. }
  452. // ================================================================ D 分区独立(核心验收)
  453. {
  454. // 真机走同一份契约:`equipment-partial` 场景里有三块分区各自失败、三块照常成功。
  455. const store = useDetailStore()
  456. store.reset()
  457. store.configure({ config: mockConfig })
  458. resetMockTransportsForTest()
  459. registerCockpitMocks({ scenario: 'equipment-partial', handlers: { registerMockTransport, resetMockTransportsForTest } })
  460. await store.selectDevice('TC-PS-WS-202609020012')
  461. const panels = equipmentPanels(store.sections.equipmentAggregate)
  462. const byName = Object.fromEntries(panels.map((panel) => [panel.name, panel]))
  463. check(
  464. 'D1-six-panels-in-order',
  465. panels.length === 6 &&
  466. panels.map((panel) => panel.name).join(',') === 'archive,metrics,warnings,alarms,workOrders,latestImage',
  467. `${panels.length} 块:${panels.map((panel) => panel.label).join(' / ')}`,
  468. )
  469. check(
  470. 'D2-one-failure-does-not-cover-others',
  471. byName.archive.section.state === SECTION_STATE.FAILED &&
  472. byName.metrics.section.state === SECTION_STATE.SUCCESS &&
  473. byName.warnings.section.state === SECTION_STATE.SUCCESS &&
  474. byName.alarms.section.state === SECTION_STATE.SUCCESS &&
  475. byName.workOrders.section.state === SECTION_STATE.FAILED &&
  476. byName.latestImage.section.state === SECTION_STATE.FAILED,
  477. `档案=${byName.archive.section.state} 指标=${byName.metrics.section.state} 预警=${byName.warnings.section.state} 报警=${byName.alarms.section.state} 工单=${byName.workOrders.section.state} 图片=${byName.latestImage.section.state}`,
  478. )
  479. check(
  480. 'D3-successful-panels-still-render-content',
  481. byName.metrics.hasContent === true &&
  482. byName.metrics.section.data.metrics.length > 0 &&
  483. byName.warnings.hasContent === true &&
  484. byName.warnings.section.data.length > 0,
  485. `指标 ${byName.metrics.section.data.metrics.length} 条、预警 ${byName.warnings.section.data.length} 条仍然画得出来`,
  486. )
  487. check(
  488. 'D4-failed-panels-say-why',
  489. byName.archive.hasContent === false &&
  490. byName.archive.placeholder.includes('加载失败') &&
  491. byName.archive.section.reason === '设备档案数据暂不可用',
  492. `失败块显示「${byName.archive.placeholder}」并带上后端原因`,
  493. )
  494. // 对抗式反证:换一个**整体成功**的场景,同样的判据必须全绿 ——
  495. // 否则上面那条 D2 可能只是因为"所有块都失败了"而恒真。
  496. resetMockTransportsForTest()
  497. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  498. const allGoodStore = useDetailStore()
  499. allGoodStore.reset()
  500. allGoodStore.configure({ config: mockConfig })
  501. await allGoodStore.selectDevice('TC-PS-WS-202609020012')
  502. const allGood = equipmentPanels(allGoodStore.sections.equipmentAggregate)
  503. check(
  504. 'D5-guard-has-teeth',
  505. allGood.every((panel) => panel.section.state === SECTION_STATE.SUCCESS) &&
  506. allGood.every((panel) => panel.hasContent === true) &&
  507. allGood.length === 6,
  508. '成功场景下六块全绿(因此 D2 的"部分失败"是有信息量的,不是恒真)',
  509. )
  510. // 空场景:六块都该说「没有数据」,而不是显示虚假零值。
  511. resetMockTransportsForTest()
  512. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  513. const emptyStore = useDetailStore()
  514. emptyStore.reset()
  515. emptyStore.configure({ config: mockConfig })
  516. await emptyStore.selectDevice('TC-PS-WS-202609020012')
  517. const emptyPanels = equipmentPanels(emptyStore.sections.equipmentAggregate)
  518. check(
  519. 'D6-empty-scenario-all-placeholders',
  520. emptyPanels.every((panel) => panel.section.state === SECTION_STATE.EMPTY && panel.hasContent === false) &&
  521. emptyPanels.every((panel) => panel.placeholder === '该分区当前没有数据'),
  522. '空场景六块都是明确占位说明,没有一块冒充有数据',
  523. )
  524. // 过期场景:指标块过期但有值,其它块照常成功(过期不牵连)。
  525. resetMockTransportsForTest()
  526. registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
  527. const staleStore = useDetailStore()
  528. staleStore.reset()
  529. staleStore.configure({ config: mockConfig })
  530. await staleStore.selectDevice('TC-PS-WS-202609020012')
  531. const stalePanels = Object.fromEntries(equipmentPanels(staleStore.sections.equipmentAggregate).map((panel) => [panel.name, panel]))
  532. check(
  533. 'D7-stale-is-value-not-empty',
  534. stalePanels.metrics.section.state === SECTION_STATE.STALE &&
  535. stalePanels.metrics.section.retained === true &&
  536. stalePanels.metrics.hasContent === true &&
  537. stalePanels.metrics.section.data.metrics.length > 0 &&
  538. stalePanels.archive.section.state === SECTION_STATE.SUCCESS,
  539. `指标块 ${stalePanels.metrics.section.state} 但仍画出 ${stalePanels.metrics.section.data.metrics.length} 条读数;档案块照常 ${stalePanels.archive.section.state}`,
  540. )
  541. }
  542. // ================================================================ D2 整包失败 ≠ 分区级失败
  543. {
  544. // 这两件事必须分开,否则界面会对值班人员撒谎:
  545. // · 整包失败(端点读不到,data 为 null)→ 六块一起没数据,「其它分区不受影响」是假话;
  546. // · 分区级失败(data 在、各分区自带 state)→ 真正的部分失败,六块照常渲染。
  547. const wholeFailed = { state: SECTION_STATE.FAILED, data: null, loaded: true, reason: 'unreachable' }
  548. const partialData = toEquipmentDetailAggregate({
  549. deviceCode: 'EQ-P',
  550. aggregateState: 'PARTIAL',
  551. archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }),
  552. metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-P', state: 'CURRENT', metrics: [{ metricCode: 'COD', value: 1 }] } }),
  553. })
  554. const partial = { state: SECTION_STATE.SUCCESS, data: partialData, loaded: true }
  555. check(
  556. 'D8-whole-failure-detected',
  557. aggregateUnavailable(wholeFailed) === true && aggregateUnavailable(partial) === false,
  558. '整包失败与分区级失败分得开',
  559. )
  560. check(
  561. 'D9-partial-failure-still-renders-panels',
  562. aggregateUnavailable(partial) === false && equipmentPanels(partial).length === 6,
  563. `分区级失败仍渲染六块(档案=${equipmentPanels(partial)[0].section.state}、指标=${equipmentPanels(partial)[1].section.state})`,
  564. )
  565. // 未加载(还没请求)既不是失败也不是就绪:不该显示"这台设备详情没取到"。
  566. check(
  567. 'D10-not-loaded-is-not-a-failure',
  568. aggregateUnavailable({ state: SECTION_STATE.EMPTY, data: null, loaded: false }) === false &&
  569. aggregateUnavailable(null) === false,
  570. '还没取数时不报"没取到"',
  571. )
  572. // 整包失败**无法**从载荷里认出是哪台设备(data 为 null、带不出编码)——
  573. // 所以判定刻意不比对编码,而靠 store 的 token 纪律保证「失败态必定属于当前这台」。
  574. // 这条断言把这个前提钉住:一旦有人让失败分区带上编码,就该重新审视上面的推理。
  575. check(
  576. 'D11-whole-failure-carries-no-device-code',
  577. aggregateDeviceCode(wholeFailed) === '' && equipmentDetailMatches(wholeFailed, 'EQ-P') === false,
  578. '失败分区带不出设备编码(故判定不依赖编码;靠 token 纪律保证归属当前设备)',
  579. )
  580. }
  581. // ================================================================ E 最新图片只展示一张
  582. {
  583. const one = imageView({ url: '/cockpit/a.webp', capturedAt: 'T' })
  584. check(
  585. 'E1-image-shows-backend-designated-one',
  586. one.hasImage === true && one.url === '/cockpit/a.webp',
  587. `画的是后端给的那一张:${one.url}`,
  588. )
  589. check(
  590. 'E2-image-empty-and-missing-safe',
  591. imageView(null).hasImage === false && imageView({}).hasImage === false && imageView({ url: ' ' }).hasImage === false,
  592. '后端没给 / 给了空串都不画空图',
  593. )
  594. check(
  595. 'E3-image-never-indexes-a-list',
  596. imageView([{ url: '/a.webp' }, { url: '/b.webp' }]).hasImage === false,
  597. '后端若给数组,前端**不**挑第一张(挑图=替后端做业务判断)',
  598. )
  599. check(
  600. 'E4-adapter-image-is-single-value',
  601. toDeviceImage({ url: '/cockpit/a.webp' }).url === '/cockpit/a.webp' &&
  602. (() => {
  603. const archive = toSection('archive', rawSection('SUCCESS', { data: { deviceCode: 'EQ-1', latestImage: { url: '/cockpit/a.webp' } } }))
  604. return !!archive.data.image && !Array.isArray(archive.data.image) && archive.data.image.url === '/cockpit/a.webp'
  605. })(),
  606. '档案里的 latestImage 是单值对象,不是列表',
  607. )
  608. // 静态守卫:渲染层不该出现任何"从多张里挑一张"的代码。
  609. const viewCode = readCode('domain/equipmentDetailView.js')
  610. const componentCode = readCode('components/EquipmentDetailSections.vue')
  611. check(
  612. 'E5-no-picking-among-images',
  613. !/\.find\(|\.filter\(|\.sort\(|\.slice\(/.test(viewCode.split('export function imageView')[1].split('export function')[0] || '') &&
  614. !/latestImage\s*\[|\bimages\b/.test(componentCode),
  615. '图片路径里没有 find/filter/sort/slice,组件也不认数组',
  616. )
  617. }
  618. // ================================================================ F 静态守卫
  619. {
  620. const componentCode = readVueCode('components/EquipmentDetailSections.vue')
  621. const cardCode = readVueCode('components/PointDetailCard.vue')
  622. const viewCode = readCode('domain/equipmentDetailView.js')
  623. const storeCode = readCode('stores/detailStore.js')
  624. check(
  625. 'F1-component-does-not-build-api-paths',
  626. !/\/equipments\/|\/monitoring-points\//.test(componentCode) && !/fetch\(/.test(componentCode),
  627. '接口路径只在 services/detailApi.js,组件不自己拼',
  628. )
  629. check(
  630. 'F2-component-has-no-backend-field-names',
  631. !/deviceModel|deviceSpec|ownershipUnit|maintainerPhone|expectedServiceYears|metricCode|warningNo|alarmId|workOrderCode|latestImage|aggregateState|stateReason/.test(componentCode),
  632. '组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)',
  633. )
  634. check(
  635. 'F3-component-does-not-sort',
  636. !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(viewCode),
  637. '展示顺序由计划与后端顺序决定,前端不重排',
  638. )
  639. check(
  640. 'F4-section-state-comes-from-section-state-module',
  641. /sectionView/.test(readCode('components/SectionStateTag.vue')) && !/is-lv1|#ff4d5e/.test(componentCode),
  642. '分区状态色键与文案只来自 domain/sectionState.js(组件不写第二套)',
  643. )
  644. // 票 05 的真机走查逮到过「界面漏出内部原因码」(`unavailable` 那一次)。
  645. // 设备详情多了一个同类风险面:后端 `stateReason` 与字段缺失清单都是接口词汇,
  646. // 直接铺到大屏上就是给值班人员看噪音。所以两类词汇都必须在 domain 层翻掉,
  647. // 组件里**不许**出现任何后端代号(当作词匹配,不要求带引号 ——
  648. // 真正会漏到界面上的形态恰恰是模板里的裸文本 `<p>SAMPLE_OUTDATED</p>`,
  649. // 只查引号字面量会放过它;这条守卫第一版就是这么假绿的)。
  650. const reasonCodeLiterals = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY']
  651. const leaked = reasonCodeLiterals.filter((code) => new RegExp(`\\b${code}\\b`).test(componentCode))
  652. check(
  653. 'F4b-no-backend-reason-codes-in-ui',
  654. leaked.length === 0,
  655. leaked.length ? `组件里出现后端代号:${leaked.join(', ')}` : '组件零后端代号(都是 domain 翻好的人话)',
  656. )
  657. // 字段缺失清单同理:组件只画条数,字段路径不许出现在可见文案里。
  658. check(
  659. 'F4c-no-field-paths-in-visible-text',
  660. !/\{\{\s*panel\.missing\.fields/.test(componentCode) &&
  661. !/\{\{\s*panel\.missing\.join/.test(componentCode) &&
  662. /:title="panel\.missing\.tooltip"/.test(componentCode),
  663. '缺失只报条数,字段路径只进 title(排查拿得到,值班人员看不到)',
  664. )
  665. check(
  666. 'F5-deferred-sections-not-rendered',
  667. DEFERRED_SECTIONS.every((name) => !EQUIPMENT_SECTION_PANELS.some((panel) => panel.name === name)) &&
  668. DEFERRED_SECTIONS.every((name) => !new RegExp(name).test(componentCode)),
  669. `时间线(票 12)与预警-报警关联(票 13)不在本票渲染计划内:${DEFERRED_SECTIONS.join(', ')}`,
  670. )
  671. check(
  672. 'F6-store-loads-aggregate-on-level-entry',
  673. /export function fetchEquipmentDetailAggregate/.test(readCode('services/detailApi.js')) &&
  674. /fetchEquipmentDetailAggregate\(deviceCode/.test(storeCode) &&
  675. /async function enterDeviceLevel/.test(storeCode),
  676. '设备级入口(selectDevice / openDevice)自己负责取数,调用方不必记得多调一次',
  677. )
  678. check(
  679. 'F7-card-does-not-hold-its-own-aggregate-state',
  680. /useDetailStore/.test(cardCode) && /EquipmentDetailSections/.test(cardCode) && !/reactive\(|ref\(/.test(cardCode.split('<script setup>')[1] || ''),
  681. '组件把状态交给 store,自己不存一份(票 10 的 F6 立场延续)',
  682. )
  683. check(
  684. 'F8-no-write-operations',
  685. !/method:\s*'(POST|PUT|PATCH|DELETE)'|method:\s*"(POST|PUT|PATCH|DELETE)"/.test(readCode('services/detailApi.js')) &&
  686. !/@click="[^"]*(save|submit|update|delete)/i.test(componentCode),
  687. '驾驶舱只读:本票不引入任何写操作(spec 验收第 6 条)',
  688. )
  689. }
  690. const failed = checks.filter((item) => !item.pass)
  691. console.log('')
  692. console.log(`T11 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  693. if (failed.length) {
  694. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  695. process.exit(1)
  696. }