t13-event-list.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. /**
  2. * T13 事件列表与预警/报警切换 纯逻辑检查(票 13)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t13-event-list.mjs
  6. *
  7. * 十段:
  8. * A 契约适配:`/events/current`(跨设备)→ 领域对象(envelope + 内部分区 + EventItem)
  9. * B 切换视图:预警/报警的字段标签与状态标签从同一个对象取(同步变化的唯一来源)
  10. * C 等级配色:1-4 级 → lv1..lv4 + 'N级' 文字;认不出落中性灰 + 文字(不猜)
  11. * D 默认条数:`latestEventRows` 按 eventTime 倒序 + eventNo 升序,默认取最新 12 条
  12. * E 事件不建独立地图标记:领域层不 import 引擎/地图服务、不产生标记计划
  13. * F 不判断预警真伪/误报:领域层与组件不出现准确率/误报/真伪判定;mock 无这些字段
  14. * G store:取数 + 切换 + token 竞态(快速切族时旧响应作废)
  15. * H mock 真过滤:`/events/current` 按 mode 返回不同族(切换真的换内容)
  16. * I 静态守卫:页面/store/组件零后端裸字段、零凭据、domain 纯函数
  17. *
  18. * 契约依据:后端 `CockpitCurrentEventsResponse` / `CockpitCurrentEventsService` /
  19. * `CockpitEventLevelColor`(`E:\pipe-ner-server`)与契约手册第 2 节「等级颜色」、
  20. * 「系统不判断预警真伪,契约中不存在任何准确率、误报率字段」。
  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 { EVENT_FAMILY } from '../../../src/modules/cockpit/domain/events.js'
  26. import {
  27. DEFAULT_EVENT_LIMIT,
  28. EVENT_STATUS_TEXT,
  29. currentEventsView,
  30. eventLevelTag,
  31. eventListRows,
  32. eventListSwitchOptions,
  33. eventListSwitchView,
  34. eventRowView,
  35. eventStatusText,
  36. isKnownEventMode,
  37. latestEventRows,
  38. toCurrentEvents,
  39. toEventItem,
  40. } from '../../../src/modules/cockpit/domain/eventList.js'
  41. import { LEVEL_TAGS, DEFAULT_TONE } from '../../../src/modules/cockpit/domain/statusTones.js'
  42. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  43. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  44. import { fetchCurrentEvents } from '../../../src/modules/cockpit/services/eventApi.js'
  45. import { useEventStore } from '../../../src/modules/cockpit/stores/eventStore.js'
  46. const checks = []
  47. /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  48. function check(name, pass, evidence) {
  49. checks.push({ name, pass })
  50. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  51. }
  52. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  53. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  54. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  55. /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
  56. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  57. const readCode = (relative) => code(read(relative))
  58. /** `.vue` 的注释是 `<!-- -->`,要单独剥。 */
  59. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  60. const readVueCode = (relative) => codeVue(read(relative))
  61. setActivePinia(createPinia())
  62. /** 造一份后端 `/events/current` 载荷(envelope + 内部分区)。 */
  63. function rawCurrentEvents({ mode = 'WARNING', items = [], state = 'SUCCESS', reason = 'SUCCESS', message = null, query = {} } = {}) {
  64. return {
  65. requestId: 'test-current-events',
  66. apiVersion: 'v1',
  67. readOnly: true,
  68. displayName: mode === 'ALARM' ? '当前报警' : '当前预警',
  69. timezone: 'Asia/Shanghai',
  70. mode,
  71. updatedAt: '2026-09-02T10:00:00+08:00',
  72. filters: { mode, level: query.level || null, industry: query.industry || null, status: query.status || null },
  73. pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
  74. mapFilter: { mode, level: null, industry: null, status: null, deviceCodes: [] },
  75. sections: {
  76. events: { state, reason, message, data: items },
  77. workOrders: { state: 'EMPTY', reason: 'NO_WORK_ORDERS', message: null, data: null },
  78. },
  79. }
  80. }
  81. /** 造一条 `EventItem` 样例(字段照后端 `CockpitCurrentEventsResponse.EventItem`)。 */
  82. function rawEventItem({ no = 'EVT-001', name = '样例事件', type = 'TEST', level = 2, status = 'ACTIVE', eventTime = '2026-09-02T10:00:00+08:00', updatedTime = null, value = 12, unit = 'm³/h', deviceCode = 'DEV-1', deviceName = '样例设备', monitoringPointCode = 'MP-001', monitoringPointName = '样例点位', location = '城区A', industry = 'GAS', min = null, max = null } = {}) {
  83. return {
  84. eventNo: no,
  85. eventName: name,
  86. eventType: type,
  87. level,
  88. levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN',
  89. status,
  90. eventTime,
  91. updatedTime,
  92. value,
  93. unit,
  94. deviceCode,
  95. deviceName,
  96. monitoringPointCode,
  97. monitoringPointName,
  98. location,
  99. industry,
  100. longitude: 110.14,
  101. latitude: 28.45,
  102. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  103. thresholdMinValue: min,
  104. thresholdMaxValue: max,
  105. workOrderSummary: null,
  106. }
  107. }
  108. // ================================================================ A 契约适配
  109. {
  110. const domain = toCurrentEvents(
  111. rawCurrentEvents({
  112. mode: 'ALARM',
  113. items: [
  114. rawEventItem({ no: 'ALM-1', level: 1, min: 10, max: 20 }),
  115. rawEventItem({ no: 'ALM-2', level: 4 }),
  116. ],
  117. }),
  118. )
  119. check(
  120. 'A1-mode-and-display-name-carried',
  121. domain.mode === 'ALARM' && domain.displayName === '当前报警',
  122. `mode=${domain.mode} displayName=${domain.displayName}`,
  123. )
  124. check(
  125. 'A2-inner-events-section-mapped',
  126. domain.sections.events.state === SECTION_STATE.SUCCESS &&
  127. Array.isArray(domain.sections.events.data) &&
  128. domain.sections.events.data.length === 2,
  129. `events.state=${domain.sections.events.state} 条数=${domain.sections.events.data && domain.sections.events.data.length}`,
  130. )
  131. check(
  132. 'A3-order-section-present-untouched',
  133. domain.sections.orders.state === SECTION_STATE.EMPTY && domain.sections.orders.data === null,
  134. `orders.state=${domain.sections.orders.state}`,
  135. )
  136. check(
  137. 'A4-event-item-domain-fields',
  138. domain.sections.events.data[0].code === 'ALM-1' &&
  139. domain.sections.events.data[0].level === 1 &&
  140. domain.sections.events.data[0].deviceCode === 'DEV-1' &&
  141. domain.sections.events.data[0].coordinate.renderable === true,
  142. 'EventItem 领域行(编码/等级/设备编码/坐标校验)',
  143. )
  144. check(
  145. 'A5-empty-events-section-mapped-empty',
  146. toCurrentEvents(rawCurrentEvents({ items: [], state: 'EMPTY', reason: 'NO_EVENTS' })).sections.events.state === SECTION_STATE.EMPTY,
  147. '空事件分区照后端 EMPTY 映射,不猜成成功',
  148. )
  149. }
  150. // ================================================================ B 切换视图
  151. {
  152. const warning = eventListSwitchView('WARNING')
  153. const alarm = eventListSwitchView('ALARM')
  154. check(
  155. 'B1-two-families-two-labels',
  156. warning.key === 'WARNING' && warning.label === '预警' && alarm.key === 'ALARM' && alarm.label === '报警',
  157. `预警 ${JSON.stringify(warning)} / 报警 ${JSON.stringify(alarm)}`,
  158. )
  159. check(
  160. 'B2-fields-and-status-label-sync-per-family',
  161. warning.codeLabel === '预警编号' &&
  162. warning.statusLabel === '预警状态' &&
  163. alarm.codeLabel === '报警编号' &&
  164. alarm.statusLabel === '报警状态',
  165. '同一对象的 codeLabel 与 statusLabel —— 切换时必然一起变(结构保证)',
  166. )
  167. check(
  168. 'B3-switch-options-order-fixed',
  169. eventListSwitchOptions().map((item) => item.key).join(',') === 'WARNING,ALARM',
  170. '分段顺序固定:预警在前、报警在后',
  171. )
  172. check(
  173. 'B4-known-mode-validation',
  174. isKnownEventMode('WARNING') &&
  175. isKnownEventMode('ALARM') &&
  176. !isKnownEventMode('OTHER') &&
  177. !isKnownEventMode(''),
  178. '只认 WARNING/ALARM(认不出的不该拿去换后端 400)',
  179. )
  180. check(
  181. 'B5-unknown-mode-falls-back-to-warning',
  182. eventListSwitchView('OTHER').key === 'WARNING',
  183. '认不出的 mode 落回预警(视图不崩),但切换入口不认它',
  184. )
  185. }
  186. // ================================================================ C 等级配色
  187. {
  188. const lv1 = eventLevelTag(1)
  189. const lv4 = eventLevelTag(4)
  190. check(
  191. 'C1-level-1-to-4-uses-level-tags',
  192. lv1.tone === 'lv1' && lv1.label === '1级' && lv4.tone === 'lv4' && lv4.label === '4级',
  193. `1级 ${JSON.stringify(lv1)} / 4级 ${JSON.stringify(lv4)}`,
  194. )
  195. check(
  196. 'C2-level-tags-is-statusTones-source',
  197. LEVEL_TAGS.map((item) => item.tone).join(',') === 'lv1,lv2,lv3,lv4',
  198. '等级色键来自 statusTones 的 LEVEL_TAGS(唯一来源,与点位状态色分离)',
  199. )
  200. check(
  201. 'C3-unknown-level-neutral-with-text',
  202. eventLevelTag(9).tone === DEFAULT_TONE && eventLevelTag(9).label === '等级未知' && eventLevelTag(null).label === '等级未知',
  203. '认不出的等级落中性灰 + 文字,不猜成某个真实等级',
  204. )
  205. check(
  206. 'C4-column-level-tag-and-text',
  207. eventRowView({ level: 3, code: 'E1', value: 1 }).level.label === '3级',
  208. '行视图带等级标签(色键 + 文字)',
  209. )
  210. }
  211. // ================================================================ D 默认最新条数
  212. {
  213. const items = [
  214. { code: 'OLD', eventTime: '2026-09-01T08:00:00+08:00' },
  215. { code: 'NEW', eventTime: '2026-09-02T10:00:00+08:00' },
  216. { code: 'MID', eventTime: '2026-09-02T09:00:00+08:00' },
  217. { code: 'NO_TIME' },
  218. ]
  219. const rows = latestEventRows(items, 2)
  220. check(
  221. 'D1-newest-first-by-event-time',
  222. rows.map((item) => item.code).join(',') === 'NEW,MID',
  223. `取最新 2 条:${rows.map((item) => item.code).join(',')}`,
  224. )
  225. check(
  226. 'D2-default-limit-in-10-15-range',
  227. DEFAULT_EVENT_LIMIT >= 10 && DEFAULT_EVENT_LIMIT <= 15,
  228. `DEFAULT_EVENT_LIMIT=${DEFAULT_EVENT_LIMIT}(验收「默认展示最新 10-15 条」)`,
  229. )
  230. check(
  231. 'D3-limit-caps-out',
  232. latestEventRows(items).length <= DEFAULT_EVENT_LIMIT && latestEventRows(items).length <= 4,
  233. `不传 limit 时按默认上限截断(这里 4 条全保留)`,
  234. )
  235. check(
  236. 'D4-view-rows-are-latest-limit',
  237. currentEventsView({
  238. mode: 'WARNING',
  239. sections: { events: { state: 'success', loaded: true, data: items.map((item) => ({ ...item, status: '', level: null })) } },
  240. }).rows.length === 4,
  241. '视图行 = 最新 N 条(纯函数层收口,组件不自己截)',
  242. )
  243. check(
  244. 'D5-view-rows-are-view-shape',
  245. (() => {
  246. const view = currentEventsView({
  247. mode: 'WARNING',
  248. sections: { events: { state: 'success', loaded: true, data: items.map((item) => ({ ...item, status: '', level: null })) } },
  249. })
  250. const first = view.rows[0]
  251. // 组件只认视图行:等级 tag(tone+label)、状态文本、量值、设备名。
  252. // 直接把领域行给组件会让这些字段全是 undefined —— 逻辑闸门必须拦住。
  253. return (
  254. first &&
  255. typeof first.level === 'object' &&
  256. typeof first.level.tone === 'string' &&
  257. typeof first.statusText === 'string' &&
  258. typeof first.quantity === 'string'
  259. )
  260. })(),
  261. '视图行带等级 tag/状态文本/量值(组件可读的最终形状)',
  262. )
  263. }
  264. // ================================================================ E 事件不建独立地图标记(静态守卫)
  265. {
  266. const eventListSource = readCode('domain/eventList.js')
  267. const paneSource = readVueCode('components/EventListPane.vue')
  268. const storeSource = readCode('stores/eventStore.js')
  269. check(
  270. 'E1-domain-no-map-engine-import',
  271. !/from '\.\/.*(mapService|cesium|mapApi)|from '\.\.\/services\/(mapService|cesiumEngine|mapApi|pointPick)/.test(eventListSource),
  272. '领域层不 import 引擎/地图服务(没有标记计划可产生)',
  273. )
  274. check(
  275. 'E2-pane-no-map-engine-import',
  276. !/mapService|cesium|marker|billboard|Viewer/.test(paneSource),
  277. '组件不碰地图引擎/标记(事件只带设备编码,地图由票 14 回联)',
  278. )
  279. check(
  280. 'E3-store-no-marker-plan',
  281. // 票 14 起 store 允许调地图服务(下相机目标 + 高亮),但**建标记计划**仍是它的禁区:
  282. // 事件/详情只强调已有实体,绝不 `sync/clear/planPointMarkers` 或自画 billboard。
  283. !/billboard|markerSvg|planPointMarkers|syncPointMarkers|clearPointMarkers/.test(storeSource),
  284. 'store 只驱动地图(相机/高亮),不产生标记计划',
  285. )
  286. check(
  287. 'E4-event-keeps-device-code-only',
  288. /deviceCode/.test(eventListSource) && !/longitude.*renderable|renderable.*longitude/m.test(eventListSource),
  289. '事件以设备编码为回联键;坐标只作票 14 的输入,不在这里上图',
  290. )
  291. }
  292. // ================================================================ F 不判断预警真伪/误报(静态守卫)
  293. {
  294. const eventListSource = readCode('domain/eventList.js')
  295. const paneSource = readVueCode('components/EventListPane.vue')
  296. const mockSource = readCode('mocks/contractMocks.js')
  297. check(
  298. 'F1-no-accuracy-false-alarm-field',
  299. !/准确率|误报|真伪|accuracy|false.?alarm|verdict/.test(eventListSource + paneSource),
  300. '领域层与组件不发明任何真伪/准确率/误报判定(后端契约就没有这些字段)',
  301. )
  302. check(
  303. 'F2-mock-has-no-judgement-field',
  304. !/准确率|误报|真伪|accuracy|false.?alarm/.test(mockSource),
  305. 'mock 不造「准确率/误报率」字段(照后端契约,系统不判断事件真伪)',
  306. )
  307. check(
  308. 'F3-status-shown-as-backend-gives',
  309. eventStatusText('ACTIVE') === '进行中' &&
  310. eventStatusText('UNKNOWN_NEW') === 'UNKNOWN_NEW' &&
  311. eventStatusText('') === '',
  312. '状态直译已知值、认不出的回原码、空回空串 —— 只呈现后端的判断,不替后端裁决',
  313. )
  314. }
  315. // ================================================================ G store 取数、切换与竞态
  316. {
  317. const store = useEventStore()
  318. store.reset()
  319. store.configure({ config: mockConfig })
  320. resetMockTransportsForTest()
  321. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  322. // 初始取数:默认 WARNING。
  323. await store.loadCurrentEvents({ mode: 'WARNING' })
  324. const first = store.sections.currentEvents
  325. check(
  326. 'G1-loads-warning-by-default',
  327. store.mode === 'WARNING' &&
  328. first.state === SECTION_STATE.SUCCESS &&
  329. first.loaded === true &&
  330. store.view.switchView.key === 'WARNING' &&
  331. store.view.rows.length === DEFAULT_EVENT_LIMIT,
  332. `mode=${store.mode} 行数=${store.view.rows.length}`,
  333. )
  334. // 切换:setMode('ALARM') → 换族重取 + 视图同步变化。
  335. await store.setMode('ALARM')
  336. check(
  337. 'G2-set-mode-switches-family-and-reloads',
  338. store.mode === 'ALARM' &&
  339. store.view.switchView.key === 'ALARM' &&
  340. store.view.rows.length === DEFAULT_EVENT_LIMIT,
  341. `切换后 mode=${store.mode} 行数=${store.view.rows.length}`,
  342. )
  343. check(
  344. 'G3-switch-field-labels-follow-mode',
  345. store.view.switchView.codeLabel === '报警编号' && store.view.switchView.statusLabel === '报警状态',
  346. '告警族的字段标签与状态标签(同一切换状态,同步变化)',
  347. )
  348. // 竞态:快速切 预警→报警→预警,只让最后一次落进分区。
  349. let releaseSlow = null
  350. resetMockTransportsForTest()
  351. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  352. registerMockTransport('/events/current?mode=WARNING', async () => {
  353. await new Promise((resolve) => {
  354. releaseSlow = resolve
  355. })
  356. return rawCurrentEvents({ mode: 'WARNING', items: [rawEventItem({ no: 'WRN-SLOW' })] })
  357. })
  358. const pendingW = store.loadCurrentEvents({ mode: 'WARNING' })
  359. const pendingA = store.loadCurrentEvents({ mode: 'ALARM' })
  360. await pendingA
  361. releaseSlow()
  362. await pendingW
  363. await new Promise((resolve) => setTimeout(resolve, 0))
  364. check(
  365. 'G4-race-latest-mode-wins',
  366. store.mode === 'ALARM' && store.view.switchView.key === 'ALARM',
  367. `快速切族后停在 ${store.mode}(迟到的 WARNING 被丢弃)`,
  368. )
  369. // 非法 mode 不改状态、不触发请求。
  370. const before = store.mode
  371. await store.setMode('BOGUS')
  372. check(
  373. 'G5-invalid-mode-ignored',
  374. store.mode === before,
  375. `setMode('BOGUS') 不改状态(保持 ${store.mode})`,
  376. )
  377. }
  378. // ================================================================ H mock 真过滤(切换真的换内容)
  379. {
  380. resetMockTransportsForTest()
  381. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  382. const warning = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig })
  383. const alarm = await fetchCurrentEvents({ mode: 'ALARM', config: mockConfig })
  384. const warningCodes = (warning.data.sections.events.data || []).map((item) => item.code)
  385. const alarmCodes = (alarm.data.sections.events.data || []).map((item) => item.code)
  386. check(
  387. 'H1-mock-filters-by-mode',
  388. warning.data.mode === 'WARNING' &&
  389. alarm.data.mode === 'ALARM' &&
  390. warningCodes.length >= DEFAULT_EVENT_LIMIT &&
  391. alarmCodes.length >= DEFAULT_EVENT_LIMIT &&
  392. warningCodes.every((code) => code.startsWith('WRN-')) &&
  393. alarmCodes.every((code) => code.startsWith('ALM-')),
  394. `预警 ${warningCodes.length} 条(WRN-*)、报警 ${alarmCodes.length} 条(ALM-*)—— 两种列表内容不同(12 条上限是视图层的活,见 H3)`,
  395. )
  396. // 空样例(with onlyPath isolation)→ 内部分区 EMPTY、视图无内容、零行(不画假数据)。
  397. resetMockTransportsForTest()
  398. registerCockpitMocks({ scenario: 'empty', onlyPath: '/events/current', handlers: { registerMockTransport, resetMockTransportsForTest } })
  399. const empty = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig })
  400. const emptyView = currentEventsView(empty.data, empty.data.sections.events)
  401. resetMockTransportsForTest()
  402. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  403. check(
  404. 'H2-mock-empty-scenario',
  405. empty.data.sections.events.state === SECTION_STATE.EMPTY &&
  406. emptyView.hasContent === false &&
  407. emptyView.rows.length === 0,
  408. `空样例事件分区 ${empty.data.sections.events.state}、视图行 ${emptyView.rows.length}(不画假数据)`,
  409. )
  410. // 顶层不判空:events 内部 EMPTY 时顶层仍是 200 信封。
  411. const warningView = currentEventsView(warning.data, warning.data.sections.events)
  412. check(
  413. 'H3-view-total-from-pagination',
  414. warningView.total >= DEFAULT_EVENT_LIMIT && warningView.limited === true,
  415. `视图 total=${warningView.total}、limited=${warningView.limited}(样例共 14 条 > 默认 12)`,
  416. )
  417. }
  418. // ================================================================ I 静态守卫
  419. {
  420. // 页面/store/组件不得出现后端裸字段名(票 05 的纪律)。
  421. // 与 t05 的守卫面一致:services 允许出现路径串(`/events/current` 是 URL,不是字段名),
  422. // 所以 eventApi.js 不进 D1 那份名单;domain 是适配层,本来就该出现后端字段。
  423. const pageAndStoreFiles = [
  424. 'CockpitShell.vue',
  425. 'components/EventListPane.vue',
  426. 'stores/eventStore.js',
  427. ]
  428. const BACKEND_FIELD_PATTERN = /\b(eventNo|eventName|eventType|levelColor|monitoringPointCode|coordinateValidation|thresholdMinValue|thresholdMaxValue|mapFilter|workOrders|displayName)\b/
  429. for (const file of pageAndStoreFiles) {
  430. const text = read(file)
  431. const found = BACKEND_FIELD_PATTERN.exec(text)
  432. check(`I1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段')
  433. }
  434. const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
  435. for (const file of [...pageAndStoreFiles, 'services/eventApi.js', 'domain/eventList.js']) {
  436. check(`I2-${file}-no-credential`, !CREDENTIAL_PATTERN.test(read(file)), '不碰凭据')
  437. }
  438. const domainFiles = ['domain/eventList.js', 'domain/events.js', 'domain/statusTones.js']
  439. for (const file of domainFiles) {
  440. const text = read(file)
  441. check(`I3-${file}-pure`, !/\bfetch\s*\(|XMLHttpRequest/.test(text), '领域转换不自己发请求')
  442. check(`I4-${file}-no-service-import`, !/from '\.\.\/services\//.test(text), 'domain 不 import service')
  443. }
  444. // 切换状态必须落在 store(共享)而非组件(票 19 四面板共用)。
  445. check(
  446. 'I5-switch-state-lives-in-store',
  447. /setMode/.test(read('stores/eventStore.js')) && !/<select|v-model/.test(readVueCode('components/EventListPane.vue')),
  448. '分段按钮只调 store 的 setMode(组件不私藏切换状态)',
  449. )
  450. }
  451. const failed = checks.filter((item) => !item.pass)
  452. console.log('')
  453. console.log(`T13 事件列表检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  454. if (failed.length) {
  455. console.log('未通过:')
  456. for (const item of failed) console.log(` · ${item.name}`)
  457. process.exit(1)
  458. }
  459. console.log('T13 事件列表全部通过。')