| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504 |
- /**
- * T13 事件列表与预警/报警切换 纯逻辑检查(票 13)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t13-event-list.mjs
- *
- * 十段:
- * A 契约适配:`/events/current`(跨设备)→ 领域对象(envelope + 内部分区 + EventItem)
- * B 切换视图:预警/报警的字段标签与状态标签从同一个对象取(同步变化的唯一来源)
- * C 等级配色:1-4 级 → lv1..lv4 + 'N级' 文字;认不出落中性灰 + 文字(不猜)
- * D 默认条数:`latestEventRows` 按 eventTime 倒序 + eventNo 升序,默认取最新 12 条
- * E 事件不建独立地图标记:领域层不 import 引擎/地图服务、不产生标记计划
- * F 不判断预警真伪/误报:领域层与组件不出现准确率/误报/真伪判定;mock 无这些字段
- * G store:取数 + 切换 + token 竞态(快速切族时旧响应作废)
- * H mock 真过滤:`/events/current` 按 mode 返回不同族(切换真的换内容)
- * I 静态守卫:页面/store/组件零后端裸字段、零凭据、domain 纯函数
- *
- * 契约依据:后端 `CockpitCurrentEventsResponse` / `CockpitCurrentEventsService` /
- * `CockpitEventLevelColor`(`E:\pipe-ner-server`)与契约手册第 2 节「等级颜色」、
- * 「系统不判断预警真伪,契约中不存在任何准确率、误报率字段」。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js'
- import { EVENT_FAMILY } from '../../../src/modules/cockpit/domain/events.js'
- import {
- DEFAULT_EVENT_LIMIT,
- EVENT_STATUS_TEXT,
- currentEventsView,
- eventLevelTag,
- eventListRows,
- eventListSwitchOptions,
- eventListSwitchView,
- eventRowView,
- eventStatusText,
- isKnownEventMode,
- latestEventRows,
- toCurrentEvents,
- toEventItem,
- } from '../../../src/modules/cockpit/domain/eventList.js'
- import { LEVEL_TAGS, DEFAULT_TONE } from '../../../src/modules/cockpit/domain/statusTones.js'
- import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
- import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
- import { fetchCurrentEvents } from '../../../src/modules/cockpit/services/eventApi.js'
- import { useEventStore } from '../../../src/modules/cockpit/stores/eventStore.js'
- const checks = []
- /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const readCode = (relative) => code(read(relative))
- /** `.vue` 的注释是 `<!-- -->`,要单独剥。 */
- const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
- const readVueCode = (relative) => codeVue(read(relative))
- setActivePinia(createPinia())
- /** 造一份后端 `/events/current` 载荷(envelope + 内部分区)。 */
- function rawCurrentEvents({ mode = 'WARNING', items = [], state = 'SUCCESS', reason = 'SUCCESS', message = null, query = {} } = {}) {
- return {
- requestId: 'test-current-events',
- apiVersion: 'v1',
- readOnly: true,
- displayName: mode === 'ALARM' ? '当前报警' : '当前预警',
- timezone: 'Asia/Shanghai',
- mode,
- updatedAt: '2026-09-02T10:00:00+08:00',
- filters: { mode, level: query.level || null, industry: query.industry || null, status: query.status || null },
- pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
- mapFilter: { mode, level: null, industry: null, status: null, deviceCodes: [] },
- sections: {
- events: { state, reason, message, data: items },
- workOrders: { state: 'EMPTY', reason: 'NO_WORK_ORDERS', message: null, data: null },
- },
- }
- }
- /** 造一条 `EventItem` 样例(字段照后端 `CockpitCurrentEventsResponse.EventItem`)。 */
- 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 } = {}) {
- return {
- eventNo: no,
- eventName: name,
- eventType: type,
- level,
- levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN',
- status,
- eventTime,
- updatedTime,
- value,
- unit,
- deviceCode,
- deviceName,
- monitoringPointCode,
- monitoringPointName,
- location,
- industry,
- longitude: 110.14,
- latitude: 28.45,
- coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
- thresholdMinValue: min,
- thresholdMaxValue: max,
- workOrderSummary: null,
- }
- }
- // ================================================================ A 契约适配
- {
- const domain = toCurrentEvents(
- rawCurrentEvents({
- mode: 'ALARM',
- items: [
- rawEventItem({ no: 'ALM-1', level: 1, min: 10, max: 20 }),
- rawEventItem({ no: 'ALM-2', level: 4 }),
- ],
- }),
- )
- check(
- 'A1-mode-and-display-name-carried',
- domain.mode === 'ALARM' && domain.displayName === '当前报警',
- `mode=${domain.mode} displayName=${domain.displayName}`,
- )
- check(
- 'A2-inner-events-section-mapped',
- domain.sections.events.state === SECTION_STATE.SUCCESS &&
- Array.isArray(domain.sections.events.data) &&
- domain.sections.events.data.length === 2,
- `events.state=${domain.sections.events.state} 条数=${domain.sections.events.data && domain.sections.events.data.length}`,
- )
- check(
- 'A3-order-section-present-untouched',
- domain.sections.orders.state === SECTION_STATE.EMPTY && domain.sections.orders.data === null,
- `orders.state=${domain.sections.orders.state}`,
- )
- check(
- 'A4-event-item-domain-fields',
- domain.sections.events.data[0].code === 'ALM-1' &&
- domain.sections.events.data[0].level === 1 &&
- domain.sections.events.data[0].deviceCode === 'DEV-1' &&
- domain.sections.events.data[0].coordinate.renderable === true,
- 'EventItem 领域行(编码/等级/设备编码/坐标校验)',
- )
- check(
- 'A5-empty-events-section-mapped-empty',
- toCurrentEvents(rawCurrentEvents({ items: [], state: 'EMPTY', reason: 'NO_EVENTS' })).sections.events.state === SECTION_STATE.EMPTY,
- '空事件分区照后端 EMPTY 映射,不猜成成功',
- )
- }
- // ================================================================ B 切换视图
- {
- const warning = eventListSwitchView('WARNING')
- const alarm = eventListSwitchView('ALARM')
- check(
- 'B1-two-families-two-labels',
- warning.key === 'WARNING' && warning.label === '预警' && alarm.key === 'ALARM' && alarm.label === '报警',
- `预警 ${JSON.stringify(warning)} / 报警 ${JSON.stringify(alarm)}`,
- )
- check(
- 'B2-fields-and-status-label-sync-per-family',
- warning.codeLabel === '预警编号' &&
- warning.statusLabel === '预警状态' &&
- alarm.codeLabel === '报警编号' &&
- alarm.statusLabel === '报警状态',
- '同一对象的 codeLabel 与 statusLabel —— 切换时必然一起变(结构保证)',
- )
- check(
- 'B3-switch-options-order-fixed',
- eventListSwitchOptions().map((item) => item.key).join(',') === 'WARNING,ALARM',
- '分段顺序固定:预警在前、报警在后',
- )
- check(
- 'B4-known-mode-validation',
- isKnownEventMode('WARNING') &&
- isKnownEventMode('ALARM') &&
- !isKnownEventMode('OTHER') &&
- !isKnownEventMode(''),
- '只认 WARNING/ALARM(认不出的不该拿去换后端 400)',
- )
- check(
- 'B5-unknown-mode-falls-back-to-warning',
- eventListSwitchView('OTHER').key === 'WARNING',
- '认不出的 mode 落回预警(视图不崩),但切换入口不认它',
- )
- }
- // ================================================================ C 等级配色
- {
- const lv1 = eventLevelTag(1)
- const lv4 = eventLevelTag(4)
- check(
- 'C1-level-1-to-4-uses-level-tags',
- lv1.tone === 'lv1' && lv1.label === '1级' && lv4.tone === 'lv4' && lv4.label === '4级',
- `1级 ${JSON.stringify(lv1)} / 4级 ${JSON.stringify(lv4)}`,
- )
- check(
- 'C2-level-tags-is-statusTones-source',
- LEVEL_TAGS.map((item) => item.tone).join(',') === 'lv1,lv2,lv3,lv4',
- '等级色键来自 statusTones 的 LEVEL_TAGS(唯一来源,与点位状态色分离)',
- )
- check(
- 'C3-unknown-level-neutral-with-text',
- eventLevelTag(9).tone === DEFAULT_TONE && eventLevelTag(9).label === '等级未知' && eventLevelTag(null).label === '等级未知',
- '认不出的等级落中性灰 + 文字,不猜成某个真实等级',
- )
- check(
- 'C4-column-level-tag-and-text',
- eventRowView({ level: 3, code: 'E1', value: 1 }).level.label === '3级',
- '行视图带等级标签(色键 + 文字)',
- )
- }
- // ================================================================ D 默认最新条数
- {
- const items = [
- { code: 'OLD', eventTime: '2026-09-01T08:00:00+08:00' },
- { code: 'NEW', eventTime: '2026-09-02T10:00:00+08:00' },
- { code: 'MID', eventTime: '2026-09-02T09:00:00+08:00' },
- { code: 'NO_TIME' },
- ]
- const rows = latestEventRows(items, 2)
- check(
- 'D1-newest-first-by-event-time',
- rows.map((item) => item.code).join(',') === 'NEW,MID',
- `取最新 2 条:${rows.map((item) => item.code).join(',')}`,
- )
- check(
- 'D2-default-limit-in-10-15-range',
- DEFAULT_EVENT_LIMIT >= 10 && DEFAULT_EVENT_LIMIT <= 15,
- `DEFAULT_EVENT_LIMIT=${DEFAULT_EVENT_LIMIT}(验收「默认展示最新 10-15 条」)`,
- )
- check(
- 'D3-limit-caps-out',
- latestEventRows(items).length <= DEFAULT_EVENT_LIMIT && latestEventRows(items).length <= 4,
- `不传 limit 时按默认上限截断(这里 4 条全保留)`,
- )
- check(
- 'D4-view-rows-are-latest-limit',
- currentEventsView({
- mode: 'WARNING',
- sections: { events: { state: 'success', loaded: true, data: items.map((item) => ({ ...item, status: '', level: null })) } },
- }).rows.length === 4,
- '视图行 = 最新 N 条(纯函数层收口,组件不自己截)',
- )
- check(
- 'D5-view-rows-are-view-shape',
- (() => {
- const view = currentEventsView({
- mode: 'WARNING',
- sections: { events: { state: 'success', loaded: true, data: items.map((item) => ({ ...item, status: '', level: null })) } },
- })
- const first = view.rows[0]
- // 组件只认视图行:等级 tag(tone+label)、状态文本、量值、设备名。
- // 直接把领域行给组件会让这些字段全是 undefined —— 逻辑闸门必须拦住。
- return (
- first &&
- typeof first.level === 'object' &&
- typeof first.level.tone === 'string' &&
- typeof first.statusText === 'string' &&
- typeof first.quantity === 'string'
- )
- })(),
- '视图行带等级 tag/状态文本/量值(组件可读的最终形状)',
- )
- }
- // ================================================================ E 事件不建独立地图标记(静态守卫)
- {
- const eventListSource = readCode('domain/eventList.js')
- const paneSource = readVueCode('components/EventListPane.vue')
- const storeSource = readCode('stores/eventStore.js')
- check(
- 'E1-domain-no-map-engine-import',
- !/from '\.\/.*(mapService|cesium|mapApi)|from '\.\.\/services\/(mapService|cesiumEngine|mapApi|pointPick)/.test(eventListSource),
- '领域层不 import 引擎/地图服务(没有标记计划可产生)',
- )
- check(
- 'E2-pane-no-map-engine-import',
- !/mapService|cesium|marker|billboard|Viewer/.test(paneSource),
- '组件不碰地图引擎/标记(事件只带设备编码,地图由票 14 回联)',
- )
- check(
- 'E3-store-no-marker-plan',
- // 票 14 起 store 允许调地图服务(下相机目标 + 高亮),但**建标记计划**仍是它的禁区:
- // 事件/详情只强调已有实体,绝不 `sync/clear/planPointMarkers` 或自画 billboard。
- !/billboard|markerSvg|planPointMarkers|syncPointMarkers|clearPointMarkers/.test(storeSource),
- 'store 只驱动地图(相机/高亮),不产生标记计划',
- )
- check(
- 'E4-event-keeps-device-code-only',
- /deviceCode/.test(eventListSource) && !/longitude.*renderable|renderable.*longitude/m.test(eventListSource),
- '事件以设备编码为回联键;坐标只作票 14 的输入,不在这里上图',
- )
- }
- // ================================================================ F 不判断预警真伪/误报(静态守卫)
- {
- const eventListSource = readCode('domain/eventList.js')
- const paneSource = readVueCode('components/EventListPane.vue')
- const mockSource = readCode('mocks/contractMocks.js')
- check(
- 'F1-no-accuracy-false-alarm-field',
- !/准确率|误报|真伪|accuracy|false.?alarm|verdict/.test(eventListSource + paneSource),
- '领域层与组件不发明任何真伪/准确率/误报判定(后端契约就没有这些字段)',
- )
- check(
- 'F2-mock-has-no-judgement-field',
- !/准确率|误报|真伪|accuracy|false.?alarm/.test(mockSource),
- 'mock 不造「准确率/误报率」字段(照后端契约,系统不判断事件真伪)',
- )
- check(
- 'F3-status-shown-as-backend-gives',
- eventStatusText('ACTIVE') === '进行中' &&
- eventStatusText('UNKNOWN_NEW') === 'UNKNOWN_NEW' &&
- eventStatusText('') === '',
- '状态直译已知值、认不出的回原码、空回空串 —— 只呈现后端的判断,不替后端裁决',
- )
- }
- // ================================================================ G store 取数、切换与竞态
- {
- const store = useEventStore()
- store.reset()
- store.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- // 初始取数:默认 WARNING。
- await store.loadCurrentEvents({ mode: 'WARNING' })
- const first = store.sections.currentEvents
- check(
- 'G1-loads-warning-by-default',
- store.mode === 'WARNING' &&
- first.state === SECTION_STATE.SUCCESS &&
- first.loaded === true &&
- store.view.switchView.key === 'WARNING' &&
- store.view.rows.length === DEFAULT_EVENT_LIMIT,
- `mode=${store.mode} 行数=${store.view.rows.length}`,
- )
- // 切换:setMode('ALARM') → 换族重取 + 视图同步变化。
- await store.setMode('ALARM')
- check(
- 'G2-set-mode-switches-family-and-reloads',
- store.mode === 'ALARM' &&
- store.view.switchView.key === 'ALARM' &&
- store.view.rows.length === DEFAULT_EVENT_LIMIT,
- `切换后 mode=${store.mode} 行数=${store.view.rows.length}`,
- )
- check(
- 'G3-switch-field-labels-follow-mode',
- store.view.switchView.codeLabel === '报警编号' && store.view.switchView.statusLabel === '报警状态',
- '告警族的字段标签与状态标签(同一切换状态,同步变化)',
- )
- // 竞态:快速切 预警→报警→预警,只让最后一次落进分区。
- let releaseSlow = null
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- registerMockTransport('/events/current?mode=WARNING', async () => {
- await new Promise((resolve) => {
- releaseSlow = resolve
- })
- return rawCurrentEvents({ mode: 'WARNING', items: [rawEventItem({ no: 'WRN-SLOW' })] })
- })
- const pendingW = store.loadCurrentEvents({ mode: 'WARNING' })
- const pendingA = store.loadCurrentEvents({ mode: 'ALARM' })
- await pendingA
- releaseSlow()
- await pendingW
- await new Promise((resolve) => setTimeout(resolve, 0))
- check(
- 'G4-race-latest-mode-wins',
- store.mode === 'ALARM' && store.view.switchView.key === 'ALARM',
- `快速切族后停在 ${store.mode}(迟到的 WARNING 被丢弃)`,
- )
- // 非法 mode 不改状态、不触发请求。
- const before = store.mode
- await store.setMode('BOGUS')
- check(
- 'G5-invalid-mode-ignored',
- store.mode === before,
- `setMode('BOGUS') 不改状态(保持 ${store.mode})`,
- )
- }
- // ================================================================ H mock 真过滤(切换真的换内容)
- {
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const warning = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig })
- const alarm = await fetchCurrentEvents({ mode: 'ALARM', config: mockConfig })
- const warningCodes = (warning.data.sections.events.data || []).map((item) => item.code)
- const alarmCodes = (alarm.data.sections.events.data || []).map((item) => item.code)
- check(
- 'H1-mock-filters-by-mode',
- warning.data.mode === 'WARNING' &&
- alarm.data.mode === 'ALARM' &&
- warningCodes.length >= DEFAULT_EVENT_LIMIT &&
- alarmCodes.length >= DEFAULT_EVENT_LIMIT &&
- warningCodes.every((code) => code.startsWith('WRN-')) &&
- alarmCodes.every((code) => code.startsWith('ALM-')),
- `预警 ${warningCodes.length} 条(WRN-*)、报警 ${alarmCodes.length} 条(ALM-*)—— 两种列表内容不同(12 条上限是视图层的活,见 H3)`,
- )
- // 空样例(with onlyPath isolation)→ 内部分区 EMPTY、视图无内容、零行(不画假数据)。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'empty', onlyPath: '/events/current', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const empty = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig })
- const emptyView = currentEventsView(empty.data, empty.data.sections.events)
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- check(
- 'H2-mock-empty-scenario',
- empty.data.sections.events.state === SECTION_STATE.EMPTY &&
- emptyView.hasContent === false &&
- emptyView.rows.length === 0,
- `空样例事件分区 ${empty.data.sections.events.state}、视图行 ${emptyView.rows.length}(不画假数据)`,
- )
- // 顶层不判空:events 内部 EMPTY 时顶层仍是 200 信封。
- const warningView = currentEventsView(warning.data, warning.data.sections.events)
- check(
- 'H3-view-total-from-pagination',
- warningView.total >= DEFAULT_EVENT_LIMIT && warningView.limited === true,
- `视图 total=${warningView.total}、limited=${warningView.limited}(样例共 14 条 > 默认 12)`,
- )
- }
- // ================================================================ I 静态守卫
- {
- // 页面/store/组件不得出现后端裸字段名(票 05 的纪律)。
- // 与 t05 的守卫面一致:services 允许出现路径串(`/events/current` 是 URL,不是字段名),
- // 所以 eventApi.js 不进 D1 那份名单;domain 是适配层,本来就该出现后端字段。
- const pageAndStoreFiles = [
- 'CockpitShell.vue',
- 'components/EventListPane.vue',
- 'stores/eventStore.js',
- ]
- const BACKEND_FIELD_PATTERN = /\b(eventNo|eventName|eventType|levelColor|monitoringPointCode|coordinateValidation|thresholdMinValue|thresholdMaxValue|mapFilter|workOrders|displayName)\b/
- for (const file of pageAndStoreFiles) {
- const text = read(file)
- const found = BACKEND_FIELD_PATTERN.exec(text)
- check(`I1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段')
- }
- const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
- for (const file of [...pageAndStoreFiles, 'services/eventApi.js', 'domain/eventList.js']) {
- check(`I2-${file}-no-credential`, !CREDENTIAL_PATTERN.test(read(file)), '不碰凭据')
- }
- const domainFiles = ['domain/eventList.js', 'domain/events.js', 'domain/statusTones.js']
- for (const file of domainFiles) {
- const text = read(file)
- check(`I3-${file}-pure`, !/\bfetch\s*\(|XMLHttpRequest/.test(text), '领域转换不自己发请求')
- check(`I4-${file}-no-service-import`, !/from '\.\.\/services\//.test(text), 'domain 不 import service')
- }
- // 切换状态必须落在 store(共享)而非组件(票 19 四面板共用)。
- check(
- 'I5-switch-state-lives-in-store',
- /setMode/.test(read('stores/eventStore.js')) && !/<select|v-model/.test(readVueCode('components/EventListPane.vue')),
- '分段按钮只调 store 的 setMode(组件不私藏切换状态)',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T13 事件列表检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.name}`)
- process.exit(1)
- }
- console.log('T13 事件列表全部通过。')
|