t14-event-detail.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. /**
  2. * T14 事件详情与设备定位 纯逻辑检查(票 14)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t14-event-detail.mjs
  6. *
  7. * 八段(每条都对着票 14 的验收原句):
  8. * A 验收 1:详情字段齐全(编码/设备编码/指标/阈值/实际值/等级/时间/状态)
  9. * B 等级与状态复用**同一张表**(票 13 的词表,不在这里长出第二份映射)
  10. * C 验收 2+3:定位按**设备编码**回联点位;高亮的编码同时含点位码与设备码(管线匹配键)
  11. * D 验收 5:三种"点不动"分得开(不在已加载范围 / 后端判不可上图 / 地图未就绪),
  12. * 且原因用的是票 07 的同一张中文词表
  13. * E 验收 5:无有效坐标时详情**仍完整可看**,并在"位置"一栏给出无地图位置原因
  14. * F 验收 3:高亮**不新建地图实体**(事件不变成独立图层)—— 静态守卫 + 计划不变
  15. * G 验收 4:store 的打开/返回不动列表上下文(行、族、分区一个字节没变),定位不重取;
  16. * 高亮返回 0 时如实报「尚未出现在地图上」
  17. * H 静态守卫:组件/store 零后端裸字段、零凭据、domain 不 import service
  18. *
  19. * 契约依据:`CockpitCurrentEventsResponse.EventItem`(票 13 的 `toEventItem`)与
  20. * CONTEXT.md 的「两级详情 / 无地图位置 / 管线→设备的关联方向」。
  21. */
  22. import { readFileSync } from 'node:fs'
  23. import { createPinia, setActivePinia } from 'pinia'
  24. import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js'
  25. import { LEVEL_TAGS } from '../../../src/modules/cockpit/domain/statusTones.js'
  26. import { COORDINATE_REASON_TEXT, isRenderable } from '../../../src/modules/cockpit/domain/coordinate.js'
  27. import { LOCATE_RESULT, LOCATE_TEXT } from '../../../src/modules/cockpit/domain/mapLocate.js'
  28. import { toEventItem, eventRowView, latestEventRows } from '../../../src/modules/cockpit/domain/eventList.js'
  29. import {
  30. EVENT_DETAIL_RESULT,
  31. eventDetailView,
  32. eventHighlightCodes,
  33. eventLocationView,
  34. planEventLocate,
  35. } from '../../../src/modules/cockpit/domain/eventDetail.js'
  36. import { planPointMarkers } from '../../../src/modules/cockpit/domain/pointStatus.js'
  37. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  38. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  39. import { fetchCurrentEvents } from '../../../src/modules/cockpit/services/eventApi.js'
  40. import { EMPHASIS_STYLE, shouldEmphasize } from '../../../src/modules/cockpit/domain/mapEmphasis.js'
  41. import { useEventStore } from '../../../src/modules/cockpit/stores/eventStore.js'
  42. const checks = []
  43. function check(name, pass, evidence) {
  44. checks.push({ name, pass })
  45. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  46. }
  47. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  48. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  49. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  50. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  51. const readCode = (relative) => code(read(relative))
  52. /** `.vue` 的注释是 `<!-- -->`,要单独剥。 */
  53. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  54. const readVueCode = (relative) => codeVue(read(relative))
  55. /** 取一个具名函数的**正文**(用于「这个接缝里有没有干某件事」的静态守卫)。 */
  56. function functionBody(text, name) {
  57. const start = text.indexOf(`function ${name}(`)
  58. if (start < 0) return ''
  59. let depth = 0
  60. for (let i = text.indexOf('{', start); i < text.length; i += 1) {
  61. if (text[i] === '{') depth += 1
  62. if (text[i] === '}') {
  63. depth -= 1
  64. if (depth === 0) return text.slice(start, i + 1)
  65. }
  66. }
  67. return text.slice(start)
  68. }
  69. setActivePinia(createPinia())
  70. /** 后端 `/events/current` 的一条 `EventItem`(字段照契约)。 */
  71. function rawItem({
  72. no = 'WRN-2001',
  73. name = '供水压力预警',
  74. type = 'WATER_PRESSURE',
  75. level = 2,
  76. status = 'PENDING',
  77. value = 0.28,
  78. unit = 'MPa',
  79. deviceCode = 'EQ-001',
  80. monitoringPointCode = 'MP-001',
  81. min = 0.3,
  82. max = 0.6,
  83. validation = { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  84. } = {}) {
  85. return {
  86. eventNo: no,
  87. eventName: name,
  88. eventType: type,
  89. level,
  90. levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN',
  91. status,
  92. eventTime: '2026-09-02T09:55:00+08:00',
  93. updatedTime: '2026-09-02T09:56:00+08:00',
  94. value,
  95. unit,
  96. deviceCode,
  97. deviceName: '供水压力计-001',
  98. monitoringPointCode,
  99. monitoringPointName: '供水干管监测点',
  100. location: '城区B',
  101. industry: 'WATER_SUPPLY',
  102. longitude: 110.141,
  103. latitude: 28.452,
  104. coordinateValidation: validation,
  105. thresholdMinValue: min,
  106. thresholdMaxValue: max,
  107. workOrderSummary: null,
  108. }
  109. }
  110. /** 领域行(票 13 的 `toEventItem` → 列表视图行,票 14 的详情读它)。 */
  111. const rowOf = (raw) => eventRowView(toEventItem(raw))
  112. /** 一张图已有的对象(形状同 `domain/mapSummary.js`)。 */
  113. const mapObject = ({ code: objectCode = 'MP-001', deviceCodes = ['EQ-001'], renderable = true, reason = null } = {}) => ({
  114. kind: 'monitoring-point',
  115. code: objectCode,
  116. name: '供水干管监测点',
  117. status: 'NORMAL',
  118. statusReasons: [],
  119. deviceCodes,
  120. longitude: 110.141,
  121. latitude: 28.452,
  122. coordinate: { state: renderable ? 'VALID' : 'UNVERIFIED', reason, crs: 'WGS84', renderable, validated: true },
  123. })
  124. // ================================================================ A 详情字段齐全
  125. {
  126. const view = eventDetailView(rowOf(rawItem()))
  127. const wanted = [
  128. ['事件编码', view.code, 'WRN-2001'],
  129. ['设备编码', view.deviceCode, 'EQ-001'],
  130. ['指标', view.metric, 'WATER_PRESSURE'],
  131. ['阈值', view.threshold, '0.3 ~ 0.6 MPa'],
  132. ['实际值', view.value, '0.28 MPa'],
  133. ['等级', view.level.label, '2级'],
  134. ['时间', view.eventTime, '2026-09-02T09:55:00+08:00'],
  135. ['状态', view.status, '待处理'],
  136. ]
  137. const bad = wanted.filter(([, actual, expected]) => actual !== expected)
  138. check(
  139. 'A1-detail-lists-all-required-fields',
  140. bad.length === 0 && view.level.tone === LEVEL_TAGS[1].tone,
  141. bad.length ? `不符:${bad.map(([label, actual]) => `${label}=${actual}`).join(' / ')}` : '8 项字段齐全(含等级色键 + 文字)',
  142. )
  143. // 缺失值不伪装零值:null 走占位符,0 是**有效**数据(票 16 的纪律)。
  144. const sparse = eventDetailView(rowOf(rawItem({ value: null, min: null, max: null, type: '' })))
  145. const zero = eventDetailView(rowOf(rawItem({ value: 0 })))
  146. check(
  147. 'A2-missing-not-zero',
  148. sparse.value === '—' && sparse.threshold === '—' && sparse.metric === '—' && zero.value === '0 MPa',
  149. `缺失 value=${sparse.value}(占位符)、真实的 0 值=${zero.value}(照实显示,不当缺失)`,
  150. )
  151. // 只给最小阈值(报警常见形状):≥ 而不是编一个上界。
  152. const minOnly = eventDetailView(rowOf(rawItem({ max: null })))
  153. check('A3-threshold-half-open', minOnly.threshold === '≥ 0.3 MPa', `只有下界时显示 ${minOnly.threshold}`)
  154. }
  155. // ================================================================ B 等级/状态复用同一张表
  156. {
  157. const row = rowOf(rawItem({ level: 4, status: 'WEIRD_NEW_CODE' }))
  158. const view = eventDetailView(row)
  159. check(
  160. 'B1-same-tables-as-list',
  161. view.level.label === row.level.label &&
  162. view.level.tone === row.level.tone &&
  163. view.status === row.statusText &&
  164. view.status === 'WEIRD_NEW_CODE',
  165. `详情等级/状态与列表行逐个相等(${view.level.label} / ${view.status});认不出的状态回原码`,
  166. )
  167. }
  168. // ================================================================ C 定位:按设备编码回联
  169. {
  170. const objects = [mapObject({ objectCode: 'MP-001', deviceCodes: ['EQ-001'] }), mapObject({ objectCode: 'MP-009', deviceCodes: ['EQ-009'] })]
  171. const row = rowOf(rawItem({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001' }))
  172. const plan = planEventLocate(row, objects, true)
  173. check(
  174. 'C1-locates-by-device-code',
  175. plan.result === EVENT_DETAIL_RESULT.OK &&
  176. plan.object.code === 'MP-001' &&
  177. plan.target &&
  178. plan.target.lon === 110.141 &&
  179. plan.cameraHeight === undefined &&
  180. plan.object.code !== 'MP-009',
  181. `按设备编码 EQ-001 找到点位 ${plan.object && plan.object.code},相机目标 (${plan.target.lon}, ${plan.target.lat}, ${plan.target.height})`,
  182. )
  183. check(
  184. 'C2-highlight-codes-include-device-and-point',
  185. plan.codes.includes('EQ-001') && plan.codes.includes('MP-001'),
  186. `高亮编码 = ${JSON.stringify(plan.codes)}(点位码强调标记、设备码匹配关联管线 —— 票 22 的管线按设备关联)`,
  187. )
  188. // 设备只带编码、没有点位码时也能回联(事件列表可以只有 deviceCode)。
  189. const deviceOnly = planEventLocate(rowOf(rawItem({ monitoringPointCode: null })), objects, true)
  190. check(
  191. 'C3-locates-without-point-code',
  192. deviceOnly.result === EVENT_DETAIL_RESULT.OK && deviceOnly.object.code === 'MP-001',
  193. '只有设备编码(无点位码)时仍按 deviceCodes 命中点位',
  194. )
  195. check(
  196. 'C4-same-seat-as-ticket-17',
  197. LOCATE_RESULT.OK === EVENT_DETAIL_RESULT.OK &&
  198. LOCATE_RESULT.MISSING === EVENT_DETAIL_RESULT.MISSING &&
  199. LOCATE_RESULT.BLOCKED === EVENT_DETAIL_RESULT.BLOCKED &&
  200. LOCATE_RESULT.UNAVAILABLE === EVENT_DETAIL_RESULT.UNAVAILABLE,
  201. '四种定位结果沿用票 17/18 的 mapLocate 词表(不新造一套结果码)',
  202. )
  203. }
  204. // ================================================================ D 三种「点不动」分得开
  205. {
  206. const row = rowOf(rawItem())
  207. const missing = planEventLocate(row, [], true)
  208. check(
  209. 'D1-missing-keeps-list-data',
  210. missing.result === EVENT_DETAIL_RESULT.MISSING && missing.reasonText === LOCATE_TEXT.MISSING,
  211. `不在已加载范围 → ${missing.result}(「${missing.reasonText}」,数据可能还没翻到,不是错误)`,
  212. )
  213. const blocked = planEventLocate(row, [mapObject({ renderable: false, reason: 'UNVERIFIED_COORDINATE' })], true)
  214. check(
  215. 'D2-blocked-reuses-ticket-07-wording',
  216. blocked.result === EVENT_DETAIL_RESULT.BLOCKED &&
  217. blocked.reasonText === COORDINATE_REASON_TEXT.UNVERIFIED_COORDINATE &&
  218. isRenderable(blocked.object) === false,
  219. `后端判不可上图 → ${blocked.result},文案「${blocked.reasonText}」(与票 07 的「无地图位置」同一张词表)`,
  220. )
  221. const notReady = planEventLocate(row, [mapObject()], false)
  222. check(
  223. 'D3-unavailable-is-not-an-error',
  224. notReady.result === EVENT_DETAIL_RESULT.UNAVAILABLE && notReady.reasonText === LOCATE_TEXT.UNAVAILABLE,
  225. `地图未就绪 → ${notReady.result}(「${notReady.reasonText}」,与「数据没有」分开说)`,
  226. )
  227. // 事件自带坐标被判非法时,**先于**回联就如实报(不假装能定位)。
  228. const badEvent = planEventLocate(
  229. rowOf(rawItem({ validation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' } })),
  230. [mapObject()],
  231. true,
  232. )
  233. check(
  234. 'D4-invalid-event-coordinate-blocks',
  235. badEvent.result === EVENT_DETAIL_RESULT.BLOCKED && badEvent.reasonText === COORDINATE_REASON_TEXT.ZERO_COORDINATE,
  236. `事件自身坐标非法 → ${badEvent.result}(「${badEvent.reasonText}」)`,
  237. )
  238. }
  239. // ================================================================ E 无坐标仍可查看
  240. {
  241. const blockedRow = rowOf(rawItem({ validation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } }))
  242. const view = eventDetailView(blockedRow)
  243. const plan = planEventLocate(blockedRow, [mapObject()], true)
  244. check(
  245. 'E1-detail-complete-when-no-location',
  246. view.code === 'WRN-2001' &&
  247. view.deviceCode === 'EQ-001' &&
  248. view.value === '0.28 MPa' &&
  249. view.eventTime === '2026-09-02T09:55:00+08:00' &&
  250. view.status === '待处理' &&
  251. view.hasLocation === false,
  252. `无有效坐标时 8 项字段仍齐全,hasLocation=${view.hasLocation}`,
  253. )
  254. check(
  255. 'E2-no-location-reason-is-shown',
  256. view.location.includes(COORDINATE_REASON_TEXT.LONGITUDE_OUT_OF_RANGE) &&
  257. plan.reasonText === COORDINATE_REASON_TEXT.LONGITUDE_OUT_OF_RANGE,
  258. `位置一栏写「${view.location}」、定位反馈写「${plan.reasonText}」—— 两句都是同一张词表里的实话`,
  259. )
  260. // 有坐标但后端没给文本位置:仍说得出「可定位」。
  261. const textless = eventLocationView(rowOf(rawItem({ location: null })))
  262. check(
  263. 'E3-locatable-without-text',
  264. textless.hasLocation === true && !!textless.location,
  265. `后端可上图但没给文本位置 → hasLocation=${textless.hasLocation}、显示「${textless.location}」`,
  266. )
  267. }
  268. // 走查用的「无坐标」样例必须真的能让整条路径复现(否则真机 D 组只是走过场)。
  269. {
  270. resetMockTransportsForTest()
  271. registerCockpitMocks({
  272. scenario: 'event-detail-no-coordinate',
  273. onlyPath: '/events/current',
  274. handlers: { registerMockTransport, resetMockTransportsForTest },
  275. })
  276. const payload = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig })
  277. resetMockTransportsForTest()
  278. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  279. const rows = latestEventRows(payload.data.sections.events.data).map(eventRowView)
  280. const view = eventDetailView(rows[0])
  281. const plan = planEventLocate(rows[0], [mapObject()], true)
  282. check(
  283. 'E4-mock-scenario-covers-no-location',
  284. rows.length >= 12 &&
  285. rows[0].code.startsWith('WRN-') &&
  286. view.code === rows[0].code &&
  287. view.hasLocation === false &&
  288. view.location.includes(COORDINATE_REASON_TEXT.UNVERIFIED_COORDINATE) &&
  289. plan.result === EVENT_DETAIL_RESULT.BLOCKED,
  290. `样例 event-detail-no-coordinate:${rows.length} 行事件仍可看,详情 hasLocation=${view.hasLocation}、位置「${view.location}」、定位 ${plan.result} —— 真机 D 组不是走过场`,
  291. )
  292. }
  293. // ================================================================ F 高亮不新建实体
  294. {
  295. const objects = [mapObject(), mapObject({ objectCode: 'MP-009', deviceCodes: ['EQ-009'] })]
  296. const before = planPointMarkers(objects)
  297. const after = planPointMarkers(objects)
  298. check(
  299. 'F1-marker-plan-unchanged',
  300. before.markers.length === after.markers.length &&
  301. JSON.stringify(before.byStatus) === JSON.stringify(after.byStatus) &&
  302. before.total === 2,
  303. `定位前后标记计划逐位相同(共 ${before.total} 个),事件不进标记计划 —— 验收第 3 条是结构性的`,
  304. )
  305. const engine = readCode('services/cesiumEngine.js')
  306. const highlight = functionBody(engine, 'highlightEntities')
  307. check(
  308. 'F2-highlight-adds-no-entity',
  309. !!highlight && !/entities\.add\(/.test(highlight) && /entity\.billboard\.scale/.test(highlight),
  310. 'highlightEntities 只改已存在实体的显示参数(billboard.scale / polyline.width),零 entities.add',
  311. )
  312. const pipeline = functionBody(engine, 'pipelineSource')
  313. check(
  314. 'F3-pipeline-seat-is-ready',
  315. /cockpit-pipelines/.test(engine) && !!pipeline,
  316. '管线数据源(票 22)已经在高亮的扫描范围内:落地当天按设备码自动一起高亮,不需要再改本票',
  317. )
  318. const service = readCode('services/mapService.js')
  319. const serviceHighlight = functionBody(service, 'highlightMapObject')
  320. check(
  321. 'F4-service-highlight-is-read-only',
  322. !!serviceHighlight && !/markerPlan\s*=/.test(serviceHighlight) && /markerPlan\.markers\.find/.test(serviceHighlight),
  323. 'highlightMapObject 只读 markerPlan(把设备码解析成点位码),不改计划、不重画标记',
  324. )
  325. // ---- 真实行为:强调判定是纯函数,直接断言(不是读源码、也不是自己跟自己比)----
  326. const markerCandidates = ['MP-002', '', 'EQ-002', ['EQ-002']]
  327. const otherCandidates = ['MP-001', '', 'EQ-001', ['EQ-001']]
  328. const pipeCandidates = ['', 'PIPE-9', 'EQ-002', []]
  329. check(
  330. 'F5-emphasis-hits-point-and-pipeline-by-device',
  331. shouldEmphasize(markerCandidates, new Set(['EQ-002'])) === true &&
  332. shouldEmphasize(otherCandidates, new Set(['EQ-002'])) === false &&
  333. shouldEmphasize(pipeCandidates, new Set(['EQ-002'])) === true &&
  334. EMPHASIS_STYLE.markerScale > 1 &&
  335. EMPHASIS_STYLE.pipelineWidth > 0,
  336. '事件按设备码一次命中**两个**实体:点位标记(属性里的设备码)+ 关联管线(pipelineCode/deviceCode)—— 验收第 3 条的两半',
  337. )
  338. check(
  339. 'F6-empty-target-restores-everything',
  340. shouldEmphasize(markerCandidates, new Set()) === false &&
  341. shouldEmphasize(pipeCandidates, []) === false &&
  342. shouldEmphasize(markerCandidates, null) === false,
  343. '目标为空 = 全部还原(判 false);引擎对**每个实体**都写回上一次记下的原值,收起详情后标记不会一直胖着(早先版本在这里提前 return,一趟都没走)',
  344. )
  345. check(
  346. 'F7-engine-applies-the-verdict',
  347. /shouldEmphasize/.test(highlight) &&
  348. /EMPHASIS_STYLE\.markerScale/.test(highlight) &&
  349. /base\.polylineWidth/.test(highlight) &&
  350. !/wanted\.size\)\s*return/.test(highlight),
  351. '引擎只把判定写成显示参数:命中写强调值、未命中写回记下的原值,且空目标时**不提前返回**(还原路径走得通)',
  352. )
  353. check(
  354. 'F8-emphasis-is-observable',
  355. /dataset\.mapEmphasis = String\(matched\)/.test(service) &&
  356. /delete record\.host\.dataset\.mapEmphasis/.test(service),
  357. '服务层把强调计数写进宿主 `data-map-emphasis`、随地图销毁一并 delete:真机走查据此断言"收起后回到 0",不是读内部变量',
  358. )
  359. }
  360. // ================================================================ G store:上下文与结果
  361. {
  362. const store = useEventStore()
  363. store.reset()
  364. store.configure({ config: mockConfig })
  365. resetMockTransportsForTest()
  366. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  367. await store.loadCurrentEvents({ mode: 'WARNING' })
  368. const rowsBefore = store.view.rows.map((row) => row.key)
  369. const sectionBefore = store.sections.currentEvents
  370. const domainBefore = store.currentDomain
  371. store.openEvent(store.view.rows[0])
  372. const sectionOpen = store.sections.currentEvents
  373. check(
  374. 'G1-open-keeps-list-context',
  375. !!store.selectedEvent &&
  376. store.mode === 'WARNING' &&
  377. store.sections.currentEvents === sectionBefore &&
  378. store.currentDomain === domainBefore &&
  379. JSON.stringify(store.view.rows.map((row) => row.key)) === JSON.stringify(rowsBefore),
  380. `打开详情后列表逐位不变(${rowsBefore.length} 行、族 ${store.mode}、分区对象同一引用)—— 列表在 store,收起/展开详情不触发任何重取`,
  381. )
  382. check(
  383. 'G2-open-keeps-section-identity',
  384. store.sections.currentEvents.loaded === true &&
  385. store.sections.currentEvents.state === SECTION_STATE.SUCCESS &&
  386. sectionOpen === sectionBefore,
  387. '详情打开不新建分区、不改分区状态',
  388. )
  389. const calls = { focus: [], highlight: [] }
  390. // 用**选中那一行的**编码造地图对象:定位是按设备编码回联,样例写死编码就会点空。
  391. const selected = store.selectedEvent
  392. const objects = [mapObject({ objectCode: selected.monitoringPointCode, deviceCodes: [selected.deviceCode] })]
  393. const ok = await store.locateEvent(store.selectedEvent, {
  394. objects,
  395. mapReady: true,
  396. focus: (target) => {
  397. calls.focus.push(target)
  398. return true
  399. },
  400. highlight: (codes) => {
  401. calls.highlight.push(codes)
  402. return 1
  403. },
  404. })
  405. check(
  406. 'G3-locate-drives-camera-and-highlight',
  407. ok.result === LOCATE_RESULT.OK &&
  408. calls.focus.length === 1 &&
  409. calls.focus[0].lon === objects[0].longitude &&
  410. calls.highlight.length === 1 &&
  411. calls.highlight[0].includes(selected.deviceCode) &&
  412. store.sections.currentEvents === sectionBefore,
  413. `已下发相机目标 + 高亮编码 ${JSON.stringify(calls.highlight[0])};列表分区仍是同一个(定位不重取)`,
  414. )
  415. const notOnMap = await store.locateEvent(store.selectedEvent, {
  416. objects,
  417. mapReady: true,
  418. focus: () => true,
  419. highlight: () => 0,
  420. })
  421. check(
  422. 'G4-highlight-zero-is-reported-honestly',
  423. notOnMap.result === LOCATE_RESULT.UNAVAILABLE && /尚未出现在地图上/.test(notOnMap.reasonText),
  424. `高亮命中 0 个 → ${notOnMap.result}(「${notOnMap.reasonText}」,不假装定位成功)`,
  425. )
  426. store.closeEvent()
  427. check(
  428. 'G5-close-returns-to-list-untouched',
  429. store.selectedEvent === null &&
  430. store.locate.result === '' &&
  431. store.mode === 'WARNING' &&
  432. store.sections.currentEvents === sectionBefore &&
  433. JSON.stringify(store.view.rows.map((row) => row.code)) === JSON.stringify(rowsBefore),
  434. '返回事件列表:详情收起、定位结果清空,族/筛选/分区/行序全部原样(验收第 4 条)',
  435. )
  436. // 事件列表行本身就是详情的数据源(详情不需要第二个端点)。
  437. const sharedSeat = latestEventRows(domainBefore.sections.events.data).map(eventRowView)
  438. check(
  439. 'G6-detail-reads-the-list-row',
  440. sharedSeat.length >= 12 && sharedSeat[0].code === store.view.rows[0].code && !!eventDetailView(sharedSeat[0]).threshold,
  441. '详情直接消费列表行(票 13 的 14 条样例里阈值/量值都在),没有为详情新开接口',
  442. )
  443. }
  444. // ================================================================ H 静态守卫
  445. {
  446. const pageAndStoreFiles = [
  447. 'components/EventDetailCard.vue',
  448. 'components/EventListPane.vue',
  449. 'stores/eventStore.js',
  450. ]
  451. const BACKEND_FIELD_PATTERN = /\b(eventNo|eventName|eventType|levelColor|monitoringPointCode|coordinateValidation|thresholdMinValue|thresholdMaxValue|workOrderSummary|mapFilter|workOrders|displayName)\b/
  452. for (const file of pageAndStoreFiles) {
  453. const text = read(file)
  454. const found = BACKEND_FIELD_PATTERN.exec(text)
  455. check(`H1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段')
  456. }
  457. const ROUTE_PATTERN = /['"`]\/[a-z-]+\//
  458. for (const file of pageAndStoreFiles) {
  459. check(`H2-${file}-no-route-string`, !ROUTE_PATTERN.test(readCode(file)) && !ROUTE_PATTERN.test(readVueCode(file)), '不自己拼接口路径(路径归 services)')
  460. }
  461. const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
  462. for (const file of [...pageAndStoreFiles, 'domain/eventDetail.js']) {
  463. check(`H3-${file}-no-credential`, !CREDENTIAL_PATTERN.test(read(file)), '不碰凭据')
  464. }
  465. check('H4-domain-pure', !/\bfetch\s*\(|XMLHttpRequest/.test(read('domain/eventDetail.js')), 'domain/eventDetail.js 不自己发请求')
  466. check('H5-domain-no-service-import', !/from '\.\.\/services\//.test(read('domain/eventDetail.js')), 'domain/eventDetail.js 不 import service')
  467. // 详情必须能收起(返回列表),且返回不重取:收起只清选择。
  468. const storeCode = readCode('stores/eventStore.js')
  469. const closeBody = functionBody(storeCode, 'closeEvent')
  470. check(
  471. 'H6-close-does-not-refetch',
  472. !!closeBody && !/loadCurrentEvents|fetch/.test(closeBody) && /selectedEvent\.value = null/.test(closeBody),
  473. 'closeEvent 只清选择与高亮,零取数(否则「返回」会把列表滚回第 1 页)',
  474. )
  475. // 事件详情不新增标记:domain 不 import 引擎,也不产生 marker 计划。
  476. check(
  477. 'H7-detail-plans-no-marker',
  478. !/planPointMarkers|syncPointMarkers|Cesium/.test(read('domain/eventDetail.js')),
  479. '详情/定位层不建标记计划、不认识 Cesium',
  480. )
  481. }
  482. const failed = checks.filter((item) => !item.pass)
  483. console.log('')
  484. console.log(`T14 事件详情与设备定位检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  485. if (failed.length) {
  486. console.log('未通过:')
  487. for (const item of failed) console.log(` · ${item.name}`)
  488. process.exit(1)
  489. }
  490. console.log('T14 事件详情与设备定位全部通过。')