/** * T14 事件详情与设备定位 纯逻辑检查(票 14)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t14-event-detail.mjs * * 八段(每条都对着票 14 的验收原句): * A 验收 1:详情字段齐全(编码/设备编码/指标/阈值/实际值/等级/时间/状态) * B 等级与状态复用**同一张表**(票 13 的词表,不在这里长出第二份映射) * C 验收 2+3:定位按**设备编码**回联点位;高亮的编码同时含点位码与设备码(管线匹配键) * D 验收 5:三种"点不动"分得开(不在已加载范围 / 后端判不可上图 / 地图未就绪), * 且原因用的是票 07 的同一张中文词表 * E 验收 5:无有效坐标时详情**仍完整可看**,并在"位置"一栏给出无地图位置原因 * F 验收 3:高亮**不新建地图实体**(事件不变成独立图层)—— 静态守卫 + 计划不变 * G 验收 4:store 的打开/返回不动列表上下文(行、族、分区一个字节没变),定位不重取; * 高亮返回 0 时如实报「尚未出现在地图上」 * H 静态守卫:组件/store 零后端裸字段、零凭据、domain 不 import service * * 契约依据:`CockpitCurrentEventsResponse.EventItem`(票 13 的 `toEventItem`)与 * CONTEXT.md 的「两级详情 / 无地图位置 / 管线→设备的关联方向」。 */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js' import { LEVEL_TAGS } from '../../../src/modules/cockpit/domain/statusTones.js' import { COORDINATE_REASON_TEXT, isRenderable } from '../../../src/modules/cockpit/domain/coordinate.js' import { LOCATE_RESULT, LOCATE_TEXT } from '../../../src/modules/cockpit/domain/mapLocate.js' import { toEventItem, eventRowView, latestEventRows } from '../../../src/modules/cockpit/domain/eventList.js' import { EVENT_DETAIL_RESULT, eventDetailView, eventHighlightCodes, eventLocationView, planEventLocate, } from '../../../src/modules/cockpit/domain/eventDetail.js' import { planPointMarkers } from '../../../src/modules/cockpit/domain/pointStatus.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 { EMPHASIS_STYLE, shouldEmphasize } from '../../../src/modules/cockpit/domain/mapEmphasis.js' import { useEventStore } from '../../../src/modules/cockpit/stores/eventStore.js' const checks = [] 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)) /** 取一个具名函数的**正文**(用于「这个接缝里有没有干某件事」的静态守卫)。 */ function functionBody(text, name) { const start = text.indexOf(`function ${name}(`) if (start < 0) return '' let depth = 0 for (let i = text.indexOf('{', start); i < text.length; i += 1) { if (text[i] === '{') depth += 1 if (text[i] === '}') { depth -= 1 if (depth === 0) return text.slice(start, i + 1) } } return text.slice(start) } setActivePinia(createPinia()) /** 后端 `/events/current` 的一条 `EventItem`(字段照契约)。 */ function rawItem({ no = 'WRN-2001', name = '供水压力预警', type = 'WATER_PRESSURE', level = 2, status = 'PENDING', value = 0.28, unit = 'MPa', deviceCode = 'EQ-001', monitoringPointCode = 'MP-001', min = 0.3, max = 0.6, validation = { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, } = {}) { return { eventNo: no, eventName: name, eventType: type, level, levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN', status, eventTime: '2026-09-02T09:55:00+08:00', updatedTime: '2026-09-02T09:56:00+08:00', value, unit, deviceCode, deviceName: '供水压力计-001', monitoringPointCode, monitoringPointName: '供水干管监测点', location: '城区B', industry: 'WATER_SUPPLY', longitude: 110.141, latitude: 28.452, coordinateValidation: validation, thresholdMinValue: min, thresholdMaxValue: max, workOrderSummary: null, } } /** 领域行(票 13 的 `toEventItem` → 列表视图行,票 14 的详情读它)。 */ const rowOf = (raw) => eventRowView(toEventItem(raw)) /** 一张图已有的对象(形状同 `domain/mapSummary.js`)。 */ const mapObject = ({ code: objectCode = 'MP-001', deviceCodes = ['EQ-001'], renderable = true, reason = null } = {}) => ({ kind: 'monitoring-point', code: objectCode, name: '供水干管监测点', status: 'NORMAL', statusReasons: [], deviceCodes, longitude: 110.141, latitude: 28.452, coordinate: { state: renderable ? 'VALID' : 'UNVERIFIED', reason, crs: 'WGS84', renderable, validated: true }, }) // ================================================================ A 详情字段齐全 { const view = eventDetailView(rowOf(rawItem())) const wanted = [ ['事件编码', view.code, 'WRN-2001'], ['设备编码', view.deviceCode, 'EQ-001'], ['指标', view.metric, 'WATER_PRESSURE'], ['阈值', view.threshold, '0.3 ~ 0.6 MPa'], ['实际值', view.value, '0.28 MPa'], ['等级', view.level.label, '2级'], ['时间', view.eventTime, '2026-09-02T09:55:00+08:00'], ['状态', view.status, '待处理'], ] const bad = wanted.filter(([, actual, expected]) => actual !== expected) check( 'A1-detail-lists-all-required-fields', bad.length === 0 && view.level.tone === LEVEL_TAGS[1].tone, bad.length ? `不符:${bad.map(([label, actual]) => `${label}=${actual}`).join(' / ')}` : '8 项字段齐全(含等级色键 + 文字)', ) // 缺失值不伪装零值:null 走占位符,0 是**有效**数据(票 16 的纪律)。 const sparse = eventDetailView(rowOf(rawItem({ value: null, min: null, max: null, type: '' }))) const zero = eventDetailView(rowOf(rawItem({ value: 0 }))) check( 'A2-missing-not-zero', sparse.value === '—' && sparse.threshold === '—' && sparse.metric === '—' && zero.value === '0 MPa', `缺失 value=${sparse.value}(占位符)、真实的 0 值=${zero.value}(照实显示,不当缺失)`, ) // 只给最小阈值(报警常见形状):≥ 而不是编一个上界。 const minOnly = eventDetailView(rowOf(rawItem({ max: null }))) check('A3-threshold-half-open', minOnly.threshold === '≥ 0.3 MPa', `只有下界时显示 ${minOnly.threshold}`) } // ================================================================ B 等级/状态复用同一张表 { const row = rowOf(rawItem({ level: 4, status: 'WEIRD_NEW_CODE' })) const view = eventDetailView(row) check( 'B1-same-tables-as-list', view.level.label === row.level.label && view.level.tone === row.level.tone && view.status === row.statusText && view.status === 'WEIRD_NEW_CODE', `详情等级/状态与列表行逐个相等(${view.level.label} / ${view.status});认不出的状态回原码`, ) } // ================================================================ C 定位:按设备编码回联 { const objects = [mapObject({ objectCode: 'MP-001', deviceCodes: ['EQ-001'] }), mapObject({ objectCode: 'MP-009', deviceCodes: ['EQ-009'] })] const row = rowOf(rawItem({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001' })) const plan = planEventLocate(row, objects, true) check( 'C1-locates-by-device-code', plan.result === EVENT_DETAIL_RESULT.OK && plan.object.code === 'MP-001' && plan.target && plan.target.lon === 110.141 && plan.cameraHeight === undefined && plan.object.code !== 'MP-009', `按设备编码 EQ-001 找到点位 ${plan.object && plan.object.code},相机目标 (${plan.target.lon}, ${plan.target.lat}, ${plan.target.height})`, ) check( 'C2-highlight-codes-include-device-and-point', plan.codes.includes('EQ-001') && plan.codes.includes('MP-001'), `高亮编码 = ${JSON.stringify(plan.codes)}(点位码强调标记、设备码匹配关联管线 —— 票 22 的管线按设备关联)`, ) // 设备只带编码、没有点位码时也能回联(事件列表可以只有 deviceCode)。 const deviceOnly = planEventLocate(rowOf(rawItem({ monitoringPointCode: null })), objects, true) check( 'C3-locates-without-point-code', deviceOnly.result === EVENT_DETAIL_RESULT.OK && deviceOnly.object.code === 'MP-001', '只有设备编码(无点位码)时仍按 deviceCodes 命中点位', ) check( 'C4-same-seat-as-ticket-17', LOCATE_RESULT.OK === EVENT_DETAIL_RESULT.OK && LOCATE_RESULT.MISSING === EVENT_DETAIL_RESULT.MISSING && LOCATE_RESULT.BLOCKED === EVENT_DETAIL_RESULT.BLOCKED && LOCATE_RESULT.UNAVAILABLE === EVENT_DETAIL_RESULT.UNAVAILABLE, '四种定位结果沿用票 17/18 的 mapLocate 词表(不新造一套结果码)', ) } // ================================================================ D 三种「点不动」分得开 { const row = rowOf(rawItem()) const missing = planEventLocate(row, [], true) check( 'D1-missing-keeps-list-data', missing.result === EVENT_DETAIL_RESULT.MISSING && missing.reasonText === LOCATE_TEXT.MISSING, `不在已加载范围 → ${missing.result}(「${missing.reasonText}」,数据可能还没翻到,不是错误)`, ) const blocked = planEventLocate(row, [mapObject({ renderable: false, reason: 'UNVERIFIED_COORDINATE' })], true) check( 'D2-blocked-reuses-ticket-07-wording', blocked.result === EVENT_DETAIL_RESULT.BLOCKED && blocked.reasonText === COORDINATE_REASON_TEXT.UNVERIFIED_COORDINATE && isRenderable(blocked.object) === false, `后端判不可上图 → ${blocked.result},文案「${blocked.reasonText}」(与票 07 的「无地图位置」同一张词表)`, ) const notReady = planEventLocate(row, [mapObject()], false) check( 'D3-unavailable-is-not-an-error', notReady.result === EVENT_DETAIL_RESULT.UNAVAILABLE && notReady.reasonText === LOCATE_TEXT.UNAVAILABLE, `地图未就绪 → ${notReady.result}(「${notReady.reasonText}」,与「数据没有」分开说)`, ) // 事件自带坐标被判非法时,**先于**回联就如实报(不假装能定位)。 const badEvent = planEventLocate( rowOf(rawItem({ validation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' } })), [mapObject()], true, ) check( 'D4-invalid-event-coordinate-blocks', badEvent.result === EVENT_DETAIL_RESULT.BLOCKED && badEvent.reasonText === COORDINATE_REASON_TEXT.ZERO_COORDINATE, `事件自身坐标非法 → ${badEvent.result}(「${badEvent.reasonText}」)`, ) } // ================================================================ E 无坐标仍可查看 { const blockedRow = rowOf(rawItem({ validation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } })) const view = eventDetailView(blockedRow) const plan = planEventLocate(blockedRow, [mapObject()], true) check( 'E1-detail-complete-when-no-location', view.code === 'WRN-2001' && view.deviceCode === 'EQ-001' && view.value === '0.28 MPa' && view.eventTime === '2026-09-02T09:55:00+08:00' && view.status === '待处理' && view.hasLocation === false, `无有效坐标时 8 项字段仍齐全,hasLocation=${view.hasLocation}`, ) check( 'E2-no-location-reason-is-shown', view.location.includes(COORDINATE_REASON_TEXT.LONGITUDE_OUT_OF_RANGE) && plan.reasonText === COORDINATE_REASON_TEXT.LONGITUDE_OUT_OF_RANGE, `位置一栏写「${view.location}」、定位反馈写「${plan.reasonText}」—— 两句都是同一张词表里的实话`, ) // 有坐标但后端没给文本位置:仍说得出「可定位」。 const textless = eventLocationView(rowOf(rawItem({ location: null }))) check( 'E3-locatable-without-text', textless.hasLocation === true && !!textless.location, `后端可上图但没给文本位置 → hasLocation=${textless.hasLocation}、显示「${textless.location}」`, ) } // 走查用的「无坐标」样例必须真的能让整条路径复现(否则真机 D 组只是走过场)。 { resetMockTransportsForTest() registerCockpitMocks({ scenario: 'event-detail-no-coordinate', onlyPath: '/events/current', handlers: { registerMockTransport, resetMockTransportsForTest }, }) const payload = await fetchCurrentEvents({ mode: 'WARNING', config: mockConfig }) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) const rows = latestEventRows(payload.data.sections.events.data).map(eventRowView) const view = eventDetailView(rows[0]) const plan = planEventLocate(rows[0], [mapObject()], true) check( 'E4-mock-scenario-covers-no-location', rows.length >= 12 && rows[0].code.startsWith('WRN-') && view.code === rows[0].code && view.hasLocation === false && view.location.includes(COORDINATE_REASON_TEXT.UNVERIFIED_COORDINATE) && plan.result === EVENT_DETAIL_RESULT.BLOCKED, `样例 event-detail-no-coordinate:${rows.length} 行事件仍可看,详情 hasLocation=${view.hasLocation}、位置「${view.location}」、定位 ${plan.result} —— 真机 D 组不是走过场`, ) } // ================================================================ F 高亮不新建实体 { const objects = [mapObject(), mapObject({ objectCode: 'MP-009', deviceCodes: ['EQ-009'] })] const before = planPointMarkers(objects) const after = planPointMarkers(objects) check( 'F1-marker-plan-unchanged', before.markers.length === after.markers.length && JSON.stringify(before.byStatus) === JSON.stringify(after.byStatus) && before.total === 2, `定位前后标记计划逐位相同(共 ${before.total} 个),事件不进标记计划 —— 验收第 3 条是结构性的`, ) const engine = readCode('services/cesiumEngine.js') const highlight = functionBody(engine, 'highlightEntities') check( 'F2-highlight-adds-no-entity', !!highlight && !/entities\.add\(/.test(highlight) && /entity\.billboard\.scale/.test(highlight), 'highlightEntities 只改已存在实体的显示参数(billboard.scale / polyline.width),零 entities.add', ) const pipeline = functionBody(engine, 'pipelineSource') check( 'F3-pipeline-seat-is-ready', /cockpit-pipelines/.test(engine) && !!pipeline, '管线数据源(票 22)已经在高亮的扫描范围内:落地当天按设备码自动一起高亮,不需要再改本票', ) const service = readCode('services/mapService.js') const serviceHighlight = functionBody(service, 'highlightMapObject') check( 'F4-service-highlight-is-read-only', !!serviceHighlight && !/markerPlan\s*=/.test(serviceHighlight) && /markerPlan\.markers\.find/.test(serviceHighlight), 'highlightMapObject 只读 markerPlan(把设备码解析成点位码),不改计划、不重画标记', ) // ---- 真实行为:强调判定是纯函数,直接断言(不是读源码、也不是自己跟自己比)---- const markerCandidates = ['MP-002', '', 'EQ-002', ['EQ-002']] const otherCandidates = ['MP-001', '', 'EQ-001', ['EQ-001']] const pipeCandidates = ['', 'PIPE-9', 'EQ-002', []] check( 'F5-emphasis-hits-point-and-pipeline-by-device', shouldEmphasize(markerCandidates, new Set(['EQ-002'])) === true && shouldEmphasize(otherCandidates, new Set(['EQ-002'])) === false && shouldEmphasize(pipeCandidates, new Set(['EQ-002'])) === true && EMPHASIS_STYLE.markerScale > 1 && EMPHASIS_STYLE.pipelineWidth > 0, '事件按设备码一次命中**两个**实体:点位标记(属性里的设备码)+ 关联管线(pipelineCode/deviceCode)—— 验收第 3 条的两半', ) check( 'F6-empty-target-restores-everything', shouldEmphasize(markerCandidates, new Set()) === false && shouldEmphasize(pipeCandidates, []) === false && shouldEmphasize(markerCandidates, null) === false, '目标为空 = 全部还原(判 false);引擎对**每个实体**都写回上一次记下的原值,收起详情后标记不会一直胖着(早先版本在这里提前 return,一趟都没走)', ) check( 'F7-engine-applies-the-verdict', /shouldEmphasize/.test(highlight) && /EMPHASIS_STYLE\.markerScale/.test(highlight) && /base\.polylineWidth/.test(highlight) && !/wanted\.size\)\s*return/.test(highlight), '引擎只把判定写成显示参数:命中写强调值、未命中写回记下的原值,且空目标时**不提前返回**(还原路径走得通)', ) check( 'F8-emphasis-is-observable', /dataset\.mapEmphasis = String\(matched\)/.test(service) && /delete record\.host\.dataset\.mapEmphasis/.test(service), '服务层把强调计数写进宿主 `data-map-emphasis`、随地图销毁一并 delete:真机走查据此断言"收起后回到 0",不是读内部变量', ) } // ================================================================ G store:上下文与结果 { const store = useEventStore() store.reset() store.configure({ config: mockConfig }) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) await store.loadCurrentEvents({ mode: 'WARNING' }) const rowsBefore = store.view.rows.map((row) => row.key) const sectionBefore = store.sections.currentEvents const domainBefore = store.currentDomain store.openEvent(store.view.rows[0]) const sectionOpen = store.sections.currentEvents check( 'G1-open-keeps-list-context', !!store.selectedEvent && store.mode === 'WARNING' && store.sections.currentEvents === sectionBefore && store.currentDomain === domainBefore && JSON.stringify(store.view.rows.map((row) => row.key)) === JSON.stringify(rowsBefore), `打开详情后列表逐位不变(${rowsBefore.length} 行、族 ${store.mode}、分区对象同一引用)—— 列表在 store,收起/展开详情不触发任何重取`, ) check( 'G2-open-keeps-section-identity', store.sections.currentEvents.loaded === true && store.sections.currentEvents.state === SECTION_STATE.SUCCESS && sectionOpen === sectionBefore, '详情打开不新建分区、不改分区状态', ) const calls = { focus: [], highlight: [] } // 用**选中那一行的**编码造地图对象:定位是按设备编码回联,样例写死编码就会点空。 const selected = store.selectedEvent const objects = [mapObject({ objectCode: selected.monitoringPointCode, deviceCodes: [selected.deviceCode] })] const ok = await store.locateEvent(store.selectedEvent, { objects, mapReady: true, focus: (target) => { calls.focus.push(target) return true }, highlight: (codes) => { calls.highlight.push(codes) return 1 }, }) check( 'G3-locate-drives-camera-and-highlight', ok.result === LOCATE_RESULT.OK && calls.focus.length === 1 && calls.focus[0].lon === objects[0].longitude && calls.highlight.length === 1 && calls.highlight[0].includes(selected.deviceCode) && store.sections.currentEvents === sectionBefore, `已下发相机目标 + 高亮编码 ${JSON.stringify(calls.highlight[0])};列表分区仍是同一个(定位不重取)`, ) const notOnMap = await store.locateEvent(store.selectedEvent, { objects, mapReady: true, focus: () => true, highlight: () => 0, }) check( 'G4-highlight-zero-is-reported-honestly', notOnMap.result === LOCATE_RESULT.UNAVAILABLE && /尚未出现在地图上/.test(notOnMap.reasonText), `高亮命中 0 个 → ${notOnMap.result}(「${notOnMap.reasonText}」,不假装定位成功)`, ) store.closeEvent() check( 'G5-close-returns-to-list-untouched', store.selectedEvent === null && store.locate.result === '' && store.mode === 'WARNING' && store.sections.currentEvents === sectionBefore && JSON.stringify(store.view.rows.map((row) => row.code)) === JSON.stringify(rowsBefore), '返回事件列表:详情收起、定位结果清空,族/筛选/分区/行序全部原样(验收第 4 条)', ) // 事件列表行本身就是详情的数据源(详情不需要第二个端点)。 const sharedSeat = latestEventRows(domainBefore.sections.events.data).map(eventRowView) check( 'G6-detail-reads-the-list-row', sharedSeat.length >= 12 && sharedSeat[0].code === store.view.rows[0].code && !!eventDetailView(sharedSeat[0]).threshold, '详情直接消费列表行(票 13 的 14 条样例里阈值/量值都在),没有为详情新开接口', ) } // ================================================================ H 静态守卫 { const pageAndStoreFiles = [ 'components/EventDetailCard.vue', 'components/EventListPane.vue', 'stores/eventStore.js', ] const BACKEND_FIELD_PATTERN = /\b(eventNo|eventName|eventType|levelColor|monitoringPointCode|coordinateValidation|thresholdMinValue|thresholdMaxValue|workOrderSummary|mapFilter|workOrders|displayName)\b/ for (const file of pageAndStoreFiles) { const text = read(file) const found = BACKEND_FIELD_PATTERN.exec(text) check(`H1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段') } const ROUTE_PATTERN = /['"`]\/[a-z-]+\// for (const file of pageAndStoreFiles) { check(`H2-${file}-no-route-string`, !ROUTE_PATTERN.test(readCode(file)) && !ROUTE_PATTERN.test(readVueCode(file)), '不自己拼接口路径(路径归 services)') } const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/ for (const file of [...pageAndStoreFiles, 'domain/eventDetail.js']) { check(`H3-${file}-no-credential`, !CREDENTIAL_PATTERN.test(read(file)), '不碰凭据') } check('H4-domain-pure', !/\bfetch\s*\(|XMLHttpRequest/.test(read('domain/eventDetail.js')), 'domain/eventDetail.js 不自己发请求') check('H5-domain-no-service-import', !/from '\.\.\/services\//.test(read('domain/eventDetail.js')), 'domain/eventDetail.js 不 import service') // 详情必须能收起(返回列表),且返回不重取:收起只清选择。 const storeCode = readCode('stores/eventStore.js') const closeBody = functionBody(storeCode, 'closeEvent') check( 'H6-close-does-not-refetch', !!closeBody && !/loadCurrentEvents|fetch/.test(closeBody) && /selectedEvent\.value = null/.test(closeBody), 'closeEvent 只清选择与高亮,零取数(否则「返回」会把列表滚回第 1 页)', ) // 事件详情不新增标记:domain 不 import 引擎,也不产生 marker 计划。 check( 'H7-detail-plans-no-marker', !/planPointMarkers|syncPointMarkers|Cesium/.test(read('domain/eventDetail.js')), '详情/定位层不建标记计划、不认识 Cesium', ) } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T14 事件详情与设备定位检查:${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('T14 事件详情与设备定位全部通过。')