t10-point-detail.mjs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. /**
  2. * T10 监测点详情与设备卡片 纯逻辑检查(票 10)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t10-point-detail.mjs
  6. *
  7. * 六段:
  8. * A 两级状态机:地图对象 → 选择 → 点位级 → 设备级 → 返回/关闭(纯函数,不取数、不碰地图)
  9. * B 点位摘要与设备卡片视图:名称/编码/状态/原因/关联设备数量;**卡片顺序原样来自后端**
  10. * C store:按编码取数、同一目标不重复取数、A→B 竞态收口、选设备不取数、失败不白屏
  11. * D 关联设备不重复上图:地图对象与标记计划在整条详情流程里一个字节不变(含对抗式反证)
  12. * E 点选手势与拾取边界:拖拽不点选、只认左键、只拾点位标记
  13. * F 静态守卫:组件不拼接口路径、不认后端裸字段、不对设备卡片排序
  14. *
  15. * 契约依据:CONTEXT.md「两级详情」(先点位摘要+设备卡片,再选设备进设备详情)与
  16. * `CockpitMonitoringPointDetailResponse` 的字段(`domain/pointDetail.js` 已按它适配)。
  17. */
  18. import { readFileSync } from 'node:fs'
  19. import { createPinia, setActivePinia } from 'pinia'
  20. import {
  21. DETAIL_LEVEL,
  22. DETAIL_PLACEHOLDER,
  23. associatedDeviceCount,
  24. backToPoint,
  25. closeDetail,
  26. deviceCardOf,
  27. deviceCards,
  28. emptyDetailSelection,
  29. isSamePointTarget,
  30. openDevice,
  31. openPoint,
  32. pointSummary,
  33. reasonText,
  34. selectionFromMapObject,
  35. statusView,
  36. } from '../../../src/modules/cockpit/domain/detailFlow.js'
  37. import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
  38. import { POINT_STATUS_VISUALS, UNKNOWN_VISUAL, planPointMarkers } from '../../../src/modules/cockpit/domain/pointStatus.js'
  39. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  40. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  41. import { CLICK_MAX_DRAG_PX, attachPointPick, isClickGesture } from '../../../src/modules/cockpit/services/pointPick.js'
  42. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  43. import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  44. const checks = []
  45. function check(name, pass, evidence) {
  46. checks.push({ name, pass })
  47. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  48. }
  49. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  50. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  51. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  52. /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
  53. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  54. const readCode = (relative) => code(read(relative))
  55. /** 造一张设备卡片(后端 `CockpitMonitoringPointDeviceCard`)。 */
  56. const rawDevice = ({ code: deviceCode = 'EQ-001', name = '供水压力计-001', typeName = '压力计', status = 'ALARM', label = '报警', reasons = [] } = {}) => ({
  57. deviceCode,
  58. deviceName: name,
  59. deviceTypeName: typeName,
  60. deviceStatus: status,
  61. statusLabel: label,
  62. statusReasons: reasons,
  63. statusTime: '2026-09-02T09:59:00+08:00',
  64. })
  65. /** 造一个后端监测点详情载荷。 */
  66. const rawPointDetail = ({ code = 'MP-002', name = '燃气调压站监测点', status = 'ALARM', devices = [], statistics, reasonList } = {}) => ({
  67. monitoringPointCode: code,
  68. monitoringPointName: name,
  69. pointStatus: status,
  70. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
  71. statusReasons: reasonList || [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
  72. deviceStatistics:
  73. statistics === undefined
  74. ? {
  75. totalDeviceCount: devices.length,
  76. alarmDeviceCount: 1,
  77. warningDeviceCount: 0,
  78. workOrderDeviceCount: 0,
  79. normalDeviceCount: 1,
  80. offlineDeviceCount: 0,
  81. }
  82. : statistics,
  83. devices,
  84. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  85. statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }],
  86. })
  87. const detailOf = (options) => toMonitoringPointDetail(rawPointDetail(options))
  88. /**
  89. * 假元素:把 DOM 事件序列在 node 里跑一遍(`attachPointPick` 只要求
  90. * `addEventListener`/`removeEventListener`,所以不需要 jsdom)。
  91. */
  92. function fakeElement() {
  93. const listeners = new Map()
  94. return {
  95. addEventListener(type, handler) {
  96. listeners.set(type, [...(listeners.get(type) || []), handler])
  97. },
  98. removeEventListener(type, handler) {
  99. listeners.set(type, (listeners.get(type) || []).filter((item) => item !== handler))
  100. },
  101. emit(type, event) {
  102. for (const handler of listeners.get(type) || []) handler(event)
  103. },
  104. count(type) {
  105. return (listeners.get(type) || []).length
  106. },
  107. }
  108. }
  109. /** 地图对象(`domain/mapSummary.js` 的形状)。 */const mapObject = (kind, code, name = `${code} 点位`) => ({
  110. kind,
  111. code,
  112. name,
  113. status: kind === 'monitoring-point' ? 'NORMAL' : '',
  114. statusReasons: [],
  115. longitude: 110.141,
  116. latitude: 28.452,
  117. coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  118. missing: [],
  119. })
  120. // ================================================================ A 两级状态机
  121. {
  122. const empty = emptyDetailSelection()
  123. check(
  124. 'A1-empty-selection-is-none',
  125. empty.level === DETAIL_LEVEL.NONE && empty.pointCode === '' && empty.deviceCode === '',
  126. JSON.stringify(empty),
  127. )
  128. const pointLevel = openPoint(empty, 'MP-002')
  129. check(
  130. 'A2-open-point-enters-first-level',
  131. pointLevel.level === DETAIL_LEVEL.POINT && pointLevel.pointCode === 'MP-002' && pointLevel.deviceCode === '',
  132. JSON.stringify(pointLevel),
  133. )
  134. const deviceLevel = openDevice(pointLevel, 'EQ-009')
  135. check(
  136. 'A3-select-device-enters-second-level',
  137. deviceLevel.level === DETAIL_LEVEL.EQUIPMENT && deviceLevel.pointCode === 'MP-002' && deviceLevel.deviceCode === 'EQ-009',
  138. `点位编码保留:${deviceLevel.pointCode}`,
  139. )
  140. // 换点位要把设备选择清掉:否则会把上一台设备的详情挂在新的点位下面。
  141. const switched = openPoint(deviceLevel, 'MP-003')
  142. check(
  143. 'A4-switching-point-clears-device',
  144. switched.level === DETAIL_LEVEL.POINT && switched.pointCode === 'MP-003' && switched.deviceCode === '',
  145. JSON.stringify(switched),
  146. )
  147. const back = backToPoint(deviceLevel)
  148. check(
  149. 'A5-back-returns-to-point-without-device',
  150. back.level === DETAIL_LEVEL.POINT && back.pointCode === 'MP-002' && back.deviceCode === '',
  151. JSON.stringify(back),
  152. )
  153. // 未关联设备直接进第二级:它没有点位摘要可回,「返回点位」不该把用户送到不存在的点位。
  154. const unassociated = selectionFromMapObject(mapObject('unassociated-equipment', 'EQ-100', '井盖-100'))
  155. check(
  156. 'A6-unassociated-equipment-goes-straight-to-device',
  157. unassociated.level === DETAIL_LEVEL.EQUIPMENT && unassociated.pointCode === '' && unassociated.deviceCode === 'EQ-100' && backToPoint(unassociated).level === DETAIL_LEVEL.NONE,
  158. JSON.stringify(unassociated),
  159. )
  160. check(
  161. 'A7-point-marker-maps-to-point-level',
  162. selectionFromMapObject(mapObject('monitoring-point', 'MP-002')).level === DETAIL_LEVEL.POINT,
  163. '点监测点 → 第一级',
  164. )
  165. check(
  166. 'A8-unknown-object-opens-nothing',
  167. selectionFromMapObject({ kind: 'pipeline', code: 'PL-1' }) === null &&
  168. selectionFromMapObject(mapObject('monitoring-point', '')) === null &&
  169. selectionFromMapObject(null) === null,
  170. '认不出的对象与没有编码的对象都不打开卡片(不猜)',
  171. )
  172. check(
  173. 'A9-close-is-empty',
  174. closeDetail().level === DETAIL_LEVEL.NONE && openDevice(pointLevel, '').deviceCode === '',
  175. '关闭回空选择;空编码不改变选择',
  176. )
  177. check(
  178. 'A10-same-point-target',
  179. isSamePointTarget(deviceLevel, 'MP-002') === true && isSamePointTarget(deviceLevel, 'MP-003') === false && isSamePointTarget(empty, 'MP-002') === false,
  180. '同一目标(含已选设备那一级)才判「不用重复取数」',
  181. )
  182. }
  183. // ================================================================ B 摘要与卡片视图
  184. {
  185. const detail = detailOf({
  186. code: 'MP-002',
  187. name: '燃气调压站监测点',
  188. status: 'ALARM',
  189. devices: [rawDevice({ code: 'EQ-002', name: '智能井盖-002', status: 'NORMAL', label: '正常' }), rawDevice({ code: 'EQ-001', reasons: ['压力超高'] })],
  190. })
  191. const summary = pointSummary(detail)
  192. check(
  193. 'B1-summary-has-five-fields',
  194. ['name', 'code', 'status', 'reason', 'deviceCount'].every((key) => key in summary),
  195. Object.keys(summary).join(', '),
  196. )
  197. check(
  198. 'B2-summary-values',
  199. summary.name === '燃气调压站监测点' && summary.code === 'MP-002' && summary.status.label === '报警' && summary.status.key === 'ALARM' && summary.deviceCount === 2,
  200. JSON.stringify({ name: summary.name, code: summary.code, status: summary.status.label, count: summary.deviceCount }),
  201. )
  202. check('B3-summary-reason', summary.reason === '压力超高', `原因:${summary.reason}`)
  203. check(
  204. 'B4-summary-status-has-shape-and-label',
  205. !!summary.status.shape && !!summary.status.tone && !!summary.status.label,
  206. `${summary.status.tone}/${summary.status.shape}/${summary.status.label}`,
  207. )
  208. // 原因:去重保序、混吃两种形状(点位是对象数组、设备卡片是字符串数组)。
  209. check(
  210. 'B5-reason-dedupes-and-keeps-order',
  211. reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]) === '压力超高;通信中断' &&
  212. reasonText(['压力超高', '压力超高']) === '压力超高',
  213. reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]),
  214. )
  215. check(
  216. 'B6-no-reason-is-empty-not-normal',
  217. reasonText([]) === '' && reasonText(undefined) === '' && pointSummary(detailOf({ reasonList: [] })).reason === '',
  218. '后端没给原因时是空串(界面显示「暂无原因」),不是「正常」',
  219. )
  220. check(
  221. 'B7-unknown-status-not-guessed',
  222. statusView('MAINTENANCE').key === UNKNOWN_VISUAL.key && pointSummary(detailOf({ status: 'MAINTENANCE' })).status.label === UNKNOWN_VISUAL.label,
  223. `认不出的点位状态 → ${UNKNOWN_VISUAL.label}`,
  224. )
  225. check(
  226. 'B8-backend-label-wins',
  227. statusView('ALARM', '报警(后端词)').label === '报警(后端词)' && POINT_STATUS_VISUALS.ALARM.label === '报警',
  228. '后端给了 statusLabel 就用后端的词,前端不另造业务词汇',
  229. )
  230. check(
  231. 'B9-missing-fields-placeholder',
  232. pointSummary(detailOf({ name: '' })).name === DETAIL_PLACEHOLDER && deviceCards(detailOf({ devices: [rawDevice({ name: '' })] }))[0].name === DETAIL_PLACEHOLDER,
  233. `缺字段显示 ${DETAIL_PLACEHOLDER},不留空白也不编造`,
  234. )
  235. // 关联设备数量:后端统计是权威值;统计整段缺失时按已返回卡片数如实报,不显示假 0。
  236. check(
  237. 'B10-count-prefers-backend-statistics',
  238. associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: { totalDeviceCount: 7, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 } })) === 7,
  239. '后端给 7 台、只回了 2 张卡片时,摘要仍说 7(统计是权威值)',
  240. )
  241. check(
  242. 'B11-count-falls-back-to-cards-when-statistics-missing',
  243. associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: null })) === 2,
  244. '统计缺失时按已返回的 2 张卡片报,不显示「0 台」',
  245. )
  246. check(
  247. 'B12-count-zero-is-real-zero',
  248. associatedDeviceCount(detailOf({ devices: [], statistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 } })) === 0,
  249. '后端确实说 0 台时就是 0(真 0 不是缺失)',
  250. )
  251. // 验收第 3 条:设备卡片顺序由后端返回顺序决定 —— 两种排列都原样输出,说明顺序来自输入。
  252. const firstOrder = ['EQ-B', 'EQ-A', 'EQ-C']
  253. const secondOrder = ['EQ-C', 'EQ-B', 'EQ-A']
  254. const cardsOf = (order) => deviceCards(detailOf({ devices: order.map((code) => rawDevice({ code })) })).map((card) => card.code)
  255. check(
  256. 'B13-device-card-order-is-backend-order',
  257. cardsOf(firstOrder).join(',') === firstOrder.join(',') && cardsOf(secondOrder).join(',') === secondOrder.join(','),
  258. `${cardsOf(firstOrder).join(',')} / ${cardsOf(secondOrder).join(',')}`,
  259. )
  260. check(
  261. 'B14-card-view-fields',
  262. (() => {
  263. const card = deviceCards(detailOf({ devices: [rawDevice({ code: 'EQ-1', typeName: '压力计', status: 'ALARM', label: '报警', reasons: ['压力超高'] })] }))[0]
  264. return card.code === 'EQ-1' && card.typeName === '压力计' && card.status.label === '报警' && card.reason === '压力超高'
  265. })(),
  266. '卡片带编码/名称/类型/状态/原因',
  267. )
  268. check(
  269. 'B15-device-card-of',
  270. deviceCardOf(detail, 'EQ-001').code === 'EQ-001' && deviceCardOf(detail, 'EQ-404') === null && deviceCardOf(detail, '') === null,
  271. '第二级的表头按设备编码取卡片,取不到回 null',
  272. )
  273. }
  274. // ================================================================ C store:取数与层级
  275. {
  276. setActivePinia(createPinia())
  277. const detailStore = useDetailStore()
  278. const mapStore = useMapStore()
  279. let pointRequests = 0
  280. resetMockTransportsForTest()
  281. registerCockpitMocks({ scenario: 'success', mapSnapshot: { pageSize: 2 }, handlers: { registerMockTransport, resetMockTransportsForTest } })
  282. // 计数用:精确路径优先于 `/monitoring-points/*` 模板(票 05 的匹配顺序)。
  283. registerMockTransport('/monitoring-points/MP-COUNT', () => {
  284. pointRequests += 1
  285. return rawPointDetail({ code: 'MP-COUNT', devices: [rawDevice({ code: 'EQ-002' }), rawDevice({ code: 'EQ-001' })] })
  286. })
  287. detailStore.configure({ config: mockConfig })
  288. mapStore.setup({ config: mockConfig })
  289. await mapStore.advance()
  290. const selection = await detailStore.openPoint('MP-COUNT')
  291. const section = detailStore.sections.pointDetail
  292. check(
  293. 'C1-open-point-loads-first-level',
  294. selection.level === DETAIL_LEVEL.POINT && selection.pointCode === 'MP-COUNT' && section.state === 'success' && section.data.code === 'MP-COUNT' && section.loaded === true,
  295. JSON.stringify({ level: selection.level, state: section.state, code: section.data && section.data.code }),
  296. )
  297. check('C2-fetch-happened-once', pointRequests === 1, `请求 ${pointRequests} 次`)
  298. // 同一个点位再点一次:层级回到第一级,但不再发请求(点位摘要没变)。
  299. await detailStore.selectDevice('EQ-001')
  300. const reopened = await detailStore.openPoint('MP-COUNT')
  301. check(
  302. 'C3-same-point-does-not-refetch',
  303. reopened.level === DETAIL_LEVEL.POINT && reopened.deviceCode === '' && pointRequests === 1,
  304. `再次打开后请求数仍为 ${pointRequests}`,
  305. )
  306. // 第二级:换层级,并按设备编码加载设备详情聚合(票 11 接手了这条取数;
  307. // 票 10 的票面原本就写明「设备详情聚合的加载归票 11」——那条接缝当时是**延期**,
  308. // 不是永久契约,所以这里断言的是票 11 之后的行为)。
  309. const deviceLevel = await detailStore.selectDevice('EQ-001')
  310. check(
  311. 'C4-select-device-enters-level-and-loads-by-code',
  312. deviceLevel.level === DETAIL_LEVEL.EQUIPMENT &&
  313. deviceLevel.deviceCode === 'EQ-001' &&
  314. deviceLevel.pointCode === 'MP-COUNT' &&
  315. detailStore.sections.equipmentAggregate.loaded === true &&
  316. detailStore.sections.equipmentAggregate.data.deviceCode === 'EQ-001' &&
  317. pointRequests === 1,
  318. JSON.stringify({
  319. level: deviceLevel.level,
  320. aggregateCode: detailStore.sections.equipmentAggregate.data.deviceCode,
  321. pointRequests,
  322. }),
  323. )
  324. check(
  325. 'C5-point-detail-kept-for-device-header',
  326. deviceCardOf(detailStore.sections.pointDetail.data, deviceLevel.deviceCode).code === 'EQ-001',
  327. '第二级的表头仍读第一级已加载的数据(不必再请求)',
  328. )
  329. // 返回第一级:不清掉已加载的点位摘要,也不再取数。
  330. const back = detailStore.backToPoint()
  331. check(
  332. 'C6-back-keeps-loaded-section',
  333. back.level === DETAIL_LEVEL.POINT && detailStore.sections.pointDetail.loaded === true && pointRequests === 1,
  334. `返回后请求数仍为 ${pointRequests}`,
  335. )
  336. // 竞态:先点 A(慢)再点 B(快),B 的数据必须赢,A 迟到不得覆盖。
  337. let releaseA = null
  338. let releaseBCalled = false
  339. registerMockTransport('/monitoring-points/MP-A', () => new Promise((resolve) => {
  340. releaseA = () => resolve(rawPointDetail({ code: 'MP-A', name: 'A 点位' }))
  341. }))
  342. registerMockTransport('/monitoring-points/MP-B', () => {
  343. releaseBCalled = true
  344. return Promise.resolve(rawPointDetail({ code: 'MP-B', name: 'B 点位' }))
  345. })
  346. const pendingA = detailStore.openPoint('MP-A')
  347. const doneB = await detailStore.openPoint('MP-B')
  348. const afterB = detailStore.sections.pointDetail.data.code
  349. releaseA()
  350. await pendingA
  351. await new Promise((resolve) => setTimeout(resolve, 0))
  352. check(
  353. 'C7-race-latest-wins',
  354. releaseBCalled === true && afterB === 'MP-B' && doneB.pointCode === 'MP-B' && detailStore.sections.pointDetail.data.code === 'MP-B',
  355. `B 落地后 section.code=${detailStore.sections.pointDetail.data.code}(A 迟到被丢弃)`,
  356. )
  357. // 关闭:选择回空,界面据此收掉浮层。
  358. const closed = detailStore.closeDetail()
  359. check('C8-close-clears-selection', closed.level === DETAIL_LEVEL.NONE && detailStore.selection.pointCode === '', JSON.stringify(closed))
  360. // 失败样例:分区如实失败,但选择仍在点位级(界面显示失败态,不是白屏、也不自动关窗)。
  361. resetMockTransportsForTest()
  362. registerCockpitMocks({ scenario: 'failed', onlyPath: '/monitoring-points/*', handlers: { registerMockTransport, resetMockTransportsForTest } })
  363. const failedStore = useDetailStore()
  364. failedStore.reset()
  365. failedStore.configure({ config: mockConfig })
  366. await failedStore.openPoint('MP-FAIL')
  367. check(
  368. 'C9-failure-keeps-selection-and-reports',
  369. failedStore.selection.level === DETAIL_LEVEL.POINT &&
  370. failedStore.sections.pointDetail.state === 'failed' &&
  371. failedStore.sections.pointDetail.loaded === true &&
  372. failedStore.sections.pointDetail.data === null,
  373. JSON.stringify({ level: failedStore.selection.level, state: failedStore.sections.pointDetail.state }),
  374. )
  375. // 失败之后**再点同一个点位**要能重试(留下恢复路径):第一次抛错、第二次成功。
  376. let flakyAttempts = 0
  377. resetMockTransportsForTest()
  378. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  379. registerMockTransport('/monitoring-points/MP-FLAKY', () => {
  380. flakyAttempts += 1
  381. if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败')
  382. return rawPointDetail({ code: 'MP-FLAKY', name: '重试点位' })
  383. })
  384. const retryStore = useDetailStore()
  385. retryStore.reset()
  386. retryStore.configure({ config: mockConfig })
  387. await retryStore.openPoint('MP-FLAKY')
  388. const firstAttemptState = retryStore.sections.pointDetail.state
  389. await retryStore.openPoint('MP-FLAKY')
  390. check(
  391. 'C11-failed-point-can-be-retried',
  392. firstAttemptState === 'failed' &&
  393. flakyAttempts === 2 &&
  394. retryStore.sections.pointDetail.state === 'success' &&
  395. retryStore.sections.pointDetail.data.code === 'MP-FLAKY',
  396. `第一次 ${firstAttemptState} → 再点一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.pointDetail.state}`,
  397. )
  398. // 未关联设备入口:没有点位编码,不请求点位详情。
  399. resetMockTransportsForTest()
  400. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  401. const deviceOnly = useDetailStore()
  402. deviceOnly.reset()
  403. deviceOnly.configure({ config: mockConfig })
  404. await deviceOnly.openDevice('EQ-100')
  405. check(
  406. 'C10-unassociated-device-needs-no-point-request',
  407. deviceOnly.selection.level === DETAIL_LEVEL.EQUIPMENT &&
  408. deviceOnly.selection.pointCode === '' &&
  409. deviceOnly.sections.pointDetail.loaded === false,
  410. JSON.stringify(deviceOnly.selection),
  411. )
  412. // ---- D 关联设备不重复上图 ----
  413. // 回到默认契约样例:`/monitoring-points/*` 会给两台关联设备(EQ-001 / EQ-002)。
  414. // 前后各拍一次地图对象与标记计划,验证整条详情流程(取数 → 设备卡片 → 选设备)都没碰地图。
  415. resetMockTransportsForTest()
  416. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  417. const objectsBeforeDetail = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
  418. const planBefore = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
  419. await detailStore.openPoint('MP-COUNT')
  420. detailStore.selectDevice('EQ-001')
  421. const objectsAfter = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
  422. const planAfter = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
  423. const detailCode = detailStore.sections.pointDetail.data
  424. const detailDeviceCodes = detailCode ? detailCode.devices.map((card) => card.code) : []
  425. check(
  426. 'D1-map-objects-untouched-by-detail-flow',
  427. objectsAfter.join(',') === objectsBeforeDetail.join(',') && planAfter.join(',') === planBefore.join(','),
  428. `${objectsAfter.length} 个地图对象、${planAfter.length} 个标记,详情流程前后一致`,
  429. )
  430. check(
  431. 'D2-associated-devices-are-not-markers',
  432. detailDeviceCodes.length === 2 && detailDeviceCodes.every((deviceCode) => !planAfter.some((key) => key.endsWith(`|${deviceCode}`))),
  433. `详情里的设备 ${detailDeviceCodes.join(', ')} 都不在标记计划里(${planAfter.join(' / ')})`,
  434. )
  435. // 对抗式反证:把设备当**地图对象**喂进计划确实会多出标记 —— 所以上面那条不是恒真。
  436. const wouldBeMarkers = planPointMarkers(
  437. detailDeviceCodes.map((deviceCode) => mapObject('unassociated-equipment', deviceCode, deviceCode)),
  438. )
  439. check(
  440. 'D3-guard-has-teeth',
  441. wouldBeMarkers.markers.length === detailDeviceCodes.length && wouldBeMarkers.markers.length > 0,
  442. `把设备当地图对象喂进去会多出 ${wouldBeMarkers.markers.length} 个标记(因此「不喂」才是有意义的约束)`,
  443. )
  444. }
  445. // ================================================================ E 点选手势与拾取边界
  446. {
  447. check(
  448. 'E1-click-gesture-threshold',
  449. isClickGesture({ x: 100, y: 100 }, { x: 100, y: 100 }) === true &&
  450. isClickGesture({ x: 100, y: 100 }, { x: 102, y: 102 }) === true &&
  451. isClickGesture({ x: 100, y: 100 }, { x: 100 + CLICK_MAX_DRAG_PX + 2, y: 100 }) === false &&
  452. isClickGesture({ x: 100, y: 100 }, { x: 40, y: 220 }) === false,
  453. `阈值 ${CLICK_MAX_DRAG_PX}px:拖拽不点选`,
  454. )
  455. check(
  456. 'E2-gesture-safe-on-missing-input',
  457. isClickGesture(null, { x: 1, y: 1 }) === false && isClickGesture({ x: 0, y: 0 }, undefined) === false,
  458. )
  459. const pickCode = readCode('services/pointPick.js')
  460. check(
  461. 'E3-pick-input-only-observes',
  462. !/preventDefault|stopPropagation/.test(pickCode) && /event\.button === 0/.test(pickCode) && /pointerdown/.test(pickCode) && /pointerup/.test(pickCode),
  463. '只观察、只认左键:相机拖拽手感不受影响',
  464. )
  465. check(
  466. 'E4-pick-input-has-detach',
  467. /export function attachPointPick/.test(pickCode) && /removeEventListener/.test(pickCode),
  468. '返回 detach:离开 /cockpit 时摘干净',
  469. )
  470. // 真行为(脱浏览器):拿一个假元素把 DOM 事件序列跑一遍 ——
  471. // 「点一下开卡片、拖一下不开、右键不开、摘掉之后不再开」四件事在绑定层就定死。
  472. {
  473. const element = fakeElement()
  474. const picks = []
  475. const detach = attachPointPick(element, (point) => picks.push(point))
  476. element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
  477. element.emit('pointerup', { pointerId: 1, clientX: 101, clientY: 100 })
  478. check(
  479. 'E4b-real-click-fires-pick',
  480. picks.length === 1 && picks[0].x === 101 && picks[0].y === 100,
  481. `点一下 → onPick(${JSON.stringify(picks[0] || null)})`,
  482. )
  483. element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
  484. element.emit('pointerup', { pointerId: 1, clientX: 160, clientY: 100 })
  485. element.emit('pointerdown', { button: 2, clientX: 100, clientY: 100, pointerId: 1 })
  486. element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
  487. element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
  488. element.emit('pointercancel', { pointerId: 1, clientX: 100, clientY: 100 })
  489. check('E4c-drag-right-button-cancel-do-not-pick', picks.length === 1, `拖拽/右键/取消之后仍只有 ${picks.length} 次点选`)
  490. detach()
  491. element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
  492. element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
  493. check(
  494. 'E4d-detach-removes-listeners',
  495. element.count('pointerdown') === 0 && element.count('pointerup') === 0 && picks.length === 1,
  496. '摘掉监听后不再触发点选',
  497. )
  498. }
  499. const engineCode = readCode('services/cesiumEngine.js')
  500. check(
  501. 'E5-engine-pick-only-marker-identity',
  502. /export function pickPointMarkerAt/.test(engineCode) &&
  503. /picked\.id/.test(engineCode) &&
  504. /properties\.pointCode/.test(engineCode) &&
  505. /return \{ kind: .*code: .*status: .*\} \| null|return \{ kind/.test(engineCode),
  506. '只认带 pointCode 的实体,且回的是身份(kind/code/status),不漏 Cesium 实体',
  507. )
  508. check(
  509. 'E6-pick-uses-canvas-coordinates',
  510. /getBoundingClientRect/.test(engineCode) && /windowX - rect\.left/.test(engineCode),
  511. '事件给窗口坐标、scene.pick 吃画布坐标,先减去画布原点',
  512. )
  513. const mapServiceCode = readCode('services/mapService.js')
  514. check(
  515. 'E7-pick-lifecycle-symmetric',
  516. /record\.detachPickInput = attachPointPick/.test(mapServiceCode) &&
  517. /record\.detachPickInput\(\)/.test(mapServiceCode) &&
  518. /export function onPointPick/.test(mapServiceCode),
  519. '引擎就绪时挂、离开时摘;订阅接口由组件使用',
  520. )
  521. check(
  522. 'E8-pick-failure-opens-nothing',
  523. /if \(!picked\) return null/.test(mapServiceCode),
  524. '拾不到标记(点空白/地形/影像)时不打开任何详情',
  525. )
  526. }
  527. // ================================================================ F 静态守卫
  528. {
  529. const cardCode = readCode('components/PointDetailCard.vue')
  530. const shellCode = readCode('CockpitShell.vue')
  531. const flowCode = readCode('domain/detailFlow.js')
  532. check(
  533. 'F1-card-does-not-build-api-paths',
  534. !/\/monitoring-points\/|\/equipments\//.test(cardCode) && !/fetch\(/.test(cardCode),
  535. '接口路径只在 services/detailApi.js,组件不自己拼',
  536. )
  537. // 判据只针对**代码体的字段访问**:先剥掉 import 行 ——
  538. // 票 44 移入了 `domain/deviceStatus.js`,文件名恰好与后端裸字段同名,
  539. // 不剥离就会把「导入设备状态词表」误判成「组件读后端字段」(守卫误伤,实测确认)。
  540. // 这是收窄而不是放宽:真正的字段访问仍会被抓到。
  541. const cardCodeBody = cardCode
  542. .split('\n')
  543. .filter((line) => !/^\s*import\s/.test(line))
  544. .join('\n')
  545. check(
  546. 'F2-card-has-no-backend-field-names',
  547. !/monitoringPointCode|monitoringPointName|pointStatus|deviceStatistics|statusReasons|deviceStatus/.test(cardCodeBody),
  548. '组件只读领域字段(domain/detailFlow.js 的视图对象)',
  549. )
  550. check(
  551. 'F3-no-sorting-of-device-cards',
  552. !/\.sort\(/.test(cardCode) && !/\.sort\(/.test(flowCode),
  553. '设备卡片不排序(验收第 3 条:顺序由后端返回顺序决定)',
  554. )
  555. // 详情的三处(domain/store/组件)都不许碰地图标记:标记计划的唯一来源是 mapStore 的地图摘要。
  556. // 注意分两级判:外壳本来就要用 mapService 的相机/归位入口(票 30),所以对「是否 import mapService」
  557. // 只查详情自身那三处;而「碰不碰标记层」对四处都查。
  558. const detailFiles = ['domain/detailFlow.js', 'stores/detailStore.js', 'components/PointDetailCard.vue']
  559. const markerOffenders = [...detailFiles, 'CockpitShell.vue'].filter((file) =>
  560. /planPointMarkers|syncPointMarkersForSummary|clearPointMarkers/.test(readCode(file)),
  561. )
  562. const serviceOffenders = detailFiles.filter((file) => /mapService/.test(readCode(file)))
  563. check(
  564. 'F4-detail-path-never-touches-markers',
  565. markerOffenders.length === 0 && serviceOffenders.length === 0,
  566. markerOffenders.length || serviceOffenders.length
  567. ? `碰了标记层:${markerOffenders.join(', ')};直接依赖 mapService:${serviceOffenders.join(', ')}`
  568. : '详情流程与地图标记层零耦合(关联设备不可能被重复画上去)',
  569. )
  570. // 标记计划的唯一裁决点(票 46 更新):MapCanvas 只把**计划**交给服务层,
  571. // `planPointMarkers` 全仓只在 `domain/modeLayerPlan.js` 一处调用 ——
  572. // 模式可见性在领域层收口,服务层/组件都不做第二次过滤(意图与票 10 一致:一份计划,不散落)。
  573. const mapCanvasCode = readCode('components/MapCanvas.vue')
  574. const planPointMarkersCallers = ['domain/modeLayerPlan.js', 'services/mapService.js', 'components/MapCanvas.vue']
  575. .map((file) => ({ file, count: (readCode(file).match(/planPointMarkers\(/g) || []).length }))
  576. check(
  577. 'F5-marker-source-is-map-summary-only',
  578. /syncPointMarkersForPlan\(\{/.test(mapCanvasCode) &&
  579. planPointMarkersCallers.every((item) => (item.file === 'domain/modeLayerPlan.js') === (item.count === 1)),
  580. 'mapLayerPlan 是 planPointMarkers 的唯一调用点,MapCanvas 只把计划交给 syncPointMarkersForPlan',
  581. )
  582. check(
  583. 'F6-card-mounts-detail-store',
  584. /useDetailStore/.test(cardCode) && /detailStore\.selectDevice/.test(cardCode) && /detailStore\.closeDetail/.test(cardCode),
  585. '组件的动作全部交回 store(组件里没有自己的状态机副本)',
  586. )
  587. check(
  588. 'F7-shell-positions-card-without-map-rebuild',
  589. /v-if="detailOpen &&/.test(shellCode) && /cockpit-shell__detail/.test(shellCode) && /PointDetailCard/.test(shellCode),
  590. '浮层是 main 的兄弟并由外壳定位:地图节点结构不变(票 02 的常驻语义;票 46 起条件含选择态暂停)',
  591. )
  592. }
  593. const failed = checks.filter((item) => !item.pass)
  594. console.log('')
  595. console.log(`T10 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  596. if (failed.length) {
  597. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  598. process.exit(1)
  599. }