/** * 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(//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')) && !/ !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 事件列表全部通过。')